Skip to main content

ui_storybook/components/cursor/
cursor_studio_shell.rs

1//! `CursorStudioShell` + `CursorStylePicker` (M-UI.20 / AUT-140) —
2//! the lower style strip in Cursor Studio and the structural shell
3//! that holds the preview + inspector + style picker.
4
5use leptos::prelude::*;
6
7/// Cursor style options shown in the bottom strip.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum CursorStyle {
10    /// System default — uses the OS pointer.
11    System,
12    /// Standard arrow.
13    Arrow,
14    /// Soft / rounded arrow.
15    Soft,
16    /// Solid dot.
17    Dot,
18    /// Hollow ring.
19    Ring,
20    /// Crosshair / reticle.
21    Reticle,
22    /// Tactile / 3D-pressed cursor.
23    Tactile,
24    /// Hide the cursor entirely.
25    Hide,
26}
27
28impl CursorStyle {
29    /// Display label.
30    #[must_use]
31    pub fn label(self) -> &'static str {
32        match self {
33            CursorStyle::System => "System",
34            CursorStyle::Arrow => "Arrow",
35            CursorStyle::Soft => "Soft",
36            CursorStyle::Dot => "Dot",
37            CursorStyle::Ring => "Ring",
38            CursorStyle::Reticle => "Reticle",
39            CursorStyle::Tactile => "Tactile",
40            CursorStyle::Hide => "Hide",
41        }
42    }
43
44    /// Stable kebab-case slug.
45    #[must_use]
46    pub fn slug(self) -> &'static str {
47        match self {
48            CursorStyle::System => "system",
49            CursorStyle::Arrow => "arrow",
50            CursorStyle::Soft => "soft",
51            CursorStyle::Dot => "dot",
52            CursorStyle::Ring => "ring",
53            CursorStyle::Reticle => "reticle",
54            CursorStyle::Tactile => "tactile",
55            CursorStyle::Hide => "hide",
56        }
57    }
58
59    /// Preview glyph drawn inside each tile.
60    #[must_use]
61    pub fn glyph(self) -> &'static str {
62        match self {
63            CursorStyle::System => "◖",
64            CursorStyle::Arrow => "➤",
65            CursorStyle::Soft => "❥",
66            CursorStyle::Dot => "●",
67            CursorStyle::Ring => "○",
68            CursorStyle::Reticle => "⊕",
69            CursorStyle::Tactile => "◎",
70            CursorStyle::Hide => "∅",
71        }
72    }
73}
74
75/// One tile in the picker.
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct CursorStyleTileView {
78    /// Which style this tile represents.
79    pub style: CursorStyle,
80    /// Display label override (falls back to `CursorStyle::label` when None).
81    pub label: Option<&'static str>,
82    /// `true` for the active tile.
83    pub selected: bool,
84    /// `true` to dim the tile.
85    pub disabled: bool,
86}
87
88/// Picker view-model.
89#[derive(Clone, Debug, PartialEq, Eq)]
90pub struct CursorStylePickerView {
91    /// Tiles in display order.
92    pub tiles: Vec<CursorStyleTileView>,
93}
94
95#[component]
96pub fn CursorStyleTile(view: CursorStyleTileView) -> impl IntoView {
97    let style = view.style;
98    let mut class = String::from("cursor-style-tile");
99    if view.selected {
100        class.push_str(" cursor-style-tile-selected");
101    }
102    if view.disabled {
103        class.push_str(" cursor-style-tile-disabled");
104    }
105    let label = view.label.unwrap_or_else(|| style.label());
106    let glyph = style.glyph();
107    let slug = style.slug();
108    let pressed = view.selected;
109    view! {
110        <button class=class data-style=slug aria-pressed=pressed disabled=view.disabled>
111            <span class="cursor-style-tile-preview" aria-hidden="true">{glyph}</span>
112            <span class="cursor-style-tile-label">{label}</span>
113        </button>
114    }
115}
116
117#[component]
118pub fn CursorStylePicker(view: CursorStylePickerView) -> impl IntoView {
119    let tiles: Vec<_> = view
120        .tiles
121        .into_iter()
122        .map(|t| view! { <CursorStyleTile view=t /> })
123        .collect();
124    view! {
125        <section class="cursor-style-picker" aria-label="Cursor style">
126            <span class="cursor-style-heading">"CURSOR STYLE"</span>
127            <div class="cursor-style-tiles">{tiles}</div>
128        </section>
129    }
130}
131
132/// Shell view-model.
133#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct CursorStudioShellView {
135    /// Picker shown at the bottom.
136    pub picker: CursorStylePickerView,
137}
138
139#[component]
140pub fn CursorStudioShell(
141    /// Shell view-model.
142    view: CursorStudioShellView,
143    /// Top preview slot.
144    #[prop(optional)]
145    preview: Option<Children>,
146    /// Right inspector slot.
147    #[prop(optional)]
148    inspector: Option<Children>,
149) -> impl IntoView {
150    view! {
151        <section class="cursor-studio-shell" aria-label="Cursor Studio">
152            <div class="cursor-studio-body">
153                <div class="cursor-studio-preview">{preview.map(|p| p())}</div>
154                <aside class="cursor-studio-inspector">{inspector.map(|i| i())}</aside>
155            </div>
156            <CursorStylePicker view=view.picker />
157        </section>
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164
165    #[test]
166    fn cursor_style_slugs_unique() {
167        let styles = [
168            CursorStyle::System,
169            CursorStyle::Arrow,
170            CursorStyle::Soft,
171            CursorStyle::Dot,
172            CursorStyle::Ring,
173            CursorStyle::Reticle,
174            CursorStyle::Tactile,
175            CursorStyle::Hide,
176        ];
177        let slugs: Vec<_> = styles.iter().map(|s| s.slug()).collect();
178        let mut sorted = slugs.clone();
179        sorted.sort_unstable();
180        sorted.dedup();
181        assert_eq!(sorted.len(), slugs.len());
182    }
183
184    #[test]
185    fn cursor_style_label_round_trips() {
186        for s in [CursorStyle::Arrow, CursorStyle::Dot, CursorStyle::Hide] {
187            assert!(!s.label().is_empty());
188            assert!(!s.glyph().is_empty());
189        }
190    }
191}