Charts · Navigation · Forms: band names widen a sideways chart's gutter; Pagination takes a size, speaks the locale and jumps to a typed page
- A horizontal chart's gutter grows to its widest band name, floored at the theme gutter and capped at half the chart; a name past the cap ends in an ellipsis (#10). - Pagination's previous and next tooltips read i18n (English and zh-CN built in, app catalogs override); `.size(ControlSize)` sizes every control, Sm by default; `.jump()` adds a Go to field (#11). - NumberInput keys the owner's value as shown: a draft survives a blur, a value set while focused lands when focus leaves, and an echo of its own commit is no new value. A ranged field holds room for its widest bound through `Input::least`.
This commit is contained in:
1 parent
2ce336895c
commit
bd3772ad54
17 files changed
+438
-51
No files matched your search
@@ -66,7 +66,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
|
|||||||
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
- gpui paints pictures without a transform, so a picture turns by its pixels: `media::turn` turns decoded frames by quarter turns off the main thread, an asset keyed by the picture and the turn. gpui's loader turns a photo upright by its EXIF orientation first, so `media::ImageViewer` turns from the upright frame and `media::ImageUpload` measures that frame. An svg turns to any angle through its `Transformation`, so a control that turns shows one (`interaction::Rotatable`).
|
||||||
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
- A box that keeps a cursor over its items rings the item the cursor is on, not the box, as SelectableList marks its row (`interaction::SelectionArea`).
|
||||||
- Boxes that scroll together go through `interaction::ScrollSync`: the pane a wheel turned leads, and a canvas laid before the panes copies its place in prepaint, before they draw, so no frame shows them apart.
|
- Boxes that scroll together go through `interaction::ScrollSync`: the pane a wheel turned leads, and a canvas laid before the panes copies its place in prepaint, before they draw, so no frame shows them apart.
|
||||||
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). A cursor inside a seeded view stays while it lies in what is shown, so the owner's echo of a turn leaves it (`calendar::CalendarMonthView`, `YearView`).
|
- A component that takes a starting value from its owner keeps it in `layout::seeded`: a new value from the owner replaces local drags. A field that reports its words as they change keeps what it sent, so the owner's echo leaves the caret alone (`mail::SignatureEditor`). A cursor inside a seeded view stays while it lies in what is shown, so the owner's echo of a turn leaves it (`calendar::CalendarMonthView`, `YearView`). A number field keys the owner's value as shown: a new one waits while the field is focused and lands before the blur commits, and an echo of its own commit is no new value (`forms::NumberInput`). A ranged number field holds room for its widest bound through `Input::least`, since a text field has no width of its own.
|
||||||
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces.
|
- The capture harness turns on reduced motion, so shots are still: repeating motion rests on one frame, and a shimmer shows no band. gpui's mac window stops its display link while occluded, so a capture advances a moving picture only on the draws the harness forces.
|
||||||
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
|
- Each capture pass starts its page fresh (`Gallery::fresh`): page state lives under the pass, so the dark pass runs its script from the same start as the light one.
|
||||||
- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level.
|
- The capture harness finds gpui windows by handle through `raw-window-handle`. AppKit popups, which gpui does not own, come from the window list by process and level.
|
||||||
|
|||||||
@@ -58,14 +58,16 @@ pub fn pagination(window: &mut Window, cx: &mut App) -> impl IntoElement + use<>
|
|||||||
let now = *page.read(cx);
|
let now = *page.read(cx);
|
||||||
section(
|
section(
|
||||||
"Pagination",
|
"Pagination",
|
||||||
"The first, the last, and the pages around this one. A gap stands for two or more pages.",
|
"The first, the last, and the pages around this one. A gap stands for two or more pages. Type a page to go there.",
|
||||||
cx,
|
cx,
|
||||||
)
|
)
|
||||||
.child(specimen(
|
.child(specimen(
|
||||||
format!("page {now} of 20"),
|
format!("page {now} of 20"),
|
||||||
probe(
|
probe(
|
||||||
"pagination",
|
"pagination",
|
||||||
Pagination::new("pagination", now, 20).on_change(move |to, _, cx| set(&page, to, cx)),
|
Pagination::new("pagination", now, 20)
|
||||||
|
.jump()
|
||||||
|
.on_change(move |to, _, cx| set(&page, to, cx)),
|
||||||
),
|
),
|
||||||
cx,
|
cx,
|
||||||
))
|
))
|
||||||
|
|||||||
+15
-5
@@ -10,9 +10,12 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
theme::{ActiveTheme, Radius, TextSize},
|
theme::{ActiveTheme, Radius, TextSize},
|
||||||
typography::{LEADING, tabular, text_width},
|
typography::{Ellipsis, LEADING, tabular, text_width},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Room between a gutter's label and the plot.
|
||||||
|
pub(crate) const PAD: Rems = Rems(0.5);
|
||||||
|
|
||||||
/// A label centered under `x`, below the frame.
|
/// A label centered under `x`, below the frame.
|
||||||
pub(crate) fn below(x: f32, frame: Rect, label: Div) -> Div {
|
pub(crate) fn below(x: f32, frame: Rect, label: Div) -> Div {
|
||||||
div()
|
div()
|
||||||
@@ -27,7 +30,7 @@ pub(crate) fn below(x: f32, frame: Rect, label: Div) -> Div {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// A label centered on `y`, set right in the gutter.
|
/// A label centered on `y`, set right in the gutter.
|
||||||
pub(crate) fn beside(y: f32, gutter: Rems, label: Div) -> Div {
|
pub(crate) fn beside(y: f32, gutter: Pixels, label: Div) -> Div {
|
||||||
div()
|
div()
|
||||||
.absolute()
|
.absolute()
|
||||||
.left_0()
|
.left_0()
|
||||||
@@ -37,7 +40,7 @@ pub(crate) fn beside(y: f32, gutter: Rems, label: Div) -> Div {
|
|||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_end()
|
.justify_end()
|
||||||
.pr_2()
|
.pr(PAD)
|
||||||
.child(label)
|
.child(label)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +96,7 @@ pub(crate) fn marks(
|
|||||||
if plot.horizontal {
|
if plot.horizontal {
|
||||||
below(*at, rect, label)
|
below(*at, rect, label)
|
||||||
} else {
|
} else {
|
||||||
beside(*at, sizes.gutter, label)
|
beside(*at, Pixels::from(rect.x), label)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -116,7 +119,14 @@ pub(crate) fn marks(
|
|||||||
.filter(|(ix, _)| ix % every == 0)
|
.filter(|(ix, _)| ix % every == 0)
|
||||||
.map(|(_, (at, label))| {
|
.map(|(_, (at, label))| {
|
||||||
if plot.horizontal {
|
if plot.horizontal {
|
||||||
beside(*at, sizes.gutter, text(label.clone()))
|
let name = div()
|
||||||
|
.debug_selector(|| "chart-band".into())
|
||||||
|
.min_w_0()
|
||||||
|
.text_right()
|
||||||
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
|
.text_color(colors.fg_subtle)
|
||||||
|
.child(Ellipsis::new(label.clone()));
|
||||||
|
beside(*at, Pixels::from(rect.x), name)
|
||||||
} else {
|
} else {
|
||||||
below(*at, rect, text(label.clone()))
|
below(*at, rect, text(label.clone()))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ impl RenderOnce for BurndownChart {
|
|||||||
let levels: Vec<Div> = burn
|
let levels: Vec<Div> = burn
|
||||||
.ticks
|
.ticks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|(y, value)| beside(*y, sizes.gutter, label(compact(*value))))
|
.map(|(y, value)| beside(*y, sizes.gutter.to_pixels(rem), label(compact(*value))))
|
||||||
.collect();
|
.collect();
|
||||||
let dates: Vec<Div> = marks(self.first, count as i32)
|
let dates: Vec<Div> = marks(self.first, count as i32)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ impl RenderOnce for CalendarHeatmap {
|
|||||||
.expect("seven days hold every weekday");
|
.expect("seven days hold every weekday");
|
||||||
beside(
|
beside(
|
||||||
top + side * (weekday as f32 + 0.5),
|
top + side * (weekday as f32 + 0.5),
|
||||||
sizes.gutter,
|
sizes.gutter.to_pixels(rem),
|
||||||
text(date.strftime("%a").to_string()),
|
text(date.strftime("%a").to_string()),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|||||||
+17
-8
@@ -6,7 +6,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
axes::{anchored, marks},
|
axes::{PAD, anchored, marks},
|
||||||
geometry::{Geometry, Ink, Rect, frame},
|
geometry::{Geometry, Ink, Rect, frame},
|
||||||
glide::Glide,
|
glide::Glide,
|
||||||
paint::{Pen, measure, tint},
|
paint::{Pen, measure, tint},
|
||||||
@@ -17,7 +17,8 @@ use super::{
|
|||||||
use crate::{
|
use crate::{
|
||||||
buttons::{Button, ButtonVariant},
|
buttons::{Button, ButtonVariant},
|
||||||
motion,
|
motion,
|
||||||
theme::{ActiveTheme, ControlSize, Radius},
|
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||||
|
typography::text_width,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) type Format = Rc<dyn Fn(f64) -> String>;
|
pub(crate) type Format = Rc<dyn Fn(f64) -> String>;
|
||||||
@@ -127,12 +128,20 @@ pub(crate) fn plot(plot: Plot, base: Div, window: &mut Window, cx: &mut App) ->
|
|||||||
.filter(|(_, to)| *to < count)
|
.filter(|(_, to)| *to < count)
|
||||||
.unwrap_or((0, count.saturating_sub(1)));
|
.unwrap_or((0, count.saturating_sub(1)));
|
||||||
let box_size = (f32::from(bounds.size.width), f32::from(bounds.size.height));
|
let box_size = (f32::from(bounds.size.width), f32::from(bounds.size.height));
|
||||||
let rect = frame(
|
let gutter = if plot.horizontal {
|
||||||
box_size,
|
let size = theme.text_size(TextSize::Xs).to_pixels(rem);
|
||||||
pixels(sizes.gutter),
|
let widest = plot
|
||||||
pixels(sizes.foot),
|
.labels
|
||||||
pixels(sizes.inset),
|
.iter()
|
||||||
);
|
.map(|label| f32::from(text_width(label, size, window)))
|
||||||
|
.fold(0.0, f32::max);
|
||||||
|
(widest + pixels(PAD))
|
||||||
|
.max(pixels(sizes.gutter))
|
||||||
|
.min(box_size.0 / 2.0)
|
||||||
|
} else {
|
||||||
|
pixels(sizes.gutter)
|
||||||
|
};
|
||||||
|
let rect = frame(box_size, gutter, pixels(sizes.foot), pixels(sizes.inset));
|
||||||
let scene = Scene {
|
let scene = Scene {
|
||||||
frame: rect,
|
frame: rect,
|
||||||
values: &values,
|
values: &values,
|
||||||
|
|||||||
@@ -115,7 +115,11 @@ impl RenderOnce for RealtimeChart {
|
|||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.text_size(theme.text_size(TextSize::Xs))
|
.text_size(theme.text_size(TextSize::Xs))
|
||||||
.text_color(colors.fg_subtle);
|
.text_color(colors.fg_subtle);
|
||||||
beside(scale.at(*tick), sizes.gutter, label.child(compact(*tick)))
|
beside(
|
||||||
|
scale.at(*tick),
|
||||||
|
sizes.gutter.to_pixels(rem),
|
||||||
|
label.child(compact(*tick)),
|
||||||
|
)
|
||||||
});
|
});
|
||||||
let (ink, levels) = (
|
let (ink, levels) = (
|
||||||
tint(&colors, 0),
|
tint(&colors, 0),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
use jiff::civil::date;
|
use jiff::civil::date;
|
||||||
|
|
||||||
|
use super::axes::PAD;
|
||||||
use super::{
|
use super::{
|
||||||
AreaChart, BarChart, BoxPlot, Bullet, BulletChart, CalendarHeatmap, ChartLegend, ChordDiagram,
|
AreaChart, BarChart, BoxPlot, Bullet, BulletChart, CalendarHeatmap, ChartLegend, ChordDiagram,
|
||||||
FunnelChart, GanttChart, HeatmapChart, Histogram, LineChart, NetworkGraph, ParallelCoordinates,
|
FunnelChart, GanttChart, HeatmapChart, Histogram, LineChart, NetworkGraph, ParallelCoordinates,
|
||||||
@@ -330,3 +331,45 @@ fn every_chart_fills_a_column_its_block_measures_by_content(cx: &mut TestAppCont
|
|||||||
"each chart spans the card inside its padding"
|
"each chart spans the card inside its padding"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A sideways bar chart `wide` across with one band named `name`.
|
||||||
|
struct Sideways(String, f32);
|
||||||
|
|
||||||
|
impl Render for Sideways {
|
||||||
|
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().w(px(self.1)).child(
|
||||||
|
BarChart::new("sideways", [self.0.clone()])
|
||||||
|
.series(Series::new("s", [3.0]))
|
||||||
|
.horizontal(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_long_band_name_widens_the_gutter_to_half_the_chart(cx: &mut TestAppContext) {
|
||||||
|
cx.update(Theme::init);
|
||||||
|
let long = "x".repeat(200);
|
||||||
|
for (name, wide) in [
|
||||||
|
("claude-sonnet-4-5-20250929", 400.0),
|
||||||
|
(&long, 400.0),
|
||||||
|
(&long, 80.0),
|
||||||
|
] {
|
||||||
|
let (_, cx) = cx.add_window_view(|_, _| Sideways(name.to_string(), wide));
|
||||||
|
settle(cx);
|
||||||
|
let root = cx.debug_bounds("chart-root").expect("the chart draws");
|
||||||
|
let band = cx.debug_bounds("chart-band").expect("the band is named");
|
||||||
|
let half = root.left() + px(wide / 2.0);
|
||||||
|
let pad = cx.update(|window, _| PAD.to_pixels(window.rem_size()));
|
||||||
|
assert!(
|
||||||
|
band.left() >= root.left(),
|
||||||
|
"{wide}: the name's box starts in the chart"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
band.right() + pad <= half,
|
||||||
|
"{wide}: the gutter takes half at most"
|
||||||
|
);
|
||||||
|
if wide > 200.0 {
|
||||||
|
assert!(band.size.width > px(48.0), "past the theme gutter");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -371,7 +371,7 @@ impl RenderOnce for HeatmapChart {
|
|||||||
let row_labels = self.rows.iter().enumerate().map(|(ix, (label, _))| {
|
let row_labels = self.rows.iter().enumerate().map(|(ix, (label, _))| {
|
||||||
beside(
|
beside(
|
||||||
rect.y + cell_h * (ix as f32 + 0.5),
|
rect.y + cell_h * (ix as f32 + 0.5),
|
||||||
sizes.gutter,
|
sizes.gutter.to_pixels(rem),
|
||||||
text(label.clone()),
|
text(label.clone()),
|
||||||
)
|
)
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-3
@@ -1,6 +1,6 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement,
|
||||||
RenderOnce, Styled, Window, div, prelude::*,
|
RenderOnce, SharedString, Styled, Window, div, prelude::*,
|
||||||
};
|
};
|
||||||
|
|
||||||
use super::TextInput;
|
use super::TextInput;
|
||||||
@@ -19,6 +19,7 @@ pub struct Input {
|
|||||||
suffix: Option<AnyElement>,
|
suffix: Option<AnyElement>,
|
||||||
clearable: bool,
|
clearable: bool,
|
||||||
invalid: bool,
|
invalid: bool,
|
||||||
|
least: Option<SharedString>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Input {
|
impl Input {
|
||||||
@@ -30,6 +31,7 @@ impl Input {
|
|||||||
suffix: None,
|
suffix: None,
|
||||||
clearable: false,
|
clearable: false,
|
||||||
invalid: false,
|
invalid: false,
|
||||||
|
least: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,12 @@ impl Input {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holds room to show `text` whole.
|
||||||
|
pub fn least(mut self, text: impl Into<SharedString>) -> Self {
|
||||||
|
self.least = Some(text.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Adds a clear button while there is text.
|
/// Adds a clear button while there is text.
|
||||||
pub fn clearable(mut self) -> Self {
|
pub fn clearable(mut self) -> Self {
|
||||||
self.clearable = true;
|
self.clearable = true;
|
||||||
@@ -125,8 +133,16 @@ impl RenderOnce for Input {
|
|||||||
.flex_1()
|
.flex_1()
|
||||||
.min_w_0()
|
.min_w_0()
|
||||||
.child(self.state)
|
.child(self.state)
|
||||||
// An empty strut keeps the frame's inset to type in.
|
// A strut keeps the frame's inset to type in.
|
||||||
.child(div().px(theme.control_padding(self.size)).border_x_1()),
|
.child(
|
||||||
|
div()
|
||||||
|
.px(theme.control_padding(self.size))
|
||||||
|
.border_x_1()
|
||||||
|
.h_0()
|
||||||
|
.overflow_hidden()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.children(self.least),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.when(self.clearable && !empty && !disabled, |frame| {
|
.when(self.clearable && !empty && !disabled, |frame| {
|
||||||
frame.child(
|
frame.child(
|
||||||
|
|||||||
+26
-9
@@ -67,6 +67,9 @@ struct Numeric {
|
|||||||
limits: (f64, f64, f64, usize),
|
limits: (f64, f64, f64, usize),
|
||||||
/// Shows no number; an empty field commits nothing.
|
/// Shows no number; an empty field commits nothing.
|
||||||
blank: bool,
|
blank: bool,
|
||||||
|
/// The owner's value as shown; a new one replaces a draft once focus leaves.
|
||||||
|
owner: Option<String>,
|
||||||
|
pending: bool,
|
||||||
_events: Subscription,
|
_events: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +165,8 @@ fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity<Numeric>
|
|||||||
value: 0.0,
|
value: 0.0,
|
||||||
limits: (f64::MIN, f64::MAX, 1.0, 0),
|
limits: (f64::MIN, f64::MAX, 1.0, 0),
|
||||||
blank: false,
|
blank: false,
|
||||||
|
owner: None,
|
||||||
|
pending: false,
|
||||||
_events: events,
|
_events: events,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -331,14 +336,27 @@ impl RenderOnce for NumberInput {
|
|||||||
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||||
let state = numeric(&self.id, window, cx);
|
let state = numeric(&self.id, window, cx);
|
||||||
let (value, min, max, step) = (self.value, self.min, self.max, self.step);
|
let (value, min, max, step) = (self.value, self.min, self.max, self.step);
|
||||||
state.update(cx, |numeric, _| {
|
let shown = match self.blank {
|
||||||
|
Some(_) => String::new(),
|
||||||
|
None => format::number(value, self.precision, Separators::EN),
|
||||||
|
};
|
||||||
|
let input = state.read(cx).input.clone();
|
||||||
|
let editing = input.read(cx).focus().is_focused(window);
|
||||||
|
let echo = input.read(cx).text() == shown;
|
||||||
|
let reseed = state.update(cx, |numeric, _| {
|
||||||
numeric.on_change = self.on_change.clone();
|
numeric.on_change = self.on_change.clone();
|
||||||
numeric.on_commit = self.on_commit.clone();
|
numeric.on_commit = self.on_commit.clone();
|
||||||
numeric.value = value;
|
|
||||||
numeric.limits = (min, max, step, self.precision);
|
numeric.limits = (min, max, step, self.precision);
|
||||||
numeric.blank = self.blank.is_some();
|
numeric.blank = self.blank.is_some();
|
||||||
|
if numeric.owner.as_ref() != Some(&shown) {
|
||||||
|
numeric.owner = Some(shown.clone());
|
||||||
|
numeric.value = value;
|
||||||
|
numeric.pending = !echo;
|
||||||
|
}
|
||||||
|
let reseed = numeric.pending && !editing;
|
||||||
|
numeric.pending &= editing;
|
||||||
|
reseed
|
||||||
});
|
});
|
||||||
let input = state.read(cx).input.clone();
|
|
||||||
if input.read(cx).label_text() != &self.label {
|
if input.read(cx).label_text() != &self.label {
|
||||||
let label = self.label.clone();
|
let label = self.label.clone();
|
||||||
input.update(cx, |input, cx| input.set_label(label, cx));
|
input.update(cx, |input, cx| input.set_label(label, cx));
|
||||||
@@ -347,12 +365,7 @@ impl RenderOnce for NumberInput {
|
|||||||
if input.read(cx).placeholder_text() != &placeholder {
|
if input.read(cx).placeholder_text() != &placeholder {
|
||||||
input.update(cx, |input, cx| input.set_placeholder(placeholder, cx));
|
input.update(cx, |input, cx| input.set_placeholder(placeholder, cx));
|
||||||
}
|
}
|
||||||
let shown = match self.blank {
|
if reseed {
|
||||||
Some(_) => String::new(),
|
|
||||||
None => format::number(value, self.precision, Separators::EN),
|
|
||||||
};
|
|
||||||
let editing = input.read(cx).focus().is_focused(window);
|
|
||||||
if !editing && input.read(cx).text() != shown {
|
|
||||||
input.update(cx, |input, cx| input.set_text(shown, cx));
|
input.update(cx, |input, cx| input.set_text(shown, cx));
|
||||||
}
|
}
|
||||||
let now = state.read(cx).current(cx);
|
let now = state.read(cx).current(cx);
|
||||||
@@ -430,8 +443,12 @@ impl RenderOnce for NumberInput {
|
|||||||
now > min,
|
now > min,
|
||||||
Box::new(down),
|
Box::new(down),
|
||||||
));
|
));
|
||||||
|
let ends = [min, max].map(|end| format::number(end, self.precision, Separators::EN));
|
||||||
|
let widest = ends.into_iter().max_by_key(|end| end.chars().count());
|
||||||
|
let ranged = min > f64::MIN && max < f64::MAX;
|
||||||
let field = Input::new(&input)
|
let field = Input::new(&input)
|
||||||
.size(self.size)
|
.size(self.size)
|
||||||
|
.when_some(widest.filter(|_| ranged), Input::least)
|
||||||
.prefix(
|
.prefix(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
use super::{parse, settle};
|
use gpui::{Context, Entity, IntoElement, Render, TestAppContext, VisualTestContext, Window};
|
||||||
|
|
||||||
|
use super::{NumberInput, parse, settle};
|
||||||
|
use crate::{forms::bind_keys, theme::Theme};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_grouped_and_typographic_minus_and_refuses_junk() {
|
fn parses_grouped_and_typographic_minus_and_refuses_junk() {
|
||||||
@@ -29,3 +32,92 @@ fn settle_rounds_before_it_clamps() {
|
|||||||
fn settle_refuses_a_range_without_a_number_at_its_precision() {
|
fn settle_refuses_a_range_without_a_number_at_its_precision() {
|
||||||
settle(0.25, 0.21, 0.29, 1);
|
settle(0.25, 0.21, 0.29, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A field whose owner keeps each commit and shows `precision` places.
|
||||||
|
struct Owned {
|
||||||
|
value: f64,
|
||||||
|
precision: usize,
|
||||||
|
commits: Vec<f64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Owned {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
NumberInput::new("owned", self.value)
|
||||||
|
.precision(self.precision)
|
||||||
|
.on_commit(move |value, _, cx| {
|
||||||
|
view.update(cx, |owned, cx| {
|
||||||
|
owned.commits.push(value);
|
||||||
|
owned.value = value;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn owned(
|
||||||
|
value: f64,
|
||||||
|
precision: usize,
|
||||||
|
cx: &mut TestAppContext,
|
||||||
|
) -> (Entity<Owned>, &mut VisualTestContext) {
|
||||||
|
cx.update(|cx| {
|
||||||
|
Theme::init(cx);
|
||||||
|
bind_keys(cx);
|
||||||
|
});
|
||||||
|
let (view, cx) = cx.add_window_view(move |_, _| Owned {
|
||||||
|
value,
|
||||||
|
precision,
|
||||||
|
commits: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.update(|window, _| window.activate_window());
|
||||||
|
cx.run_until_parked();
|
||||||
|
(view, cx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn commits(view: &Entity<Owned>, cx: &mut VisualTestContext) -> Vec<f64> {
|
||||||
|
view.read_with(cx, |owned, _| owned.commits.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_value_the_owner_sets_while_focused_shows_once_focus_leaves(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = owned(4.0, 0, cx);
|
||||||
|
cx.update(|window, cx| window.focus_next(cx));
|
||||||
|
view.update(cx, |owned, cx| {
|
||||||
|
owned.value = 42.0;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, cx| window.blur(cx));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(commits(&view, cx), [42.0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn new_places_reformat_the_same_value(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = owned(1.25, 0, cx);
|
||||||
|
view.update(cx, |owned, cx| {
|
||||||
|
owned.precision = 2;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
cx.update(|window, cx| window.focus_next(cx));
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
assert_eq!(commits(&view, cx), [1.25], "the field shows 1.25, not 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_owner_echo_of_a_commit_keeps_the_next_draft(cx: &mut TestAppContext) {
|
||||||
|
let (view, cx) = owned(4.0, 0, cx);
|
||||||
|
cx.update(|window, cx| window.focus_next(cx));
|
||||||
|
for typed in ["12", "18"] {
|
||||||
|
cx.simulate_keystrokes("secondary-a");
|
||||||
|
cx.simulate_input(typed);
|
||||||
|
if typed == "12" {
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.update(|window, cx| window.blur(cx));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(commits(&view, cx), [12.0, 18.0]);
|
||||||
|
}
|
||||||
@@ -281,6 +281,21 @@ fn a_blank_field_commits_only_what_is_typed(cx: &mut TestAppContext) {
|
|||||||
assert_eq!(view.read_with(cx, |view, _| view.values.clone()), [0.0]);
|
assert_eq!(view.read_with(cx, |view, _| view.values.clone()), [0.0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn a_blur_commits_the_typed_number(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Committed { values: Vec::new() });
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.activate_window();
|
||||||
|
window.focus_next(cx);
|
||||||
|
});
|
||||||
|
cx.simulate_keystrokes("secondary-a");
|
||||||
|
cx.simulate_input("12");
|
||||||
|
cx.update(|window, cx| window.blur(cx));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(view.read_with(cx, |view, _| view.values.clone()), [12.0]);
|
||||||
|
}
|
||||||
|
|
||||||
struct Pin {
|
struct Pin {
|
||||||
code: String,
|
code: String,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ const EN: &[(&str, &str)] = &[
|
|||||||
("time.month.other", "{n} months"),
|
("time.month.other", "{n} months"),
|
||||||
("time.year.one", "{n} year"),
|
("time.year.one", "{n} year"),
|
||||||
("time.year.other", "{n} years"),
|
("time.year.other", "{n} years"),
|
||||||
|
("pagination.previous", "Previous page"),
|
||||||
|
("pagination.next", "Next page"),
|
||||||
|
("pagination.jump", "Go to"),
|
||||||
];
|
];
|
||||||
|
|
||||||
const ZH_CN: &[(&str, &str)] = &[
|
const ZH_CN: &[(&str, &str)] = &[
|
||||||
@@ -122,6 +125,9 @@ const ZH_CN: &[(&str, &str)] = &[
|
|||||||
("time.month.other", "{n} 个月"),
|
("time.month.other", "{n} 个月"),
|
||||||
("time.year.one", "{n} 年"),
|
("time.year.one", "{n} 年"),
|
||||||
("time.year.other", "{n} 年"),
|
("time.year.other", "{n} 年"),
|
||||||
|
("pagination.previous", "上一页"),
|
||||||
|
("pagination.next", "下一页"),
|
||||||
|
("pagination.jump", "跳至"),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key.
|
/// Ely's own message for `key` in `locale`, English for a locale Ely does not ship; fails on an unknown key.
|
||||||
|
|||||||
+71
-18
@@ -7,7 +7,8 @@ use gpui::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
buttons::{Button, ButtonVariant, IconButton},
|
buttons::{Button, ButtonVariant, IconButton},
|
||||||
forms::Run,
|
forms::{NumberInput, Run},
|
||||||
|
i18n,
|
||||||
primitives::IconName,
|
primitives::IconName,
|
||||||
theme::{ActiveTheme, ControlSize, TextSize},
|
theme::{ActiveTheme, ControlSize, TextSize},
|
||||||
typography::format::{self, Separators},
|
typography::format::{self, Separators},
|
||||||
@@ -49,6 +50,8 @@ pub struct Pagination {
|
|||||||
page: usize,
|
page: usize,
|
||||||
pages: usize,
|
pages: usize,
|
||||||
near: usize,
|
near: usize,
|
||||||
|
size: ControlSize,
|
||||||
|
jump: bool,
|
||||||
on_change: Option<OnPage>,
|
on_change: Option<OnPage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,10 +63,23 @@ impl Pagination {
|
|||||||
page,
|
page,
|
||||||
pages,
|
pages,
|
||||||
near: 1,
|
near: 1,
|
||||||
|
size: ControlSize::Sm,
|
||||||
|
jump: false,
|
||||||
on_change: None,
|
on_change: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn size(mut self, size: ControlSize) -> Self {
|
||||||
|
self.size = size;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A field after Next that goes to the page typed.
|
||||||
|
pub fn jump(mut self) -> Self {
|
||||||
|
self.jump = true;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Pages shown on each side of the current one.
|
/// Pages shown on each side of the current one.
|
||||||
pub fn near(mut self, near: usize) -> Self {
|
pub fn near(mut self, near: usize) -> Self {
|
||||||
self.near = near;
|
self.near = near;
|
||||||
@@ -89,16 +105,43 @@ impl RenderOnce for Pagination {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
let subtle = cx.theme().colors.fg_subtle;
|
let theme = cx.theme();
|
||||||
let (back, ahead) = (go.clone(), go.clone());
|
let (size, subtle, small) = (
|
||||||
|
self.size,
|
||||||
|
theme.colors.fg_subtle,
|
||||||
|
theme.text_size(TextSize::Sm),
|
||||||
|
);
|
||||||
|
let (back, ahead, jump) = (go.clone(), go.clone(), go.clone());
|
||||||
|
let to = i18n::text(cx, "pagination.jump", &[]);
|
||||||
|
let jump = self.jump.then(|| {
|
||||||
|
let words = div()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.text_size(small)
|
||||||
|
.text_color(subtle)
|
||||||
|
.child(to.clone());
|
||||||
|
div().flex().items_center().gap_2().child(words).child(
|
||||||
|
div().flex_none().child(
|
||||||
|
NumberInput::new((self.id.clone(), "jump"), page as f64)
|
||||||
|
.label(to)
|
||||||
|
.range(1.0, pages as f64)
|
||||||
|
.precision(0)
|
||||||
|
.size(size)
|
||||||
|
.on_commit(move |to, window, cx| {
|
||||||
|
if to as usize != page {
|
||||||
|
jump(to as usize, window, cx);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
});
|
||||||
let cells = list
|
let cells = list
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(move |(ix, entry)| match entry {
|
.map(move |(ix, entry)| match entry {
|
||||||
Some(number) => {
|
Some(number) => {
|
||||||
let go = go.clone();
|
let go = go.clone();
|
||||||
Button::new(("page", number), number.to_string())
|
let button = Button::new(("page", number), number.to_string())
|
||||||
.size(ControlSize::Sm)
|
.size(size)
|
||||||
.variant(if number == page {
|
.variant(if number == page {
|
||||||
ButtonVariant::Secondary
|
ButtonVariant::Secondary
|
||||||
} else {
|
} else {
|
||||||
@@ -108,7 +151,10 @@ impl RenderOnce for Pagination {
|
|||||||
if number != page {
|
if number != page {
|
||||||
go(number, window, cx);
|
go(number, window, cx);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
div()
|
||||||
|
.debug_selector(move || format!("pagination-page-{number}"))
|
||||||
|
.child(button)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
None => div()
|
None => div()
|
||||||
@@ -120,26 +166,33 @@ impl RenderOnce for Pagination {
|
|||||||
});
|
});
|
||||||
div()
|
div()
|
||||||
.id(self.id)
|
.id(self.id)
|
||||||
|
.debug_selector(|| "pagination-root".into())
|
||||||
.flex()
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
.items_center()
|
.items_center()
|
||||||
.gap_1()
|
.gap_1()
|
||||||
.child(
|
.child(
|
||||||
IconButton::new("previous", IconName::ChevronLeft)
|
div().debug_selector(|| "pagination-previous".into()).child(
|
||||||
.size(ControlSize::Sm)
|
IconButton::new("previous", IconName::ChevronLeft)
|
||||||
.variant(ButtonVariant::Ghost)
|
.size(size)
|
||||||
.tooltip("Previous page")
|
.variant(ButtonVariant::Ghost)
|
||||||
.disabled(page == 1)
|
.tooltip(i18n::text(cx, "pagination.previous", &[]))
|
||||||
.on_click(move |_, window, cx| back(page - 1, window, cx)),
|
.disabled(page == 1)
|
||||||
|
.on_click(move |_, window, cx| back(page - 1, window, cx)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.children(cells)
|
.children(cells)
|
||||||
.child(
|
.child(
|
||||||
IconButton::new("next", IconName::ChevronRight)
|
div().debug_selector(|| "pagination-next".into()).child(
|
||||||
.size(ControlSize::Sm)
|
IconButton::new("next", IconName::ChevronRight)
|
||||||
.variant(ButtonVariant::Ghost)
|
.size(size)
|
||||||
.tooltip("Next page")
|
.variant(ButtonVariant::Ghost)
|
||||||
.disabled(page == pages)
|
.tooltip(i18n::text(cx, "pagination.next", &[]))
|
||||||
.on_click(move |_, window, cx| ahead(page + 1, window, cx)),
|
.disabled(page == pages)
|
||||||
|
.on_click(move |_, window, cx| ahead(page + 1, window, cx)),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
.children(jump)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod pages;
|
||||||
mod tabs;
|
mod tabs;
|
||||||
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
use gpui::{
|
||||||
|
Context, IntoElement, ParentElement, Render, Styled, TestAppContext, Window, div,
|
||||||
|
prelude::FluentBuilder,
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::setup;
|
||||||
|
use crate::{
|
||||||
|
navigation::Pagination,
|
||||||
|
theme::{ActiveTheme, ControlSize, TextSize},
|
||||||
|
typography::text_width,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct Paged {
|
||||||
|
size: ControlSize,
|
||||||
|
page: usize,
|
||||||
|
jump: bool,
|
||||||
|
picked: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Paged {
|
||||||
|
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let view = cx.entity();
|
||||||
|
let text = cx.theme().text_size(TextSize::Base);
|
||||||
|
div().text_size(text).child(
|
||||||
|
Pagination::new("pages", self.page, 100)
|
||||||
|
.size(self.size)
|
||||||
|
.when(self.jump, Pagination::jump)
|
||||||
|
.on_change(move |to, _, cx| {
|
||||||
|
view.update(cx, |paged, cx| {
|
||||||
|
paged.picked.push(to);
|
||||||
|
paged.page = to;
|
||||||
|
cx.notify();
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_jump_field_goes_to_the_page_typed_within_range(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let (view, cx) = cx.add_window_view(|_, _| Paged {
|
||||||
|
size: ControlSize::Sm,
|
||||||
|
page: 3,
|
||||||
|
jump: true,
|
||||||
|
picked: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.update(|window, cx| {
|
||||||
|
window.activate_window();
|
||||||
|
window.focus_prev(cx);
|
||||||
|
});
|
||||||
|
for typed in ["78", "500"] {
|
||||||
|
cx.simulate_keystrokes("secondary-a");
|
||||||
|
cx.simulate_input(typed);
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
cx.run_until_parked();
|
||||||
|
}
|
||||||
|
cx.simulate_keystrokes("secondary-a");
|
||||||
|
cx.simulate_input("88");
|
||||||
|
cx.update(|window, cx| window.blur(cx));
|
||||||
|
cx.run_until_parked();
|
||||||
|
assert_eq!(
|
||||||
|
view.read_with(cx, |paged, _| paged.picked.clone()),
|
||||||
|
[78, 100, 88]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn every_control_takes_the_size_asked(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
for size in [ControlSize::Sm, ControlSize::Md, ControlSize::Lg] {
|
||||||
|
let (_, cx) = cx.add_window_view(move |_, _| Paged {
|
||||||
|
size,
|
||||||
|
page: 3,
|
||||||
|
jump: false,
|
||||||
|
picked: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let tall =
|
||||||
|
cx.update(|window, cx| cx.theme().control_height(size).to_pixels(window.rem_size()));
|
||||||
|
for control in [
|
||||||
|
"pagination-previous",
|
||||||
|
"pagination-page-3",
|
||||||
|
"pagination-page-4",
|
||||||
|
"pagination-next",
|
||||||
|
] {
|
||||||
|
let bounds = cx.debug_bounds(control).expect("drawn");
|
||||||
|
assert_eq!(bounds.size.height, tall, "{control} at {size:?}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
fn the_jump_field_holds_the_last_page_at_every_size(cx: &mut TestAppContext) {
|
||||||
|
setup(cx);
|
||||||
|
let sizes = [
|
||||||
|
(ControlSize::Sm, TextSize::Sm),
|
||||||
|
(ControlSize::Md, TextSize::Base),
|
||||||
|
(ControlSize::Lg, TextSize::Md),
|
||||||
|
];
|
||||||
|
for (size, text) in sizes {
|
||||||
|
let (_, cx) = cx.add_window_view(move |_, _| Paged {
|
||||||
|
size,
|
||||||
|
page: 100,
|
||||||
|
jump: true,
|
||||||
|
picked: Vec::new(),
|
||||||
|
});
|
||||||
|
cx.run_until_parked();
|
||||||
|
let slot = cx.debug_bounds("input-text").expect("the field draws");
|
||||||
|
let digits = cx.update(|window, cx| {
|
||||||
|
let text = cx.theme().text_size(text);
|
||||||
|
text_width("100", text.to_pixels(window.rem_size()), window)
|
||||||
|
});
|
||||||
|
assert!(
|
||||||
|
slot.size.width >= digits,
|
||||||
|
"{size:?}: {slot:?} under {digits:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user