ui_storybook/components/cursor/
cursor_preview_canvas.rs1use leptos::prelude::*;
9
10use crate::components::editor::{
11 InspectorTab, InspectorTabs, PropertyControlView, PropertyRowView, PropertySection,
12 PropertySectionView,
13};
14
15#[derive(Clone, Debug, PartialEq, Eq, Default)]
17pub enum CursorPreviewBackend {
18 #[default]
20 CssFallback,
21 WispAsset {
23 asset_path: &'static str,
25 },
26 RuntimeUnavailable,
28}
29
30impl CursorPreviewBackend {
31 #[must_use]
33 pub fn slug(&self) -> &'static str {
34 match self {
35 CursorPreviewBackend::CssFallback => "css-fallback",
36 CursorPreviewBackend::WispAsset { .. } => "wisp-asset",
37 CursorPreviewBackend::RuntimeUnavailable => "runtime-unavailable",
38 }
39 }
40}
41
42#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct CursorColor {
45 pub label: &'static str,
47 pub css: &'static str,
49}
50
51#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ClickEffect {
54 Ring,
56 Pulse,
58 Spotlight,
60 None,
62}
63
64impl ClickEffect {
65 #[must_use]
67 pub fn label(self) -> &'static str {
68 match self {
69 ClickEffect::Ring => "Ring",
70 ClickEffect::Pulse => "Pulse",
71 ClickEffect::Spotlight => "Spotlight",
72 ClickEffect::None => "None",
73 }
74 }
75
76 #[must_use]
78 pub fn slug(self) -> &'static str {
79 match self {
80 ClickEffect::Ring => "ring",
81 ClickEffect::Pulse => "pulse",
82 ClickEffect::Spotlight => "spotlight",
83 ClickEffect::None => "none",
84 }
85 }
86}
87
88#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
90pub enum CursorBehaviorView {
91 #[default]
93 Natural,
94 Smooth,
96 Snap,
98}
99
100impl CursorBehaviorView {
101 #[must_use]
103 pub fn label(self) -> &'static str {
104 match self {
105 CursorBehaviorView::Natural => "Natural",
106 CursorBehaviorView::Smooth => "Smooth",
107 CursorBehaviorView::Snap => "Snap to UI",
108 }
109 }
110}
111
112#[derive(Clone, Debug, PartialEq)]
114pub struct CursorAppearanceView {
115 pub size_percent: u16,
117 pub selected_color: CursorColor,
119 pub halo_enabled: bool,
121 pub halo_strength_percent: u16,
123 pub click_effect: ClickEffect,
125 pub smoothing_percent: u16,
127 pub trail_enabled: bool,
129 pub behavior: CursorBehaviorView,
131}
132
133impl CursorAppearanceView {
134 #[must_use]
136 pub fn clamped_size(&self) -> u16 {
137 self.size_percent.min(200)
138 }
139}
140
141#[component]
142pub fn CursorPreviewCanvas(
143 backend: CursorPreviewBackend,
145 #[prop(optional, default = "linear-gradient(135deg,#f8fafc,#e5e7eb)")]
148 card_background: &'static str,
149) -> impl IntoView {
150 let style = format!("background: {card_background};");
151 let body = match backend {
152 CursorPreviewBackend::CssFallback => view! {
153 <span class="cursor-preview-arrow" aria-hidden="true">"➤"</span>
154 }
155 .into_any(),
156 CursorPreviewBackend::WispAsset { asset_path } => view! {
157 <img class="cursor-preview-asset" src=asset_path alt="Cursor preview" />
158 }
159 .into_any(),
160 CursorPreviewBackend::RuntimeUnavailable => view! {
161 <span class="cursor-preview-banner">"Runtime unavailable — CSS preview"</span>
162 }
163 .into_any(),
164 };
165 view! {
166 <div class="cursor-preview-canvas" style=style role="img" aria-label="Cursor preview">
167 <span class="cursor-preview-ring" aria-hidden="true"></span>
168 {body}
169 </div>
170 }
171}
172
173#[component]
174pub fn CursorAppearancePanel(view: CursorAppearanceView) -> impl IntoView {
175 let appearance = appearance_section(&view);
176 let click = click_effect_section(&view);
177 let motion = motion_section(&view);
178 let behavior = behavior_section(&view);
179 view! {
180 <aside class="cursor-appearance-panel" aria-label="Cursor appearance">
181 <InspectorTabs tabs=vec![InspectorTab::Cursor] active=InspectorTab::Cursor />
182 <div class="cursor-appearance-body">
183 <PropertySection section=appearance />
184 <PropertySection section=click />
185 <PropertySection section=motion />
186 <PropertySection section=behavior />
187 </div>
188 <div class="cursor-appearance-footer">
189 <button class="btn btn-outline btn-sm">"Reset"</button>
190 <button class="btn btn-default btn-sm">"Apply"</button>
191 </div>
192 </aside>
193 }
194}
195
196fn appearance_section(v: &CursorAppearanceView) -> PropertySectionView {
197 PropertySectionView {
198 title: "APPEARANCE",
199 rows: vec![
200 PropertyRowView {
201 label: "Size",
202 value: Some(leak(format!("{}%", v.clamped_size()))),
203 disabled: false,
204 control: PropertyControlView::SliderPercent {
205 percent: pct_from_size(v.clamped_size()),
206 },
207 },
208 PropertyRowView {
209 label: "Color",
210 value: Some(v.selected_color.label),
211 disabled: false,
212 control: PropertyControlView::ColorSwatches {
213 swatches: vec!["#fafafa", "#facc15", "#38bdf8", "#a78bfa", "#f97316"],
214 selected: color_index(&v.selected_color),
215 },
216 },
217 PropertyRowView {
218 label: "Halo",
219 value: None,
220 disabled: false,
221 control: PropertyControlView::Toggle { on: v.halo_enabled },
222 },
223 PropertyRowView {
224 label: "Halo strength",
225 value: Some(leak(format!("{}%", v.halo_strength_percent.min(100)))),
226 disabled: !v.halo_enabled,
227 control: PropertyControlView::SliderPercent {
228 percent: v.halo_strength_percent.min(100) as u8,
229 },
230 },
231 ],
232 }
233}
234
235fn click_effect_section(v: &CursorAppearanceView) -> PropertySectionView {
236 PropertySectionView {
237 title: "CLICK EFFECT",
238 rows: vec![PropertyRowView {
239 label: "Effect",
240 value: Some(v.click_effect.label()),
241 disabled: false,
242 control: PropertyControlView::SelectPill {
243 current_label: v.click_effect.label(),
244 },
245 }],
246 }
247}
248
249fn motion_section(v: &CursorAppearanceView) -> PropertySectionView {
250 PropertySectionView {
251 title: "MOTION",
252 rows: vec![
253 PropertyRowView {
254 label: "Smoothing",
255 value: Some(leak(format!("{}%", v.smoothing_percent.min(100)))),
256 disabled: false,
257 control: PropertyControlView::SliderPercent {
258 percent: v.smoothing_percent.min(100) as u8,
259 },
260 },
261 PropertyRowView {
262 label: "Trail",
263 value: None,
264 disabled: false,
265 control: PropertyControlView::Toggle {
266 on: v.trail_enabled,
267 },
268 },
269 ],
270 }
271}
272
273fn behavior_section(v: &CursorAppearanceView) -> PropertySectionView {
274 PropertySectionView {
275 title: "BEHAVIOR",
276 rows: vec![PropertyRowView {
277 label: "Preset",
278 value: Some(v.behavior.label()),
279 disabled: false,
280 control: PropertyControlView::SelectPill {
281 current_label: v.behavior.label(),
282 },
283 }],
284 }
285}
286
287fn pct_from_size(size: u16) -> u8 {
288 let pct = u32::from(size) * 100 / 200;
289 u8::try_from(pct.min(100)).expect("clamped value fits in u8")
290}
291
292fn color_index(c: &CursorColor) -> usize {
293 let palette = ["#fafafa", "#facc15", "#38bdf8", "#a78bfa", "#f97316"];
294 palette.iter().position(|p| *p == c.css).unwrap_or(0)
295}
296
297fn leak(s: String) -> &'static str {
298 Box::leak(s.into_boxed_str())
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn click_effect_slugs_unique() {
307 let slugs = [
308 ClickEffect::Ring.slug(),
309 ClickEffect::Pulse.slug(),
310 ClickEffect::Spotlight.slug(),
311 ClickEffect::None.slug(),
312 ];
313 let mut sorted = slugs.to_vec();
314 sorted.sort_unstable();
315 sorted.dedup();
316 assert_eq!(sorted.len(), slugs.len());
317 }
318
319 #[test]
320 fn cursor_appearance_clamps_size() {
321 let v = CursorAppearanceView {
322 size_percent: 350,
323 selected_color: CursorColor {
324 label: "White",
325 css: "#fafafa",
326 },
327 halo_enabled: true,
328 halo_strength_percent: 250,
329 click_effect: ClickEffect::Ring,
330 smoothing_percent: 110,
331 trail_enabled: false,
332 behavior: CursorBehaviorView::Natural,
333 };
334 assert_eq!(v.clamped_size(), 200);
335 }
336}