feat: add spinner component

This commit is contained in:
2026-11-03 16:39:13 +07:00
parent 978aecb651
commit 7399d92f36
11 changed files with 595 additions and 114 deletions
+2
View File
@@ -32,6 +32,7 @@ full = [
"popover", "popover",
"scroll_area", "scroll_area",
"segmented", "segmented",
"spinner",
"tooltip", "tooltip",
"menu", "menu",
"select", "select",
@@ -55,6 +56,7 @@ input = ["button"]
popover = [] popover = []
scroll_area = [] scroll_area = []
segmented = [] segmented = []
spinner = []
tooltip = [] tooltip = []
menu = ["scroll_area"] menu = ["scroll_area"]
select = ["menu"] select = ["menu"]
+3
View File
@@ -66,6 +66,9 @@ pub mod scroll_area;
#[cfg(feature = "segmented")] #[cfg(feature = "segmented")]
pub mod segmented; pub mod segmented;
#[cfg(feature = "spinner")]
pub mod spinner;
#[cfg(feature = "toast")] #[cfg(feature = "toast")]
pub mod toast; pub mod toast;
+288
View File
@@ -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<f32>,
variant: SpinnerVariant,
size: Size,
a11y_label: Option<SharedString>,
}
impl Spinner {
/// Creates a spinning ring.
pub fn new<I>(id: I) -> Self
where
I: Into<ElementId>,
{
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<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
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<Canvas<()>> {
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> {
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<Pixels>,
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<Pixels>, radius: Pixels, angle: f32) -> Point<Pixels> {
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<Pixels>,
radius: Pixels,
stroke: Pixels,
start: f32,
sweep: f32,
) -> Option<Path<Pixels>> {
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<Pixels>, radius: Pixels, stroke: Pixels) -> Option<Path<Pixels>> {
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());
}
}
+104
View File
@@ -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<RefCell<usize>>,
}
impl Render for SpinnerHost {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<RefCell<usize>>) {
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");
}
+1
View File
@@ -25,6 +25,7 @@ sidebar = [
tabs = [ tabs = [
"dep:gpui-native-core", "dep:gpui-native-core",
"gpui-native-core/button", "gpui-native-core/button",
"gpui-native-core/spinner",
"gpui-native-core/tooltip", "gpui-native-core/tooltip",
] ]
tree = [] tree = []
+6 -12
View File
@@ -8,6 +8,7 @@ use gpui::{
}; };
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::Sizable as _; use gpui_native_core::common::Sizable as _;
use gpui_native_core::spinner::Spinner;
use gpui_native_core::tooltip::Tooltip; use gpui_native_core::tooltip::Tooltip;
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, Size};
@@ -362,7 +363,11 @@ fn render_tab(
} }
if tab.busy && !tab.disabled { 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 { if tab.closeable && on_close.is_some() && !tab.disabled {
@@ -418,14 +423,3 @@ fn render_tab_close(
}) })
.into_any_element() .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()
}
+20
View File
@@ -0,0 +1,20 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 2v4" />
<path d="m16.2 7.8 2.9-2.9" />
<path d="M18 12h4" />
<path d="m16.2 16.2 2.9 2.9" />
<path d="M12 18v4" />
<path d="m4.9 19.1 2.9-2.9" />
<path d="M2 12h4" />
<path d="m4.9 4.9 2.9 2.9" />
</svg>

After

Width:  |  Height:  |  Size: 434 B

