From 84bc45df5f8cffaf0a103f05a9f5ef196414bfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 16:48:07 -0400 Subject: [PATCH] =?UTF-8?q?Theme=20=C2=B7=20Editing:=20segments=20that=20g?= =?UTF-8?q?ive=20way,=20every=20color=20named?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SegmentedControl's segments now give way when narrow, their labels ending in an ellipsis, down to twice a control's height each; past that the strip scrolls as before. The pill's border and padding moved onto the scrolling strip, and the row inside it takes that floor as its least width, so the strip scrolls exactly to its last segment. The palette macro now writes each color's name from its field: Palette and Syntax gain NAMES and token_mut for every color, the quiet fills too. Tests press a density segment and each slider, and fit the density strip in 280px with the text a step and a half larger. --- TASKS.md | 1 + src/buttons/segmented.rs | 25 ++++-- src/settings/tests/theme.rs | 62 ++++++++++++- src/theme/palette.rs | 173 ++++++++++++++---------------------- src/theme/tests.rs | 27 ++++-- 5 files changed, 167 insertions(+), 121 deletions(-) diff --git a/TASKS.md b/TASKS.md index 50db78b..59c1dd1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -419,3 +419,4 @@ Per-component lines live in `tasks/`. Tags there: | T38a | 1 | FAIL | the resize grip and edge handles hung half outside the box; the edges' sides, a free turn and a band with Shift untested | | T38a | 2 | PASS | | | T38b | 1 | PASS | | +| T39a | 1 | FAIL | a larger text size cut the density strip at 280px; ten palette colors had no name; the density and sliders untested | diff --git a/src/buttons/segmented.rs b/src/buttons/segmented.rs index f34745a..2e59da1 100644 --- a/src/buttons/segmented.rs +++ b/src/buttons/segmented.rs @@ -12,11 +12,12 @@ use crate::{ motion::{self, Axis, Marker, glide, measure_item, measure_origin, slide}, primitives::{FocusRing, Icon, IconName, tab_stop}, theme::{ActiveTheme, ControlSize, Elevation, Radius}, + typography::Ellipsis, }; type OnChange = Rc; -/// Mutually exclusive segments. A thumb slides to the chosen one; too narrow for them, the strip scrolls sideways and a focused segment comes into view. +/// Mutually exclusive segments. A thumb slides to the chosen one. Narrow, the segments give way and their labels end in an ellipsis, down to twice a control's height each; narrower still, the strip scrolls sideways and a focused segment comes into view. #[derive(IntoElement)] pub struct SegmentedControl { id: ElementId, @@ -99,6 +100,7 @@ impl RenderOnce for SegmentedControl { let theme = cx.theme(); let colors = &theme.colors; let (text, icon_size) = label_size(self.size); + let (least, count) = (theme.control_height(self.size) * 2.0, values.len()); let duration = motion::duration(motion::SLOW, cx); let thumb = marker.map( |Marker { @@ -140,6 +142,7 @@ impl RenderOnce for SegmentedControl { .relative() .flex() .flex_1() + .min_w_0() .items_center() .justify_center() .gap_1p5() @@ -172,12 +175,15 @@ impl RenderOnce for SegmentedControl { .when_some(icon, |segment, icon| { segment.child(Icon::new(icon).size(icon_size).color(fg)) }) - .when(!label.is_empty(), |segment| segment.child(label)) + .when(!label.is_empty(), |segment| { + segment.child(div().min_w_0().child(Ellipsis::new(label))) + }) .child(measure_item(measure, ix, Axis::Horizontal)); div() .relative() .flex() .flex_1() + .min_w_0() .child(segment) .child(reveal) }); @@ -186,13 +192,9 @@ impl RenderOnce for SegmentedControl { .relative() .flex() .flex_grow() - .flex_shrink_0() + .flex_shrink() + .min_w(least * count as f32) .items_center() - .p_0p5() - .rounded(theme.radius(Radius::Lg)) - .bg(colors.sunken) - .border_1() - .border_color(colors.border) .child(measure_origin(state.clone(), Axis::Horizontal)) .children(thumb) .children(segments); @@ -201,7 +203,12 @@ impl RenderOnce for SegmentedControl { .id((self.id, "strip")) .flex() .overflow_x_scroll() - .track_scroll(&scroll), + .track_scroll(&scroll) + .p_0p5() + .rounded(theme.radius(Radius::Lg)) + .bg(colors.sunken) + .border_1() + .border_color(colors.border), ) .child(control) } diff --git a/src/settings/tests/theme.rs b/src/settings/tests/theme.rs index 49d6026..4c12ef2 100644 --- a/src/settings/tests/theme.rs +++ b/src/settings/tests/theme.rs @@ -1,9 +1,12 @@ -use gpui::{AnyElement, App, Entity, IntoElement, TestAppContext, Window}; +use gpui::{ + AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, Styled, + TestAppContext, Window, +}; -use super::{Desk, desk, said, say, tab, tap}; +use super::{Desk, desk, said, say, settle, tab, tap}; use crate::{ settings::{ThemeDraft, ThemeEditor}, - theme::ActiveTheme, + theme::{ActiveTheme, Theme}, }; fn editor(_: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { @@ -62,3 +65,56 @@ fn a_color_well_sets_its_own_color(cx: &mut TestAppContext) { tap("enter", cx); assert_eq!(said(&host, cx), ["#224466"]); } + +fn look(_: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { + ThemeEditor::new("editor", ThemeDraft::of(cx.theme())) + .on_change(move |draft, _, cx| { + let words = format!( + "{:?} corners {} text {}", + draft.density, draft.radius_scale, draft.font_scale + ); + say(&owner, words, cx) + }) + .into_any_element() +} + +/// Stops: Compact, Standard, Comfortable, corners, text size. Compact picks the density alone, and Right on each slider moves its own measure a step. +#[gpui::test] +fn the_density_and_the_sliders_move_their_own_field(cx: &mut TestAppContext) { + let (host, cx) = desk(look, cx); + tab(1, cx); + tap("space", cx); + tab(4, cx); + tap("right", cx); + tab(5, cx); + tap("right", cx); + assert_eq!( + said(&host, cx), + [ + "Compact corners 1 text 1", + "Standard corners 1.25 text 1", + "Standard corners 1 text 1.05" + ] + ); +} + +fn narrow(window: &mut Window, cx: &mut App, owner: Entity) -> AnyElement { + gpui::div() + .debug_selector(|| "narrow".into()) + .w(gpui::px(280.0)) + .child(editor(window, cx, owner)) + .into_any_element() +} + +/// At 280px with the text a step and a half larger, the density strip keeps every segment inside the box, its labels giving way. +#[gpui::test] +fn the_density_strip_fits_a_narrow_box_with_larger_text(cx: &mut TestAppContext) { + let (_, cx) = desk(narrow, cx); + cx.update(|_, cx| Theme::update(cx, |theme| theme.font_scale = 1.15)); + settle(cx); + let frame = cx.debug_bounds("narrow").expect("the box"); + let last = cx + .debug_bounds("segment Comfortable") + .expect("the last segment"); + assert!(last.right() <= frame.right(), "{last:?} inside {frame:?}"); +} diff --git a/src/theme/palette.rs b/src/theme/palette.rs index 0b61627..226dafd 100644 --- a/src/theme/palette.rs +++ b/src/theme/palette.rs @@ -31,16 +31,33 @@ impl Mix for [Hsla; N] { } macro_rules! mixable { - ($(#[$meta:meta])* $name:ident { $($field:ident: $ty:ty),* $(,)? }) => { + ($(#[$meta:meta])* $name:ident { $($color:ident),* $(,)? ; $($field:ident: $ty:ty),* $(,)? }) => { $(#[$meta])* #[derive(Clone, Debug, PartialEq)] pub struct $name { + $(pub $color: Hsla,)* $(pub $field: $ty,)* } impl Mix for $name { fn mix(&self, to: &Self, t: f32) -> Self { - Self { $($field: self.$field.mix(&to.$field, t),)* } + Self { + $($color: self.$color.mix(&to.$color, t),)* + $($field: self.$field.mix(&to.$field, t),)* + } + } + } + + impl $name { + /// The names of its colors, each a field's. + pub const NAMES: &'static [&'static str] = &[$(stringify!($color)),*]; + + /// The color named `token`, a field's name; any other name fails. + pub fn token_mut(&mut self, token: &str) -> &mut Hsla { + match token { + $(stringify!($color) => &mut self.$color,)* + other => panic!("no {} color {other}", stringify!($name).to_lowercase()), + } } } }; @@ -49,60 +66,62 @@ macro_rules! mixable { mixable!( /// Code token colors. Syntax { - keyword: Hsla, - string: Hsla, - number: Hsla, - comment: Hsla, - function: Hsla, - type_name: Hsla, - constant: Hsla, - property: Hsla, - tag: Hsla, - attribute: Hsla, - operator: Hsla, - punctuation: Hsla, - variable: Hsla, + keyword, + string, + number, + comment, + function, + type_name, + constant, + property, + tag, + attribute, + operator, + punctuation, + variable, + ; } ); mixable!( /// Semantic colors. Components read these, never literals. Palette { - bg: Hsla, - surface: Hsla, - sunken: Hsla, - overlay: Hsla, - hover: Hsla, - active: Hsla, - border: Hsla, - border_strong: Hsla, - fg: Hsla, - fg_muted: Hsla, - fg_subtle: Hsla, - fg_disabled: Hsla, - accent: Hsla, - accent_hover: Hsla, - on_accent: Hsla, - on_media: Hsla, - focus: Hsla, - link: Hsla, - selection: Hsla, - success: Hsla, - warning: Hsla, - danger: Hsla, - info: Hsla, - success_subtle: Hsla, - warning_subtle: Hsla, - danger_subtle: Hsla, - info_subtle: Hsla, - backdrop: Hsla, - media_backdrop: Hsla, - shimmer: Hsla, - shadow: Hsla, - tooltip_bg: Hsla, - tooltip_fg: Hsla, - paper: Hsla, - ink: Hsla, + bg, + surface, + sunken, + overlay, + hover, + active, + border, + border_strong, + fg, + fg_muted, + fg_subtle, + fg_disabled, + accent, + accent_hover, + on_accent, + on_media, + focus, + link, + selection, + success, + warning, + danger, + info, + success_subtle, + warning_subtle, + danger_subtle, + info_subtle, + backdrop, + media_backdrop, + shimmer, + shadow, + tooltip_bg, + tooltip_fg, + paper, + ink, + ; chart: [Hsla; 8], ansi: [Hsla; 16], syntax: Syntax, @@ -122,61 +141,7 @@ pub const HUE_NAMES: [&str; 8] = [ "Blue", "Teal", "Ochre", "Rose", "Violet", "Green", "Rust", "Cyan", ]; -impl Syntax { - /// The color of the token kind named `token`, a field's name; any other name fails. - pub fn token_mut(&mut self, token: &str) -> &mut Hsla { - match token { - "keyword" => &mut self.keyword, - "string" => &mut self.string, - "number" => &mut self.number, - "comment" => &mut self.comment, - "function" => &mut self.function, - "type_name" => &mut self.type_name, - "constant" => &mut self.constant, - "property" => &mut self.property, - "tag" => &mut self.tag, - "attribute" => &mut self.attribute, - "operator" => &mut self.operator, - "punctuation" => &mut self.punctuation, - "variable" => &mut self.variable, - other => panic!("no syntax color {other}"), - } - } -} - impl Palette { - /// The color named `token`, a field's name; any other name fails. - pub fn token_mut(&mut self, token: &str) -> &mut Hsla { - match token { - "bg" => &mut self.bg, - "surface" => &mut self.surface, - "sunken" => &mut self.sunken, - "overlay" => &mut self.overlay, - "hover" => &mut self.hover, - "active" => &mut self.active, - "border" => &mut self.border, - "border_strong" => &mut self.border_strong, - "fg" => &mut self.fg, - "fg_muted" => &mut self.fg_muted, - "fg_subtle" => &mut self.fg_subtle, - "fg_disabled" => &mut self.fg_disabled, - "accent" => &mut self.accent, - "accent_hover" => &mut self.accent_hover, - "on_accent" => &mut self.on_accent, - "focus" => &mut self.focus, - "link" => &mut self.link, - "selection" => &mut self.selection, - "success" => &mut self.success, - "warning" => &mut self.warning, - "danger" => &mut self.danger, - "info" => &mut self.info, - "tooltip_bg" => &mut self.tooltip_bg, - "tooltip_fg" => &mut self.tooltip_fg, - "shadow" => &mut self.shadow, - other => panic!("no palette color {other}"), - } - } - /// The chart color at `hue`; a hue past the chart fails, naming `owner`. pub fn hue(&self, hue: usize, owner: impl std::fmt::Display) -> Hsla { *self diff --git a/src/theme/tests.rs b/src/theme/tests.rs index 499feeb..a60b05b 100644 --- a/src/theme/tests.rs +++ b/src/theme/tests.rs @@ -1,6 +1,6 @@ use gpui::{TestAppContext, hsla}; -use super::{ActiveTheme, Mode, Palette, Theme}; +use super::{ActiveTheme, Mode, Palette, Syntax, Theme}; /// An owner's palette serves its own mode; the other keeps Ely's, and none brings Ely's back. #[gpui::test] @@ -19,12 +19,29 @@ fn an_owners_palette_serves_its_mode_alone(cx: &mut TestAppContext) { assert_eq!(cx.read(|cx| cx.theme().palette()), Palette::light(false)); } +/// Every color of the palette and of code answers to its field's name, the quiet fills too. #[test] -fn every_named_color_answers() { +fn every_color_answers_to_its_name() { let mut palette = Palette::light(false); - *palette.token_mut("accent") = hsla(0.1, 0.2, 0.3, 1.0); - assert_eq!(palette.accent, hsla(0.1, 0.2, 0.3, 1.0)); - *palette.syntax.token_mut("comment") = hsla(0.4, 0.2, 0.3, 1.0); + for (ix, name) in Palette::NAMES.iter().enumerate() { + *palette.token_mut(name) = hsla(ix as f32 / 64.0, 0.5, 0.5, 1.0); + } + assert_eq!(Palette::NAMES.len(), 35, "every color of the palette"); + let at = |name: &str| { + Palette::NAMES + .iter() + .position(|each| *each == name) + .expect("a name") + }; + assert_eq!( + palette.success_subtle, + hsla(at("success_subtle") as f32 / 64.0, 0.5, 0.5, 1.0) + ); + assert_eq!(palette.ink, hsla(at("ink") as f32 / 64.0, 0.5, 0.5, 1.0)); + for name in Syntax::NAMES { + *palette.syntax.token_mut(name) = hsla(0.4, 0.2, 0.3, 1.0); + } + assert_eq!(Syntax::NAMES.len(), 13, "every color of code"); assert_eq!(palette.syntax.comment, hsla(0.4, 0.2, 0.3, 1.0)); }