diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2b1790e..52614dd 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -32,6 +32,7 @@ full = [ "popover", "scroll_area", "segmented", + "spinner", "tooltip", "menu", "select", @@ -55,6 +56,7 @@ input = ["button"] popover = [] scroll_area = [] segmented = [] +spinner = [] tooltip = [] menu = ["scroll_area"] select = ["menu"] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 00a3928..60c7925 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -66,6 +66,9 @@ pub mod scroll_area; #[cfg(feature = "segmented")] pub mod segmented; +#[cfg(feature = "spinner")] +pub mod spinner; + #[cfg(feature = "toast")] pub mod toast; diff --git a/crates/core/src/spinner.rs b/crates/core/src/spinner.rs new file mode 100644 index 0000000..e9c130a --- /dev/null +++ b/crates/core/src/spinner.rs @@ -0,0 +1,288 @@ +//! The loading ring: spins for unknown work, or holds a determinate arc. + +use std::cell::Cell; +use std::f32::consts::{PI, TAU}; +use std::rc::Rc; +use std::time::Duration; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationElement, AnimationExt as _, App, Canvas, ElementId, Hsla, + InteractiveElement as _, IntoElement, ParentElement as _, Path, PathBuilder, Pixels, Point, + RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Transformation, + Window, canvas, div, percentage, point, px, +}; +use gpui_native_icons::{Icon, IconRole, icon}; +use gpui_native_theme::{ActiveTheme as _, Size}; + +use crate::common::Sizable; + +/// One revolution takes this long, the system loaders' cadence. +const SPIN_PERIOD: Duration = Duration::from_millis(800); +/// The share of the circle the spinning arc paints. +const SPIN_SWEEP: f32 = 0.75; +/// The ring stroke as a share of the side. +const RING_STROKE: f32 = 0.125; + +/// The visual language of a [`Spinner`]. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum SpinnerVariant { + /// The painted arc ring. + #[default] + Ring, + /// The platform loader glyph, rotated. + Icon, +} + +/// A loading ring. +#[derive(IntoElement)] +pub struct Spinner { + id: ElementId, + value: Option, + variant: SpinnerVariant, + size: Size, + a11y_label: Option, +} + +impl Spinner { + /// Creates a spinning ring. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + value: None, + variant: SpinnerVariant::default(), + size: Size::default(), + a11y_label: None, + } + } + + /// Sets the spinner's visual language. + pub fn variant(mut self, variant: SpinnerVariant) -> Self { + self.variant = variant; + self + } + + /// Paints a determinate arc at `value` (`0..=1`) instead of spinning. + pub fn value(mut self, value: f32) -> Self { + self.value = Some(value); + self + } + + /// Sets the accessibility label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl Sizable for Spinner { + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + +impl RenderOnce for Spinner { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let side = theme.sizes.resolve(self.size).icon_size; + let stroke = side * RING_STROKE; + let track = theme.colors.border; + let accent = theme.ramps.primary.base; + let value = self.value.map(|value| value.clamp(0.0, 1.0)); + + div() + .id(self.id) + .flex_none() + .size(side) + .role(Role::ProgressIndicator) + .when_some(self.a11y_label, |spinner, label| spinner.aria_label(label)) + .when_some(value, |spinner, value| { + spinner + .aria_numeric_value(f64::from(value)) + .aria_min_numeric_value(0.0) + .aria_max_numeric_value(1.0) + }) + .child(match (self.variant, value) { + (SpinnerVariant::Ring, Some(value)) => { + determinate_ring(side, stroke, track, accent, value).into_any_element() + } + (SpinnerVariant::Ring, None) => { + spinning_ring(side, stroke, accent).into_any_element() + } + (SpinnerVariant::Icon, Some(_)) => icon(IconRole::Loader) + .size(side) + .color(accent) + .into_any_element(), + (SpinnerVariant::Icon, None) => spinning_icon(side, accent).into_any_element(), + }) + } +} + +fn determinate_ring( + side: Pixels, + stroke: Pixels, + track: Hsla, + accent: Hsla, + value: f32, +) -> Canvas<()> { + canvas( + |_, _, _| (), + move |bounds, (), window, _| { + let center = bounds.center(); + let radius = bounds.size.width / 2.0 - stroke / 2.0; + paint_circle(center, radius, stroke, track, window); + if value >= 1.0 { + paint_circle(center, radius, stroke, accent, window); + } else if value > 0.0 + && let Some(path) = arc_path(center, radius, stroke, 0.0, value * TAU) + { + window.paint_path(path, accent); + } + }, + ) + .size(side) +} + +fn spinning_ring(side: Pixels, stroke: Pixels, accent: Hsla) -> AnimationElement> { + let angle = Rc::new(Cell::new(0.0)); + let spun = angle.clone(); + canvas( + |_, _, _| (), + move |bounds, (), window, _| { + let radius = bounds.size.width / 2.0 - stroke / 2.0; + if let Some(path) = arc_path( + bounds.center(), + radius, + stroke, + spun.get(), + SPIN_SWEEP * TAU, + ) { + window.paint_path(path, accent); + } + }, + ) + .size(side) + .with_animation( + "spin", + Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.), + move |ring, delta| { + angle.set(delta * TAU); + ring + }, + ) +} + +fn spinning_icon(side: Pixels, accent: Hsla) -> AnimationElement { + icon(IconRole::Loader) + .size(side) + .color(accent) + .with_animation( + "spin", + Animation::new(SPIN_PERIOD).repeat().with_max_fps(60.), + |glyph, delta| glyph.with_transformation(Transformation::rotate(percentage(delta))), + ) +} + +fn paint_circle( + center: Point, + radius: Pixels, + stroke: Pixels, + color: Hsla, + window: &mut Window, +) { + if let Some(path) = circle_path(center, radius, stroke) { + window.paint_path(path, color); + } +} + +/// A point on the ring at `angle`, clockwise from 12 o'clock. +fn ring_point(center: Point, radius: Pixels, angle: f32) -> Point { + point( + center.x + radius * angle.sin(), + center.y - radius * angle.cos(), + ) +} + +/// A stroked arc from `start`, sweeping clockwise by `sweep` radians. +fn arc_path( + center: Point, + radius: Pixels, + stroke: Pixels, + start: f32, + sweep: f32, +) -> Option> { + let mut builder = PathBuilder::stroke(stroke); + builder.move_to(ring_point(center, radius, start)); + builder.arc_to( + point(radius, radius), + px(0.), + sweep > PI, + true, + ring_point(center, radius, start + sweep), + ); + builder.build().ok() +} + +/// A stroked full circle; `None` on degenerate geometry. +fn circle_path(center: Point, radius: Pixels, stroke: Pixels) -> Option> { + let mut builder = PathBuilder::stroke(stroke); + builder.move_to(ring_point(center, radius, 0.0)); + builder.arc_to( + point(radius, radius), + px(0.), + false, + true, + ring_point(center, radius, PI), + ); + builder.arc_to( + point(radius, radius), + px(0.), + false, + true, + ring_point(center, radius, TAU), + ); + builder.build().ok() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::f32::consts::FRAC_PI_2; + + #[test] + fn ring_points_run_clockwise_from_twelve() { + let center = point(px(10.0), px(20.0)); + let radius = px(4.0); + assert_eq!(ring_point(center, radius, 0.0), point(px(10.0), px(16.0))); + assert_eq!( + ring_point(center, radius, FRAC_PI_2), + point(px(14.0), px(20.0)) + ); + assert_eq!(ring_point(center, radius, PI), point(px(10.0), px(24.0))); + assert_eq!( + ring_point(center, radius, -FRAC_PI_2), + point(px(6.0), px(20.0)) + ); + } + + #[test] + fn arcs_tessellate_across_the_range() { + let center = point(px(0.0), px(0.0)); + let radius = px(8.0); + let stroke = px(2.0); + // The large-arc flag flips past half a circle; both sides must build. + for fraction in [0.25, 0.5, 0.75] { + assert!( + arc_path(center, radius, stroke, 0.0, fraction * TAU).is_some(), + "fraction {fraction} failed to build" + ); + } + assert!(circle_path(center, radius, stroke).is_some()); + } +} diff --git a/crates/core/tests/spinner.rs b/crates/core/tests/spinner.rs new file mode 100644 index 0000000..d725afd --- /dev/null +++ b/crates/core/tests/spinner.rs @@ -0,0 +1,104 @@ +use std::{cell::RefCell, rc::Rc, time::Duration}; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Context, IntoElement, ParentElement as _, Render, Styled as _, TestAppContext, Window, div, +}; +use gpui_native_core::spinner::{Spinner, SpinnerVariant}; +use gpui_native_theme::Theme; + +struct SpinnerHost { + variant: SpinnerVariant, + determinate: bool, + renders: Rc>, +} + +impl Render for SpinnerHost { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + *self.renders.borrow_mut() += 1; + let spinner = Spinner::new("test-spinner") + .variant(self.variant) + .when(self.determinate, |spinner| spinner.value(0.4)); + div().size_full().child(spinner) + } +} + +fn host_window( + variant: SpinnerVariant, + determinate: bool, + reduce_motion: bool, +) -> (TestAppContext, Rc>) { + let mut cx = TestAppContext::single(); + cx.update(|cx| { + Theme::init(cx); + if reduce_motion { + cx.set_reduce_motion(true); + } + }); + let renders = Rc::new(RefCell::new(0)); + let host_renders = renders.clone(); + cx.add_window(move |_, _| SpinnerHost { + variant, + determinate, + renders: host_renders, + }); + cx.run_until_parked(); + (cx, renders) +} + +#[test] +fn spinning_repaints_on_the_animation_clock() { + let (cx, renders) = host_window(SpinnerVariant::Ring, false, false); + assert_eq!(*renders.borrow(), 1); + + // The 60fps cap drives re-renders with timers, not frame callbacks. + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + assert!( + *renders.borrow() > 1, + "the spinner must repaint while spinning" + ); +} + +#[test] +fn determinate_paints_one_static_frame() { + let (cx, renders) = host_window(SpinnerVariant::Ring, true, false); + assert_eq!(*renders.borrow(), 1); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + assert_eq!(*renders.borrow(), 1, "a determinate ring holds still"); +} + +#[test] +fn reduce_motion_paints_one_static_frame() { + let (cx, renders) = host_window(SpinnerVariant::Ring, false, true); + assert_eq!(*renders.borrow(), 1); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + assert_eq!(*renders.borrow(), 1, "reduced motion must freeze the spin"); +} + +#[test] +fn the_icon_variant_repaints_while_spinning() { + let (cx, renders) = host_window(SpinnerVariant::Icon, false, false); + assert_eq!(*renders.borrow(), 1); + + cx.executor().advance_clock(Duration::from_millis(100)); + cx.run_until_parked(); + assert!( + *renders.borrow() > 1, + "the icon must repaint while spinning" + ); +} + +#[test] +fn the_icon_variant_holds_still_when_determinate() { + let (cx, renders) = host_window(SpinnerVariant::Icon, true, false); + assert_eq!(*renders.borrow(), 1); + + cx.executor().advance_clock(Duration::from_millis(200)); + cx.run_until_parked(); + assert_eq!(*renders.borrow(), 1, "a held icon must not animate"); +} diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index 9077d5d..78f413d 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -25,6 +25,7 @@ sidebar = [ tabs = [ "dep:gpui-native-core", "gpui-native-core/button", + "gpui-native-core/spinner", "gpui-native-core/tooltip", ] tree = [] diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs index 8c4199d..841ff81 100644 --- a/crates/data/src/tabs.rs +++ b/crates/data/src/tabs.rs @@ -8,6 +8,7 @@ use gpui::{ }; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::common::Sizable as _; +use gpui_native_core::spinner::Spinner; use gpui_native_core::tooltip::Tooltip; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; @@ -362,7 +363,11 @@ fn render_tab( } if tab.busy && !tab.disabled { - strip_tab = strip_tab.child(busy_slot(cx)); + strip_tab = strip_tab.child( + Spinner::new(("tab-busy", index)) + .small() + .accessibility_label("Loading"), + ); } if tab.closeable && on_close.is_some() && !tab.disabled { @@ -418,14 +423,3 @@ fn render_tab_close( }) .into_any_element() } - -fn busy_slot(cx: &App) -> AnyElement { - let theme = cx.theme(); - div() - .flex_none() - .size(theme.sizes.medium.dense_icon_size) - .rounded(theme.radius.full) - .border_1() - .border_color(theme.colors.border) - .into_any_element() -} diff --git a/crates/icons/assets/loader.svg b/crates/icons/assets/loader.svg new file mode 100644 index 0000000..a9b897b --- /dev/null +++ b/crates/icons/assets/loader.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/crates/icons/src/lib.rs b/crates/icons/src/lib.rs index df4053a..490a744 100644 --- a/crates/icons/src/lib.rs +++ b/crates/icons/src/lib.rs @@ -4,7 +4,7 @@ use gpui::prelude::FluentBuilder as _; use gpui::{ AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, - Styled as _, Window, div, px, svg, + Styled as _, Transformation, Window, div, px, svg, }; #[cfg_attr(not(target_os = "linux"), allow(dead_code))] @@ -34,6 +34,7 @@ pub enum IconRole { WindowMinimize, WindowMaximize, WindowRestore, + Loader, } impl IconRole { @@ -58,6 +59,7 @@ impl IconRole { Self::WindowMinimize, Self::WindowMaximize, Self::WindowRestore, + Self::Loader, ] } @@ -82,6 +84,7 @@ impl IconRole { Self::WindowMinimize => "icons/minus.svg", Self::WindowMaximize => "icons/square.svg", Self::WindowRestore => "icons/copy.svg", + Self::Loader => "icons/loader.svg", } } @@ -106,6 +109,7 @@ impl IconRole { Self::WindowMinimize => "window-minimize", Self::WindowMaximize => "window-maximize", Self::WindowRestore => "window-restore", + Self::Loader => "loader", } } @@ -180,6 +184,7 @@ impl IconRole { Self::WindowMinimize => &["window-minimize-symbolic", "window-minimize"], Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"], Self::WindowRestore => &["window-restore-symbolic", "window-restore"], + Self::Loader => &["process-working-symbolic", "process-working"], } } } @@ -190,6 +195,7 @@ pub struct Icon { role: IconRole, size: Pixels, color: Option, + transformation: Option, } /// Creates an icon for the role at the default size of 16px. @@ -198,6 +204,7 @@ pub fn icon(role: IconRole) -> Icon { role, size: px(16.0), color: None, + transformation: None, } } @@ -212,6 +219,12 @@ impl Icon { self } + /// An extra SVG transformation, e.g. a spinner's rotation. + pub fn with_transformation(mut self, transformation: Transformation) -> Self { + self.transformation = Some(transformation); + self + } + /// The asset key a resolved freedesktop file is served under. #[cfg(target_os = "linux")] fn freedesktop_asset_path(&self) -> Option { @@ -232,27 +245,29 @@ impl RenderOnce for Icon { } impl Icon { - /// Windows: the Segoe Fluent codepoint drawn as text in the system font. + /// Windows: the Segoe Fluent codepoint drawn as text in the system font; + /// roles without a codepoint fall back to the bundled SVG. #[cfg(target_os = "windows")] fn render_glyph(self) -> impl IntoElement { - div() - .flex_none() - .size(self.size) - .flex() - .items_center() - .justify_center() - .when_some(segoe::glyph(self.role), |el, glyph| { - el.child( + match segoe::glyph(self.role) { + Some(glyph) => div() + .flex_none() + .size(self.size) + .flex() + .items_center() + .justify_center() + .child( div() .font_family(segoe::FONT_FAMILY) .text_size(self.size) .when_some(self.color, |el, color| el.text_color(color)) .child(glyph.to_string()), ) - }) + .into_any_element(), + None => self.render_svg().into_any_element(), + } } - #[cfg(not(target_os = "windows"))] fn render_svg(self) -> impl IntoElement { #[cfg(target_os = "linux")] let path: SharedString = self @@ -266,7 +281,10 @@ impl Icon { svg() .size_full() .path(path) - .when_some(self.color, |el, color| el.text_color(color)), + .when_some(self.color, |el, color| el.text_color(color)) + .when_some(self.transformation, |el, transformation| { + el.with_transformation(transformation) + }), ) } } @@ -294,6 +312,7 @@ fn asset_bytes(path: &str) -> Option<&'static [u8]> { "icons/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")), "icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")), "icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")), + "icons/loader.svg" => Some(include_bytes!("../assets/loader.svg")), _ => None, } } diff --git a/crates/icons/src/segoe.rs b/crates/icons/src/segoe.rs index 69d6942..736eaac 100644 --- a/crates/icons/src/segoe.rs +++ b/crates/icons/src/segoe.rs @@ -6,6 +6,9 @@ use crate::IconRole; /// The Segoe Fluent Icons codepoint for the role. pub(crate) fn glyph(role: IconRole) -> Option { let codepoint = match role { + // No Segoe spinner glyph; the icon falls back to the bundled SVG, + // which the spinner rotates. + IconRole::Loader => return None, IconRole::Check => 0xE73E, // CheckMark IconRole::Close => 0xE8BB, // ChromeClose IconRole::ChevronDown => 0xE70D, // ChevronDown @@ -37,9 +40,12 @@ mod tests { use super::*; #[test] - fn every_role_maps_to_a_codepoint() { + fn every_role_maps_a_codepoint_or_falls_back_to_the_bundled_svg() { for role in crate::IconRole::all() { - assert!(glyph(*role).is_some(), "missing codepoint for {role:?}"); + assert!( + glyph(*role).is_some() || crate::asset_bytes(role.asset_path()).is_some(), + "no Segoe codepoint or bundled asset for {role:?}" + ); } } diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 6758b41..75b7649 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -8,13 +8,14 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Spinner** (`core`) — the loading ring, indeterminate + determinate. +- [ ] **Spinner** (`core`) — the loading ring, indeterminate + determinate; + painted-ring and rotating-icon variants. Replaces `Tab::busy`'s placeholder ring; land before signed wires repo loading into dock tabs. - - [ ] API sketch - - [ ] Implementation (theme-baked, motion inline per AGENTS.md) - - [ ] Tests - - [ ] Gallery panel + - [x] API sketch + - [x] Implementation (theme-baked, motion inline per AGENTS.md) + - [x] Tests + - [x] Gallery panel - [ ] Review ## Queue — to follow diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 20a562f..1e0a727 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -25,6 +25,7 @@ use gpui_native_core::scroll_area::{ScrollArea, ScrollAxes}; use gpui_native_core::segmented::{Segmented, SegmentedOption}; use gpui_native_core::select::{Select, SelectOption}; use gpui_native_core::slider::Slider; +use gpui_native_core::spinner::{Spinner, SpinnerVariant}; use gpui_native_core::switch::Switch; use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView}; use gpui_native_core::toggle::Toggle; @@ -66,6 +67,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("badge", "Badge"), ("avatar", "Avatar"), ("progress", "Progress"), + ("spinner", "Spinner"), ("list", "List"), ("tree", "Tree"), ("tabs", "Tabs"), @@ -451,6 +453,10 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("spinner", "Spinner", |_, cx| cx.new(|_| SpinnerDemo).into()), + cx, + ); dock.register_panel( PanelDescriptor::new("list", "List", |_, cx| { cx.new(|_| ListDemo { selected: None }).into() @@ -1305,6 +1311,47 @@ impl Render for ProgressDemo { } } +struct SpinnerDemo; + +impl Render for SpinnerDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + + ScrollArea::new("spinner-demo") + .size_full() + .p_4() + .child(demo_section( + "spinning and determinate", + colors, + h_flex() + .gap_3() + .child(Spinner::new("spinner-demo-spin")) + .child(Spinner::new("spinner-demo-small").small()) + .child(Spinner::new("spinner-demo-large").large()) + .child(Spinner::new("spinner-demo-quarter").value(0.25)) + .child(Spinner::new("spinner-demo-half").value(0.5)) + .child(Spinner::new("spinner-demo-full").value(1.0)), + )) + .child(demo_section( + "icon variant", + colors, + h_flex() + .gap_3() + .child(Spinner::new("spinner-demo-icon").variant(SpinnerVariant::Icon)) + .child( + Spinner::new("spinner-demo-icon-small") + .variant(SpinnerVariant::Icon) + .small(), + ) + .child( + Spinner::new("spinner-demo-icon-held") + .variant(SpinnerVariant::Icon) + .value(0.5), + ), + )) + } +} + struct ListDemo { selected: Option, } @@ -1526,90 +1573,86 @@ impl Render for TabsDemo { let colors = cx.theme().colors; let pill_count = self.pill_tabs.len(); - ScrollArea::new("tabs-demo") - .size_full() - .p_4() - .child( - v_flex().gap_3() - .child(demo_section( - "underline", - colors, - Tabs::new("tabs-demo-underline") - .selected(self.selected) - .on_change(move |selected, _, cx| { - view.update(cx, |demo, cx| { - demo.selected = selected; + ScrollArea::new("tabs-demo").size_full().p_4().child( + v_flex() + .gap_3() + .child(demo_section( + "underline", + colors, + Tabs::new("tabs-demo-underline") + .selected(self.selected) + .on_change(move |selected, _, cx| { + view.update(cx, |demo, cx| { + demo.selected = selected; + cx.notify(); + }); + }) + .tabs(|_, _| { + vec![ + Tab::new("Overview"), + Tab::new("Details"), + Tab::new("Settings"), + Tab::new("Locked").disabled(true), + ] + }), + )) + .child(match self.selected { + 0 => div() + .child("The overview panel is the app's business — the strip only reports.") + .into_any_element(), + 1 => div() + .child("Details: rows, lists and trees all live under their own tabs.") + .into_any_element(), + _ => div() + .child("Settings: keyboard focus rides the strip; arrows switch tabs.") + .into_any_element(), + }) + .child(demo_section( + "pill", + colors, + Tabs::new("tabs-demo-pill") + .variant(TabsVariant::Pill) + .selected(self.pill_selected) + .on_change(move |selected, _, cx| { + view_pill.update(cx, |demo, cx| { + demo.pill_selected = selected; + cx.notify(); + }); + }) + .on_close(move |index, _, cx| { + view_close.update(cx, |demo, cx| { + if index < demo.pill_tabs.len() { + demo.pill_tabs.remove(index); + demo.pill_selected = demo + .pill_selected + .min(demo.pill_tabs.len().saturating_sub(1)); cx.notify(); - }); - }) - .tabs(|_, _| { - vec![ - Tab::new("Overview"), - Tab::new("Details"), - Tab::new("Settings"), - Tab::new("Locked").disabled(true), - ] - }), - )) - .child(match self.selected { - 0 => div() - .child( - "The overview panel is the app's business — the strip only reports.", - ) - .into_any_element(), - 1 => div() - .child("Details: rows, lists and trees all live under their own tabs.") - .into_any_element(), - _ => div() - .child("Settings: keyboard focus rides the strip; arrows switch tabs.") - .into_any_element(), - }) - .child(demo_section( - "pill", - colors, - Tabs::new("tabs-demo-pill") - .variant(TabsVariant::Pill) - .selected(self.pill_selected) - .on_change(move |selected, _, cx| { - view_pill.update(cx, |demo, cx| { - demo.pill_selected = selected; - cx.notify(); - }); - }) - .on_close(move |index, _, cx| { - view_close.update(cx, |demo, cx| { - if index < demo.pill_tabs.len() { - demo.pill_tabs.remove(index); - demo.pill_selected = demo - .pill_selected - .min(demo.pill_tabs.len().saturating_sub(1)); - cx.notify(); - } - }); - }) - .tabs({ - let labels = self.pill_tabs.clone(); - move |_, _| { - labels - .iter() - .map(|label| { - Tab::new(label.clone()) - .closeable(true) - .busy(label == "Syncing") - }) - .collect() } - }), - )) - .child( - div() - .text_xs() - .text_color(colors.muted_foreground) - .child(format!( - "{pill_count} tabs left; the ring on \"Syncing\" is the Spinner placeholder" - )), - ), - ) + }); + }) + .tabs({ + let labels = self.pill_tabs.clone(); + move |_, _| { + labels + .iter() + .map(|label| { + Tab::new(label.clone()) + .closeable(true) + .busy(label == "Syncing") + }) + .collect() + } + }), + )) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!( + "{pill_count} tabs left; \"Syncing\" spins while it loads" + )), + ), + ) } }