diff --git a/AGENTS.md b/AGENTS.md index 668ff62..2b2a9bb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). - 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. -- 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. - 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. diff --git a/examples/gallery/pages/navigation/paths.rs b/examples/gallery/pages/navigation/paths.rs index d6ff983..07500d1 100644 --- a/examples/gallery/pages/navigation/paths.rs +++ b/examples/gallery/pages/navigation/paths.rs @@ -58,14 +58,16 @@ pub fn pagination(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> let now = *page.read(cx); section( "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, ) .child(specimen( format!("page {now} of 20"), probe( "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, )) diff --git a/src/charts/axes.rs b/src/charts/axes.rs index 8cdc295..39b4851 100644 --- a/src/charts/axes.rs +++ b/src/charts/axes.rs @@ -10,9 +10,12 @@ use super::{ }; use crate::{ 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. pub(crate) fn below(x: f32, frame: Rect, label: 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. -pub(crate) fn beside(y: f32, gutter: Rems, label: Div) -> Div { +pub(crate) fn beside(y: f32, gutter: Pixels, label: Div) -> Div { div() .absolute() .left_0() @@ -37,7 +40,7 @@ pub(crate) fn beside(y: f32, gutter: Rems, label: Div) -> Div { .flex() .items_center() .justify_end() - .pr_2() + .pr(PAD) .child(label) } @@ -93,7 +96,7 @@ pub(crate) fn marks( if plot.horizontal { below(*at, rect, label) } else { - beside(*at, sizes.gutter, label) + beside(*at, Pixels::from(rect.x), label) } }) .collect(); @@ -116,7 +119,14 @@ pub(crate) fn marks( .filter(|(ix, _)| ix % every == 0) .map(|(_, (at, label))| { 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 { below(*at, rect, text(label.clone())) } diff --git a/src/charts/burndown.rs b/src/charts/burndown.rs index 804f0e1..79da911 100644 --- a/src/charts/burndown.rs +++ b/src/charts/burndown.rs @@ -135,7 +135,7 @@ impl RenderOnce for BurndownChart { let levels: Vec
= burn .ticks .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(); let dates: Vec
= marks(self.first, count as i32) .into_iter() diff --git a/src/charts/calendar.rs b/src/charts/calendar.rs index 1b8dd16..d783cda 100644 --- a/src/charts/calendar.rs +++ b/src/charts/calendar.rs @@ -125,7 +125,7 @@ impl RenderOnce for CalendarHeatmap { .expect("seven days hold every weekday"); beside( top + side * (weekday as f32 + 0.5), - sizes.gutter, + sizes.gutter.to_pixels(rem), text(date.strftime("%a").to_string()), ) }); diff --git a/src/charts/plot.rs b/src/charts/plot.rs index 8307f21..afd91ff 100644 --- a/src/charts/plot.rs +++ b/src/charts/plot.rs @@ -6,7 +6,7 @@ use gpui::{ }; use super::{ - axes::{anchored, marks}, + axes::{PAD, anchored, marks}, geometry::{Geometry, Ink, Rect, frame}, glide::Glide, paint::{Pen, measure, tint}, @@ -17,7 +17,8 @@ use super::{ use crate::{ buttons::{Button, ButtonVariant}, motion, - theme::{ActiveTheme, ControlSize, Radius}, + theme::{ActiveTheme, ControlSize, Radius, TextSize}, + typography::text_width, }; pub(crate) type Format = Rc String>; @@ -127,12 +128,20 @@ pub(crate) fn plot(plot: Plot, base: Div, window: &mut Window, cx: &mut App) -> .filter(|(_, to)| *to < count) .unwrap_or((0, count.saturating_sub(1))); let box_size = (f32::from(bounds.size.width), f32::from(bounds.size.height)); - let rect = frame( - box_size, - pixels(sizes.gutter), - pixels(sizes.foot), - pixels(sizes.inset), - ); + let gutter = if plot.horizontal { + let size = theme.text_size(TextSize::Xs).to_pixels(rem); + let widest = plot + .labels + .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 { frame: rect, values: &values, diff --git a/src/charts/realtime.rs b/src/charts/realtime.rs index 83dc6e5..3ad4103 100644 --- a/src/charts/realtime.rs +++ b/src/charts/realtime.rs @@ -115,7 +115,11 @@ impl RenderOnce for RealtimeChart { .whitespace_nowrap() .text_size(theme.text_size(TextSize::Xs)) .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) = ( tint(&colors, 0), diff --git a/src/charts/tests.rs b/src/charts/tests.rs index 601a195..88494ef 100644 --- a/src/charts/tests.rs +++ b/src/charts/tests.rs @@ -9,6 +9,7 @@ use gpui::{ }; use jiff::civil::date; +use super::axes::PAD; use super::{ AreaChart, BarChart, BoxPlot, Bullet, BulletChart, CalendarHeatmap, ChartLegend, ChordDiagram, 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" ); } + +/// 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) -> 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"); + } + } +} diff --git a/src/charts/tiles.rs b/src/charts/tiles.rs index 5ece70e..8a92369 100644 --- a/src/charts/tiles.rs +++ b/src/charts/tiles.rs @@ -371,7 +371,7 @@ impl RenderOnce for HeatmapChart { let row_labels = self.rows.iter().enumerate().map(|(ix, (label, _))| { beside( rect.y + cell_h * (ix as f32 + 0.5), - sizes.gutter, + sizes.gutter.to_pixels(rem), text(label.clone()), ) }); diff --git a/src/forms/input.rs b/src/forms/input.rs index 1fc1ee8..974bee8 100644 --- a/src/forms/input.rs +++ b/src/forms/input.rs @@ -1,6 +1,6 @@ use gpui::{ AnyElement, App, Entity, InteractiveElement, IntoElement, MouseButton, ParentElement, - RenderOnce, Styled, Window, div, prelude::*, + RenderOnce, SharedString, Styled, Window, div, prelude::*, }; use super::TextInput; @@ -19,6 +19,7 @@ pub struct Input { suffix: Option, clearable: bool, invalid: bool, + least: Option, } impl Input { @@ -30,6 +31,7 @@ impl Input { suffix: None, clearable: false, invalid: false, + least: None, } } @@ -48,6 +50,12 @@ impl Input { self } + /// Holds room to show `text` whole. + pub fn least(mut self, text: impl Into) -> Self { + self.least = Some(text.into()); + self + } + /// Adds a clear button while there is text. pub fn clearable(mut self) -> Self { self.clearable = true; @@ -125,8 +133,16 @@ impl RenderOnce for Input { .flex_1() .min_w_0() .child(self.state) - // An empty strut keeps the frame's inset to type in. - .child(div().px(theme.control_padding(self.size)).border_x_1()), + // A strut keeps the frame's inset to type in. + .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| { frame.child( diff --git a/src/forms/number.rs b/src/forms/number.rs index 27d0f43..dd9479d 100644 --- a/src/forms/number.rs +++ b/src/forms/number.rs @@ -67,6 +67,9 @@ struct Numeric { limits: (f64, f64, f64, usize), /// Shows no number; an empty field commits nothing. blank: bool, + /// The owner's value as shown; a new one replaces a draft once focus leaves. + owner: Option, + pending: bool, _events: Subscription, } @@ -162,6 +165,8 @@ fn numeric(id: &ElementId, window: &mut Window, cx: &mut App) -> Entity value: 0.0, limits: (f64::MIN, f64::MAX, 1.0, 0), blank: false, + owner: None, + pending: false, _events: events, } }) @@ -331,14 +336,27 @@ impl RenderOnce for NumberInput { fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement { let state = numeric(&self.id, window, cx); 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_commit = self.on_commit.clone(); - numeric.value = value; numeric.limits = (min, max, step, self.precision); 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 { let label = self.label.clone(); 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 { input.update(cx, |input, cx| input.set_placeholder(placeholder, cx)); } - let shown = match self.blank { - 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 { + if reseed { input.update(cx, |input, cx| input.set_text(shown, cx)); } let now = state.read(cx).current(cx); @@ -430,8 +443,12 @@ impl RenderOnce for NumberInput { now > min, 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) .size(self.size) + .when_some(widest.filter(|_| ranged), Input::least) .prefix( div() .flex() diff --git a/src/forms/number/tests.rs b/src/forms/number/tests.rs index 3df8de0..20be77e 100644 --- a/src/forms/number/tests.rs +++ b/src/forms/number/tests.rs @@ -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] 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() { 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, +} + +impl Render for Owned { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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, &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, cx: &mut VisualTestContext) -> Vec { + 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]); +} diff --git a/src/forms/tests/mod.rs b/src/forms/tests/mod.rs index e4cf873..e84aac0 100644 --- a/src/forms/tests/mod.rs +++ b/src/forms/tests/mod.rs @@ -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]); } +#[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 { code: String, } diff --git a/src/i18n/builtin.rs b/src/i18n/builtin.rs index 7ea1666..8764393 100644 --- a/src/i18n/builtin.rs +++ b/src/i18n/builtin.rs @@ -62,6 +62,9 @@ const EN: &[(&str, &str)] = &[ ("time.month.other", "{n} months"), ("time.year.one", "{n} year"), ("time.year.other", "{n} years"), + ("pagination.previous", "Previous page"), + ("pagination.next", "Next page"), + ("pagination.jump", "Go to"), ]; const ZH_CN: &[(&str, &str)] = &[ @@ -122,6 +125,9 @@ const ZH_CN: &[(&str, &str)] = &[ ("time.month.other", "{n} 个月"), ("time.year.one", "{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. diff --git a/src/navigation/pages.rs b/src/navigation/pages.rs index 9868f7e..5983d6f 100644 --- a/src/navigation/pages.rs +++ b/src/navigation/pages.rs @@ -7,7 +7,8 @@ use gpui::{ use crate::{ buttons::{Button, ButtonVariant, IconButton}, - forms::Run, + forms::{NumberInput, Run}, + i18n, primitives::IconName, theme::{ActiveTheme, ControlSize, TextSize}, typography::format::{self, Separators}, @@ -49,6 +50,8 @@ pub struct Pagination { page: usize, pages: usize, near: usize, + size: ControlSize, + jump: bool, on_change: Option, } @@ -60,10 +63,23 @@ impl Pagination { page, pages, near: 1, + size: ControlSize::Sm, + jump: false, 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. pub fn near(mut self, near: usize) -> Self { self.near = near; @@ -89,16 +105,43 @@ impl RenderOnce for Pagination { } }) }; - let subtle = cx.theme().colors.fg_subtle; - let (back, ahead) = (go.clone(), go.clone()); + let theme = cx.theme(); + 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 .into_iter() .enumerate() .map(move |(ix, entry)| match entry { Some(number) => { let go = go.clone(); - Button::new(("page", number), number.to_string()) - .size(ControlSize::Sm) + let button = Button::new(("page", number), number.to_string()) + .size(size) .variant(if number == page { ButtonVariant::Secondary } else { @@ -108,7 +151,10 @@ impl RenderOnce for Pagination { if number != page { go(number, window, cx); } - }) + }); + div() + .debug_selector(move || format!("pagination-page-{number}")) + .child(button) .into_any_element() } None => div() @@ -120,26 +166,33 @@ impl RenderOnce for Pagination { }); div() .id(self.id) + .debug_selector(|| "pagination-root".into()) .flex() + .flex_wrap() .items_center() .gap_1() .child( - IconButton::new("previous", IconName::ChevronLeft) - .size(ControlSize::Sm) - .variant(ButtonVariant::Ghost) - .tooltip("Previous page") - .disabled(page == 1) - .on_click(move |_, window, cx| back(page - 1, window, cx)), + div().debug_selector(|| "pagination-previous".into()).child( + IconButton::new("previous", IconName::ChevronLeft) + .size(size) + .variant(ButtonVariant::Ghost) + .tooltip(i18n::text(cx, "pagination.previous", &[])) + .disabled(page == 1) + .on_click(move |_, window, cx| back(page - 1, window, cx)), + ), ) .children(cells) .child( - IconButton::new("next", IconName::ChevronRight) - .size(ControlSize::Sm) - .variant(ButtonVariant::Ghost) - .tooltip("Next page") - .disabled(page == pages) - .on_click(move |_, window, cx| ahead(page + 1, window, cx)), + div().debug_selector(|| "pagination-next".into()).child( + IconButton::new("next", IconName::ChevronRight) + .size(size) + .variant(ButtonVariant::Ghost) + .tooltip(i18n::text(cx, "pagination.next", &[])) + .disabled(page == pages) + .on_click(move |_, window, cx| ahead(page + 1, window, cx)), + ), ) + .children(jump) } } diff --git a/src/navigation/tests/mod.rs b/src/navigation/tests/mod.rs index f192cf1..db8a997 100644 --- a/src/navigation/tests/mod.rs +++ b/src/navigation/tests/mod.rs @@ -1,3 +1,4 @@ +mod pages; mod tabs; use gpui::{ diff --git a/src/navigation/tests/pages.rs b/src/navigation/tests/pages.rs new file mode 100644 index 0000000..bbd0329 --- /dev/null +++ b/src/navigation/tests/pages.rs @@ -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, +} + +impl Render for Paged { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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:?}" + ); + } +}