From 8c038e2c240a3325976ce118efab9a603fadc1a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 28 Sep 2026 06:37:06 -0400 Subject: [PATCH] =?UTF-8?q?Tooling=20=C2=B7=20Workbench:=20the=20knobs=20s?= =?UTF-8?q?eed=20the=20settings;=20knobs=20checked?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The knobs join a layout::seeded seed, so a changed starting value, option list or range starts every setting over, and a renamed knob no longer fails. new() refuses no knobs, twin names and a number outside its range. Controls are keyed by knob name. The test presses keys without a refresh, so only a notify redraws, and walks the switch twice, the slider twice and a pick the owner takes away. --- src/tooling/playground.rs | 119 +++++++++++++++++++++++++++----------- src/tooling/tests.rs | 52 ++++++++++++++--- 2 files changed, 129 insertions(+), 42 deletions(-) diff --git a/src/tooling/playground.rs b/src/tooling/playground.rs index 3152b5d..b691b0b 100644 --- a/src/tooling/playground.rs +++ b/src/tooling/playground.rs @@ -1,12 +1,13 @@ use std::rc::Rc; use gpui::{ - AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, - Styled, Window, div, + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, }; use crate::{ forms::{Choice, Select, Slider, Switch}, + layout::seeded::use_seeded, theme::{ActiveTheme, Radius, TextSize}, typography::{format, tabular}, }; @@ -14,7 +15,7 @@ use crate::{ type Preview = Rc AnyElement>; /// A property a playground edits: a switch, a choice among names, or a number in a range, each by its name. -#[derive(Clone, Debug)] +#[derive(Clone, Debug, PartialEq)] pub enum Knob { Toggle(SharedString, bool), Choice(SharedString, Vec, usize), @@ -96,7 +97,7 @@ impl Settings { } } -/// A component beside the knobs that shape it: each knob edits one property as the preview redraws with the settings. Narrow, the knobs drop below. +/// A component beside the knobs that shape it: each knob edits one property as the preview redraws with the settings. The knobs are the owner's: when any changes, every setting starts over from them. Narrow, the knobs drop below. #[derive(IntoElement)] pub struct Playground { id: ElementId, @@ -105,14 +106,32 @@ pub struct Playground { } impl Playground { + /// Fails on no knobs, two knobs of one name, or a number that starts outside its range. pub fn new( id: impl Into, knobs: impl IntoIterator, preview: impl Fn(&Settings, &mut Window, &mut App) -> AnyElement + 'static, ) -> Self { + let (id, knobs): (ElementId, Vec) = (id.into(), knobs.into_iter().collect()); + assert!(!knobs.is_empty(), "playground {id:?} has no knobs"); + for (ix, knob) in knobs.iter().enumerate() { + assert!( + knobs[..ix] + .iter() + .all(|before| before.name() != knob.name()), + "playground {id:?} has two knobs named {}", + knob.name() + ); + if let Knob::Number(name, value, (least, most), _) = knob { + assert!( + (*least..=*most).contains(value), + "playground {id:?}: {name} starts at {value}, outside {least}..={most}" + ); + } + } Self { - id: id.into(), - knobs: knobs.into_iter().collect(), + id, + knobs, preview: Rc::new(preview), } } @@ -121,35 +140,37 @@ impl Playground { impl RenderOnce for Playground { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let id = self.id; - assert!(!self.knobs.is_empty(), "playground {id:?} has no knobs"); - let settings = window.use_keyed_state((id.clone(), "settings"), cx, { - let knobs = self.knobs.clone(); - move |_, _| { - Settings( - knobs - .iter() - .map(|knob| (knob.name().clone(), knob.first())) - .collect(), - ) - } - }); - let now = settings.read(cx).clone(); + let firsts = Settings( + self.knobs + .iter() + .map(|knob| (knob.name().clone(), knob.first())) + .collect(), + ); + // The knobs join the seed, so a changed option list or range starts over too. + let settings = use_seeded( + (id.clone(), "settings"), + (self.knobs.clone(), firsts), + window, + cx, + ); + let now = settings.read(cx).value.1.clone(); let preview = (self.preview)(&now, window, cx); let theme = cx.theme(); let colors = &theme.colors; - let rows = self.knobs.iter().enumerate().map(|(ix, knob)| { + let rows = self.knobs.iter().map(|knob| { let (name, set) = (knob.name().clone(), settings.clone()); + let key = (id.clone(), format!("knob-{name}")); let control = match knob { - Knob::Toggle(..) => Switch::new((id.clone(), format!("knob-{ix}")), now.on(&name)) + Knob::Toggle(..) => Switch::new(key, now.on(&name)) .on_change(move |on, _, cx| { set.update(cx, |settings, cx| { - settings.set(&name, Setting::On(on)); + settings.value.1.set(&name, Setting::On(on)); cx.notify(); }) }) .into_any_element(), Knob::Choice(_, options, _) => Select::new( - (id.clone(), format!("knob-{ix}")), + key, options .iter() .map(|option| Choice::new(option.clone(), option.clone())), @@ -157,7 +178,7 @@ impl RenderOnce for Playground { .selected(now.picked(&name).clone()) .on_change(move |option, _, cx| { set.update(cx, |settings, cx| { - settings.set(&name, Setting::Picked(option.clone())); + settings.value.1.set(&name, Setting::Picked(option.clone())); cx.notify(); }) }) @@ -168,27 +189,29 @@ impl RenderOnce for Playground { .gap_2() .child( div().flex_1().min_w_0().child( - Slider::new((id.clone(), format!("knob-{ix}")), now.number(&name)) + Slider::new(key, now.number(&name)) .range(*least, *most) .step(*step) .on_change(move |value, _, cx| { set.update(cx, |settings, cx| { - settings.set(&name, Setting::Number(value)); + settings.value.1.set(&name, Setting::Number(value)); cx.notify(); }) }), ), ) - .child( + .child({ + let shown = format::number( + now.number(knob.name()), + format::decimals(*step), + format::Separators::EN, + ); tabular(div()) + .debug_selector(|| format!("knob-readout-{}-{shown}", knob.name())) .flex_none() .text_color(colors.fg_muted) - .child(format::number( - now.number(knob.name()), - format::decimals(*step), - format::Separators::EN, - )), - ) + .child(shown.clone()) + }) .into_any_element(), }; div() @@ -236,6 +259,8 @@ impl RenderOnce for Playground { #[cfg(test)] mod tests { + use gpui::IntoElement; + use super::{Knob, Setting, Settings}; #[test] @@ -257,6 +282,34 @@ mod tests { assert_eq!(settings.number("share"), 0.7); } + #[test] + #[should_panic(expected = "has no knobs")] + fn a_playground_without_knobs_fails() { + super::Playground::new("p", [], |_, _, _| gpui::div().into_any_element()); + } + + #[test] + #[should_panic(expected = "has two knobs named size")] + fn twin_knob_names_fail() { + let size = || Knob::Toggle("size".into(), true); + super::Playground::new("p", [size(), size()], |_, _, _| { + gpui::div().into_any_element() + }); + } + + #[test] + #[should_panic(expected = "share starts at 5, outside 0..=1")] + fn a_number_outside_its_range_fails() { + let share = Knob::Number("share".into(), 5.0, (0.0, 1.0), 0.1); + super::Playground::new("p", [share], |_, _, _| gpui::div().into_any_element()); + } + + #[test] + #[should_panic(expected = "no knob named color")] + fn a_name_the_knobs_lack_fails() { + Settings(vec![("size".into(), Setting::Picked("Md".into()))]).on("color"); + } + #[test] #[should_panic(expected = "playground knob size is Picked")] fn a_setting_asked_in_another_kind_fails() { diff --git a/src/tooling/tests.rs b/src/tooling/tests.rs index a5865ed..39408f8 100644 --- a/src/tooling/tests.rs +++ b/src/tooling/tests.rs @@ -3,6 +3,8 @@ use gpui::{ ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, }; +use std::cell::Cell; + use super::{Knob, Playground}; use crate::{ forms, @@ -23,6 +25,10 @@ impl Render for Stage { } } +thread_local! { + static LARGE: Cell = const { Cell::new(true) }; +} + fn playground() -> AnyElement { Playground::new( "playground", @@ -30,14 +36,17 @@ fn playground() -> AnyElement { Knob::Toggle("disabled".into(), false), Knob::Choice( "size".into(), - vec!["Sm".into(), "Md".into(), "Lg".into()], + ["Sm", "Md", "Lg"][..2 + usize::from(LARGE.get())] + .iter() + .map(|size| (*size).into()) + .collect(), 1, ), - Knob::Number("share".into(), 0.4, (0.0, 1.0), 0.1), + Knob::Number("share".into(), 0.5, (0.0, 1.0), 0.25), ], |settings, _, _| { let shown = format!( - "preview-{}-{}-{:.1}", + "preview-{}-{}-{:.2}", settings.on("disabled"), settings.picked("size"), settings.number("share") @@ -56,13 +65,14 @@ fn settle(cx: &mut VisualTestContext) { cx.run_until_parked(); } +/// Presses a key with no refresh of its own, so only what notifies redraws. fn press(key: &str, cx: &mut VisualTestContext) { cx.simulate_keystrokes(key); - settle(cx); + cx.run_until_parked(); cx.simulate_event(KeyUpEvent { keystroke: Keystroke::parse(key).expect("a key"), }); - settle(cx); + cx.run_until_parked(); } /// Each knob's edit redraws the preview with the new settings. @@ -84,16 +94,40 @@ fn knobs_redraw_the_preview(cx: &mut TestAppContext) { settle(cx); let shown = |selector: &'static str, cx: &mut VisualTestContext| cx.debug_bounds(selector).is_some(); - assert!(shown("preview-false-Md-0.4", cx)); + assert!(shown("preview-false-Md-0.50", cx)); press("tab", cx); press("space", cx); - assert!(shown("preview-true-Md-0.4", cx), "the switch turned"); + assert!(shown("preview-true-Md-0.50", cx), "the switch turned"); press("tab", cx); for key in ["down", "down", "enter"] { press(key, cx); } - assert!(shown("preview-true-Lg-0.4", cx), "the choice moved"); + assert!(shown("preview-true-Lg-0.50", cx), "the choice moved"); press("tab", cx); press("right", cx); - assert!(shown("preview-true-Lg-0.5", cx), "the number stepped"); + press("right", cx); + assert!( + shown("preview-true-Lg-1.00", cx), + "the number stepped twice" + ); + assert!( + shown("knob-readout-share-1.00", cx), + "its readout in the step's places" + ); + press("shift-tab", cx); + press("shift-tab", cx); + press("space", cx); + assert!( + shown("preview-false-Lg-1.00", cx), + "the switch showed its own setting, so a second press turned it back" + ); + LARGE.set(false); + settle(cx); + press("tab", cx); + press("tab", cx); + press("right", cx); + assert!( + shown("preview-false-Md-0.75", cx), + "the owner took away the picked size, so every setting started over" + ); }