From 1dbdafd0569a68e4e7e382cb514830ab5ccf502d 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:08:40 -0400 Subject: [PATCH] =?UTF-8?q?Tooling=20=C2=B7=20Workbench:=20a=20playground,?= =?UTF-8?q?=20and=20the=20gallery=20as=20the=20workbench?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tooling::Playground lays a preview beside its knobs, each a switch, a choice or a number that edits one property; the preview redraws from the settings, which fail on a name or kind the knobs lack. The gallery app is the ComponentGallery; its page 43 shows both. --- examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/tooling/mod.rs | 102 ++++++++++ src/lib.rs | 1 + src/tooling/mod.rs | 5 + src/tooling/playground.rs | 266 ++++++++++++++++++++++++++ src/tooling/tests.rs | 99 ++++++++++ tasks/ch31-43.md | 4 +- 7 files changed, 477 insertions(+), 2 deletions(-) create mode 100644 examples/gallery/pages/tooling/mod.rs create mode 100644 src/tooling/mod.rs create mode 100644 src/tooling/playground.rs create mode 100644 src/tooling/tests.rs diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index b4dc7f5..c1315f8 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -39,6 +39,7 @@ mod shell; mod tables; mod terminal; mod theme; +mod tooling; mod typography; use gpui::{AnyElement, App, Window}; @@ -98,6 +99,7 @@ pub const ALL: &[Page] = &[ i18n::PAGE, maps::PAGE, misc::PAGE, + tooling::PAGE, ]; pub use i18n::install as install_i18n; diff --git a/examples/gallery/pages/tooling/mod.rs b/examples/gallery/pages/tooling/mod.rs new file mode 100644 index 0000000..97a2f91 --- /dev/null +++ b/examples/gallery/pages/tooling/mod.rs @@ -0,0 +1,102 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant}, + motion::ProgressRing, + theme::ControlSize, + tooling::{Knob, Playground}, +}; +use gpui::{AnyElement, App, IntoElement, ParentElement, Styled, Window, div, px}; + +use super::Page; +use crate::probe::probe; +use crate::script::Step; +use crate::ui::section; + +pub const PAGE: Page = Page { + number: 43, + slug: "tooling", + title: "Library Tooling", + summary: "Tools for building with the library: this gallery, and a playground that edits a component live.", + render, + script: &[ + Step::DownAt("button-playground", 472.0, 207.0), + Step::UpAt("button-playground", 472.0, 207.0), + Step::Shot("playground-loading"), + ], +}; + +fn render(_: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(section( + "ComponentGallery / Storybook", + "This app is the gallery: a page per chapter of the library, light and dark, each with scripted states. The --page flag opens one page, and --capture photographs every page and state.", + cx, + )) + .child( + section( + "PlaygroundPanel", + "A component beside the knobs that shape it: a switch, a choice or a number edits one property, and the preview redraws.", + cx, + ) + .child( + div() + .flex() + .flex_col() + .gap_6() + .child(probe( + "button-playground", + div().w(px(720.0)).child(Playground::new( + "button-playground", + [ + Knob::Choice( + "variant".into(), + ["Primary", "Secondary", "Outline", "Ghost", "Danger"] + .map(Into::into) + .into(), + 0, + ), + Knob::Choice("size".into(), ["Sm", "Md", "Lg"].map(Into::into).into(), 1), + Knob::Toggle("disabled".into(), false), + Knob::Toggle("loading".into(), false), + ], + |settings, _, _| { + let variant = match settings.picked("variant").as_ref() { + "Primary" => ButtonVariant::Primary, + "Secondary" => ButtonVariant::Secondary, + "Outline" => ButtonVariant::Outline, + "Ghost" => ButtonVariant::Ghost, + "Danger" => ButtonVariant::Danger, + other => panic!("gallery playground: no variant {other}"), + }; + let size = match settings.picked("size").as_ref() { + "Sm" => ControlSize::Sm, + "Md" => ControlSize::Md, + "Lg" => ControlSize::Lg, + other => panic!("gallery playground: no size {other}"), + }; + Button::new("played", "Save changes") + .variant(variant) + .size(size) + .disabled(settings.on("disabled")) + .loading(settings.on("loading")) + .into_any_element() + }, + )))) + .child(div().w(px(720.0)).child(Playground::new( + "ring-playground", + [ + Knob::Number("value".into(), 0.62, (0.0, 1.0), 0.01), + Knob::Toggle("percent".into(), true), + ], + |settings, _, _| { + let ring = ProgressRing::new("played-ring", settings.number("value") as f32); + match settings.on("percent") { + true => ring.percent(), + false => ring, + } + .into_any_element() + }, + ))), + ), + ) + .into_any_element() +} diff --git a/src/lib.rs b/src/lib.rs index 536bd25..e3340a4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -41,6 +41,7 @@ pub mod shell; pub mod tables; pub mod terminal; pub mod theme; +pub mod tooling; pub mod typography; pub use assets::Assets; diff --git a/src/tooling/mod.rs b/src/tooling/mod.rs new file mode 100644 index 0000000..b118cf4 --- /dev/null +++ b/src/tooling/mod.rs @@ -0,0 +1,5 @@ +mod playground; +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use playground::{Knob, Playground, Setting, Settings}; diff --git a/src/tooling/playground.rs b/src/tooling/playground.rs new file mode 100644 index 0000000..3152b5d --- /dev/null +++ b/src/tooling/playground.rs @@ -0,0 +1,266 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, SharedString, + Styled, Window, div, +}; + +use crate::{ + forms::{Choice, Select, Slider, Switch}, + theme::{ActiveTheme, Radius, TextSize}, + typography::{format, tabular}, +}; + +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)] +pub enum Knob { + Toggle(SharedString, bool), + Choice(SharedString, Vec, usize), + Number(SharedString, f64, (f64, f64), f64), +} + +impl Knob { + fn name(&self) -> &SharedString { + match self { + Knob::Toggle(name, _) | Knob::Choice(name, _, _) | Knob::Number(name, _, _, _) => name, + } + } + + /// The setting it starts on. + fn first(&self) -> Setting { + match self { + Knob::Toggle(_, on) => Setting::On(*on), + Knob::Choice(name, options, at) => Setting::Picked( + options + .get(*at) + .unwrap_or_else(|| panic!("playground knob {name}: no option {at}")) + .clone(), + ), + Knob::Number(_, value, _, _) => Setting::Number(*value), + } + } +} + +/// Where a knob stands. +#[derive(Clone, Debug, PartialEq)] +pub enum Setting { + On(bool), + Picked(SharedString), + Number(f64), +} + +/// The knobs' settings by name; asking for a name the knobs lack, or in another kind, fails. +#[derive(Clone, Debug, PartialEq)] +pub struct Settings(Vec<(SharedString, Setting)>); + +impl Settings { + fn get(&self, name: &str) -> &Setting { + self.0 + .iter() + .find(|(each, _)| each.as_ref() == name) + .map(|(_, setting)| setting) + .unwrap_or_else(|| panic!("playground: no knob named {name}")) + } + + pub fn on(&self, name: &str) -> bool { + match self.get(name) { + Setting::On(on) => *on, + other => panic!("playground knob {name} is {other:?}, not a switch"), + } + } + + pub fn picked(&self, name: &str) -> &SharedString { + match self.get(name) { + Setting::Picked(option) => option, + other => panic!("playground knob {name} is {other:?}, not a choice"), + } + } + + pub fn number(&self, name: &str) -> f64 { + match self.get(name) { + Setting::Number(value) => *value, + other => panic!("playground knob {name} is {other:?}, not a number"), + } + } + + fn set(&mut self, name: &str, setting: Setting) { + let slot = self + .0 + .iter_mut() + .find(|(each, _)| each.as_ref() == name) + .unwrap_or_else(|| panic!("playground: no knob named {name}")); + log::info!("playground: {name} is {setting:?}"); + slot.1 = setting; + } +} + +/// 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. +#[derive(IntoElement)] +pub struct Playground { + id: ElementId, + knobs: Vec, + preview: Preview, +} + +impl Playground { + pub fn new( + id: impl Into, + knobs: impl IntoIterator, + preview: impl Fn(&Settings, &mut Window, &mut App) -> AnyElement + 'static, + ) -> Self { + Self { + id: id.into(), + knobs: knobs.into_iter().collect(), + preview: Rc::new(preview), + } + } +} + +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 preview = (self.preview)(&now, window, cx); + let theme = cx.theme(); + let colors = &theme.colors; + let rows = self.knobs.iter().enumerate().map(|(ix, knob)| { + let (name, set) = (knob.name().clone(), settings.clone()); + let control = match knob { + Knob::Toggle(..) => Switch::new((id.clone(), format!("knob-{ix}")), now.on(&name)) + .on_change(move |on, _, cx| { + set.update(cx, |settings, cx| { + settings.set(&name, Setting::On(on)); + cx.notify(); + }) + }) + .into_any_element(), + Knob::Choice(_, options, _) => Select::new( + (id.clone(), format!("knob-{ix}")), + options + .iter() + .map(|option| Choice::new(option.clone(), option.clone())), + ) + .selected(now.picked(&name).clone()) + .on_change(move |option, _, cx| { + set.update(cx, |settings, cx| { + settings.set(&name, Setting::Picked(option.clone())); + cx.notify(); + }) + }) + .into_any_element(), + Knob::Number(_, _, (least, most), step) => div() + .flex() + .items_center() + .gap_2() + .child( + div().flex_1().min_w_0().child( + Slider::new((id.clone(), format!("knob-{ix}")), now.number(&name)) + .range(*least, *most) + .step(*step) + .on_change(move |value, _, cx| { + set.update(cx, |settings, cx| { + settings.set(&name, Setting::Number(value)); + cx.notify(); + }) + }), + ), + ) + .child( + tabular(div()) + .flex_none() + .text_color(colors.fg_muted) + .child(format::number( + now.number(knob.name()), + format::decimals(*step), + format::Separators::EN, + )), + ) + .into_any_element(), + }; + div() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_size(theme.text_size(TextSize::Xs)) + .font_weight(FontWeight::MEDIUM) + .text_color(colors.fg_muted) + .child(knob.name().clone()), + ) + .child(control) + }); + div() + .flex() + .flex_wrap() + .gap_4() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .flex() + .items_center() + .justify_center() + .p_6() + .rounded(theme.radius(Radius::Lg)) + .border_1() + .border_color(colors.border) + .bg(colors.sunken) + .child(preview), + ) + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .flex() + .flex_col() + .gap_3() + .children(rows), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Knob, Setting, Settings}; + + #[test] + fn settings_answer_by_name_and_kind() { + let knobs = [ + Knob::Toggle("disabled".into(), true), + Knob::Choice("size".into(), vec!["Sm".into(), "Md".into()], 1), + Knob::Number("share".into(), 0.4, (0.0, 1.0), 0.1), + ]; + let mut settings = Settings( + knobs + .iter() + .map(|knob| (knob.name().clone(), knob.first())) + .collect(), + ); + assert!(settings.on("disabled")); + assert_eq!(settings.picked("size").as_ref(), "Md"); + settings.set("share", Setting::Number(0.7)); + assert_eq!(settings.number("share"), 0.7); + } + + #[test] + #[should_panic(expected = "playground knob size is Picked")] + fn a_setting_asked_in_another_kind_fails() { + let settings = Settings(vec![("size".into(), Setting::Picked("Md".into()))]); + settings.on("size"); + } +} diff --git a/src/tooling/tests.rs b/src/tooling/tests.rs new file mode 100644 index 0000000..a5865ed --- /dev/null +++ b/src/tooling/tests.rs @@ -0,0 +1,99 @@ +use gpui::{ + AnyElement, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, + ParentElement, Render, Styled, TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{Knob, Playground}; +use crate::{ + forms, + primitives::{FocusNext, FocusPrev, FocusScope}, + theme::Theme, +}; + +struct Stage { + root: FocusHandle, +} + +impl Render for Stage { + fn render(&mut self, _: &mut Window, _: &mut Context) -> impl IntoElement { + FocusScope::new(&self.root) + .root() + .size_full() + .child(div().w(px(600.0)).child(playground())) + } +} + +fn playground() -> AnyElement { + Playground::new( + "playground", + [ + Knob::Toggle("disabled".into(), false), + Knob::Choice( + "size".into(), + vec!["Sm".into(), "Md".into(), "Lg".into()], + 1, + ), + Knob::Number("share".into(), 0.4, (0.0, 1.0), 0.1), + ], + |settings, _, _| { + let shown = format!( + "preview-{}-{}-{:.1}", + settings.on("disabled"), + settings.picked("size"), + settings.number("share") + ); + div().debug_selector(|| shown).into_any_element() + }, + ) + .into_any_element() +} + +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn press(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + settle(cx); + cx.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +/// Each knob's edit redraws the preview with the new settings. +#[gpui::test] +fn knobs_redraw_the_preview(cx: &mut TestAppContext) { + cx.update(|cx| { + Theme::init(cx); + forms::bind_keys(cx); + cx.bind_keys([ + gpui::KeyBinding::new("tab", FocusNext, None), + gpui::KeyBinding::new("shift-tab", FocusPrev, None), + ]); + }); + let (view, cx) = cx.add_window_view(|_, cx| Stage { + root: cx.focus_handle(), + }); + let root = view.read_with(cx, |stage, _| stage.root.clone()); + cx.update(|window, _| window.focus(&root)); + settle(cx); + let shown = + |selector: &'static str, cx: &mut VisualTestContext| cx.debug_bounds(selector).is_some(); + assert!(shown("preview-false-Md-0.4", cx)); + press("tab", cx); + press("space", cx); + assert!(shown("preview-true-Md-0.4", 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"); + press("tab", cx); + press("right", cx); + assert!(shown("preview-true-Lg-0.5", cx), "the number stepped"); +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index fe0b239..e39765a 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -354,8 +354,8 @@ ## T43a Tooling · Workbench — `src/tooling` -- [ ] ComponentGallery / Storybook -- [ ] PlaygroundPanel +- [x] ComponentGallery / Storybook → `examples/gallery`, a page per chapter, light and dark, with scripted states; `--page` opens one and `--capture` photographs every page and state +- [x] PlaygroundPanel → `tooling::Playground`: a preview beside knobs, each a switch, a choice or a number that edits one property ## T43b Tooling · Inspection