feat: add spinner component
This commit is contained in:
@@ -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"]
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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
@@ -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()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 |
+27
-8
@@ -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) {
|
||||||
|
Some(glyph) => div()
|
||||||
.flex_none()
|
.flex_none()
|
||||||
.size(self.size)
|
.size(self.size)
|
||||||
.flex()
|
.flex()
|
||||||
.items_center()
|
.items_center()
|
||||||
.justify_center()
|
.justify_center()
|
||||||
.when_some(segoe::glyph(self.role), |el, glyph| {
|
.child(
|
||||||
el.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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,11 +1573,9 @@ 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(
|
|
||||||
v_flex().gap_3()
|
|
||||||
.child(demo_section(
|
.child(demo_section(
|
||||||
"underline",
|
"underline",
|
||||||
colors,
|
colors,
|
||||||
@@ -1553,9 +1598,7 @@ impl Render for TabsDemo {
|
|||||||
))
|
))
|
||||||
.child(match self.selected {
|
.child(match self.selected {
|
||||||
0 => div()
|
0 => div()
|
||||||
.child(
|
.child("The overview panel is the app's business — the strip only reports.")
|
||||||
"The overview panel is the app's business — the strip only reports.",
|
|
||||||
)
|
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
1 => div()
|
1 => div()
|
||||||
.child("Details: rows, lists and trees all live under their own tabs.")
|
.child("Details: rows, lists and trees all live under their own tabs.")
|
||||||
@@ -1606,7 +1649,7 @@ impl Render for TabsDemo {
|
|||||||
.text_xs()
|
.text_xs()
|
||||||
.text_color(colors.muted_foreground)
|
.text_color(colors.muted_foreground)
|
||||||
.child(format!(
|
.child(format!(
|
||||||
"{pill_count} tabs left; the ring on \"Syncing\" is the Spinner placeholder"
|
"{pill_count} tabs left; \"Syncing\" spins while it loads"
|
||||||
)),
|
)),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user