From a6cd9a1f45248d14ba73e128a237682bed64b880 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:27:33 +0000 Subject: [PATCH 1/7] gallery: wire theme toggle; screenshots: add states component (WI-3263) Graft-Changeset: cd0490557468add432078273698ad5d1167f3be97670e5ee89c133b5c9b58540f Graft-Session: ses_01M6M3T131ZF1ZC6Q43DZPAEPS Graft-Work-Item: WI-3263 Graft-Batch: 32 --- examples/gallery/src/main.rs | 13 +++++++++++-- examples/screenshots/src/main.rs | 28 +++++++++++++++++++++++++++- scripts/screenshots.sh | 1 + 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index e00e010..7f2e6d9 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -95,8 +95,17 @@ fn main() { }) }) .group("View", |g| { - g.toggle_button("Dark", "icons/dark_theme.svg", true, |_, _, _, _| {}) - .toggle_button("Light", "icons/light_theme.svg", false, |_, _, _, _| {}) + g.toggle_button("Dark", "icons/dark_theme.svg", true, |_, _, _, cx| { + fluent_core::Theme::apply_dark(cx); + }) + .toggle_button( + "Light", + "icons/light_theme.svg", + false, + |_, _, _, cx| { + fluent_core::Theme::apply_light(cx); + }, + ) }) }) .tab("Edit", |t| { diff --git a/examples/screenshots/src/main.rs b/examples/screenshots/src/main.rs index a62246c..1dae99c 100644 --- a/examples/screenshots/src/main.rs +++ b/examples/screenshots/src/main.rs @@ -11,7 +11,7 @@ // Component names: button icon-button toggle-button checkbox switch label // badge chip divider progress-bar spinner skeleton avatar app-dot // connection-badge text-input textarea searchbox dropdown combobox -// radio-group field card section-header +// radio-group field card section-header states use std::fs; @@ -134,6 +134,7 @@ impl Render for Screenshots { "field" => ss_field(text_filled, textarea).into_any_element(), "card" => ss_cards().into_any_element(), "section-header" => ss_section_headers().into_any_element(), + "states" => ss_states().into_any_element(), other => div() .p(px(24.0)) .child(Label::new(format!("Unknown component: {other}"))) @@ -567,6 +568,31 @@ fn ss_section_headers() -> impl IntoElement { .child(Label::new("home-nas.local").size(LabelSize::Body)) } +// ── Loading / validation states ─────────────────────────────────────────── +fn ss_states() -> impl IntoElement { + col() + .w(px(360.0)) + .child(group( + "Indeterminate progress", + ProgressBar::new(0.0).indeterminate(), + )) + .child(group("Spinner", Spinner::new().size(24.0))) + .child(group( + "Field validation", + col() + .child( + Field::new(div().w_full().child(Label::new("value"))) + .label("Error") + .validation(FieldValidationState::Error, "Something is wrong."), + ) + .child( + Field::new(div().w_full().child(Label::new("value"))) + .label("Warning") + .validation(FieldValidationState::Warning, "Check this value."), + ), + )) +} + // ── Entry point ─────────────────────────────────────────────────────────── fn main() { diff --git a/scripts/screenshots.sh b/scripts/screenshots.sh index 8da3304..e178131 100755 --- a/scripts/screenshots.sh +++ b/scripts/screenshots.sh @@ -49,6 +49,7 @@ ALL_COMPONENTS=( field card section-header + states ) # ── Argument parsing ────────────────────────────────────────────────────── From d4b44684a7834a657b0d7883a011314c353bb298 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:27:58 +0000 Subject: [PATCH 2/7] Truncate long labels with an ellipsis in tabs, tree, table, title bar, message bar Graft-Changeset: c11eb07f88364186d74f185a15d6532619cf456c24424640b4413b9b6917da8b7 Graft-Session: ses_01M6M3SVAZP77D65ZVZFNBQCRN Graft-Work-Item: WI-3268 Graft-Batch: 32 --- CHANGELOG.md | 2 ++ crates/fluent-app/src/title_bar.rs | 5 +++-- crates/fluent-core/src/components.rs | 3 +++ crates/fluent-core/src/lib.rs | 2 ++ crates/fluent-core/src/truncate.rs | 16 ++++++++++++++++ crates/fluent-layout/src/data_table.rs | 6 +++--- crates/fluent-layout/src/message_bar.rs | 5 +++-- crates/fluent-layout/src/tab_strip.rs | 7 +++++-- crates/fluent-layout/src/tree.rs | 4 ++-- 9 files changed, 39 insertions(+), 11 deletions(-) create mode 100644 crates/fluent-core/src/truncate.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index a2574d2..950f1bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ per crate. ## Unreleased +- Single-line ellipsis truncation (`TruncateExt::truncate_label`) on tab labels (max width 240px token), tree rows, data table cells and headers, the title bar title and message bar/toast titles (WI-3268). + Workspace release. Includes `VideoSurface` (3.1.0), the AccessKit tree snapshot test helper and the `test-a11y-activation` vendor patch (headless test-window AccessKit activation) that rdpapp-fluent needs. Crate versions are unchanged, as in v3.1.0; consumers pin the tag. - fluent-primitives: deny missing public API documentation and document every exported control and supporting type (WI-2945). diff --git a/crates/fluent-app/src/title_bar.rs b/crates/fluent-app/src/title_bar.rs index 1dc7bcd..df9fe27 100644 --- a/crates/fluent-app/src/title_bar.rs +++ b/crates/fluent-app/src/title_bar.rs @@ -1,4 +1,4 @@ -use fluent_core::{LayoutDirection, Theme, ThemeProvider as _}; +use fluent_core::{LayoutDirection, Theme, ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, svg, ClickEvent, Context, IntoElement, MouseButton, MouseDownEvent, Render, SharedString, Window, @@ -139,6 +139,7 @@ impl Render for TitleBar { let accent_fg = colors.on_neutral_accent; let mut title_area = div() .flex_1() + .min_w_0() .h_full() .flex() .items_center() @@ -151,7 +152,7 @@ impl Render for TitleBar { if let Some(path) = icon { title_area = title_area.child(svg().path(path).size(px(16.0)).text_color(accent_fg)); } - let mut title_area = title_area.child(title); + let mut title_area = title_area.child(div().truncate_label().child(title)); if let Some(status) = status { title_area = title_area.child( div() diff --git a/crates/fluent-core/src/components.rs b/crates/fluent-core/src/components.rs index 9366f85..54ecc64 100644 --- a/crates/fluent-core/src/components.rs +++ b/crates/fluent-core/src/components.rs @@ -65,6 +65,8 @@ pub struct ComponentTokens { pub content_tab_icon_size: f32, /// Public API item. pub content_tab_close_size: f32, + /// Maximum width of a content tab before its label is ellipsised. + pub content_tab_max_width: f32, /// Public API item. pub ribbon_tab_height: f32, /// Public API item. @@ -112,6 +114,7 @@ impl Default for ComponentTokens { content_tab_strip_height: 32.0, content_tab_icon_size: 14.0, content_tab_close_size: 14.0, + content_tab_max_width: 240.0, ribbon_tab_height: 28.0, ribbon_content_button_height: 64.0, ribbon_group_label_height: 16.0, diff --git a/crates/fluent-core/src/lib.rs b/crates/fluent-core/src/lib.rs index 3444185..ee676ee 100644 --- a/crates/fluent-core/src/lib.rs +++ b/crates/fluent-core/src/lib.rs @@ -10,6 +10,7 @@ mod shadows; mod spacing; mod stroke_width; mod theme; +mod truncate; mod typography; /// Re-export the underlying UI toolkit for examples and downstream Fluent crates. @@ -29,6 +30,7 @@ pub use stroke_width::StrokeWidthTokens; pub use theme::{ Brightness, LayoutDirection, LayoutDirectionProvider, Theme, ThemeMode, ThemeProvider, }; +pub use truncate::TruncateExt; pub use typography::{TypeRamp, TypographyTokens}; #[cfg(test)] diff --git a/crates/fluent-core/src/truncate.rs b/crates/fluent-core/src/truncate.rs new file mode 100644 index 0000000..93c5e8b --- /dev/null +++ b/crates/fluent-core/src/truncate.rs @@ -0,0 +1,16 @@ +use gpui::Styled; + +/// Single-line truncation with a trailing ellipsis. +pub trait TruncateExt: Styled + Sized { + /// Keeps the element on one line, clips overflow and ends it with an + /// ellipsis. Also sets `min-width: 0` so a flex item can shrink below its + /// content width. + fn truncate_label(self) -> Self { + self.min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + } +} + +impl TruncateExt for T {} diff --git a/crates/fluent-layout/src/data_table.rs b/crates/fluent-layout/src/data_table.rs index ebfea48..3e005c2 100644 --- a/crates/fluent-layout/src/data_table.rs +++ b/crates/fluent-layout/src/data_table.rs @@ -4,7 +4,7 @@ use crate::{ data_status::{status_block, DataStatusKind}, virtual_list::VirtualList, }; -use fluent_core::ThemeProvider as _; +use fluent_core::{ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, App, ClickEvent, IntoElement, KeyDownEvent, RenderOnce, Role, SharedString, Window, @@ -518,7 +518,7 @@ impl RenderOnce for DataTable { .filter(|_| active_sort) .map(|(_, direction)| direction.toggle()) .unwrap_or(DataSortDirection::Ascending); - let mut label = div().child(column.title.clone()); + let mut label = div().truncate_label().child(column.title.clone()); if active_sort { let arrow = match self.sort.as_ref().map(|(_, direction)| *direction) { Some(DataSortDirection::Ascending) => " ↑", @@ -676,7 +676,7 @@ impl RenderOnce for DataTable { .text_color(colors_for_rows.on_neutral) .border_r_1() .border_color(colors_for_rows.stroke_neutral_subtle) - .child(data_cell.text); + .child(div().truncate_label().child(data_cell.text)); let width = current_widths.get(col_idx).copied().unwrap_or(120.0); cell = cell.w(px(width)).flex_none(); row_el = row_el.child(cell); diff --git a/crates/fluent-layout/src/message_bar.rs b/crates/fluent-layout/src/message_bar.rs index 433cfb3..740d4d3 100644 --- a/crates/fluent-layout/src/message_bar.rs +++ b/crates/fluent-layout/src/message_bar.rs @@ -1,4 +1,4 @@ -use fluent_core::ThemeProvider as _; +use fluent_core::{ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, svg, App, ClickEvent, IntoElement, RenderOnce, Role, SharedString, Window, }; @@ -96,13 +96,14 @@ impl RenderOnce for MessageBar { MessageIntent::Error => colors.status_error_border, }; - let mut text = div().flex().flex_col().gap(px(spacing.xs)); + let mut text = div().flex().flex_col().min_w_0().gap(px(spacing.xs)); if let Some(title) = self.title { text = text.child( div() .text_size(px(typography.body.size)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_neutral) + .truncate_label() .child(title), ); } diff --git a/crates/fluent-layout/src/tab_strip.rs b/crates/fluent-layout/src/tab_strip.rs index b750aa6..93a2ee0 100644 --- a/crates/fluent-layout/src/tab_strip.rs +++ b/crates/fluent-layout/src/tab_strip.rs @@ -1,6 +1,6 @@ use std::sync::Arc; -use fluent_core::{LayoutDirection, Theme, ThemeProvider as _}; +use fluent_core::{LayoutDirection, Theme, ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, svg, ClickEvent, Context, FocusHandle, IntoElement, KeyDownEvent, Render, Role, SharedString, Window, @@ -218,6 +218,8 @@ impl Render for TabStrip { .gap(px(spacing.xs)) .px(px(spacing.md)) .h_full() + .max_w(px(components.content_tab_max_width)) + .flex_none() .bg(tab_bg) .cursor_pointer() .text_size(px(typography.caption.size)) @@ -236,7 +238,7 @@ impl Render for TabStrip { ); } - tab_el = tab_el.child(tab_label); + tab_el = tab_el.child(div().truncate_label().child(tab_label)); if let Some(handler) = close_handler { let close_btn_id = ("tab-close", (n_tabs as u64 * 100 + i as u64)); @@ -244,6 +246,7 @@ impl Render for TabStrip { div() .id(close_btn_id) .size(px(components.content_tab_close_size)) + .flex_none() .flex() .items_center() .justify_center() diff --git a/crates/fluent-layout/src/tree.rs b/crates/fluent-layout/src/tree.rs index a01dd7d..8a81b43 100644 --- a/crates/fluent-layout/src/tree.rs +++ b/crates/fluent-layout/src/tree.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use crate::data_status::{status_block, DataStatusKind}; -use fluent_core::ThemeProvider as _; +use fluent_core::{ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, svg, App, ClickEvent, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, RenderOnce, Role, SharedString, Window, @@ -608,7 +608,7 @@ impl Tree { ); } - content = content.child(item.label); + content = content.child(div().truncate_label().child(item.label)); if item.loading { content = content.child(div().text_color(colors.on_subtle).child("Loading…")); } From 806c184d3b2a757ba3c2d878a134cbcbac902d25 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:37:06 +0000 Subject: [PATCH 3/7] text input: error stroke uses status_error; selection and title-bar close hover use theme colours Graft-Changeset: c87c463bf592e6e181cc467599a98191625fe85fa7400ecf390ab1b3315883611 Graft-Session: ses_01M6M3SNAF2TAD0G0R0Y1EGQNQ Graft-Work-Item: WI-3264 Graft-Batch: 32 --- CHANGELOG.md | 2 ++ crates/fluent-app/src/title_bar.rs | 2 +- crates/fluent-primitives/src/text_input.rs | 6 +++--- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 950f1bf..822b77f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ per crate. ## Unreleased +- Text input error state and selection, and title-bar close hover, now use theme colours (WI-3264, WI-3266) + - Single-line ellipsis truncation (`TruncateExt::truncate_label`) on tab labels (max width 240px token), tree rows, data table cells and headers, the title bar title and message bar/toast titles (WI-3268). Workspace release. Includes `VideoSurface` (3.1.0), the AccessKit tree snapshot test helper and the `test-a11y-activation` vendor patch (headless test-window AccessKit activation) that rdpapp-fluent needs. Crate versions are unchanged, as in v3.1.0; consumers pin the tag. diff --git a/crates/fluent-app/src/title_bar.rs b/crates/fluent-app/src/title_bar.rs index df9fe27..23e3577 100644 --- a/crates/fluent-app/src/title_bar.rs +++ b/crates/fluent-app/src/title_bar.rs @@ -100,7 +100,7 @@ impl Render for TitleBar { let bar_bg = colors.surface_dim; let fg = colors.on_neutral; let ctrl_hover = colors.subtle_hover; - let close_hover: gpui::Hsla = gpui::rgb(0xC42B1C).into(); + let close_hover = colors.status_error; // Drag: left-button press starts window move; consecutive presses // within DOUBLE_CLICK_WINDOW_MS toggle maximize / restore instead. diff --git a/crates/fluent-primitives/src/text_input.rs b/crates/fluent-primitives/src/text_input.rs index 64dc80a..c9e9eae 100644 --- a/crates/fluent-primitives/src/text_input.rs +++ b/crates/fluent-primitives/src/text_input.rs @@ -2,7 +2,7 @@ use std::ops::Range; use fluent_core::{Theme, ThemeProvider as _}; use gpui::{ - div, fill, point, prelude::*, px, relative, rgba, size, App, Bounds, ClipboardItem, Context, + div, fill, point, prelude::*, px, relative, size, App, Bounds, ClipboardItem, Context, CursorStyle, Element, ElementId, ElementInputHandler, Entity, EntityInputHandler, FocusHandle, Focusable, GlobalElementId, IntoElement, KeyDownEvent, LayoutId, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent, PaintQuad, Pixels, Render, ShapedLine, SharedString, Style, @@ -711,7 +711,7 @@ impl Element for TextElement { .shape_line(display_text, font_size, &runs, None); let cursor_pos = line.x_for_index(display_cursor); - let selection_color = rgba(0x3B82F633); + let selection_color = fluent_core::with_alpha(colors.accent, 0.2); let (selection, cursor) = if display_sel.is_empty() { ( None, @@ -820,7 +820,7 @@ impl Render for TextInput { crate::FieldValidationState::Error => colors.status_error, crate::FieldValidationState::Warning => colors.status_warning, crate::FieldValidationState::Success => colors.status_success, - crate::FieldValidationState::None if self.error => colors.stroke_accent, + crate::FieldValidationState::None if self.error => colors.status_error, crate::FieldValidationState::None if self.disabled => colors.stroke_neutral_disabled, crate::FieldValidationState::None if focus_handle.is_focused(_window) => colors.accent, crate::FieldValidationState::None => colors.stroke_neutral, From c0460e500fff31118ab5d3bb701a14cb4b8e587e Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:39:41 +0000 Subject: [PATCH 4/7] Menus: anchored submenu flyouts, snapped MenuBar dropdown, window-wide backdrops (WI-3252, WI-3253, WI-3254) Graft-Changeset: cb5ba8ba50cbcce04572abf4508273b33b87aa0483c3debcc499575f078dac492 Graft-Session: ses_01M6M3PB9TRZ5A0A0T5SE7A0DE Graft-Work-Item: WI-3252 Graft-Batch: 32 --- CHANGELOG.md | 2 + crates/fluent-layout/src/context_menu.rs | 107 +++++++++-- crates/fluent-layout/src/menu_bar.rs | 170 +++++++++++++---- crates/fluent-layout/src/menu_button.rs | 56 +++--- crates/fluent-layout/src/menu_tree.rs | 172 +++++++++++++++++- crates/fluent-layout/src/popover.rs | 2 +- crates/fluent-primitives/src/combobox.rs | 2 +- .../fluent-primitives/src/date_time_picker.rs | 2 +- crates/fluent-primitives/src/dropdown.rs | 2 +- .../src/suggestion_popover.rs | 2 +- examples/gallery/src/main.rs | 57 +++++- 11 files changed, 492 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 822b77f..cd099a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ per crate. Workspace release. Includes `VideoSurface` (3.1.0), the AccessKit tree snapshot test helper and the `test-a11y-activation` vendor patch (headless test-window AccessKit activation) that rdpapp-fluent needs. Crate versions are unchanged, as in v3.1.0; consumers pin the tag. +- fluent-layout: submenus open beside their parent row, flip left and snap inside the window with an 8px margin; the MenuBar dropdown anchors to the trigger's bottom-left with the same fit; MenuButton and ContextMenu click-away backdrops cover the window; popups use an 8px window margin (WI-3252, WI-3253, WI-3254). + - fluent-primitives: deny missing public API documentation and document every exported control and supporting type (WI-2945). - docs: document DPI scaling conversion and resize behavior across Windows DirectX, Linux X11, and Linux Wayland, with native-run evidence requirements (WI-3061). diff --git a/crates/fluent-layout/src/context_menu.rs b/crates/fluent-layout/src/context_menu.rs index 71a282b..22c2238 100644 --- a/crates/fluent-layout/src/context_menu.rs +++ b/crates/fluent-layout/src/context_menu.rs @@ -506,7 +506,6 @@ impl ContextMenu { .min_w(px(env.components.dropdown_min_width)) .shadow_md(); - let mut row_top = env.spacing.xs; for (idx, item) in items.iter().enumerate() { let mut path = path_prefix.clone(); path.push(idx); @@ -578,7 +577,6 @@ impl ContextMenu { ); } menu = menu.child(row); - row_top += env.components.menu_item_height; } ContextMenuItem::Checkbox { label, @@ -651,7 +649,6 @@ impl ContextMenu { ) .child(div().flex_1().child(menu_tree::label_element(label, true))), ); - row_top += env.components.menu_item_height; } ContextMenuItem::Radio { label, @@ -720,7 +717,6 @@ impl ContextMenu { ) .child(div().flex_1().child(menu_tree::label_element(label, true))), ); - row_top += env.components.menu_item_height; } ContextMenuItem::Submenu { label, @@ -769,15 +765,12 @@ impl ContextMenu { .size(px(12.0)) .text_color(fg), ); - menu = menu.child(row); - if !disabled && menu_tree::path_starts_with(&self.open_path, &path) { let submenu = self.render_menu(items, path.clone(), self_weak.clone(), env, cx); - menu = menu - .child(div().absolute().left_full().top(px(row_top)).child(submenu)); + row = row.child(menu_tree::submenu_flyout(submenu, env, path.len())); } - row_top += env.components.menu_item_height; + menu = menu.child(row); } ContextMenuItem::Separator => { menu = menu.child( @@ -787,7 +780,6 @@ impl ContextMenu { .my(px(env.spacing.xs)) .bg(env.colors.stroke_neutral_subtle), ); - row_top += env.components.popup_separator_height + env.spacing.xs * 2.0; } } } @@ -829,13 +821,13 @@ impl Render for ContextMenu { menu, ); - let mut overlay = div().absolute().inset_0(); + let mut overlay = div(); if !self.closing { let backdrop_closer = cx.entity().downgrade(); - overlay = overlay.child(div().id("ctx-backdrop").absolute().inset_0().on_mouse_down( - gpui::MouseButton::Left, - move |_, _, cx| { - cx.stop_propagation(); + overlay = overlay.child(menu_tree::window_backdrop( + "ctx-backdrop", + window, + move |_, cx| { backdrop_closer.update(cx, |menu, cx| menu.close(cx)).ok(); }, )); @@ -995,4 +987,89 @@ mod tests { cx.type_keys("menu"); view.read_with(cx, |view, _| assert_eq!(view.opened, Some(expected))); } + + fn bounds_of( + cx: &mut gpui::VisualTestContext, + selector: &'static str, + ) -> gpui::Bounds { + cx.selector_bounds(selector) + .unwrap_or_else(|| panic!("no bounds for {selector}")) + } + + fn submenu_menu(at: gpui::Point) -> ContextMenu { + let mut menu = ContextMenu::build(at) + .submenu("More", vec![ContextMenuItem::action("Inner", |_, _, _| {})]); + menu.open_path = vec![0]; + menu.selected_path = vec![0]; + menu + } + + #[gpui::test] + fn submenu_first_row_lines_up_with_parent_row(cx: &mut TestAppContext) { + let (_menu, cx) = open_fluent_test_window(cx, |_, _| submenu_menu(point(px(40.), px(30.)))); + cx.simulate_resize(gpui::size(px(800.), px(600.))); + cx.run_until_parked(); + cx.accessibility_snapshot(); + + let parent = bounds_of(cx, "ctx-item-row-[0]"); + let child = bounds_of(cx, "ctx-item-row-[0, 0]"); + assert_eq!( + child.top(), + parent.top(), + "first submenu row level with parent row" + ); + assert_eq!( + child.left(), + parent.right() - px(1.) + px(1.), + "submenu opens one pixel over the parent border, its own border inside it" + ); + } + + #[gpui::test] + fn submenu_flips_left_at_the_right_window_edge(cx: &mut TestAppContext) { + let (_menu, cx) = + open_fluent_test_window(cx, |_, _| submenu_menu(point(px(600.), px(30.)))); + cx.simulate_resize(gpui::size(px(800.), px(600.))); + cx.run_until_parked(); + cx.accessibility_snapshot(); + + let parent = bounds_of(cx, "ctx-item-row-[0]"); + let child = bounds_of(cx, "ctx-item-row-[0, 0]"); + assert!( + child.right() <= parent.left() + px(2.), + "{child:?} vs {parent:?}" + ); + assert!(child.left() >= px(8.)); + assert_eq!(child.top(), parent.top()); + } + + #[gpui::test] + fn submenu_snaps_inside_the_bottom_window_edge(cx: &mut TestAppContext) { + let (_menu, cx) = open_fluent_test_window(cx, |_, _| submenu_menu(point(px(40.), px(30.)))); + cx.simulate_resize(gpui::size(px(800.), px(80.))); + cx.run_until_parked(); + cx.accessibility_snapshot(); + + let child = bounds_of(cx, "ctx-item-row-[0, 0]"); + assert!(child.bottom() <= px(80. - 8. + 1.), "{child:?}"); + } + + #[gpui::test] + fn clicking_anywhere_outside_closes_the_menu(cx: &mut TestAppContext) { + let (menu, cx) = open_fluent_test_window(cx, |_, _| { + ContextMenu::build(point(px(20.), px(20.))).action("Open", |_, _, _| {}) + }); + cx.simulate_resize(gpui::size(px(800.), px(600.))); + cx.run_until_parked(); + cx.accessibility_snapshot(); + + cx.click_at( + point(px(700.), px(500.)), + gpui::MouseButton::Left, + gpui::Modifiers::none(), + ); + menu.read_with(cx, |menu, _| assert!(menu.closing)); + cx.executor().advance_clock(Duration::from_millis(200)); + menu.read_with(cx, |menu, _| assert!(!menu.open)); + } } diff --git a/crates/fluent-layout/src/menu_bar.rs b/crates/fluent-layout/src/menu_bar.rs index 168c0a6..0e45105 100644 --- a/crates/fluent-layout/src/menu_bar.rs +++ b/crates/fluent-layout/src/menu_bar.rs @@ -2,9 +2,9 @@ use std::sync::{Arc, Mutex}; use fluent_core::{LayoutDirection, Theme, ThemeProvider as _}; use gpui::{ - anchored, canvas, deferred, div, point, prelude::*, px, svg, App, Bounds, ClickEvent, Context, - FocusHandle, IntoElement, KeyDownEvent, MouseButton, Pixels, Render, Role, SharedString, - Subscription, WeakEntity, WeakFocusHandle, Window, WindowId, + anchored, canvas, deferred, div, point, prelude::*, px, svg, Anchor, App, Bounds, ClickEvent, + Context, FocusHandle, IntoElement, KeyDownEvent, MouseButton, Pixels, Render, Role, + SharedString, Subscription, WeakEntity, WeakFocusHandle, Window, WindowId, }; use crate::context_menu::ContextMenuItem; @@ -558,7 +558,6 @@ impl MenuBar { .min_w(px(env.components.menu_min_width)) .shadow_md(); - let mut row_top = env.spacing.xs; for (idx, item) in items.iter().enumerate() { let mut path = path_prefix.clone(); path.push(idx); @@ -631,7 +630,6 @@ impl MenuBar { ); } menu = menu.child(row); - row_top += env.components.menu_item_height; } ContextMenuItem::Checkbox { label, @@ -705,7 +703,6 @@ impl MenuBar { ) .child(div().flex_1().child(menu_tree::label_element(label, true))), ); - row_top += env.components.menu_item_height; } ContextMenuItem::Radio { label, @@ -775,7 +772,6 @@ impl MenuBar { ) .child(div().flex_1().child(menu_tree::label_element(label, true))), ); - row_top += env.components.menu_item_height; } ContextMenuItem::Submenu { label, @@ -814,23 +810,21 @@ impl MenuBar { cx.notify(); })); } - menu = menu.child( - row.child(menu_tree::icon_slot(icon.clone(), fg, env)) - .child(div().flex_1().child(menu_tree::label_element(label, true))) - .child( - svg() - .path("icons/chevron_right.svg") - .size(px(12.0)) - .text_color(fg), - ), - ); + row = row + .child(menu_tree::icon_slot(icon.clone(), fg, env)) + .child(div().flex_1().child(menu_tree::label_element(label, true))) + .child( + svg() + .path("icons/chevron_right.svg") + .size(px(12.0)) + .text_color(fg), + ); if !disabled && menu_tree::path_starts_with(&self.open_path, &path) { let submenu = self.render_items(items, path.clone(), self_weak.clone(), env, cx); - menu = menu - .child(div().absolute().left_full().top(px(row_top)).child(submenu)); + row = row.child(menu_tree::submenu_flyout(submenu, env, 2 + path.len())); } - row_top += env.components.menu_item_height; + menu = menu.child(row); } ContextMenuItem::Separator => { menu = menu.child( @@ -840,7 +834,6 @@ impl MenuBar { .my(px(env.spacing.xs)) .bg(env.colors.stroke_neutral_subtle), ); - row_top += env.components.popup_separator_height + env.spacing.xs * 2.0; } } } @@ -974,26 +967,28 @@ impl Render for MenuBar { item_list, ); - let vp = window.viewport_size(); let mut popup = div(); if !self.closing { let closer = self_weak.clone(); - popup = popup.child( - anchored().position(point(px(0.0), px(0.0))).child( - div() - .id("menu-backdrop") - .w(vp.width) - .h(vp.height) - .on_mouse_down(MouseButton::Left, move |_, _, cx| { - cx.stop_propagation(); - closer.update(cx, |bar, cx| bar.close(cx)).ok(); - }), - ), - ); + popup = popup.child(menu_tree::window_backdrop( + "menu-backdrop", + window, + move |_, cx| { + closer.update(cx, |bar, cx| bar.close(cx)).ok(); + }, + )); } Some( - deferred(popup.child(anchored().position(anchor).child(item_list))) - .with_priority(2), + deferred( + popup.child( + anchored() + .anchor(Anchor::TopLeft) + .position(anchor) + .snap_to_window_with_margin(px(menu_tree::SUBMENU_WINDOW_MARGIN)) + .child(item_list), + ), + ) + .with_priority(2), ) } else { None @@ -1275,4 +1270,107 @@ mod tests { cx.type_keys("f10"); cx.assert_not_focused(&handle); } + + fn submenu_bar(cx: &mut TestAppContext) -> (Entity, &mut gpui::VisualTestContext) { + open_fluent_test_window(cx, |_, cx| { + MenuBar::new(cx) + .menu("File", vec![ContextMenuItem::action("New", |_, _, _| {})]) + .menu( + "Help", + vec![ + ContextMenuItem::action("About", |_, _, _| {}), + ContextMenuItem::submenu( + "More", + vec![ + ContextMenuItem::action("Inner", |_, _, _| {}), + ContextMenuItem::submenu( + "Deeper", + vec![ContextMenuItem::action("Leaf", |_, _, _| {})], + ), + ], + ), + ], + ) + }) + } + + fn open_in_window( + bar: &Entity, + cx: &mut gpui::VisualTestContext, + index: usize, + width: f32, + open_path: Vec, + ) { + cx.simulate_resize(gpui::size(px(width), px(400.))); + cx.run_until_parked(); + cx.accessibility_snapshot(); + bar.update(cx, |bar, cx| { + bar.open_menu(index, false); + bar.open_path = open_path; + cx.notify(); + }); + // Trigger bounds are measured in prepaint and read on the next render. + cx.accessibility_snapshot(); + cx.accessibility_snapshot(); + } + + #[gpui::test] + fn dropdown_stays_attached_below_trigger_near_the_right_edge(cx: &mut TestAppContext) { + let (bar, cx) = submenu_bar(cx); + // Narrower than trigger.left + the 180px minimum dropdown width. + open_in_window(&bar, cx, 1, 240.0, Vec::new()); + + let trigger = bar.read_with(cx, |bar, _| { + bar.trigger_bounds[1] + .lock() + .unwrap() + .expect("trigger measured") + }); + let first = cx + .selector_bounds("menu-item-row-[0]") + .expect("dropdown row"); + assert!( + first.top() >= trigger.bottom(), + "{first:?} covers the bar {trigger:?}" + ); + assert!(first.right() <= px(240. - 8. + 1.), "{first:?}"); + assert!( + first.left() <= trigger.right() && first.right() >= trigger.left(), + "dropdown detached from trigger: {first:?} vs {trigger:?}" + ); + } + + #[gpui::test] + fn bar_submenu_flips_left_and_lines_up_with_its_row(cx: &mut TestAppContext) { + let (bar, cx) = submenu_bar(cx); + // The second-level submenu opens right of the dropdown (x ~50..250 and + // ~250..450); a third level would need to reach 650, so it flips left. + open_in_window(&bar, cx, 1, 640.0, vec![1, 1]); + + let parent = cx + .selector_bounds("menu-item-row-[1, 1]") + .expect("parent row"); + let child = cx + .selector_bounds("menu-item-row-[1, 1, 0]") + .expect("child row"); + assert_eq!(child.top(), parent.top()); + assert!(child.left() >= px(8.), "{child:?}"); + assert!( + child.right() <= parent.left() + px(2.), + "did not flip left: {child:?} vs {parent:?}" + ); + assert!(child.right() <= px(640. - 8. + 1.), "{child:?}"); + } + + #[gpui::test] + fn clicking_outside_closes_the_bar_dropdown(cx: &mut TestAppContext) { + let (bar, cx) = submenu_bar(cx); + open_in_window(&bar, cx, 0, 800.0, Vec::new()); + cx.click_at( + point(px(700.), px(350.)), + gpui::MouseButton::Left, + gpui::Modifiers::none(), + ); + bar.read_with(cx, |bar, _| assert!(bar.closing)); + } } diff --git a/crates/fluent-layout/src/menu_button.rs b/crates/fluent-layout/src/menu_button.rs index 07416e9..f1b7538 100644 --- a/crates/fluent-layout/src/menu_button.rs +++ b/crates/fluent-layout/src/menu_button.rs @@ -277,7 +277,6 @@ impl MenuButton { .min_w(px(env.components.menu_min_width)) .shadow_md(); - let mut row_top = env.spacing.xs; for (idx, item) in items.iter().enumerate() { let mut path = path_prefix.clone(); path.push(idx); @@ -355,7 +354,6 @@ impl MenuButton { ); } menu = menu.child(row); - row_top += env.components.menu_item_height; } ContextMenuItem::Checkbox { label, @@ -434,7 +432,6 @@ impl MenuButton { ) .child(div().flex_1().child(label.clone())), ); - row_top += env.components.menu_item_height; } ContextMenuItem::Radio { label, @@ -509,7 +506,6 @@ impl MenuButton { ) .child(div().flex_1().child(label.clone())), ); - row_top += env.components.menu_item_height; } ContextMenuItem::Submenu { label, @@ -561,15 +557,12 @@ impl MenuButton { .size(px(12.0)) .text_color(fg), ); - menu = menu.child(row); - if !disabled && menu_tree::path_starts_with(&self.open_path, &path) { let submenu = self.render_items(items, path.clone(), self_weak.clone(), env, cx); - menu = menu - .child(div().absolute().left_full().top(px(row_top)).child(submenu)); + row = row.child(menu_tree::submenu_flyout(submenu, env, path.len())); } - row_top += env.components.menu_item_height; + menu = menu.child(row); } ContextMenuItem::Separator => { menu = menu.child( @@ -579,7 +572,6 @@ impl MenuButton { .my(px(env.spacing.xs)) .bg(env.colors.stroke_neutral_subtle), ); - row_top += env.components.popup_separator_height + env.spacing.xs * 2.0; } } } @@ -717,19 +709,16 @@ impl Render for MenuButton { menu, ); - let mut overlay = div().absolute().inset_0(); + let mut overlay = div(); if !self.closing { let closer = self_weak.clone(); - overlay = overlay.child( - div() - .id("menu-button-backdrop") - .absolute() - .inset_0() - .on_mouse_down(MouseButton::Left, move |_, _, cx| { - cx.stop_propagation(); - closer.update(cx, |button, cx| button.close_popup(cx)).ok(); - }), - ); + overlay = overlay.child(menu_tree::window_backdrop( + "menu-button-backdrop", + window, + move |_, cx| { + closer.update(cx, |button, cx| button.close_popup(cx)).ok(); + }, + )); } root = root.child(deferred( @@ -737,7 +726,7 @@ impl Render for MenuButton { anchored() .anchor(Anchor::TopLeft) .position(self.popup_origin) - .snap_to_window() + .snap_to_window_with_margin(px(menu_tree::SUBMENU_WINDOW_MARGIN)) .child(menu), ), )); @@ -874,4 +863,27 @@ mod tests { ] ); } + + #[gpui::test] + fn clicking_anywhere_outside_closes_the_popup(cx: &mut TestAppContext) { + use fluent_test_utils::{open_fluent_test_window, FluentInteractionExt as _}; + + let (button, cx) = open_fluent_test_window(cx, |_, _| { + MenuButton::new("test-menu-button", "Actions").item(item("One")) + }); + cx.simulate_resize(gpui::size(px(800.), px(600.))); + cx.run_until_parked(); + cx.accessibility_snapshot(); + button.update(cx, |button, cx| button.open_popup(cx)); + cx.accessibility_snapshot(); + + cx.click_at( + point(px(700.), px(500.)), + gpui::MouseButton::Left, + gpui::Modifiers::none(), + ); + button.read_with(cx, |button, _| assert!(button.closing)); + cx.executor().advance_clock(Duration::from_millis(200)); + button.read_with(cx, |button, _| assert!(!button.open)); + } } diff --git a/crates/fluent-layout/src/menu_tree.rs b/crates/fluent-layout/src/menu_tree.rs index 8ef6e56..0b3aafd 100644 --- a/crates/fluent-layout/src/menu_tree.rs +++ b/crates/fluent-layout/src/menu_tree.rs @@ -1,7 +1,11 @@ use fluent_core::{ ColorScheme, ComponentTokens, RadiiTokens, SpacingTokens, Theme, TypographyTokens, }; -use gpui::{div, prelude::*, px, svg, AnyElement, ElementId, Keystroke, SharedString}; +use gpui::{ + deferred, div, point, prelude::*, px, svg, AnyElement, App, Bounds, Display, Edges, Element, + ElementId, GlobalElementId, InspectorElementId, Keystroke, LayoutId, Pixels, Position, + SharedString, Style, Window, +}; use crate::context_menu::ContextMenuItem; @@ -301,6 +305,7 @@ pub(crate) fn row_base( ) -> gpui::Stateful { div() .id(row_id(id_prefix, path)) + .debug_selector(|| format!("{id_prefix}-row-{path:?}")) .role(role) .aria_label(label.clone()) .flex() @@ -318,6 +323,171 @@ pub(crate) fn row_base( .text_color(fg) } +/// Gap kept between a flyout submenu and the window edge. +pub(crate) const SUBMENU_WINDOW_MARGIN: f32 = 8.0; + +/// Lays a submenu beside the submenu-trigger row it is a child of. +/// +/// The element fills the row (`absolute`, all insets zero), so its prepaint +/// bounds are the row's bounds and no row offset is tracked by hand. The +/// submenu opens to the right of the row with its first item level with the +/// row, overlapping the parent's border by one pixel as Zed's submenus do. When +/// it would run past the right window edge and fits on the left, it opens to the +/// left instead. It is then snapped inside the window with +/// [`SUBMENU_WINDOW_MARGIN`], like `anchored().snap_to_window_with_margin`. +struct SubmenuPlacement { + child: Option, + /// How far above the row the submenu's top edge sits (the parent's top + /// border and padding), so the first items align. + top_inset: Pixels, +} + +impl Element for SubmenuPlacement { + type RequestLayoutState = LayoutId; + type PrepaintState = (); + + fn id(&self) -> Option { + None + } + + fn source_location(&self) -> Option<&'static core::panic::Location<'static>> { + None + } + + fn request_layout( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + window: &mut Window, + cx: &mut App, + ) -> (LayoutId, Self::RequestLayoutState) { + let child_id = self + .child + .as_mut() + .map(|child| child.request_layout(window, cx)) + .expect("submenu placement child"); + let zero = gpui::Length::Definite(px(0.).into()); + let style = Style { + position: Position::Absolute, + display: Display::Flex, + inset: Edges { + top: zero, + right: zero, + bottom: zero, + left: zero, + }, + ..Style::default() + }; + (window.request_layout(style, [child_id], cx), child_id) + } + + fn prepaint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + row: Bounds, + child_id: &mut LayoutId, + window: &mut Window, + cx: &mut App, + ) { + let child_bounds = window.layout_bounds(*child_id); + let size = child_bounds.size; + let viewport = window.viewport_size(); + let inset = window.client_inset().unwrap_or(px(0.)); + let margin = px(SUBMENU_WINDOW_MARGIN) + inset; + let overlap = px(1.); + + let mut x = row.right() - overlap; + if x + size.width > viewport.width - margin && row.left() + overlap - size.width >= margin { + x = row.left() + overlap - size.width; + } + let mut y = row.top() - self.top_inset; + + if x + size.width > viewport.width - margin { + x = viewport.width - margin - size.width; + } + if x < margin { + x = margin; + } + if y + size.height > viewport.height - margin { + y = viewport.height - margin - size.height; + } + if y < margin { + y = margin; + } + + let offset = point(x, y) - child_bounds.origin; + let offset = point(offset.x.round(), offset.y.round()); + if let Some(child) = self.child.as_mut() { + window.with_element_offset(offset, |window| child.prepaint(window, cx)); + } + } + + fn paint( + &mut self, + _id: Option<&GlobalElementId>, + _inspector_id: Option<&InspectorElementId>, + _bounds: Bounds, + _child_id: &mut LayoutId, + _prepaint: &mut (), + window: &mut Window, + cx: &mut App, + ) { + if let Some(child) = self.child.as_mut() { + child.paint(window, cx); + } + } +} + +impl IntoElement for SubmenuPlacement { + type Element = Self; + + fn into_element(self) -> Self::Element { + self + } +} + +/// The deferred flyout for an open submenu. Add it as a child of the +/// submenu-trigger row. `priority` must exceed the parent menu's so a nested +/// flyout paints over it. +pub(crate) fn submenu_flyout( + submenu: AnyElement, + env: &MenuRenderEnv, + priority: usize, +) -> AnyElement { + deferred(SubmenuPlacement { + child: Some(div().absolute().child(submenu).into_any_element()), + // Parent border (1) plus its vertical padding. + top_inset: px(env.spacing.xs + 1.0), + }) + .with_priority(priority) + .into_any_element() +} + +/// A window-sized layer that closes its menu on any click outside the menu. +/// Place it inside a `deferred` overlay, before the menu so the menu paints +/// above it. +pub(crate) fn window_backdrop( + id: &'static str, + window: &Window, + on_close: impl Fn(&mut Window, &mut App) + 'static, +) -> AnyElement { + let viewport = window.viewport_size(); + gpui::anchored() + .position(point(px(0.), px(0.))) + .child( + div() + .id(id) + .w(viewport.width) + .h(viewport.height) + .on_mouse_down(gpui::MouseButton::Left, move |_, window, cx| { + cx.stop_propagation(); + on_close(window, cx); + }), + ) + .into_any_element() +} + pub(crate) fn icon_slot( icon: Option, fg: gpui::Hsla, diff --git a/crates/fluent-layout/src/popover.rs b/crates/fluent-layout/src/popover.rs index 7ca72c0..5b5eca6 100644 --- a/crates/fluent-layout/src/popover.rs +++ b/crates/fluent-layout/src/popover.rs @@ -61,7 +61,7 @@ impl RenderOnce for Popover { anchored() .anchor(self.anchor) .position(self.position) - .snap_to_window() + .snap_to_window_with_margin(px(8.)) .child(surface), )) } diff --git a/crates/fluent-primitives/src/combobox.rs b/crates/fluent-primitives/src/combobox.rs index a26f8e5..52867c8 100644 --- a/crates/fluent-primitives/src/combobox.rs +++ b/crates/fluent-primitives/src/combobox.rs @@ -551,7 +551,7 @@ impl Render for Combobox { anchored() .anchor(Anchor::TopLeft) .position(self.drop_origin) - .snap_to_window() + .snap_to_window_with_margin(px(8.)) .child(list), )); } diff --git a/crates/fluent-primitives/src/date_time_picker.rs b/crates/fluent-primitives/src/date_time_picker.rs index 3c62054..f0dcad6 100644 --- a/crates/fluent-primitives/src/date_time_picker.rs +++ b/crates/fluent-primitives/src/date_time_picker.rs @@ -1060,7 +1060,7 @@ impl Render for DateTimePicker { anchored() .anchor(Anchor::TopLeft) .position(self.drop_origin) - .snap_to_window() + .snap_to_window_with_margin(px(8.)) .child(panel), )); } diff --git a/crates/fluent-primitives/src/dropdown.rs b/crates/fluent-primitives/src/dropdown.rs index 8d1ea37..692e484 100644 --- a/crates/fluent-primitives/src/dropdown.rs +++ b/crates/fluent-primitives/src/dropdown.rs @@ -457,7 +457,7 @@ impl Render for Dropdown { anchored() .anchor(Anchor::TopLeft) .position(self.drop_origin) - .snap_to_window() + .snap_to_window_with_margin(px(8.)) .child(list), )); } diff --git a/crates/fluent-primitives/src/suggestion_popover.rs b/crates/fluent-primitives/src/suggestion_popover.rs index a4d759e..7bcbe5c 100644 --- a/crates/fluent-primitives/src/suggestion_popover.rs +++ b/crates/fluent-primitives/src/suggestion_popover.rs @@ -430,7 +430,7 @@ impl RenderOnce for SuggestionPopover { anchored() .anchor(self.anchor) .position(self.position) - .snap_to_window() + .snap_to_window_with_margin(px(8.)) .child(list), )) } diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 7f2e6d9..6697307 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -1,8 +1,8 @@ use families::{DemoViews, FamilyHost, FamilyNav}; use fluent_app::{FluentApp, TitleBar}; -use fluent_core::gpui::{div, prelude::*, Context, IntoElement, Render, Window}; +use fluent_core::gpui::{div, prelude::*, App, ClickEvent, Context, IntoElement, Render, Window}; use fluent_layout::{ - DockPanel, DockPosition, MenuBar, ModalStack, Pane, TabItem, TabStrip, Workspace, + DockPanel, DockPosition, MenuBar, MenuItemDef, ModalStack, Pane, TabItem, TabStrip, Workspace, }; use fluent_primitives::{InputCapture, TerminalCell, TerminalGrid, TerminalView, VideoSurface}; use fluent_ribbon::RibbonBar; @@ -26,6 +26,8 @@ impl Render for GalleryRoot { // Entry point // --------------------------------------------------------------------------- +fn noop(_: &ClickEvent, _: &mut Window, _: &mut App) {} + fn main() { FluentApp::new("FluentGUI Gallery").dark_theme().run(|cx| { let video = cx.new(VideoSurface::new); @@ -78,7 +80,56 @@ fn main() { let title_bar = cx.new(|cx| TitleBar::new(cx, "FluentGUI Gallery")); // Menu bar - let menu_bar = cx.new(MenuBar::new); + let menu_bar = cx.new(|cx| { + MenuBar::new(cx) + .menu( + "&File", + vec![ + MenuItemDef::action("New", noop), + MenuItemDef::submenu( + "Open Recent", + vec![ + MenuItemDef::action("Project One", noop), + MenuItemDef::submenu( + "Older", + vec![ + MenuItemDef::action("Project Two", noop), + MenuItemDef::submenu( + "Archive", + vec![MenuItemDef::action("Project Three", noop)], + ), + ], + ), + ], + ), + MenuItemDef::separator(), + MenuItemDef::action("Exit", noop), + ], + ) + .menu( + "&View", + vec![ + MenuItemDef::submenu( + "Zoom", + vec![ + MenuItemDef::action("Zoom In", noop), + MenuItemDef::action("Zoom Out", noop), + ], + ), + MenuItemDef::action("Full Screen", noop), + ], + ) + .menu( + "&Help", + vec![ + MenuItemDef::submenu( + "Learn", + vec![MenuItemDef::action("Documentation", noop)], + ), + MenuItemDef::action("About", noop), + ], + ) + }); // Ribbon let ribbon = cx.new(|cx| { From a9f17203c63f400b22fe0015bc0b2fdccf9a8729 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:41:44 +0000 Subject: [PATCH 5/7] command palette: scrollable results keep the selected row in view (WI-3262) Graft-Changeset: c7013dd1d703da9d3778044200fdeb63c4e71ece1d6f501db183486d8885b1000 Graft-Session: ses_01M6M3SFHNKW4W9PB4ZHHM3GQP Graft-Work-Item: WI-3261 Graft-Batch: 32 --- crates/fluent-layout/src/command_palette.rs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/crates/fluent-layout/src/command_palette.rs b/crates/fluent-layout/src/command_palette.rs index 2207107..9afa136 100644 --- a/crates/fluent-layout/src/command_palette.rs +++ b/crates/fluent-layout/src/command_palette.rs @@ -148,6 +148,7 @@ pub struct CommandPalette { entries: Vec, filtered: Vec, selected: usize, + results_scroll: gpui::ScrollHandle, max_results: Option, /// Public API item. pub on_select: Option>, @@ -164,6 +165,7 @@ impl CommandPalette { entries: vec![], filtered: vec![], selected: 0, + results_scroll: gpui::ScrollHandle::new(), max_results: None, on_select: None, } @@ -485,7 +487,15 @@ impl Render for CommandPalette { let entries = &self.entries; // Build result rows - let mut results = div().flex().flex_col().max_h(px(360.0)).overflow_hidden(); + let mut results = div() + .id("command-palette-results") + .flex() + .flex_col() + .max_h(px(360.0)) + .overflow_y_scroll() + .track_scroll(&self.results_scroll); + let mut child_count = 0usize; + let mut selected_child = None; let mut previous_group: Option = None; for (row_idx, &entry_idx) in filtered.iter().enumerate() { @@ -502,6 +512,7 @@ impl Render for CommandPalette { if group.is_some() && group != previous_group { if let Some(group_label) = group.clone() { + child_count += 1; results = results.child( div() .px(px(spacing.md)) @@ -600,8 +611,15 @@ impl Render for CommandPalette { .child(hint), ); } + if is_selected { + selected_child = Some(child_count); + } + child_count += 1; results = results.child(row_el); } + if let Some(ix) = selected_child { + self.results_scroll.scroll_to_item(ix); + } // Empty state if filtered.is_empty() { From b98d6110d1522aead9795400eb1023c2364a1c0d Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:41:50 +0000 Subject: [PATCH 6/7] Fluent type-ramp line heights and uniform button boxes (WI-3256, WI-3257) Graft-Changeset: c5013679ae589e99ee82038028dbf910764ad0e419af5ac113602369251f9fedc Graft-Session: ses_01M6M3PHHQZE8XPWZ8SBCMWH0F Graft-Work-Item: WI-3256 Graft-Batch: 32 --- CHANGELOG.md | 1 + crates/fluent-app/src/app_builder.rs | 12 +- crates/fluent-app/src/title_bar.rs | 6 +- crates/fluent-core/src/components.rs | 16 ++ crates/fluent-core/src/lib.rs | 2 +- crates/fluent-core/src/typography.rs | 26 +++ crates/fluent-layout/src/command_palette.rs | 15 +- crates/fluent-layout/src/context_menu.rs | 3 +- crates/fluent-layout/src/data_status.rs | 3 +- crates/fluent-layout/src/data_table.rs | 5 +- crates/fluent-layout/src/dialog.rs | 14 +- crates/fluent-layout/src/dock.rs | 3 +- crates/fluent-layout/src/document_viewer.rs | 9 +- crates/fluent-layout/src/menu_bar.rs | 5 +- crates/fluent-layout/src/menu_button.rs | 5 +- crates/fluent-layout/src/menu_tree.rs | 3 +- crates/fluent-layout/src/message_bar.rs | 8 +- crates/fluent-layout/src/modal.rs | 15 +- crates/fluent-layout/src/nav.rs | 3 +- crates/fluent-layout/src/selectable_list.rs | 9 +- crates/fluent-layout/src/settings_nav.rs | 5 +- crates/fluent-layout/src/tab_strip.rs | 3 +- crates/fluent-layout/src/toolbar.rs | 7 +- crates/fluent-layout/src/tree.rs | 3 +- crates/fluent-primitives/src/app_dot.rs | 1 + crates/fluent-primitives/src/avatar.rs | 1 + crates/fluent-primitives/src/badge.rs | 5 +- crates/fluent-primitives/src/button.rs | 165 ++++++++++++++---- crates/fluent-primitives/src/chip.rs | 3 +- crates/fluent-primitives/src/combobox.rs | 7 +- .../fluent-primitives/src/connection_badge.rs | 3 +- .../fluent-primitives/src/date_time_picker.rs | 11 +- crates/fluent-primitives/src/dropdown.rs | 7 +- crates/fluent-primitives/src/expander.rs | 3 +- crates/fluent-primitives/src/field.rs | 9 +- crates/fluent-primitives/src/info_label.rs | 5 +- crates/fluent-primitives/src/label.rs | 5 +- crates/fluent-primitives/src/link.rs | 3 +- crates/fluent-primitives/src/persona.rs | 3 +- crates/fluent-primitives/src/radio_group.rs | 3 +- crates/fluent-primitives/src/searchbox.rs | 3 +- .../fluent-primitives/src/section_header.rs | 3 +- .../src/suggestion_popover.rs | 9 +- crates/fluent-primitives/src/tag.rs | 26 ++- crates/fluent-primitives/src/text_input.rs | 3 +- crates/fluent-primitives/src/textarea.rs | 3 +- crates/fluent-primitives/src/toggle_button.rs | 42 +++-- crates/fluent-primitives/src/token_input.rs | 3 +- crates/fluent-primitives/src/tooltip.rs | 3 +- crates/fluent-ribbon/src/bar.rs | 17 +- 50 files changed, 378 insertions(+), 149 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cd099a4..ddc5160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ per crate. Workspace release. Includes `VideoSurface` (3.1.0), the AccessKit tree snapshot test helper and the `test-a11y-activation` vendor patch (headless test-window AccessKit activation) that rdpapp-fluent needs. Crate versions are unchanged, as in v3.1.0; consumers pin the tag. +- fluent-core, fluent-primitives, fluent-layout, fluent-ribbon, fluent-app: text no longer inherits gpui's 1.618x line height. `TextRampExt::text_ramp` sets a ramp step's size and whole-pixel line height together, `FluentApp` sets the body ramp (13/20) as the root text style, and Button, ToggleButton and dialog actions share one border-box height per size, always centre their content, and take 12/8px padding and gap from new `ComponentTokens` (WI-3256, WI-3257). - fluent-layout: submenus open beside their parent row, flip left and snap inside the window with an 8px margin; the MenuBar dropdown anchors to the trigger's bottom-left with the same fit; MenuButton and ContextMenu click-away backdrops cover the window; popups use an 8px window margin (WI-3252, WI-3253, WI-3254). - fluent-primitives: deny missing public API documentation and document every exported control and supporting type (WI-2945). diff --git a/crates/fluent-app/src/app_builder.rs b/crates/fluent-app/src/app_builder.rs index ce34984..4590a54 100644 --- a/crates/fluent-app/src/app_builder.rs +++ b/crates/fluent-app/src/app_builder.rs @@ -1,9 +1,9 @@ use std::sync::Arc; -use fluent_core::{Theme, ThemeMode}; +use fluent_core::{TextRampExt as _, Theme, ThemeMode, ThemeProvider as _}; use fluent_layout::{modal::ModalStack, ToastStack}; use gpui::{ - prelude::*, px, size, App, AssetSource, Bounds, Context, Entity, IntoElement, Render, + div, prelude::*, px, size, App, AssetSource, Bounds, Context, Entity, IntoElement, Render, SharedString, TitlebarOptions, Window, WindowBounds, WindowDecorations, WindowOptions, }; @@ -55,7 +55,13 @@ impl Render for ThemeRoot { } } - self.content.clone() + // Root text style: body ramp with a whole-pixel line height, so text that + // sets no line height of its own does not inherit gpui's 1.618x default. + let body = cx.theme().typography.body; + div() + .size_full() + .text_ramp(&body) + .child(self.content.clone()) } } diff --git a/crates/fluent-app/src/title_bar.rs b/crates/fluent-app/src/title_bar.rs index 23e3577..a820891 100644 --- a/crates/fluent-app/src/title_bar.rs +++ b/crates/fluent-app/src/title_bar.rs @@ -1,4 +1,4 @@ -use fluent_core::{LayoutDirection, Theme, ThemeProvider as _, TruncateExt as _}; +use fluent_core::{LayoutDirection, TextRampExt as _, Theme, ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, svg, ClickEvent, Context, IntoElement, MouseButton, MouseDownEvent, Render, SharedString, Window, @@ -146,7 +146,7 @@ impl Render for TitleBar { .gap(px(spacing.sm)) .when(!rtl, |area| area.pl(px(spacing.md))) .when(rtl, |area| area.pr(px(spacing.md))) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(fg) .on_mouse_down(MouseButton::Left, drag_handler); if let Some(path) = icon { @@ -156,7 +156,7 @@ impl Render for TitleBar { if let Some(status) = status { title_area = title_area.child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(status), ); diff --git a/crates/fluent-core/src/components.rs b/crates/fluent-core/src/components.rs index 54ecc64..e9c20c1 100644 --- a/crates/fluent-core/src/components.rs +++ b/crates/fluent-core/src/components.rs @@ -17,6 +17,17 @@ pub struct ComponentTokens { pub button_medium_icon_size: f32, /// Public API item. pub button_large_icon_size: f32, + /// Fluent 2 button horizontal padding for small, medium, and large controls. + /// Padding sits inside the control's border-box height. + pub button_small_padding_x: f32, + /// Public API item. + pub button_medium_padding_x: f32, + /// Public API item. + pub button_large_padding_x: f32, + /// Fluent 2 gap between a button's icon and label (small, then medium/large). + pub button_small_gap: f32, + /// Public API item. + pub button_medium_gap: f32, /// Public API item. pub menu_bar_height: f32, /// Public API item. @@ -90,6 +101,11 @@ impl Default for ComponentTokens { button_small_icon_size: 12.0, button_medium_icon_size: 16.0, button_large_icon_size: 20.0, + button_small_padding_x: 8.0, + button_medium_padding_x: 12.0, + button_large_padding_x: 16.0, + button_small_gap: 4.0, + button_medium_gap: 8.0, menu_bar_height: 28.0, menu_item_height: 28.0, menu_min_width: 200.0, diff --git a/crates/fluent-core/src/lib.rs b/crates/fluent-core/src/lib.rs index ee676ee..8f6fa0b 100644 --- a/crates/fluent-core/src/lib.rs +++ b/crates/fluent-core/src/lib.rs @@ -31,7 +31,7 @@ pub use theme::{ Brightness, LayoutDirection, LayoutDirectionProvider, Theme, ThemeMode, ThemeProvider, }; pub use truncate::TruncateExt; -pub use typography::{TypeRamp, TypographyTokens}; +pub use typography::{TextRampExt, TypeRamp, TypographyTokens}; #[cfg(test)] mod tests { diff --git a/crates/fluent-core/src/typography.rs b/crates/fluent-core/src/typography.rs index aba19e2..f3a47cf 100644 --- a/crates/fluent-core/src/typography.rs +++ b/crates/fluent-core/src/typography.rs @@ -1,3 +1,5 @@ +use gpui::{px, Styled}; + /// A single typographic ramp step. /// /// `size` and `line_height` are in logical pixels (DIPs). @@ -68,3 +70,27 @@ impl Default for TypographyTokens { } } } + +impl TypeRamp { + /// Font size as a whole-pixel length. + pub fn font_size_px(&self) -> gpui::Pixels { + px(self.size.round()) + } + + /// Line height as a whole-pixel length, so line boxes never land on half pixels. + pub fn line_height_px(&self) -> gpui::Pixels { + px(self.line_height.round()) + } +} + +/// Applies a [`TypeRamp`] step's size and whole-pixel line height together, so a +/// text size can never be set without its line height (gpui's default is 1.618x). +pub trait TextRampExt: Styled + Sized { + /// Sets `text_size` and `line_height` from `ramp`. + fn text_ramp(self, ramp: &TypeRamp) -> Self { + self.text_size(ramp.font_size_px()) + .line_height(ramp.line_height_px()) + } +} + +impl TextRampExt for T {} diff --git a/crates/fluent-layout/src/command_palette.rs b/crates/fluent-layout/src/command_palette.rs index 9afa136..07d33ff 100644 --- a/crates/fluent-layout/src/command_palette.rs +++ b/crates/fluent-layout/src/command_palette.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::{fmt, rc::Rc}; use fluent_core::{Theme, ThemeProvider as _}; @@ -518,7 +519,7 @@ impl Render for CommandPalette { .px(px(spacing.md)) .pt(px(spacing.sm)) .pb(px(4.0)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .font_weight(FontWeight::SEMIBOLD) .text_color(colors.on_subtle) .child(group_label), @@ -582,7 +583,7 @@ impl Render for CommandPalette { let mut text_col = div().flex().flex_col().flex_1().child( div() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(label_fg) .when(is_selected, |d| d.font_weight(FontWeight::SEMIBOLD)) .child(label), @@ -591,7 +592,7 @@ impl Render for CommandPalette { if let Some(sub) = subtitle { text_col = text_col.child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(sub_fg) .child(sub), ); @@ -606,7 +607,7 @@ impl Render for CommandPalette { .rounded(px(radii.sm)) .border_1() .border_color(colors.stroke_neutral_subtle) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(sub_fg) .child(hint), ); @@ -630,7 +631,7 @@ impl Render for CommandPalette { .flex() .items_center() .justify_center() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_subtle_disabled) .child("No results"), ); @@ -665,7 +666,7 @@ impl Render for CommandPalette { .child( div() .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(input_text_color) .child(query_display), ), @@ -685,7 +686,7 @@ impl Render for CommandPalette { .border_color(colors.stroke_neutral_subtle) .child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle_disabled) .child("↑↓ navigate · ↵ select · Esc close"), ), diff --git a/crates/fluent-layout/src/context_menu.rs b/crates/fluent-layout/src/context_menu.rs index 22c2238..bc5cc82 100644 --- a/crates/fluent-layout/src/context_menu.rs +++ b/crates/fluent-layout/src/context_menu.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use fluent_core::{Theme, ThemeProvider as _}; @@ -571,7 +572,7 @@ impl ContextMenu { div() .flex_none() .pl(px(env.spacing.xl)) - .text_size(px(env.typography.caption.size)) + .text_ramp(&env.typography.caption) .text_color(env.colors.on_subtle_disabled) .child(shortcut), ); diff --git a/crates/fluent-layout/src/data_status.rs b/crates/fluent-layout/src/data_status.rs index cd0b480..154e945 100644 --- a/crates/fluent-layout/src/data_status.rs +++ b/crates/fluent-layout/src/data_status.rs @@ -1,5 +1,6 @@ //! Shared loading, empty, no-results and error states for data-heavy views. +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{div, prelude::*, px, App, Role, SharedString}; @@ -59,7 +60,7 @@ pub(crate) fn status_block( .flex() .items_center() .justify_center() - .text_size(px(theme.typography.body.size)) + .text_ramp(&theme.typography.body) .text_color(color) .child(message) } diff --git a/crates/fluent-layout/src/data_table.rs b/crates/fluent-layout/src/data_table.rs index 3e005c2..4691693 100644 --- a/crates/fluent-layout/src/data_table.rs +++ b/crates/fluent-layout/src/data_table.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use crate::{ @@ -568,7 +569,7 @@ impl RenderOnce for DataTable { .items_center() .gap(px(spacing.xs)) .px(px(spacing.md)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_subtle) .border_r_1() @@ -672,7 +673,7 @@ impl RenderOnce for DataTable { .when(align == DataCellAlign::End, |d| d.justify_end()) .px(px(spacing_for_rows.md)) .py(px(spacing_for_rows.xs)) - .text_size(px(typography_for_rows.body.size)) + .text_ramp(&typography_for_rows.body) .text_color(colors_for_rows.on_neutral) .border_r_1() .border_color(colors_for_rows.stroke_neutral_subtle) diff --git a/crates/fluent-layout/src/dialog.rs b/crates/fluent-layout/src/dialog.rs index d6485e0..6d72532 100644 --- a/crates/fluent-layout/src/dialog.rs +++ b/crates/fluent-layout/src/dialog.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, prelude::*, px, svg, AnyElement, App, ClickEvent, IntoElement, RenderOnce, SharedString, @@ -104,6 +105,7 @@ impl RenderOnce for Dialog { let spacing = theme.spacing; let typography = theme.typography; let radii = theme.radii; + let components = theme.components; let mut actions = div().flex().flex_row().justify_end().gap(px(spacing.sm)); @@ -128,13 +130,17 @@ impl RenderOnce for Dialog { div() .id(("dialog-action", idx as u64)) .debug_selector(|| format!("dialog-action-{idx}")) - .px(px(spacing.md)) - .py(px(spacing.sm)) + .flex() + .flex_row() + .items_center() + .justify_center() + .h(px(components.button_medium_height)) + .px(px(components.button_medium_padding_x)) .rounded(px(radii.md)) .bg(bg) .border_1() .border_color(colors.stroke_neutral) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg) .cursor_pointer() .hover(move |s| s.bg(hover)) @@ -151,7 +157,7 @@ impl RenderOnce for Dialog { .child( div() .flex_1() - .text_size(px(typography.subtitle.size)) + .text_ramp(&typography.subtitle) .line_height(px(typography.subtitle.line_height)) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_neutral) diff --git a/crates/fluent-layout/src/dock.rs b/crates/fluent-layout/src/dock.rs index 809f76d..2a6fc64 100644 --- a/crates/fluent-layout/src/dock.rs +++ b/crates/fluent-layout/src/dock.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{LayoutDirection, Theme, ThemeProvider as _}; use gpui::{ div, prelude::*, px, svg, AnyView, ClickEvent, Context, CursorStyle, DragMoveEvent, FontWeight, @@ -146,7 +147,7 @@ impl Render for DockPanel { .child( div() .flex_1() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .font_weight(FontWeight::SEMIBOLD) .text_color(on_neutral) .child(title), diff --git a/crates/fluent-layout/src/document_viewer.rs b/crates/fluent-layout/src/document_viewer.rs index db92364..e51d499 100644 --- a/crates/fluent-layout/src/document_viewer.rs +++ b/crates/fluent-layout/src/document_viewer.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, img, prelude::*, px, App, FontWeight, IntoElement, RenderOnce, SharedString, Window, @@ -227,7 +228,7 @@ impl RenderOnce for DocumentViewer { .flex() .items_center() .justify_center() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_subtle) .child(self.empty_message), ); @@ -276,7 +277,7 @@ fn render_block(idx: usize, block: RichDocumentBlock, cx: &mut App) -> gpui::Any .p(px(spacing.md)) .rounded(px(theme.radii.sm)) .bg(colors.neutral) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .line_height(px(typography.body.line_height)) .children(runs.into_iter().map(|run| render_run(run.code(true), cx))) .into_any_element(), @@ -308,7 +309,7 @@ fn render_block(idx: usize, block: RichDocumentBlock, cx: &mut App) -> gpui::Any .rounded(px(theme.radii.sm)) .border_1() .border_color(colors.stroke_neutral_subtle) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(if alt.is_empty() { "Image".to_string() @@ -399,7 +400,7 @@ fn render_table(idx: usize, rows: Vec>, cx: &mut App) -> impl .p(px(spacing.sm)) .border_r_1() .border_color(colors.stroke_neutral_subtle) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .font_weight(if row_idx == 0 { FontWeight::SEMIBOLD } else { diff --git a/crates/fluent-layout/src/menu_bar.rs b/crates/fluent-layout/src/menu_bar.rs index 0e45105..1f23586 100644 --- a/crates/fluent-layout/src/menu_bar.rs +++ b/crates/fluent-layout/src/menu_bar.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::{Arc, Mutex}; use fluent_core::{LayoutDirection, Theme, ThemeProvider as _}; @@ -624,7 +625,7 @@ impl MenuBar { div() .flex_none() .pl(px(env.spacing.xl)) - .text_size(px(env.typography.caption.size)) + .text_ramp(&env.typography.caption) .text_color(env.colors.on_subtle_disabled) .child(shortcut), ); @@ -911,7 +912,7 @@ impl Render for MenuBar { .items_center() .px(px(spacing.md)) .h_full() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(trigger_text) .bg(trigger_bg) .rounded(px(radii.sm)) diff --git a/crates/fluent-layout/src/menu_button.rs b/crates/fluent-layout/src/menu_button.rs index f1b7538..da83a3b 100644 --- a/crates/fluent-layout/src/menu_button.rs +++ b/crates/fluent-layout/src/menu_button.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::{Arc, Mutex}; use fluent_core::{Theme, ThemeProvider as _}; @@ -348,7 +349,7 @@ impl MenuButton { div() .flex_none() .pl(px(env.spacing.xl)) - .text_size(px(env.typography.caption.size)) + .text_ramp(&env.typography.caption) .text_color(env.colors.on_subtle_disabled) .child(shortcut), ); @@ -643,7 +644,7 @@ impl Render for MenuButton { } else { colors.stroke_neutral }) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg) .child(measure_trigger); diff --git a/crates/fluent-layout/src/menu_tree.rs b/crates/fluent-layout/src/menu_tree.rs index 0b3aafd..00c5377 100644 --- a/crates/fluent-layout/src/menu_tree.rs +++ b/crates/fluent-layout/src/menu_tree.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{ ColorScheme, ComponentTokens, RadiiTokens, SpacingTokens, Theme, TypographyTokens, }; @@ -319,7 +320,7 @@ pub(crate) fn row_base( } else { env.colors.surface }) - .text_size(px(env.typography.body.size)) + .text_ramp(&env.typography.body) .text_color(fg) } diff --git a/crates/fluent-layout/src/message_bar.rs b/crates/fluent-layout/src/message_bar.rs index 740d4d3..6758a17 100644 --- a/crates/fluent-layout/src/message_bar.rs +++ b/crates/fluent-layout/src/message_bar.rs @@ -1,4 +1,4 @@ -use fluent_core::{ThemeProvider as _, TruncateExt as _}; +use fluent_core::{TextRampExt as _, ThemeProvider as _, TruncateExt as _}; use gpui::{ div, prelude::*, px, svg, App, ClickEvent, IntoElement, RenderOnce, Role, SharedString, Window, }; @@ -100,7 +100,7 @@ impl RenderOnce for MessageBar { if let Some(title) = self.title { text = text.child( div() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_neutral) .truncate_label() @@ -109,7 +109,7 @@ impl RenderOnce for MessageBar { } text = text.child( div() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(colors.on_subtle) .child(self.message), @@ -146,7 +146,7 @@ impl RenderOnce for MessageBar { .role(Role::Button) .aria_label(label.clone()) .cursor_pointer() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(accent) .hover(move |s| s.bg(hover)) diff --git a/crates/fluent-layout/src/modal.rs b/crates/fluent-layout/src/modal.rs index d837f6e..15d89f9 100644 --- a/crates/fluent-layout/src/modal.rs +++ b/crates/fluent-layout/src/modal.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{Theme, ThemeProvider as _}; use gpui::{ div, prelude::*, px, AnyView, ClickEvent, Context, Entity, FocusHandle, Global, IntoElement, @@ -302,7 +303,7 @@ impl Render for MessageDialog { div() .flex_1() .min_w_0() - .text_size(px(typography.subtitle.size)) + .text_ramp(&typography.subtitle) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_neutral) .child(title), @@ -337,7 +338,7 @@ impl Render for MessageDialog { .min_w_0() .overflow_hidden() .p(px(spacing.lg)) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(colors.on_subtle) .child(message), @@ -360,7 +361,7 @@ impl Render for MessageDialog { .rounded(px(radii.md)) .bg(colors.accent) .text_color(colors.on_accent) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .cursor_pointer() .hover({ let hover = colors.accent_hover; @@ -453,14 +454,14 @@ impl Render for ConfirmModal { .p(px(spacing.xl)) .child( div() - .text_size(px(typography.subtitle.size)) + .text_ramp(&typography.subtitle) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_neutral) .child(title), ) .child( div() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_subtle) .child(message), ) @@ -480,7 +481,7 @@ impl Render for ConfirmModal { .bg(colors.neutral) .border_1() .border_color(colors.stroke_neutral) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_neutral) .cursor_pointer() .hover(move |s| s.bg(colors.neutral_hover)) @@ -496,7 +497,7 @@ impl Render for ConfirmModal { .py(px(spacing.sm)) .rounded(px(radii.md)) .bg(colors.accent) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_accent) .cursor_pointer() .hover(move |s| s.bg(colors.accent_hover)) diff --git a/crates/fluent-layout/src/nav.rs b/crates/fluent-layout/src/nav.rs index 66a4c2a..59cce98 100644 --- a/crates/fluent-layout/src/nav.rs +++ b/crates/fluent-layout/src/nav.rs @@ -1,5 +1,6 @@ //! Fluent 2 side navigation with categories, nested groups, and roving focus. +use fluent_core::TextRampExt; use std::sync::Arc; use fluent_core::{Theme, ThemeProvider as _}; @@ -411,7 +412,7 @@ fn render_item( row = row.child( div() .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(if active { colors.on_neutral } else { diff --git a/crates/fluent-layout/src/selectable_list.rs b/crates/fluent-layout/src/selectable_list.rs index 52c6243..0d7c34c 100644 --- a/crates/fluent-layout/src/selectable_list.rs +++ b/crates/fluent-layout/src/selectable_list.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::{rc::Rc, sync::Arc}; use fluent_core::ThemeProvider as _; @@ -445,7 +446,7 @@ fn status_panel( .flex() .items_center() .justify_center() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(color) .child(message) } @@ -565,7 +566,7 @@ fn render_row( .overflow_hidden() .whitespace_nowrap() .text_ellipsis() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(if item.emphasized { FontWeight::SEMIBOLD } else { @@ -590,7 +591,7 @@ fn render_row( .overflow_hidden() .when(!variable_height, |d| d.whitespace_nowrap().text_ellipsis()) .py(px(if variable_height { 4.0 } else { 0.0 })) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(if item.disabled { colors.on_neutral_disabled } else { @@ -606,7 +607,7 @@ fn render_row( row = row.child( div() .flex_none() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(meta), ); diff --git a/crates/fluent-layout/src/settings_nav.rs b/crates/fluent-layout/src/settings_nav.rs index 88f8e4f..0da8ee6 100644 --- a/crates/fluent-layout/src/settings_nav.rs +++ b/crates/fluent-layout/src/settings_nav.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use fluent_core::{Theme, ThemeProvider as _}; @@ -207,7 +208,7 @@ impl Render for SettingsNav { .child( div() .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(FontWeight::SEMIBOLD) .text_color(on_neutral) .child(section_title), @@ -263,7 +264,7 @@ impl Render for SettingsNav { div() .flex_1() .pl(px(components.settings_nav_item_indent)) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(if is_active { FontWeight::SEMIBOLD } else { diff --git a/crates/fluent-layout/src/tab_strip.rs b/crates/fluent-layout/src/tab_strip.rs index 93a2ee0..ff3edda 100644 --- a/crates/fluent-layout/src/tab_strip.rs +++ b/crates/fluent-layout/src/tab_strip.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use fluent_core::{LayoutDirection, Theme, ThemeProvider as _, TruncateExt as _}; @@ -222,7 +223,7 @@ impl Render for TabStrip { .flex_none() .bg(tab_bg) .cursor_pointer() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(on_neutral) .border_r_1() .border_color(border_c) diff --git a/crates/fluent-layout/src/toolbar.rs b/crates/fluent-layout/src/toolbar.rs index 210a5c5..d348cc3 100644 --- a/crates/fluent-layout/src/toolbar.rs +++ b/crates/fluent-layout/src/toolbar.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use fluent_core::ThemeProvider as _; @@ -264,7 +265,7 @@ impl Toolbar { .h(px(components.dock_header_height)) .px(px(spacing.sm)) .rounded(px(radii.sm)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(fg) .child(match icon { Some(icon) => svg() @@ -330,7 +331,7 @@ impl Toolbar { .px(px(spacing.sm)) .rounded(px(radii.sm)) .bg(bg) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(fg) .child(match icon { Some(icon) => svg() @@ -444,7 +445,7 @@ impl RenderOnce for Toolbar { .h(px(components.dock_header_height)) .px(px(spacing.sm)) .rounded(px(radii.sm)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_neutral) .cursor_pointer() .hover({ diff --git a/crates/fluent-layout/src/tree.rs b/crates/fluent-layout/src/tree.rs index 8a81b43..c20f825 100644 --- a/crates/fluent-layout/src/tree.rs +++ b/crates/fluent-layout/src/tree.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use crate::data_status::{status_block, DataStatusKind}; @@ -438,7 +439,7 @@ impl Tree { } else { colors.surface }) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg); if !item.disabled { diff --git a/crates/fluent-primitives/src/app_dot.rs b/crates/fluent-primitives/src/app_dot.rs index daec971..c3e2311 100644 --- a/crates/fluent-primitives/src/app_dot.rs +++ b/crates/fluent-primitives/src/app_dot.rs @@ -80,6 +80,7 @@ impl RenderOnce for AppDot { .text_color(gpui::white()) .font_weight(FontWeight::BOLD) .text_size(px(text_size)) + .line_height(px(text_size.round())) .child(self.letter) } } diff --git a/crates/fluent-primitives/src/avatar.rs b/crates/fluent-primitives/src/avatar.rs index 8ca62f0..dffdfa0 100644 --- a/crates/fluent-primitives/src/avatar.rs +++ b/crates/fluent-primitives/src/avatar.rs @@ -78,6 +78,7 @@ impl RenderOnce for Avatar { base.bg(gradient_from_hue(hue)) .text_color(gpui::white()) .text_size(px(text_size)) + .line_height(px(text_size.round())) .font_weight(FontWeight::BOLD) .child(initials) } else if let Some(path) = self.image_path { diff --git a/crates/fluent-primitives/src/badge.rs b/crates/fluent-primitives/src/badge.rs index 2810497..0add4bd 100644 --- a/crates/fluent-primitives/src/badge.rs +++ b/crates/fluent-primitives/src/badge.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{div, prelude::*, px, App, IntoElement, RenderOnce, Window}; @@ -167,7 +168,7 @@ impl RenderOnce for CounterBadge { .flex() .items_center() .justify_center() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(if ghost { colors.accent } else { @@ -236,7 +237,7 @@ impl RenderOnce for Badge { .flex() .items_center() .justify_center() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_accent) .child(label) } diff --git a/crates/fluent-primitives/src/button.rs b/crates/fluent-primitives/src/button.rs index e7e8e94..95552e7 100644 --- a/crates/fluent-primitives/src/button.rs +++ b/crates/fluent-primitives/src/button.rs @@ -1,8 +1,8 @@ use crate::focus::FluentFocus as _; -use fluent_core::{ControlStates, NeutralColorStates, ThemeProvider as _}; +use fluent_core::{ControlStates, NeutralColorStates, TextRampExt as _, ThemeProvider as _}; use gpui::{ - div, prelude::*, px, App, ClickEvent, ElementId, Hsla, IntoElement, RenderOnce, Role, - SharedString, Window, + div, prelude::*, px, transparent_black, App, ClickEvent, ElementId, Hsla, IntoElement, + RenderOnce, Role, SharedString, Window, }; /// Visual style of a `Button`. @@ -181,7 +181,6 @@ impl RenderOnce for Button { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let theme = cx.theme(); let colors = &theme.colors; - let spacing = &theme.spacing; let radii = &theme.radii; let typography = &theme.typography; @@ -229,27 +228,28 @@ impl RenderOnce for Button { ButtonShape::Circular => px(radii.pill), }; - let (height, px_val, font_size, line_height, icon_size) = match self.size { + let components = &theme.components; + let (height, px_val, gap, ramp, icon_size) = match self.size { ButtonSize::Medium => ( - theme.components.button_medium_height, - spacing.md, - px(typography.body.size), - px(typography.body.line_height), - theme.components.button_medium_icon_size, + components.button_medium_height, + components.button_medium_padding_x, + components.button_medium_gap, + typography.body, + components.button_medium_icon_size, ), ButtonSize::Small => ( - theme.components.button_small_height, - spacing.sm, - px(typography.caption.size), - px(typography.caption.line_height), - theme.components.button_small_icon_size, + components.button_small_height, + components.button_small_padding_x, + components.button_small_gap, + typography.caption, + components.button_small_icon_size, ), ButtonSize::Large => ( - theme.components.button_large_height, - spacing.lg, - px(typography.subtitle.size), - px(typography.subtitle.line_height), - theme.components.button_large_icon_size, + components.button_large_height, + components.button_large_padding_x, + components.button_medium_gap, + typography.subtitle, + components.button_large_icon_size, ), }; @@ -260,9 +260,10 @@ impl RenderOnce for Button { let icon = self.icon; let icon_position = self.icon_position; let label = self.label; - let has_icon = icon.is_some(); let debug_id = self.id.to_string(); + let icon_debug_id = debug_id.clone(); + let label_debug_id = debug_id.clone(); let mut el = div() .id(self.id) .debug_selector(move || debug_id) @@ -271,19 +272,18 @@ impl RenderOnce for Button { .flex() .flex_row() .items_center() - .when(!has_icon, |d| d.justify_center()) // center text when no icon - .gap(px(spacing.sm)) + .justify_center() + .gap(px(gap)) .h(px(height)) .px(px(px_val)) .rounded(radius) .bg(bg) - .text_size(font_size) - .line_height(line_height) - .text_color(fg); - - if let Some(border_color) = border { - el = el.border_1().border_color(border_color); - } + .text_ramp(&ramp) + .text_color(fg) + // Every appearance carries a 1px border (transparent when it has no + // outline), so all appearances share one border-box height. + .border_1() + .border_color(border.unwrap_or_else(transparent_black)); if !disabled { el = el @@ -305,6 +305,7 @@ impl RenderOnce for Button { { el = el.child( gpui::svg() + .debug_selector(move || format!("{icon_debug_id}-icon")) .path(icon_path.clone()) .size(px(icon_size)) .text_color(fg), @@ -312,7 +313,11 @@ impl RenderOnce for Button { } if let Some(text) = label { - el = el.child(text); + el = el.child( + div() + .debug_selector(move || format!("{label_debug_id}-label")) + .child(text), + ); } if let Some(icon_path) = icon.filter(|_| icon_position == ButtonIconPosition::After) { @@ -467,7 +472,9 @@ impl RenderOnce for CompoundButton { mod tests { use std::{cell::Cell, rc::Rc}; - use fluent_test_utils::{assert_a11y_node, open_fluent_test_window, FluentInteractionExt as _}; + use fluent_test_utils::{ + assert_a11y_node, init_fluent_test, open_fluent_test_window, FluentInteractionExt as _, + }; use gpui::{Context, InputEvent as _, KeyUpEvent, Keystroke, Render, TestAppContext}; use super::*; @@ -550,6 +557,100 @@ mod tests { cx.accessibility_snapshot() } + const APPEARANCES: [ButtonAppearance; 5] = [ + ButtonAppearance::Primary, + ButtonAppearance::Secondary, + ButtonAppearance::Outline, + ButtonAppearance::Subtle, + ButtonAppearance::Transparent, + ]; + + #[gpui::test] + fn every_appearance_and_size_has_the_same_height(cx: &mut TestAppContext) { + let (_, vcx) = open_fluent_test_window(cx, |_, _| { + A11yHost(Box::new(|| { + let mut col = div().flex().flex_col().w(px(400.)); + for (i, appearance) in APPEARANCES.into_iter().enumerate() { + for (j, size) in [ButtonSize::Small, ButtonSize::Medium, ButtonSize::Large] + .into_iter() + .enumerate() + { + col = col.child( + Button::new(format!("b-{i}-{j}")) + .label("Save") + .appearance(appearance) + .size(size), + ); + } + } + col.into_any_element() + })) + }); + vcx.update(|window, app| window.draw(app).clear(app)); + let tokens = vcx.update(|_, app| app.theme().components); + let expected = [ + tokens.button_small_height, + tokens.button_medium_height, + tokens.button_large_height, + ]; + for i in 0..APPEARANCES.len() { + for (j, want) in expected.into_iter().enumerate() { + let bounds = vcx + .selector_bounds(Box::leak(format!("b-{i}-{j}").into_boxed_str())) + .expect("button laid out"); + assert_eq!( + f32::from(bounds.size.height), + want, + "appearance {i} size {j}" + ); + } + } + } + + #[gpui::test] + fn icon_and_label_are_centred_when_stretched(cx: &mut TestAppContext) { + let (_, vcx) = open_fluent_test_window(cx, |_, _| { + A11yHost(Box::new(|| { + div() + .flex() + .flex_col() + .w(px(300.)) + .child(Button::new("ic").label("Save").icon("icons/none.svg")) + .into_any_element() + })) + }); + vcx.update(|window, app| window.draw(app).clear(app)); + let button = vcx.selector_bounds("ic").expect("button"); + let icon = vcx.selector_bounds("ic-icon").expect("icon"); + let label = vcx.selector_bounds("ic-label").expect("label"); + assert_eq!(f32::from(button.size.width), 300.0); + let left = f32::from(icon.origin.x - button.origin.x); + let right = f32::from(button.right() - label.right()); + assert!( + (left - right).abs() <= 1.0, + "content not centred: {left} left vs {right} right" + ); + } + + #[gpui::test] + fn text_ramp_line_boxes_are_whole_pixels(cx: &mut TestAppContext) { + init_fluent_test(cx); + let typography = cx.update(|app| app.theme().typography); + for ramp in [ + typography.caption, + typography.body, + typography.subtitle, + typography.title, + typography.display, + ] { + let line = f32::from(ramp.line_height_px()); + let size = f32::from(ramp.font_size_px()); + assert_eq!(line.fract(), 0.0, "{ramp:?}"); + assert_eq!(size.fract(), 0.0, "{ramp:?}"); + assert!(line >= size, "{ramp:?}"); + } + } + #[gpui::test] fn accessibility_snapshot_reports_button(cx: &mut TestAppContext) { let snap = snapshot(cx, || Button::new("b").label("Save").into_any_element()); diff --git a/crates/fluent-primitives/src/chip.rs b/crates/fluent-primitives/src/chip.rs index 3513a10..78c74ac 100644 --- a/crates/fluent-primitives/src/chip.rs +++ b/crates/fluent-primitives/src/chip.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, prelude::*, px, App, ClickEvent, ElementId, IntoElement, RenderOnce, SharedString, Window, @@ -54,7 +55,7 @@ impl RenderOnce for Chip { .bg(colors.neutral) .border_1() .border_color(colors.stroke_neutral) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_neutral) .child(self.label); diff --git a/crates/fluent-primitives/src/combobox.rs b/crates/fluent-primitives/src/combobox.rs index 52867c8..eab7448 100644 --- a/crates/fluent-primitives/src/combobox.rs +++ b/crates/fluent-primitives/src/combobox.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::{Arc, Mutex}; use fluent_core::{Theme, ThemeProvider as _}; @@ -420,7 +421,7 @@ impl Render for Combobox { .child( div() .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg) .child(text), ) @@ -491,7 +492,7 @@ impl Render for Combobox { .h(px(components.dropdown_option_height)) .px(px(spacing.md)) .bg(row_bg) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_neutral); if !self.closing { @@ -536,7 +537,7 @@ impl Render for Combobox { .px(px(spacing.md)) .flex() .items_center() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_subtle_disabled) .child("No matches"), ); diff --git a/crates/fluent-primitives/src/connection_badge.rs b/crates/fluent-primitives/src/connection_badge.rs index 101da7d..0d74897 100644 --- a/crates/fluent-primitives/src/connection_badge.rs +++ b/crates/fluent-primitives/src/connection_badge.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::time::Duration; use fluent_core::ThemeProvider as _; @@ -114,7 +115,7 @@ impl RenderOnce for ConnectionBadge { .rounded(px(radii.pill)) .bg(bg) .text_color(fg) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .child(dot_el) .child(div().font_weight(gpui::FontWeight::SEMIBOLD).child(label)) } diff --git a/crates/fluent-primitives/src/date_time_picker.rs b/crates/fluent-primitives/src/date_time_picker.rs index f0dcad6..ad7a5d1 100644 --- a/crates/fluent-primitives/src/date_time_picker.rs +++ b/crates/fluent-primitives/src/date_time_picker.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::{Arc, Mutex}; use fluent_core::{Theme, ThemeProvider as _}; @@ -966,7 +967,7 @@ impl Render for DateTimePicker { .overflow_hidden() .whitespace_nowrap() .text_ellipsis() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg) .child(label), ) @@ -1020,7 +1021,7 @@ impl Render for DateTimePicker { if let Some(message) = self.invalid_message.clone() { panel = panel.child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.status_error) .child(message), ); @@ -1103,7 +1104,7 @@ fn segment_control( .w(px(58.0)) .child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .line_height(px(typography.caption.line_height)) .text_color(colors.on_subtle) .child(segment.label()), @@ -1125,7 +1126,7 @@ fn segment_control( }) .border_1() .border_color(border) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(FontWeight::SEMIBOLD) .text_color(colors.on_neutral) .cursor(if disabled { @@ -1174,7 +1175,7 @@ fn segment_step_button( .h(px(24.0)) .rounded(px(radii.sm)) .bg(colors.subtle) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(FontWeight::SEMIBOLD) .text_color(if disabled { colors.on_subtle_disabled diff --git a/crates/fluent-primitives/src/dropdown.rs b/crates/fluent-primitives/src/dropdown.rs index 692e484..14d0b8c 100644 --- a/crates/fluent-primitives/src/dropdown.rs +++ b/crates/fluent-primitives/src/dropdown.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::{Arc, Mutex}; use crate::focus::FluentFocus as _; @@ -324,7 +325,7 @@ impl Render for Dropdown { }) .border_1() .border_color(colors.stroke_neutral) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg) .child(div().flex_1().child(label)) .child( @@ -413,7 +414,7 @@ impl Render for Dropdown { .h(px(components.dropdown_option_height)) .px(px(spacing.md)) .bg(row_bg) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_neutral); if !self.closing { @@ -442,7 +443,7 @@ impl Render for Dropdown { .px(px(spacing.md)) .flex() .items_center() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_subtle_disabled) .child("No options"), ); diff --git a/crates/fluent-primitives/src/expander.rs b/crates/fluent-primitives/src/expander.rs index 4af5cc3..9393ecb 100644 --- a/crates/fluent-primitives/src/expander.rs +++ b/crates/fluent-primitives/src/expander.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, prelude::*, px, svg, AnyElement, App, ClickEvent, Div, ElementId, FocusHandle, FontWeight, @@ -152,7 +153,7 @@ impl RenderOnce for Expander { ) .child( div() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .font_weight(FontWeight::SEMIBOLD) .child(self.title), diff --git a/crates/fluent-primitives/src/field.rs b/crates/fluent-primitives/src/field.rs index cdfd7da..c292315 100644 --- a/crates/fluent-primitives/src/field.rs +++ b/crates/fluent-primitives/src/field.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{LayoutDirection, ThemeProvider as _}; use gpui::{ div, prelude::*, px, AnyElement, App, Entity, IntoElement, RenderOnce, SharedString, Window, @@ -202,7 +203,7 @@ impl RenderOnce for Field { .when(!rtl, |row| row.flex_row()) .items_center() .gap(px(spacing.xs)) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(colors.on_neutral); @@ -242,7 +243,7 @@ impl RenderOnce for Field { if let Some(helper) = self.helper_text { body = body.child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .line_height(px(typography.caption.line_height)) .text_color(colors.on_subtle) .child(helper), @@ -266,7 +267,7 @@ impl RenderOnce for Field { if let Some(helper) = self.helper_text { root = root.child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .line_height(px(typography.caption.line_height)) .text_color(colors.on_subtle) .child(helper), @@ -304,7 +305,7 @@ fn validation_message( .flex() .items_center() .gap(px(spacing.xs)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .line_height(px(typography.caption.line_height)) .text_color(color) .when(state != FieldValidationState::None, |row| { diff --git a/crates/fluent-primitives/src/info_label.rs b/crates/fluent-primitives/src/info_label.rs index 37a3b3b..f2fbfb3 100644 --- a/crates/fluent-primitives/src/info_label.rs +++ b/crates/fluent-primitives/src/info_label.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, prelude::*, px, Context, ElementId, IntoElement, Render, Role, SharedString, Window, @@ -59,7 +60,7 @@ impl Render for InfoLabel { .rounded(px(9.0)) .border_1() .border_color(colors.stroke_neutral) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .font_weight(gpui::FontWeight::SEMIBOLD) .text_color(colors.on_subtle) .hover(move |style| style.bg(hover_background)) @@ -91,7 +92,7 @@ impl Render for InfoLabel { .border_1() .border_color(colors.stroke_neutral_dim) .shadow_md() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .line_height(px(typography.caption.line_height)) .text_color(colors.on_neutral) .child(self.content.clone()), diff --git a/crates/fluent-primitives/src/label.rs b/crates/fluent-primitives/src/label.rs index 34700bf..5430698 100644 --- a/crates/fluent-primitives/src/label.rs +++ b/crates/fluent-primitives/src/label.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{ThemeProvider as _, TypographyTokens}; use gpui::{ div, prelude::*, px, App, FontWeight, Hsla, IntoElement, RenderOnce, SharedString, Window, @@ -102,7 +103,7 @@ impl RenderOnce for Label { .flex() .flex_row() .items_center() - .text_size(px(ramp.size)) + .text_ramp(&ramp) .line_height(px(ramp.line_height)) .font_weight(weight) .text_color(color); @@ -121,7 +122,7 @@ impl RenderOnce for Label { } let el = div() - .text_size(px(ramp.size)) + .text_ramp(&ramp) .line_height(px(ramp.line_height)) .font_weight(weight) .text_color(color) diff --git a/crates/fluent-primitives/src/link.rs b/crates/fluent-primitives/src/link.rs index 43aed2d..a39fb75 100644 --- a/crates/fluent-primitives/src/link.rs +++ b/crates/fluent-primitives/src/link.rs @@ -1,4 +1,5 @@ use crate::focus::FluentFocus as _; +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, prelude::*, px, App, ClickEvent, ElementId, IntoElement, RenderOnce, Role, SharedString, @@ -104,7 +105,7 @@ impl RenderOnce for Link { .role(Role::Link) .aria_label(label.clone()) .flex() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(foreground) .child(label); diff --git a/crates/fluent-primitives/src/persona.rs b/crates/fluent-primitives/src/persona.rs index 2473f7c..0fe728a 100644 --- a/crates/fluent-primitives/src/persona.rs +++ b/crates/fluent-primitives/src/persona.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{div, prelude::*, px, App, FontWeight, IntoElement, RenderOnce, SharedString, Window}; @@ -89,7 +90,7 @@ impl RenderOnce for Persona { div() .flex() .flex_col() - .text_size(px(ramp.size)) + .text_ramp(&ramp) .line_height(px(ramp.line_height)) .child(div().font_weight(FontWeight::SEMIBOLD).child(self.primary)) .when_some(self.secondary, |this, secondary| { diff --git a/crates/fluent-primitives/src/radio_group.rs b/crates/fluent-primitives/src/radio_group.rs index b472362..1e56b11 100644 --- a/crates/fluent-primitives/src/radio_group.rs +++ b/crates/fluent-primitives/src/radio_group.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use crate::focus::FluentFocus as _; @@ -195,7 +196,7 @@ impl RenderOnce for RadioGroup { .flex_row() .items_center() .gap(px(spacing.sm)) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(fg); diff --git a/crates/fluent-primitives/src/searchbox.rs b/crates/fluent-primitives/src/searchbox.rs index 40eb5ed..75331c8 100644 --- a/crates/fluent-primitives/src/searchbox.rs +++ b/crates/fluent-primitives/src/searchbox.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{Theme, ThemeProvider as _}; use gpui::{ div, prelude::*, px, svg, App, Context, CursorStyle, FocusHandle, Focusable, IntoElement, @@ -228,7 +229,7 @@ impl Render for Searchbox { .child( div() .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(fg) .child(text), diff --git a/crates/fluent-primitives/src/section_header.rs b/crates/fluent-primitives/src/section_header.rs index 493dbff..224c436 100644 --- a/crates/fluent-primitives/src/section_header.rs +++ b/crates/fluent-primitives/src/section_header.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{ div, prelude::*, px, AnyElement, App, FontWeight, IntoElement, RenderOnce, SharedString, Window, @@ -41,7 +42,7 @@ impl RenderOnce for SectionHeader { .child( div() .text_color(colors.on_neutral) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .font_weight(FontWeight::SEMIBOLD) .child(self.title), ); diff --git a/crates/fluent-primitives/src/suggestion_popover.rs b/crates/fluent-primitives/src/suggestion_popover.rs index 7bcbe5c..2a35b74 100644 --- a/crates/fluent-primitives/src/suggestion_popover.rs +++ b/crates/fluent-primitives/src/suggestion_popover.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::Arc; use fluent_core::ThemeProvider as _; @@ -328,7 +329,7 @@ impl RenderOnce for SuggestionPopover { .py(px(empty_py)) .flex() .items_center() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(colors.on_subtle_disabled) .child(self.empty_text), ); @@ -394,14 +395,14 @@ impl RenderOnce for SuggestionPopover { let mut title_row = div().flex().items_center().gap(px(spacing.md)).child( div() .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(text_color) .child(item.label), ); if let Some(meta) = meta { title_row = title_row.child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(meta), ); @@ -412,7 +413,7 @@ impl RenderOnce for SuggestionPopover { row = row.child( div() .pt(px(1.0)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle) .child(detail), ); diff --git a/crates/fluent-primitives/src/tag.rs b/crates/fluent-primitives/src/tag.rs index dd104d3..5128bed 100644 --- a/crates/fluent-primitives/src/tag.rs +++ b/crates/fluent-primitives/src/tag.rs @@ -135,10 +135,25 @@ impl RenderOnce for Tag { TagAppearance::Danger => (c.status_error_bg, c.status_error), TagAppearance::Neutral => (c.neutral, c.on_neutral), }; - let (pxv, pyv, fs) = match self.size { - TagSize::Small => (6., 2., t.typography.caption.size), - TagSize::Medium => (8., 4., t.typography.body.size), - TagSize::Large => (10., 6., t.typography.body.size + 1.), + let (pxv, pyv, fs, lh) = match self.size { + TagSize::Small => ( + 6., + 2., + t.typography.caption.size, + t.typography.caption.line_height, + ), + TagSize::Medium => ( + 8., + 4., + t.typography.body.size, + t.typography.body.line_height, + ), + TagSize::Large => ( + 10., + 6., + t.typography.body.size + 1., + t.typography.body.line_height, + ), }; let radius = match self.shape { TagShape::Circular => t.radii.pill, @@ -157,7 +172,8 @@ impl RenderOnce for Tag { .py(px(pyv)) .rounded(px(radius)) .bg(if selected { c.neutral_selected } else { bg }) - .text_size(px(fs)) + .text_size(px(fs.round())) + .line_height(px(lh.round())) .text_color(if disabled { c.on_neutral_disabled } else { fg }) .child(self.label); if !disabled && (select.is_some() || self.dismissible) { diff --git a/crates/fluent-primitives/src/text_input.rs b/crates/fluent-primitives/src/text_input.rs index c9e9eae..7decf3b 100644 --- a/crates/fluent-primitives/src/text_input.rs +++ b/crates/fluent-primitives/src/text_input.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::ops::Range; use fluent_core::{Theme, ThemeProvider as _}; @@ -856,7 +857,7 @@ impl Render for TextInput { .border_1() .border_color(indicator) .bg(bg) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(colors.on_neutral) .child( diff --git a/crates/fluent-primitives/src/textarea.rs b/crates/fluent-primitives/src/textarea.rs index bfe2ce0..9e49a52 100644 --- a/crates/fluent-primitives/src/textarea.rs +++ b/crates/fluent-primitives/src/textarea.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{Theme, ThemeProvider as _}; use gpui::{ div, prelude::*, px, App, ClipboardItem, Context, CursorStyle, FocusHandle, Focusable, @@ -234,7 +235,7 @@ impl Render for Textarea { .flex() .flex_col() .gap(px(spacing.xs)) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .line_height(px(typography.body.line_height)) .text_color(text_color); diff --git a/crates/fluent-primitives/src/toggle_button.rs b/crates/fluent-primitives/src/toggle_button.rs index 7a23902..93e2acf 100644 --- a/crates/fluent-primitives/src/toggle_button.rs +++ b/crates/fluent-primitives/src/toggle_button.rs @@ -1,8 +1,8 @@ use crate::focus::FluentFocus as _; -use fluent_core::ThemeProvider as _; +use fluent_core::{TextRampExt as _, ThemeProvider as _}; use gpui::{ - div, prelude::*, px, App, ClickEvent, ElementId, IntoElement, RenderOnce, Role, SharedString, - Toggled, Window, + div, prelude::*, px, transparent_black, App, ClickEvent, ElementId, IntoElement, RenderOnce, + Role, SharedString, Toggled, Window, }; use crate::button::ButtonSize; @@ -89,7 +89,6 @@ impl RenderOnce for ToggleButton { let theme = cx.theme(); let colors = &theme.colors; let radii = &theme.radii; - let spacing = &theme.spacing; let typography = &theme.typography; let (bg, bg_hover, fg) = if self.selected { @@ -109,10 +108,26 @@ impl RenderOnce for ToggleButton { }; let radius = px(radii.md); - let (px_val, py_val, font_size) = match self.size { - ButtonSize::Normal => (px(spacing.md), px(spacing.sm), px(typography.body.size)), - ButtonSize::Compact => (px(spacing.sm), px(spacing.xs), px(typography.caption.size)), - ButtonSize::Large => (px(spacing.lg), px(spacing.md), px(typography.subtitle.size)), + let components = &theme.components; + let (height, px_val, gap, ramp) = match self.size { + ButtonSize::Normal => ( + components.button_medium_height, + components.button_medium_padding_x, + components.button_medium_gap, + typography.body, + ), + ButtonSize::Compact => ( + components.button_small_height, + components.button_small_padding_x, + components.button_small_gap, + typography.caption, + ), + ButtonSize::Large => ( + components.button_large_height, + components.button_large_padding_x, + components.button_medium_gap, + typography.subtitle, + ), }; let disabled = self.disabled; @@ -137,12 +152,15 @@ impl RenderOnce for ToggleButton { .flex() .flex_row() .items_center() - .gap(px(spacing.sm)) - .px(px_val) - .py(py_val) + .justify_center() + .gap(px(gap)) + .h(px(height)) + .px(px(px_val)) + .border_1() + .border_color(transparent_black()) .rounded(radius) .bg(bg) - .text_size(font_size) + .text_ramp(&ramp) .text_color(fg); if !disabled { diff --git a/crates/fluent-primitives/src/token_input.rs b/crates/fluent-primitives/src/token_input.rs index cdc1c05..cbb4c9d 100644 --- a/crates/fluent-primitives/src/token_input.rs +++ b/crates/fluent-primitives/src/token_input.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use std::sync::{Arc, Mutex}; use fluent_core::{Theme, ThemeProvider as _}; @@ -576,7 +577,7 @@ impl Render for TokenInput { div() .min_w(px(120.0)) .flex_1() - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(query_color) .child(query_text), ); diff --git a/crates/fluent-primitives/src/tooltip.rs b/crates/fluent-primitives/src/tooltip.rs index a4a6287..92b1b9e 100644 --- a/crates/fluent-primitives/src/tooltip.rs +++ b/crates/fluent-primitives/src/tooltip.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::ThemeProvider as _; use gpui::{div, prelude::*, px, App, IntoElement, Render, RenderOnce, SharedString, Window}; @@ -44,7 +45,7 @@ impl Render for TooltipBubble { .border_1() .border_color(colors.stroke_neutral_dim) .shadow_md() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_neutral) .child(self.text.clone()) } diff --git a/crates/fluent-ribbon/src/bar.rs b/crates/fluent-ribbon/src/bar.rs index eeae027..4692701 100644 --- a/crates/fluent-ribbon/src/bar.rs +++ b/crates/fluent-ribbon/src/bar.rs @@ -1,3 +1,4 @@ +use fluent_core::TextRampExt; use fluent_core::{ ColorScheme, ComponentTokens, RadiiTokens, SpacingTokens, Theme, ThemeProvider as _, TypographyTokens, @@ -239,7 +240,7 @@ impl RibbonBar { ) .child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(fg) .child(label.clone()), ) @@ -294,7 +295,7 @@ impl RibbonBar { }) .child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(fg) .child(label.clone()), ) @@ -399,7 +400,7 @@ impl RibbonBar { ) .child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(fg) .child(label.clone()), ) @@ -465,7 +466,7 @@ impl RibbonBar { .flex() .items_center() .justify_center() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(label_fg) .child(group.name.clone()); @@ -664,7 +665,7 @@ impl Render for RibbonBar { } else { FontWeight::NORMAL }) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(on_neutral) .on_click(handler) .child(name) @@ -739,7 +740,7 @@ impl Render for RibbonBar { } else { FontWeight::NORMAL }) - .text_size(px(typography.body.size)) + .text_ramp(&typography.body) .text_color(fg) .on_click(handler) .child(name) @@ -780,7 +781,7 @@ impl Render for RibbonBar { .aria_label(SharedString::from(format!("Last command: {label}"))) .self_center() .px(px(spacing.sm)) - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(colors.on_subtle_disabled) .child(label), ) @@ -925,7 +926,7 @@ impl Render for RibbonBar { ) .child( div() - .text_size(px(typography.caption.size)) + .text_ramp(&typography.caption) .text_color(on_neutral) .child("More"), ), From f24d46672a1310b2bb50f29c4296689c9e0ff3f2 Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Wed, 4 Nov 2026 20:43:32 +0000 Subject: [PATCH 7/7] Resolve CHANGELOG merge (WI-3258) Graft-Changeset: c89d42927da4283068d15ac32aae42d302fb19481b59373983742f66bb76794c0 Graft-Session: ses_01M6M3S8KBR04SG39A6QGNY2Q7 Graft-Work-Item: WI-3258 Graft-Batch: 32 --- CHANGELOG.md | 2 ++ crates/fluent-primitives/src/checkbox.rs | 4 ++-- crates/fluent-primitives/src/radio_group.rs | 4 ++-- crates/fluent-primitives/src/switch.rs | 6 +++--- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ddc5160..fbc2675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,8 @@ Workspace release. Includes `VideoSurface` (3.1.0), the AccessKit tree snapshot - fluent-layout: submenus open beside their parent row, flip left and snap inside the window with an 8px margin; the MenuBar dropdown anchors to the trigger's bottom-left with the same fit; MenuButton and ContextMenu click-away backdrops cover the window; popups use an 8px window margin (WI-3252, WI-3253, WI-3254). - fluent-primitives: deny missing public API documentation and document every exported control and supporting type (WI-2945). +- fluent-primitives: switch thumb 12px with symmetric insets (40x20 track); checkbox 20px with `radii.md`; radio 20/10 (WI-3258). +- Single-line ellipsis truncation (`TruncateExt::truncate_label`) on tab labels (max width 240px token), tree rows, data table cells and headers, the title bar title and message bar/toast titles (WI-3268). - docs: document DPI scaling conversion and resize behavior across Windows DirectX, Linux X11, and Linux Wayland, with native-run evidence requirements (WI-3061). - Layout: add Fluent 2 Breadcrumb and Pagination controls with overflow menus, current-item semantics, RTL separators, and keyboard-ready controls (WI-3036). diff --git a/crates/fluent-primitives/src/checkbox.rs b/crates/fluent-primitives/src/checkbox.rs index d3bffca..ee527b7 100644 --- a/crates/fluent-primitives/src/checkbox.rs +++ b/crates/fluent-primitives/src/checkbox.rs @@ -144,8 +144,8 @@ impl RenderOnce for Checkbox { CheckboxState::Indeterminate => Toggled::Mixed, }) .when_some(label, |d, name| d.aria_label(name)) - .size(px(18.0)) - .rounded(px(radii.sm)) + .size(px(20.0)) + .rounded(px(radii.md)) .bg(box_bg) .border_1() .border_color(box_border) diff --git a/crates/fluent-primitives/src/radio_group.rs b/crates/fluent-primitives/src/radio_group.rs index 1e56b11..0294a93 100644 --- a/crates/fluent-primitives/src/radio_group.rs +++ b/crates/fluent-primitives/src/radio_group.rs @@ -241,7 +241,7 @@ impl RenderOnce for RadioGroup { row = row .child( div() - .size(px(16.0)) + .size(px(20.0)) .rounded(px(9999.0)) .border_1() .border_color(outer_border) @@ -250,7 +250,7 @@ impl RenderOnce for RadioGroup { .justify_center() .child( div() - .size(px(if checked { 8.0 } else { 0.0 })) + .size(px(if checked { 10.0 } else { 0.0 })) .rounded(px(9999.0)) .bg(colors.accent), ), diff --git a/crates/fluent-primitives/src/switch.rs b/crates/fluent-primitives/src/switch.rs index b3c9815..5f4ecf1 100644 --- a/crates/fluent-primitives/src/switch.rs +++ b/crates/fluent-primitives/src/switch.rs @@ -100,8 +100,8 @@ impl RenderOnce for Switch { let on = self.on; let label = self.label; - // Thumb offset: left (off) or right (on) - let thumb_ml = if self.on { px(20.0) } else { px(2.0) }; + // Fluent 2: 40x20 track, 12px knob, 4px inset from the outer edge (3px inside the 1px border) + let thumb_ml = if self.on { px(23.0) } else { px(3.0) }; let debug_id = self.id.to_string(); let mut el = div() @@ -135,7 +135,7 @@ impl RenderOnce for Switch { el.child( div() .absolute() - .size(px(14.0)) + .size(px(12.0)) .ml(thumb_ml) .rounded(px(9999.0)) .bg(thumb_color),