From a6f9a7b77b7016e7dc4682b9eb6bf180bc34cdf5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 19:34:01 -0400 Subject: [PATCH] =?UTF-8?q?Capture=20=C2=B7=20Roots:=20the=20roots=20round?= =?UTF-8?q?=202=20missed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sweep that read every render found 28 roots still on w_full, reached by 64 components: the field button under Select and every picker, the checked and mention fields, the slider track, NumberInput, Steps, the state views, the agent previews, and every chart and finance frame. All drop it. LivePreview's size_full becomes h_full, since its card no longer fills. Every host of those roots was listed and read, 231 in all. Five tests that mounted a picker as the window root sit in a size_full box, the index manager gives its method select a flex_1 box, and the notification center and the gallery's state frames stack their state views in a column so they stretch. Each dropped root carries a debug selector, and six chapter tests mount 43 components in a padded card whose column is measured by content, through layout::tests::narrow_width: each root must take the full 240px. With w_full back on the roots, all six fail. AGENTS.md names the roots that keep w_full and why: picture frames that keep a ratio, and rows that hand a text field its share. --- AGENTS.md | 2 +- TASKS.md | 1 + examples/gallery/pages/feedback/messages.rs | 2 +- examples/gallery/pages/feedback/states.rs | 2 +- src/agent/previews.rs | 4 +- src/agent/tests/previews.rs | 24 +++- src/charts/calendar.rs | 8 +- src/charts/cartesian.rs | 8 +- src/charts/flow.rs | 5 +- src/charts/funnel.rs | 5 +- src/charts/network.rs | 5 +- src/charts/parallel.rs | 9 +- src/charts/pie.rs | 2 +- src/charts/radar.rs | 2 +- src/charts/realtime.rs | 10 +- src/charts/relations.rs | 5 +- src/charts/schedule.rs | 7 +- src/charts/sunburst.rs | 5 +- src/charts/targets.rs | 2 +- src/charts/tests.rs | 130 +++++++++++++++++++- src/charts/tiles.rs | 10 +- src/devtools/structure.rs | 25 ++-- src/feedback/notification.rs | 2 +- src/feedback/states.rs | 2 +- src/feedback/tests/states.rs | 25 +++- src/finance/depth.rs | 9 +- src/finance/heatmap.rs | 5 +- src/finance/market.rs | 5 +- src/finance/payoff.rs | 4 +- src/finance/renko.rs | 9 +- src/finance/tests.rs | 45 ++++++- src/forms/checked.rs | 2 +- src/forms/mention.rs | 2 +- src/forms/number.rs | 2 +- src/forms/select.rs | 2 +- src/forms/slider.rs | 3 +- src/forms/tests/choices.rs | 26 ++-- src/forms/tests/dates.rs | 63 ++++++---- src/forms/tests/structure.rs | 40 +++++- src/layout/mod.rs | 2 + src/layout/tests.rs | 34 +++++ src/navigation/steps.rs | 2 +- src/navigation/tests.rs | 16 ++- 43 files changed, 465 insertions(+), 108 deletions(-) create mode 100644 src/layout/tests.rs diff --git a/AGENTS.md b/AGENTS.md index 1855b6f..b6c82b7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,7 +153,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A row that holds text beside fixed parts lets the text give way: the text takes `flex_1().min_w_0()` and an `Ellipsis`; counts, icons, dates and chevrons take `flex_none`. A label that must stay whole wraps in that block instead, as `forms::Checkbox`'s does. - A mark over a text field reads the field at paint time, from a canvas laid over it (`collab::RemoteCursor`): by then `TextInput::bounds_for` holds this frame's layout, and a still page never renders again to catch up. - A callback field holds a named `type` alias; clippy's type_complexity rejects `Option>`. A chapter shares one alias per shape. -- A component that fills its container takes no width on its root: a block or a flex column stretches it. Percents fill badly in taffy 0.9. `w_full` in a flex column that a block measures by content keeps its content's width: taffy lays the column out once at unknown width and keeps that layout when the width comes out the same. `min_w_full` resolves against a block's outer width, padding and all (`forms::FormField`). In a flex row the host gives it `flex_1`; as a window's root it sits in a `size_full` box. A root centered under a cap keeps `w_full`, since `mx_auto` needs a width to fill (`layout::Container`); so does one that keeps a ratio, since taffy 0.9 follows a ratio from a set width, not a stretch (`layout::AspectRatio`). A row that hands a text field a `flex_1` share keeps `w_full` too: stretched without a width, taffy 0.9 can keep a pass that laid the share at no width, and the field draws nothing (`forms::Input`, `InputGroup`, `TagInput`). +- A component that fills its container takes no width on its root: a block or a flex column stretches it. Percents fill badly in taffy 0.9. `w_full` in a flex column that a block measures by content keeps its content's width: taffy lays the column out once at unknown width and keeps that layout when the width comes out the same. `min_w_full` resolves against a block's outer width, padding and all (`forms::FormField`). In a flex row the host gives it `flex_1`; as a window's root it sits in a `size_full` box. A root centered under a cap keeps `w_full`, since `mx_auto` needs a width to fill (`layout::Container`); so does one that keeps a ratio, since taffy 0.9 follows a ratio from a set width, not a stretch (`layout::AspectRatio`, and `primitives::framed` under VideoPlayer, CameraPreview, ModelViewer, ParticipantTile and InpaintCanvas). A row that hands the text field element itself a `flex_1` share keeps `w_full` too: stretched without a width, taffy 0.9 can keep a pass that laid the share at no width, and the field draws nothing (`forms::Input`, `InputGroup`, `TagInput`); a row around one of those, as `NumberInput`'s is, takes none. `Button::full_width` is its host asking, since a button takes no style. Each chapter's test holds its filling roots at the full width of such a column through `layout::tests::narrow_width`, which reads a root by its `debug_selector`. - A field's `Highlight` sets color, wash, weight, slant and strike per span; measuring and painting share one set of runs, so wrapped heights match. - A block editor keeps one undo for the document: text and structure together, as snapshots through `forms::History`; it takes Undo and Redo before its fields do. - gpui calls focus listeners only while the window is active. A test that needs focus events activates its window with `window.activate_window()`. diff --git a/TASKS.md b/TASKS.md index 9ec4b8f..4957d2a 100644 --- a/TASKS.md +++ b/TASKS.md @@ -473,3 +473,4 @@ Per-component lines live in `tasks/`. Tags there: | T43c | 1 | FAIL | the audit left fg_subtle on active and every syntax color unread, both under 4.5:1; the viewer left out avatar and container sizes while claiming every token | | T43c | 2 | PASS | the AGENTS.md decision narrowed to the pairs the audit holds, in the pass commit | | T44a | 1 | FAIL | twelve roots in nine components still said w_full: a root held in a variable, a match arm or an animation passed the sweep | +| T44a | 2 | FAIL | roots built in render(mut self), returned by shared helpers or passed through them still said w_full: fields, charts, state views, Steps, previews; five measured at content width in a padded card | diff --git a/examples/gallery/pages/feedback/messages.rs b/examples/gallery/pages/feedback/messages.rs index 31bf43a..fdc4bdb 100644 --- a/examples/gallery/pages/feedback/messages.rs +++ b/examples/gallery/pages/feedback/messages.rs @@ -358,7 +358,7 @@ pub fn empties(cx: &mut App) -> impl IntoElement + use<> { .min_w_0() .h_80() .flex() - .items_center() + .flex_col() .justify_center() .rounded(theme.radius(Radius::Lg)) .border_1() diff --git a/examples/gallery/pages/feedback/states.rs b/examples/gallery/pages/feedback/states.rs index c494e58..cc2c4fb 100644 --- a/examples/gallery/pages/feedback/states.rs +++ b/examples/gallery/pages/feedback/states.rs @@ -26,7 +26,7 @@ fn frame(cx: &App) -> Div { .min_w_0() .h_80() .flex() - .items_center() + .flex_col() .justify_center() .rounded(theme.radius(Radius::Lg)) .border_1() diff --git a/src/agent/previews.rs b/src/agent/previews.rs index 0e2c713..c100de3 100644 --- a/src/agent/previews.rs +++ b/src/agent/previews.rs @@ -56,7 +56,7 @@ fn address_bar( fn card(cx: &App) -> Div { let theme = cx.theme(); div() - .w_full() + .debug_selector(|| "preview-root".into()) .flex() .flex_col() .rounded(theme.radius(Radius::Lg)) @@ -395,7 +395,7 @@ impl RenderOnce for LivePreview { .into_any_element() }); card(cx) - .size_full() + .h_full() .child(address_bar(&self.id, self.url, self.updating, reload, cx)) .child( div() diff --git a/src/agent/tests/previews.rs b/src/agent/tests/previews.rs index aa22321..50b823d 100644 --- a/src/agent/tests/previews.rs +++ b/src/agent/tests/previews.rs @@ -7,8 +7,9 @@ use gpui::{ use super::{press, settle}; use crate::{ - agent::{ArtifactPanel, BrowserPreview, ComputerUseViewer}, + agent::{ArtifactPanel, BrowserPreview, ComputerUseViewer, LivePreview}, forms, + layout::tests::narrow_width, primitives::{FocusNext, IconName}, theme::{ActiveTheme, AvatarSize, Theme}, }; @@ -288,3 +289,24 @@ fn a_screen_takes_its_pictures_shape(cx: &mut TestAppContext) { height.get() ); } + +#[gpui::test] +fn previews_fill_a_column_their_block_measures_by_content(cx: &mut TestAppContext) { + setup(cx); + let widths = [ + narrow_width(cx, "preview-root", |_, _| { + BrowserPreview::new("browser", "localhost", ["a.png"], 0).into_any_element() + }), + narrow_width(cx, "preview-root", |_, _| { + ComputerUseViewer::new("screen", "a.png").into_any_element() + }), + narrow_width(cx, "preview-root", |_, _| { + LivePreview::new("live", "localhost", div()).into_any_element() + }), + ]; + assert_eq!( + widths, + [px(240.0); 3], + "each preview spans the card inside its padding" + ); +} diff --git a/src/charts/calendar.rs b/src/charts/calendar.rs index 039549e..1b8dd16 100644 --- a/src/charts/calendar.rs +++ b/src/charts/calendar.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, Div, ElementId, Entity, Hsla, IntoElement, ParentElement, Pixels, Refineable, RenderOnce, - StyleRefinement, Styled, Window, canvas, div, fill, + App, Div, ElementId, Entity, Hsla, InteractiveElement, IntoElement, ParentElement, Pixels, + Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, }; use jiff::{Span, civil::Date}; @@ -157,7 +157,9 @@ impl RenderOnce for CalendarHeatmap { ); let hit = days.iter().map(|(rect, _)| *rect).collect::>(); let legend = key(shades, ("Less", "More"), cx); - let mut root = div().w_full().h(sizes.height * 0.5); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .h(sizes.height * 0.5); root.style().refine(self.base.style()); let chart = tracked( div().relative().flex_1().min_h_0(), diff --git a/src/charts/cartesian.rs b/src/charts/cartesian.rs index c536f39..ae961e0 100644 --- a/src/charts/cartesian.rs +++ b/src/charts/cartesian.rs @@ -1,8 +1,8 @@ use std::{collections::HashSet, rc::Rc}; use gpui::{ - App, Div, ElementId, IntoElement, Refineable, RenderOnce, SharedString, StyleRefinement, - Styled, Window, div, + App, Div, ElementId, InteractiveElement, IntoElement, Refineable, RenderOnce, SharedString, + StyleRefinement, Styled, Window, div, }; use super::{ @@ -131,7 +131,9 @@ impl Chart { /// Renders a plot at the theme's chart height unless the owner sizes it. pub(crate) fn sized(base: &mut Div, plotted: Plot, window: &mut Window, cx: &mut App) -> Div { - let mut frame = div().w_full().h(cx.theme().chart().height); + let mut frame = div() + .debug_selector(|| "chart-root".into()) + .h(cx.theme().chart().height); frame.style().refine(base.style()); plot(plotted, frame, window, cx) } diff --git a/src/charts/flow.rs b/src/charts/flow.rs index 8793b93..818e14c 100644 --- a/src/charts/flow.rs +++ b/src/charts/flow.rs @@ -342,7 +342,10 @@ impl RenderOnce for SankeyChart { let palette = colors.clone(); let (hit_nodes, hit_ribbons) = (nodes, ribbons); let (moved, left) = (flows.clone(), flows.clone()); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); root.id((self.id.clone(), "sankey")) .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { diff --git a/src/charts/funnel.rs b/src/charts/funnel.rs index 4b7afb1..f8ac018 100644 --- a/src/charts/funnel.rs +++ b/src/charts/funnel.rs @@ -184,7 +184,10 @@ impl RenderOnce for FunnelChart { frame.h / self.stages.len().max(1) as f32, ); let count = self.stages.len(); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); root.id((self.id.clone(), "funnel")) .on_mouse_move(move |event: &MouseMoveEvent, _, cx| { diff --git a/src/charts/network.rs b/src/charts/network.rs index bb426ab..c51e6cf 100644 --- a/src/charts/network.rs +++ b/src/charts/network.rs @@ -287,7 +287,10 @@ impl RenderOnce for NetworkGraph { graph.clone(), ); let press_hit = hit.clone(); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); root.id((self.id.clone(), "network")) .on_mouse_down(MouseButton::Left, move |event: &MouseDownEvent, _, cx| { diff --git a/src/charts/parallel.rs b/src/charts/parallel.rs index 5d4dacc..de50bd5 100644 --- a/src/charts/parallel.rs +++ b/src/charts/parallel.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, - RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, + App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, PathBuilder, + Pixels, Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, }; use super::{ @@ -215,7 +215,10 @@ impl RenderOnce for ParallelCoordinates { lines.clone(), ); let reach = pixels(sizes.inset); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); tracked( root, diff --git a/src/charts/pie.rs b/src/charts/pie.rs index de11c3b..6d16c7b 100644 --- a/src/charts/pie.rs +++ b/src/charts/pie.rs @@ -302,7 +302,7 @@ impl RenderOnce for PieChart { |legend, (ix, (name, _))| legend.entry(tint(&colors, ix), name.clone(), false), ); let (moved, left, measured, hit) = (ring.clone(), ring.clone(), ring.clone(), arcs.clone()); - let mut frame = div().w_full().h(sizes.height); + let mut frame = div().debug_selector(|| "chart-root".into()).h(sizes.height); frame.style().refine(self.base.style()); frame.flex().flex_col().gap_2().child(legend).child( div() diff --git a/src/charts/radar.rs b/src/charts/radar.rs index c7b79ea..1be6c12 100644 --- a/src/charts/radar.rs +++ b/src/charts/radar.rs @@ -245,7 +245,7 @@ impl RenderOnce for RadarChart { let (stroke, hairline) = (sizes.stroke.to_pixels(rem), sizes.hairline); let palette = colors.clone(); let (moved, left, measured) = (radar.clone(), radar.clone(), radar.clone()); - let mut frame = div().w_full().h(sizes.height); + let mut frame = div().debug_selector(|| "chart-root".into()).h(sizes.height); frame.style().refine(self.base.style()); frame.flex().flex_col().gap_2().children(legend).child( div() diff --git a/src/charts/realtime.rs b/src/charts/realtime.rs index a97d441..c1211fa 100644 --- a/src/charts/realtime.rs +++ b/src/charts/realtime.rs @@ -1,8 +1,9 @@ use std::time::Instant; use gpui::{ - App, Bounds, ContentMask, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, - Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, size, + App, Bounds, ContentMask, Div, ElementId, Entity, InteractiveElement, IntoElement, + ParentElement, PathBuilder, Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, + canvas, div, fill, size, }; use super::{ @@ -128,7 +129,10 @@ impl RenderOnce for RealtimeChart { pixels(sizes.inset), ); let measured = live.clone(); - let mut chart = div().relative().w_full().h(sizes.height * 0.6); + let mut chart = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height * 0.6); chart.style().refine(self.base.style()); chart .child( diff --git a/src/charts/relations.rs b/src/charts/relations.rs index ee8cc22..01eb0b9 100644 --- a/src/charts/relations.rs +++ b/src/charts/relations.rs @@ -222,7 +222,10 @@ impl RenderOnce for ChordDiagram { }) .collect(); let (palette, arcs, hit) = (colors.clone(), groups.clone(), groups); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); tracked( root, diff --git a/src/charts/schedule.rs b/src/charts/schedule.rs index 266c5eb..5e6f8d0 100644 --- a/src/charts/schedule.rs +++ b/src/charts/schedule.rs @@ -1,6 +1,7 @@ use gpui::{ - App, Bounds, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, - Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, canvas, div, fill, size, + App, Bounds, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, + PathBuilder, Pixels, Refineable, RenderOnce, SharedString, StyleRefinement, Styled, Window, + canvas, div, fill, size, }; use jiff::{ Span, @@ -267,8 +268,8 @@ impl RenderOnce for GanttChart { ); let painted = bars.clone(); let mut root = div() + .debug_selector(|| "chart-root".into()) .relative() - .w_full() .h(sizes.foot + theme.table_row(Density::Compact) * count as f32); root.style().refine(self.base.style()); tracked( diff --git a/src/charts/sunburst.rs b/src/charts/sunburst.rs index 3f8189d..0e38f27 100644 --- a/src/charts/sunburst.rs +++ b/src/charts/sunburst.rs @@ -187,7 +187,10 @@ impl RenderOnce for Sunburst { ); let (moved, left, measured, hit) = (ring.clone(), ring.clone(), ring.clone(), arcs.clone()); let painted = arcs.clone(); - let mut frame = div().relative().w_full().h(sizes.height); + let mut frame = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); frame.style().refine(self.base.style()); frame .id((self.id.clone(), "ring")) diff --git a/src/charts/targets.rs b/src/charts/targets.rs index aec26c2..7b94062 100644 --- a/src/charts/targets.rs +++ b/src/charts/targets.rs @@ -193,8 +193,8 @@ impl RenderOnce for BulletChart { colors.clone(), ); let mut root = div() + .debug_selector(|| "chart-root".into()) .relative() - .w_full() .h(theme.table_row(Density::Comfortable) * count as f32); root.style().refine(self.base.style()); tracked( diff --git a/src/charts/tests.rs b/src/charts/tests.rs index 9ad9a72..6bd47ed 100644 --- a/src/charts/tests.rs +++ b/src/charts/tests.rs @@ -10,11 +10,12 @@ use gpui::{ use jiff::civil::date; use super::{ - BarChart, Bullet, BulletChart, CalendarHeatmap, ChartLegend, ChordDiagram, FunnelChart, - GanttChart, HeatmapChart, LineChart, NetworkGraph, ParallelCoordinates, ProgressChart, - RadarChart, SankeyChart, Series, Task, Treemap, + AreaChart, BarChart, BoxPlot, Bullet, BulletChart, CalendarHeatmap, ChartLegend, ChordDiagram, + FunnelChart, GanttChart, HeatmapChart, Histogram, LineChart, NetworkGraph, ParallelCoordinates, + PieChart, Points, ProgressChart, RadarChart, RealtimeChart, SankeyChart, ScatterChart, Series, + Slice, Sunburst, Task, Treemap, ViolinPlot, WaterfallChart, }; -use crate::theme::Theme; +use crate::{layout::tests::narrow_width, theme::Theme}; #[gpui::test] fn a_chart_exports_as_svg_and_csv(cx: &mut TestAppContext) { @@ -208,3 +209,124 @@ fn tab_reaches_a_legend_entry_and_enter_toggles_it(cx: &mut TestAppContext) { settle(cx); assert_eq!(*heard.borrow(), ["Held out"]); } + +#[gpui::test] +fn every_chart_fills_a_column_its_block_measures_by_content(cx: &mut TestAppContext) { + cx.update(Theme::init); + let two = || Series::new("s", [1.0, 2.0]); + let pairs = [vec![0.0, 1.0], vec![1.0, 0.0]]; + let widths = [ + narrow_width(cx, "chart-root", move |_, _| { + LineChart::new("line", ["a", "b"]) + .series(two()) + .into_any_element() + }), + narrow_width(cx, "chart-root", move |_, _| { + AreaChart::new("area", ["a", "b"]) + .series(two()) + .into_any_element() + }), + narrow_width(cx, "chart-root", move |_, _| { + BarChart::new("bar", ["a", "b"]) + .series(two()) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + let points = Points::new("p", [(0.0, 1.0), (1.0, 2.0)]); + ScatterChart::new("scatter") + .points(points) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + Histogram::new("histogram", [1.0, 2.0, 2.0, 3.0]).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + WaterfallChart::new("waterfall") + .step("a", 2.0) + .step("b", -1.0) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + BoxPlot::new("box") + .group("a", [1.0, 2.0, 3.0]) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + ViolinPlot::new("violin") + .group("a", [1.0, 2.0, 3.0]) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + CalendarHeatmap::new("days", date(2026, 1, 5), [1.0; 14]).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + SankeyChart::new("sankey", ["a", "b"]) + .link(0, 1, 5.0) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + FunnelChart::new("funnel") + .stage("a", 2.0) + .stage("b", 1.0) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + NetworkGraph::new("network") + .node("a", 0) + .node("b", 0) + .edge(0, 1) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + ParallelCoordinates::new("parallel", ["a", "b"]) + .record("r", [1.0, 2.0]) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + PieChart::new("pie") + .slice("a", 1.0) + .slice("b", 2.0) + .into_any_element() + }), + narrow_width(cx, "chart-root", move |_, _| { + RadarChart::new("radar", ["a", "b", "c"]) + .series(Series::new("s", [1.0, 2.0, 3.0])) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + RealtimeChart::new("live", [1.0, 2.0], 2).into_any_element() + }), + narrow_width(cx, "chart-root", move |_, _| { + ChordDiagram::new("chord", ["a", "b"], pairs.to_vec()).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + GanttChart::new("gantt") + .task(Task::new("t", date(2026, 3, 1), date(2026, 3, 9))) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + Sunburst::new("sun", [Slice::new("a", 1.0)]).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + BulletChart::new("bullets") + .bullet(Bullet::new("b", 5.0, 8.0)) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + Treemap::new("treemap") + .tile("a", 1.0) + .tile("b", 2.0) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + HeatmapChart::new("heatmap", ["a"]) + .row("a", [1.0]) + .into_any_element() + }), + ]; + assert_eq!( + widths, + [px(240.0); 22], + "each chart spans the card inside its padding" + ); +} diff --git a/src/charts/tiles.rs b/src/charts/tiles.rs index 4b5e775..16caf1a 100644 --- a/src/charts/tiles.rs +++ b/src/charts/tiles.rs @@ -208,7 +208,10 @@ impl RenderOnce for Treemap { rects.clone(), rects, ); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); tracked( root, @@ -406,7 +409,10 @@ impl RenderOnce for HeatmapChart { sizes.hairline, ); let legend = key(steps(ink), ("Less", "More"), cx); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); let chart = tracked( div().relative().flex_1().min_h_0(), diff --git a/src/devtools/structure.rs b/src/devtools/structure.rs index e1697b1..7e14f68 100644 --- a/src/devtools/structure.rs +++ b/src/devtools/structure.rs @@ -410,6 +410,17 @@ impl RenderOnce for IndexManager { ); let ready = !name.read(cx).text().trim().is_empty() && !picked.is_empty(); let [chose, flipped, pick_method, made] = [(); 4].map(|_| draft.clone()); + let methods = Select::new( + (id.clone(), "method"), + METHODS.iter().map(|method| Choice::new(*method, *method)), + ) + .selected(method) + .on_change(move |value, _, cx| { + pick_method.update(cx, |draft, cx| { + draft.method = value.clone(); + cx.notify(); + }) + }); div() .flex() .flex_col() @@ -453,19 +464,7 @@ impl RenderOnce for IndexManager { }) }), ) - .child( - Select::new( - (id.clone(), "method"), - METHODS.iter().map(|method| Choice::new(*method, *method)), - ) - .selected(method) - .on_change(move |value, _, cx| { - pick_method.update(cx, |draft, cx| { - draft.method = value.clone(); - cx.notify(); - }) - }), - ), + .child(div().flex_1().min_w_0().child(methods)), ) .child( Button::new((id, "create"), "Create index") diff --git a/src/feedback/notification.rs b/src/feedback/notification.rs index d96b72c..bf558aa 100644 --- a/src/feedback/notification.rs +++ b/src/feedback/notification.rs @@ -248,7 +248,7 @@ impl RenderOnce for NotificationCenter { div() .flex_1() .flex() - .items_center() + .flex_col() .justify_center() .child( EmptyState::new( diff --git a/src/feedback/states.rs b/src/feedback/states.rs index 7ae69fc..208bc0f 100644 --- a/src/feedback/states.rs +++ b/src/feedback/states.rs @@ -53,7 +53,7 @@ pub(super) fn state_view( let theme = cx.theme(); let colors = &theme.colors; div() - .w_full() + .debug_selector(|| "state-root".into()) .flex() .flex_col() .items_center() diff --git a/src/feedback/tests/states.rs b/src/feedback/tests/states.rs index 7f4a77e..08e720c 100644 --- a/src/feedback/tests/states.rs +++ b/src/feedback/tests/states.rs @@ -10,8 +10,10 @@ use super::{press, settle, setup, wait}; use crate::{ buttons::Button, feedback::{ - ConfirmationCard, Countdown, EmptyState, ErrorBoundary, SaveState, SavingIndicator, + ConfirmationCard, Countdown, EmptyState, ErrorBoundary, ErrorView, ResultView, SaveState, + SavingIndicator, }, + layout::tests::narrow_width, primitives::{FocusScope, IconName, Measure}, }; @@ -200,3 +202,24 @@ fn a_long_inline_message_wraps_inside_its_box(cx: &mut TestAppContext) { .expect("the words draw"); assert!(text.right() <= px(200.0), "{text:?}"); } + +#[gpui::test] +fn state_views_fill_a_column_their_block_measures_by_content(cx: &mut TestAppContext) { + setup(cx); + let widths = [ + narrow_width(cx, "state-root", |_, _| { + EmptyState::new("empty", IconName::Search, "Nothing here").into_any_element() + }), + narrow_width(cx, "state-root", |_, _| { + ErrorView::not_found("missing").into_any_element() + }), + narrow_width(cx, "state-root", |_, _| { + ResultView::success("done", "Saved").into_any_element() + }), + ]; + assert_eq!( + widths, + [px(240.0); 3], + "each view spans the card inside its padding" + ); +} diff --git a/src/finance/depth.rs b/src/finance/depth.rs index 0f72451..592d6b9 100644 --- a/src/finance/depth.rs +++ b/src/finance/depth.rs @@ -1,6 +1,6 @@ use gpui::{ - App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Point, - Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, + App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, PathBuilder, + Pixels, Point, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, }; use super::quotes::moves; @@ -250,7 +250,10 @@ impl RenderOnce for DepthChart { let (stroke, bottom) = (sizes.stroke.to_pixels(rem), frame.y + frame.h); let mark = reading.map(|(price, _, total)| (xs.at(price), ys.at(total))); let palette = colors.clone(); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); tracked( root, diff --git a/src/finance/heatmap.rs b/src/finance/heatmap.rs index d2514fc..205b6c4 100644 --- a/src/finance/heatmap.rs +++ b/src/finance/heatmap.rs @@ -165,7 +165,10 @@ impl RenderOnce for MarketHeatmap { theme.radius(Radius::Sm).to_pixels(rem), colors.fg, ); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(self.base.style()); tracked( root, diff --git a/src/finance/market.rs b/src/finance/market.rs index e7d1bb9..455ac85 100644 --- a/src/finance/market.rs +++ b/src/finance/market.rs @@ -422,7 +422,10 @@ impl RenderOnce for CandlestickChart { }, }; let height = sizes.height + sizes.height * 0.3 * self.studies.len() as f32; - let mut root = div().relative().w_full().h(height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(height); root.style().refine(self.base.style()); let steering = Steering { stage: stage.clone(), diff --git a/src/finance/payoff.rs b/src/finance/payoff.rs index af37260..9ff91dc 100644 --- a/src/finance/payoff.rs +++ b/src/finance/payoff.rs @@ -196,7 +196,9 @@ impl RenderOnce for PayoffDiagram { } chart.labels = labels; chart.format = Rc::new(|value| format::currency(value, "USD")); - let mut base = div().w_full().h(cx.theme().chart().height); + let mut base = div() + .debug_selector(|| "chart-root".into()) + .h(cx.theme().chart().height); base.style().refine(self.base.style()); plot(chart, base, window, cx) } diff --git a/src/finance/renko.rs b/src/finance/renko.rs index d46e736..e51457f 100644 --- a/src/finance/renko.rs +++ b/src/finance/renko.rs @@ -1,8 +1,8 @@ use std::rc::Rc; use gpui::{ - App, Div, ElementId, Entity, IntoElement, ParentElement, PathBuilder, Pixels, Refineable, - RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, + App, Div, ElementId, Entity, InteractiveElement, IntoElement, ParentElement, PathBuilder, + Pixels, Refineable, RenderOnce, StyleRefinement, Styled, Window, canvas, div, fill, }; use jiff::tz::TimeZone; @@ -146,7 +146,10 @@ fn board( theme.radius(crate::theme::Radius::Sm).to_pixels(rem), ); let box_px = boxes.map(|size| (ys.at(0.0) - ys.at(size)).abs()); - let mut root = div().relative().w_full().h(sizes.height); + let mut root = div() + .debug_selector(|| "chart-root".into()) + .relative() + .h(sizes.height); root.style().refine(base.style()); tracked(root, (id, "marks").into(), &tiles, move |place| { hit.iter().position(|(rect, _)| rect.contains(place)) diff --git a/src/finance/tests.rs b/src/finance/tests.rs index 1e405a0..5a659c6 100644 --- a/src/finance/tests.rs +++ b/src/finance/tests.rs @@ -6,8 +6,11 @@ use gpui::{ }; use jiff::Timestamp; -use crate::finance::{CandlestickChart, ChartSync, candles::Candle, stage::Visible}; -use crate::theme::Theme; +use crate::finance::{ + CandlestickChart, ChartSync, DepthChart, Leg, MarketHeatmap, PayoffDiagram, PointFigureChart, + RenkoChart, candles::Candle, stage::Visible, +}; +use crate::{layout::tests::narrow_width, theme::Theme}; fn candles(count: usize) -> Rc> { Rc::new( @@ -106,3 +109,41 @@ fn a_lagging_chart_leaves_the_shared_window_alone(cx: &mut TestAppContext) { "the lagging chart's append is already shown" ); } + +#[gpui::test] +fn every_chart_fills_a_column_its_block_measures_by_content(cx: &mut TestAppContext) { + cx.update(Theme::init); + let leg = Leg { + call: true, + strike: 100.0, + quantity: 1.0, + premium: 2.0, + }; + let widths = [ + narrow_width(cx, "chart-root", |_, _| { + DepthChart::new("depth", [(99.0, 2.0)], [(101.0, 3.0)]).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + MarketHeatmap::new("heatmap") + .tile("A", 1.0, 0.5) + .into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + CandlestickChart::new("candles", candles(30)).into_any_element() + }), + narrow_width(cx, "chart-root", move |_, _| { + PayoffDiagram::new("payoff", [leg], (80.0, 120.0), 100.0).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + RenkoChart::new("renko", candles(30), 0.1).into_any_element() + }), + narrow_width(cx, "chart-root", |_, _| { + PointFigureChart::new("figure", candles(30), 0.1, 3).into_any_element() + }), + ]; + assert_eq!( + widths, + [px(240.0); 6], + "each chart spans the card inside its padding" + ); +} diff --git a/src/forms/checked.rs b/src/forms/checked.rs index 7854617..3842ea5 100644 --- a/src/forms/checked.rs +++ b/src/forms/checked.rs @@ -43,10 +43,10 @@ fn checked( let theme = cx.theme(); let (subtle, danger) = (theme.colors.fg_subtle, theme.colors.danger); div() + .debug_selector(|| "checked-root".into()) .flex() .flex_col() .gap_1() - .w_full() .child( Input::new(state) .size(size) diff --git a/src/forms/mention.rs b/src/forms/mention.rs index e1d682d..efd5e2e 100644 --- a/src/forms/mention.rs +++ b/src/forms/mention.rs @@ -172,9 +172,9 @@ impl Suggestions { let (up_rows, down_rows, enter_rows) = (rows.clone(), rows.clone(), rows.clone()); let (enter, trigger) = (self.pick.clone(), self.trigger); div() + .debug_selector(|| "mention-root".into()) .id(self.id) .relative() - .w_full() .capture_action(move |_: &Up, _, cx| { if open { cx.stop_propagation(); diff --git a/src/forms/number.rs b/src/forms/number.rs index b67b8fd..6b9a67f 100644 --- a/src/forms/number.rs +++ b/src/forms/number.rs @@ -401,10 +401,10 @@ impl RenderOnce for NumberInput { .child(stepper), ); div() + .debug_selector(|| "number-root".into()) .flex() .items_center() .gap_2() - .w_full() .children(label) .child( div() diff --git a/src/forms/select.rs b/src/forms/select.rs index 3884000..804eb7e 100644 --- a/src/forms/select.rs +++ b/src/forms/select.rs @@ -88,13 +88,13 @@ pub(crate) fn field_button( let theme = cx.theme(); let colors = &theme.colors; div() + .debug_selector(|| "field-root".into()) .id(id) .track_focus(focus) .relative() .flex() .items_center() .gap_2() - .w_full() .h(theme.control_height(size)) .px(theme.control_padding(size)) .rounded(theme.radius(Radius::Md)) diff --git a/src/forms/slider.rs b/src/forms/slider.rs index 1ee122f..7dc5770 100644 --- a/src/forms/slider.rs +++ b/src/forms/slider.rs @@ -243,13 +243,14 @@ impl Track { }) }); div() + .debug_selector(|| "slider-root".into()) .id(self.id) .flex_none() .map(|outer| { if vertical { outer.h_full().w(thumb).py(thumb * 0.5) } else { - outer.w_full().h(thumb).px(thumb * 0.5) + outer.h(thumb).px(thumb * 0.5) } }) .when(self.disabled, |outer| outer.opacity(0.5)) diff --git a/src/forms/tests/choices.rs b/src/forms/tests/choices.rs index 0e544ab..c93e506 100644 --- a/src/forms/tests/choices.rs +++ b/src/forms/tests/choices.rs @@ -67,14 +67,16 @@ struct Picker { impl Render for Picker { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - Select::new("select", abc(false)) - .selected(self.chosen.clone()) - .on_change(move |value, _, cx| { - view.update(cx, |view, cx| { - view.chosen = value.clone(); - cx.notify(); - }) - }) + div().size_full().child( + Select::new("select", abc(false)) + .selected(self.chosen.clone()) + .on_change(move |value, _, cx| { + view.update(cx, |view, cx| { + view.chosen = value.clone(); + cx.notify(); + }) + }), + ) } } @@ -425,9 +427,11 @@ struct Empty(bool); impl Render for Empty { fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { - Select::new("empty", []) - .placeholder("Nothing yet") - .disabled(self.0) + div().size_full().child( + Select::new("empty", []) + .placeholder("Nothing yet") + .disabled(self.0), + ) } } diff --git a/src/forms/tests/dates.rs b/src/forms/tests/dates.rs index 09ee490..4cd01f6 100644 --- a/src/forms/tests/dates.rs +++ b/src/forms/tests/dates.rs @@ -1,4 +1,7 @@ -use gpui::{Context, IntoElement, KeyUpEvent, Keystroke, Render, TestAppContext, Window}; +use gpui::{ + Context, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, Styled, TestAppContext, + Window, div, +}; use jiff::civil::{Date, Time, date, time}; use super::setup; @@ -14,15 +17,17 @@ struct Day { impl Render for Day { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - DatePicker::new("day", self.value) - .today(TODAY) - .disabled(self.disabled) - .on_change(move |picked, _, cx| { - view.update(cx, |view, cx| { - view.value = Some(picked); - cx.notify(); - }) - }) + div().size_full().child( + DatePicker::new("day", self.value) + .today(TODAY) + .disabled(self.disabled) + .on_change(move |picked, _, cx| { + view.update(cx, |view, cx| { + view.value = Some(picked); + cx.notify(); + }) + }), + ) } } @@ -48,14 +53,16 @@ struct Span { impl Render for Span { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - DateRangePicker::new("span", self.value) - .today(TODAY) - .on_change(move |picked, _, cx| { - view.update(cx, |view, cx| { - view.value = Some(picked); - cx.notify(); - }) - }) + div().size_full().child( + DateRangePicker::new("span", self.value) + .today(TODAY) + .on_change(move |picked, _, cx| { + view.update(cx, |view, cx| { + view.value = Some(picked); + cx.notify(); + }) + }), + ) } } @@ -78,14 +85,18 @@ struct Clock { impl Render for Clock { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let view = cx.entity(); - TimePicker::new("clock", self.value) - .step(15) - .on_change(move |picked, _, cx| { - view.update(cx, |view, cx| { - view.value = Some(picked); - cx.notify(); - }) - }) + div() + .size_full() + .child( + TimePicker::new("clock", self.value) + .step(15) + .on_change(move |picked, _, cx| { + view.update(cx, |view, cx| { + view.value = Some(picked); + cx.notify(); + }) + }), + ) } } diff --git a/src/forms/tests/structure.rs b/src/forms/tests/structure.rs index 5e53d1d..18722ba 100644 --- a/src/forms/tests/structure.rs +++ b/src/forms/tests/structure.rs @@ -1,12 +1,16 @@ use gpui::{ - AppContext as _, Context, Entity, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, + App, AppContext as _, Context, Entity, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, }; use super::setup; use crate::{ buttons::Button, - forms::{FieldArray, Form, FormField, Input, InputEvent, TextInput}, + forms::{ + Choice, DatePicker, EmailInput, FieldArray, Form, FormField, Input, InputEvent, + MentionInput, NumberInput, Select, Slider, TextInput, + }, + layout::tests::narrow_width, }; struct Signup { @@ -195,3 +199,35 @@ fn a_field_keeps_inside_its_cards_padding(cx: &mut TestAppContext) { "a percent min would take the card's outer width" ); } + +#[gpui::test] +fn fields_fill_a_column_their_block_measures_by_content(cx: &mut TestAppContext) { + setup(cx); + let field = + |window: &mut Window, cx: &mut App| window.use_keyed_state("field", cx, TextInput::new); + let widths = [ + narrow_width(cx, "field-root", |_, _| { + Select::new("select", [Choice::new("a", "A")]).into_any_element() + }), + narrow_width(cx, "field-root", |_, _| { + DatePicker::new("date", None).into_any_element() + }), + narrow_width(cx, "checked-root", move |window, cx| { + EmailInput::new(&field(window, cx)).into_any_element() + }), + narrow_width(cx, "mention-root", move |window, cx| { + MentionInput::new("mention", &field(window, cx)).into_any_element() + }), + narrow_width(cx, "slider-root", |_, _| { + Slider::new("slider", 0.5).into_any_element() + }), + narrow_width(cx, "number-root", |_, _| { + NumberInput::new("number", 42.0).into_any_element() + }), + ]; + assert_eq!( + widths, + [px(240.0); 6], + "each field spans the card inside its padding" + ); +} diff --git a/src/layout/mod.rs b/src/layout/mod.rs index 63da417..597c58c 100644 --- a/src/layout/mod.rs +++ b/src/layout/mod.rs @@ -12,6 +12,8 @@ mod sidebar; mod split; mod stack; mod surface; +#[cfg(all(test, feature = "test-support"))] +pub(crate) mod tests; mod viewport; mod workspace; diff --git a/src/layout/tests.rs b/src/layout/tests.rs new file mode 100644 index 0000000..ad223f6 --- /dev/null +++ b/src/layout/tests.rs @@ -0,0 +1,34 @@ +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement, Pixels, Render, Styled, TestAppContext, + Window, div, px, +}; + +type Build = Box AnyElement>; + +/// One element in a column that a padded block measures by content. +struct Narrow(Build); + +impl Render for Narrow { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + div().w(px(280.0)).flex().flex_col().child( + div().p_5().child( + div() + .flex() + .flex_col() + .child((self.0)(window, cx)) + .child("A line long enough to wrap in a narrow card, so the column fills it."), + ), + ) + } +} + +/// The width of `selector` in that column; a root that fills it takes all 240px. +pub(crate) fn narrow_width( + cx: &mut TestAppContext, + selector: &'static str, + build: impl Fn(&mut Window, &mut App) -> AnyElement + 'static, +) -> Pixels { + let (_, cx) = cx.add_window_view(|_, _| Narrow(Box::new(build))); + cx.run_until_parked(); + cx.debug_bounds(selector).expect(selector).size.width +} diff --git a/src/navigation/steps.rs b/src/navigation/steps.rs index 8236777..da5a6f1 100644 --- a/src/navigation/steps.rs +++ b/src/navigation/steps.rs @@ -200,10 +200,10 @@ impl RenderOnce for Steps { } }); div() + .debug_selector(|| "steps-root".into()) .id(self.id) .flex() .when(vertical, |steps| steps.flex_col()) - .w_full() .children(steps) } } diff --git a/src/navigation/tests.rs b/src/navigation/tests.rs index 05927ba..88a678b 100644 --- a/src/navigation/tests.rs +++ b/src/navigation/tests.rs @@ -6,10 +6,11 @@ use gpui::{ use super::{ BackForwardNavigation, Breadcrumb, Crumb, EditorTab, EditorTabs, GoToLine, LoadMore, - NavigationMenu, Tabs, Wizard, + NavigationMenu, Steps, Tabs, Wizard, }; use crate::{ forms::{Choice, TextInput}, + layout::tests::narrow_width, theme::Theme, }; @@ -456,3 +457,16 @@ fn load_more_asks_once_per_press_and_goes_once_all_show(cx: &mut TestAppContext) "all shown, no button" ); } + +#[gpui::test] +fn steps_fill_a_column_their_block_measures_by_content(cx: &mut TestAppContext) { + setup(cx); + let width = narrow_width(cx, "steps-root", |_, _| { + Steps::new("steps", [Choice::new("a", "A"), Choice::new("b", "B")], 0).into_any_element() + }); + assert_eq!( + width, + px(240.0), + "the steps span the card inside its padding" + ); +}