+32 -13
View File
@@ -4,7 +4,7 @@
use gpui::prelude::FluentBuilder as _; use gpui::prelude::FluentBuilder as _;
use gpui::{ use gpui::{
AssetSource, Hsla, IntoElement, ParentElement as _, Pixels, RenderOnce, SharedString, 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))] #[cfg_attr(not(target_os = "linux"), allow(dead_code))]
@@ -34,6 +34,7 @@ pub enum IconRole {
WindowMinimize, WindowMinimize,
WindowMaximize, WindowMaximize,
WindowRestore, WindowRestore,
Loader,
} }
impl IconRole { impl IconRole {
@@ -58,6 +59,7 @@ impl IconRole {
Self::WindowMinimize, Self::WindowMinimize,
Self::WindowMaximize, Self::WindowMaximize,
Self::WindowRestore, Self::WindowRestore,
Self::Loader,
] ]
} }
@@ -82,6 +84,7 @@ impl IconRole {
Self::WindowMinimize => "icons/minus.svg", Self::WindowMinimize => "icons/minus.svg",
Self::WindowMaximize => "icons/square.svg", Self::WindowMaximize => "icons/square.svg",
Self::WindowRestore => "icons/copy.svg", Self::WindowRestore => "icons/copy.svg",
Self::Loader => "icons/loader.svg",
} }
} }
@@ -106,6 +109,7 @@ impl IconRole {
Self::WindowMinimize => "window-minimize", Self::WindowMinimize => "window-minimize",
Self::WindowMaximize => "window-maximize", Self::WindowMaximize => "window-maximize",
Self::WindowRestore => "window-restore", Self::WindowRestore => "window-restore",
Self::Loader => "loader",
} }
} }
@@ -180,6 +184,7 @@ impl IconRole {
Self::WindowMinimize => &["window-minimize-symbolic", "window-minimize"], Self::WindowMinimize => &["window-minimize-symbolic", "window-minimize"],
Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"], Self::WindowMaximize => &["window-maximize-symbolic", "window-maximize"],
Self::WindowRestore => &["window-restore-symbolic", "window-restore"], Self::WindowRestore => &["window-restore-symbolic", "window-restore"],
Self::Loader => &["process-working-symbolic", "process-working"],
} }
} }
} }
@@ -190,6 +195,7 @@ pub struct Icon {
role: IconRole, role: IconRole,
size: Pixels, size: Pixels,
color: Option<Hsla>, color: Option<Hsla>,
transformation: Option<Transformation>,
} }
/// Creates an icon for the role at the default size of 16px. /// Creates an icon for the role at the default size of 16px.
@@ -198,6 +204,7 @@ pub fn icon(role: IconRole) -> Icon {
role, role,
size: px(16.0), size: px(16.0),
color: None, color: None,
transformation: None,
} }
} }
@@ -212,6 +219,12 @@ impl Icon {
self 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. /// The asset key a resolved freedesktop file is served under.
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
fn freedesktop_asset_path(&self) -> Option<SharedString> { fn freedesktop_asset_path(&self) -> Option<SharedString> {
@@ -232,27 +245,29 @@ impl RenderOnce for Icon {
} }
impl 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")] #[cfg(target_os = "windows")]
fn render_glyph(self) -> impl IntoElement { fn render_glyph(self) -> impl IntoElement {
div() match segoe::glyph(self.role) {
.flex_none() Some(glyph) => div()
.size(self.size) .flex_none()
.flex() .size(self.size)
.items_center() .flex()
.justify_center() .items_center()
.when_some(segoe::glyph(self.role), |el, glyph| { .justify_center()
el.child( .child(
div() div()
.font_family(segoe::FONT_FAMILY) .font_family(segoe::FONT_FAMILY)
.text_size(self.size) .text_size(self.size)
.when_some(self.color, |el, color| el.text_color(color)) .when_some(self.color, |el, color| el.text_color(color))
.child(glyph.to_string()), .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 { fn render_svg(self) -> impl IntoElement {
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let path: SharedString = self let path: SharedString = self
@@ -266,7 +281,10 @@ impl Icon {
svg() svg()
.size_full() .size_full()
.path(path) .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/eye-off.svg" => Some(include_bytes!("../assets/eye-off.svg")),
"icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")), "icons/panel-left.svg" => Some(include_bytes!("../assets/panel-left.svg")),
"icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")), "icons/panel-right.svg" => Some(include_bytes!("../assets/panel-right.svg")),
"icons/loader.svg" => Some(include_bytes!("../assets/loader.svg")),
_ => None, _ => None,
} }
} }
+8 -2
View File
@@ -6,6 +6,9 @@ use crate::IconRole;
/// The Segoe Fluent Icons codepoint for the role. /// The Segoe Fluent Icons codepoint for the role.
pub(crate) fn glyph(role: IconRole) -> Option<char> { pub(crate) fn glyph(role: IconRole) -> Option<char> {
let codepoint = match role { 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::Check => 0xE73E, // CheckMark
IconRole::Close => 0xE8BB, // ChromeClose IconRole::Close => 0xE8BB, // ChromeClose
IconRole::ChevronDown => 0xE70D, // ChevronDown IconRole::ChevronDown => 0xE70D, // ChevronDown
@@ -37,9 +40,12 @@ mod tests {
use super::*; use super::*;
#[test] #[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() { 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:?}"
);
} }
} }
+6 -5
View File
@@ -8,13 +8,14 @@ is the first downstream consumer; its screens drive the order.
## Next ## 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 Replaces `Tab::busy`'s placeholder ring; land before signed wires repo
loading into dock tabs. loading into dock tabs.
- [ ] API sketch - [x] API sketch
- [ ] Implementation (theme-baked, motion inline per AGENTS.md) - [x] Implementation (theme-baked, motion inline per AGENTS.md)
- [ ] Tests - [x] Tests
- [ ] Gallery panel - [x] Gallery panel
- [ ] Review - [ ] Review
## Queue — to follow ## Queue — to follow
+125 -82
View File
@@ -25,6 +25,7 @@ use gpui_native_core::scroll_area::{ScrollArea, ScrollAxes};
use gpui_native_core::segmented::{Segmented, SegmentedOption}; use gpui_native_core::segmented::{Segmented, SegmentedOption};
use gpui_native_core::select::{Select, SelectOption}; use gpui_native_core::select::{Select, SelectOption};
use gpui_native_core::slider::Slider; use gpui_native_core::slider::Slider;
use gpui_native_core::spinner::{Spinner, SpinnerVariant};
use gpui_native_core::switch::Switch; use gpui_native_core::switch::Switch;
use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView}; use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView};
use gpui_native_core::toggle::Toggle; use gpui_native_core::toggle::Toggle;
@@ -66,6 +67,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
("badge", "Badge"), ("badge", "Badge"),
("avatar", "Avatar"), ("avatar", "Avatar"),
("progress", "Progress"), ("progress", "Progress"),
("spinner", "Spinner"),
("list", "List"), ("list", "List"),
("tree", "Tree"), ("tree", "Tree"),
("tabs", "Tabs"), ("tabs", "Tabs"),
@@ -451,6 +453,10 @@ impl Gallery {
}), }),
cx, cx,
); );
dock.register_panel(
PanelDescriptor::new("spinner", "Spinner", |_, cx| cx.new(|_| SpinnerDemo).into()),
cx,
);
dock.register_panel( dock.register_panel(
PanelDescriptor::new("list", "List", |_, cx| { PanelDescriptor::new("list", "List", |_, cx| {
cx.new(|_| ListDemo { selected: None }).into() 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<Self>) -> 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 { struct ListDemo {
selected: Option<usize>, selected: Option<usize>,
} }
@@ -1526,90 +1573,86 @@ impl Render for TabsDemo {
let colors = cx.theme().colors; let colors = cx.theme().colors;
let pill_count = self.pill_tabs.len(); let pill_count = self.pill_tabs.len();
ScrollArea::new("tabs-demo") ScrollArea::new("tabs-demo").size_full().p_4().child(
.size_full() v_flex()
.p_4() .gap_3()
.child( .child(demo_section(
v_flex().gap_3() "underline",
.child(demo_section( colors,
"underline", Tabs::new("tabs-demo-underline")
colors, .selected(self.selected)
Tabs::new("tabs-demo-underline") .on_change(move |selected, _, cx| {
.selected(self.selected) view.update(cx, |demo, cx| {
.on_change(move |selected, _, cx| { demo.selected = selected;
view.update(cx, |demo, cx| { cx.notify();
demo.selected = selected; });
})
.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(); 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( .tabs({
div() let labels = self.pill_tabs.clone();
.text_xs() move |_, _| {
.text_color(colors.muted_foreground) labels
.child(format!( .iter()
"{pill_count} tabs left; the ring on \"Syncing\" is the Spinner placeholder" .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"
)),
),
)
} }
} }