Skip to main content

app_ui/
cursor_inspector.rs

1//! Cursor inspector — cursor styling (ED.19 / M-EDIT).
2//!
3//! The cursor controls: how big the pointer renders, how much its motion is
4//! smoothed, whether clicks throw a ripple, whether it hides while idle, and
5//! whether clicks drive auto-zoom (ED.17). All of it edits one
6//! [`CursorConfig`] on the project. The composited
7//! cursor **overlay** — a smoothed, scaled pointer with click ripples — is a
8//! `wisp` layer driven by the captured cursor track, and lands with the
9//! render-integration pass (it needs the same per-OS telemetry capture ED.17
10//! is waiting on). This chunk is the authoring side, undoable through the
11//! shared [`edit::History`].
12
13use edit::EditProject;
14use edit::style::CursorConfig;
15use leptos::prelude::*;
16
17use crate::style_inspector::parse_u32_field;
18
19type ProjectSignal = Option<RwSignal<Option<EditProject>>>;
20type HistoryStore = Option<StoredValue<Option<edit::History>>>;
21
22/// Clamp a cursor size percentage to a sane authoring range (25–400 %).
23#[must_use]
24pub fn clamp_size_pct(v: u32) -> u32 {
25    v.clamp(25, 400)
26}
27
28fn commit(project: ProjectSignal, history: HistoryStore, edit: impl FnOnce(&mut CursorConfig)) {
29    if let (Some(p), Some(h)) = (project, history) {
30        let mut cfg = p.get_untracked().map(|pr| pr.cursor).unwrap_or_default();
31        edit(&mut cfg);
32        crate::editor_edits::set_cursor(p, h, cfg);
33    }
34}
35
36fn number_field(
37    project: ProjectSignal,
38    history: HistoryStore,
39    label: &'static str,
40    value: u32,
41    max: u32,
42    set: fn(&mut CursorConfig, u32),
43) -> AnyView {
44    view! {
45        <label class="style-field">
46            <span class="style-field-label">{label}</span>
47            <input
48                class="style-field-input"
49                type="number"
50                min="0"
51                max=max.to_string()
52                prop:value=value.to_string()
53                on:change=move |ev| {
54                    let v = parse_u32_field(&event_target_value(&ev), max);
55                    commit(project, history, |c| set(c, v));
56                }
57            />
58        </label>
59    }
60    .into_any()
61}
62
63fn toggle_field(
64    project: ProjectSignal,
65    history: HistoryStore,
66    label: &'static str,
67    checked: bool,
68    set: fn(&mut CursorConfig, bool),
69) -> AnyView {
70    view! {
71        <label class="cursor-toggle">
72            <input
73                type="checkbox"
74                prop:checked=checked
75                on:change=move |ev| {
76                    let b = event_target_checked(&ev);
77                    commit(project, history, |c| set(c, b));
78                }
79            />
80            <span>{label}</span>
81        </label>
82    }
83    .into_any()
84}
85
86/// The cursor inspector: size / smoothing + ripples / hide-static / auto-zoom.
87#[component]
88pub fn CursorInspector() -> impl IntoView {
89    let project = use_context::<RwSignal<Option<EditProject>>>();
90    let history = use_context::<StoredValue<Option<edit::History>>>();
91    view! {
92        <div class="cursor-inspector">
93            <div class="clip-inspector-section">
94                <h3 class="clip-inspector-title">"Cursor"</h3>
95                {move || {
96                    let c = project.and_then(|s| s.get().map(|p| p.cursor)).unwrap_or_default();
97                    view! {
98                        <div class="style-fields">
99                            {number_field(project, history, "Size %", c.size_pct, 400, |c, v| c.size_pct = clamp_size_pct(v))}
100                            {number_field(project, history, "Smooth", c.smoothing, 100, |c, v| c.smoothing = v)}
101                        </div>
102                        {toggle_field(project, history, "Click ripples", c.click_ripples, |c, b| c.click_ripples = b)}
103                        {toggle_field(project, history, "Hide when static", c.hide_static, |c, b| c.hide_static = b)}
104                        {toggle_field(
105                            project,
106                            history,
107                            "Auto-zoom on clicks",
108                            c.auto_zoom.detect_from_cursor,
109                            |c, b| c.auto_zoom.detect_from_cursor = b,
110                        )}
111                    }
112                }}
113            </div>
114        </div>
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn size_pct_clamps_to_authoring_range() {
124        assert_eq!(clamp_size_pct(180), 180);
125        assert_eq!(clamp_size_pct(0), 25); // floor
126        assert_eq!(clamp_size_pct(9999), 400); // ceiling
127    }
128}