Skip to main content

app_ui/
style_inspector.rs

1//! Style inspector — background framing (ED.18 / M-EDIT).
2//!
3//! The "look" panel: the backdrop the screen sits on (a gradient or flat
4//! fill) plus the padding, corner radius, and drop shadow that lift the
5//! recording off it — the produced, on-a-nice-desktop framing every modern
6//! screen recorder ships. All of it drives one
7//! [`BackgroundConfig`] on the project,
8//! re-derived at render time; the *visible* framing (the drawn padding /
9//! rounded canvas / shadow) lands with the render-integration / export pass
10//! (ED.20 / ED.21). This chunk is the authoring side, undoable through the
11//! shared [`edit::History`].
12
13use edit::EditProject;
14use edit::style::{BackgroundConfig, BackgroundSource};
15use leptos::prelude::*;
16
17type ProjectSignal = Option<RwSignal<Option<EditProject>>>;
18type HistoryStore = Option<StoredValue<Option<edit::History>>>;
19
20/// Background swatch presets, in display order.
21#[must_use]
22pub fn background_presets() -> Vec<(&'static str, BackgroundSource)> {
23    vec![
24        ("Aurora", BackgroundSource::default()),
25        (
26            "Ocean",
27            BackgroundSource::Gradient {
28                from: [33, 147, 176],
29                to: [109, 213, 237],
30                angle_deg: 135.0,
31            },
32        ),
33        (
34            "Sunset",
35            BackgroundSource::Gradient {
36                from: [255, 126, 95],
37                to: [254, 180, 123],
38                angle_deg: 135.0,
39            },
40        ),
41        ("Graphite", BackgroundSource::Color { rgb: [38, 40, 46] }),
42        (
43            "Snow",
44            BackgroundSource::Color {
45                rgb: [240, 240, 242],
46            },
47        ),
48    ]
49}
50
51/// A CSS `background` value for a [`BackgroundSource`] — used both for the
52/// swatch previews and (eventually) the live canvas backdrop.
53#[must_use]
54pub fn source_css(source: &BackgroundSource) -> String {
55    match source {
56        BackgroundSource::Gradient {
57            from,
58            to,
59            angle_deg,
60        } => format!(
61            "linear-gradient({angle_deg}deg, rgb({},{},{}), rgb({},{},{}))",
62            from[0], from[1], from[2], to[0], to[1], to[2]
63        ),
64        BackgroundSource::Color { rgb } => format!("rgb({},{},{})", rgb[0], rgb[1], rgb[2]),
65        // Wallpapers need bundled assets (deferred) — show a neutral chip.
66        BackgroundSource::Wallpaper { .. } => "#33363d".to_owned(),
67    }
68}
69
70/// Whether two background sources are the same preset.
71#[must_use]
72pub fn is_active_source(current: &BackgroundSource, preset: &BackgroundSource) -> bool {
73    current == preset
74}
75
76/// Parse a numeric field to a `u32`, clamped to `0..=max`. Non-numeric → 0.
77#[must_use]
78pub fn parse_u32_field(s: &str, max: u32) -> u32 {
79    s.trim().parse::<u32>().unwrap_or(0).min(max)
80}
81
82fn swatches(project: ProjectSignal, history: HistoryStore) -> AnyView {
83    let current = project
84        .and_then(|s| s.get().map(|p| p.background.source))
85        .unwrap_or_default();
86    background_presets()
87        .into_iter()
88        .map(|(name, source)| {
89            let mut class = String::from("style-swatch");
90            if is_active_source(&current, &source) {
91                class.push_str(" style-swatch--active");
92            }
93            let css = source_css(&source);
94            view! {
95                <button
96                    class=class
97                    title=name
98                    style=format!("background:{css}")
99                    on:click=move |_| {
100                        if let (Some(p), Some(h)) = (project, history) {
101                            let mut cfg = p
102                                .get_untracked()
103                                .map(|pr| pr.background)
104                                .unwrap_or_default();
105                            cfg.source = source.clone();
106                            crate::editor_edits::set_background(p, h, cfg);
107                        }
108                    }
109                ></button>
110            }
111        })
112        .collect_view()
113        .into_any()
114}
115
116/// One labelled numeric field bound to a `BackgroundConfig` field.
117fn number_field(
118    project: ProjectSignal,
119    history: HistoryStore,
120    label: &'static str,
121    value: u32,
122    max: u32,
123    set: fn(&mut BackgroundConfig, u32),
124) -> AnyView {
125    view! {
126        <label class="style-field">
127            <span class="style-field-label">{label}</span>
128            <input
129                class="style-field-input"
130                type="number"
131                min="0"
132                max=max.to_string()
133                prop:value=value.to_string()
134                on:change=move |ev| {
135                    if let (Some(p), Some(h)) = (project, history) {
136                        let mut cfg = p.get_untracked().map(|pr| pr.background).unwrap_or_default();
137                        set(&mut cfg, parse_u32_field(&event_target_value(&ev), max));
138                        crate::editor_edits::set_background(p, h, cfg);
139                    }
140                }
141            />
142        </label>
143    }
144    .into_any()
145}
146
147/// The style inspector: backdrop swatches + padding / radius / shadow.
148#[component]
149pub fn StyleInspector() -> impl IntoView {
150    let project = use_context::<RwSignal<Option<EditProject>>>();
151    let history = use_context::<StoredValue<Option<edit::History>>>();
152    view! {
153        <div class="style-inspector">
154            <div class="clip-inspector-section">
155                <h3 class="clip-inspector-title">"Background"</h3>
156                <div class="style-swatches">{move || swatches(project, history)}</div>
157            </div>
158            <div class="clip-inspector-section">
159                <h3 class="clip-inspector-title">"Framing"</h3>
160                <div class="style-fields">
161                    {move || {
162                        let bg = project
163                            .and_then(|s| s.get().map(|p| p.background))
164                            .unwrap_or_default();
165                        view! {
166                            {number_field(project, history, "Padding", bg.padding, 400, |c, v| c.padding = v)}
167                            {number_field(project, history, "Radius", bg.corner_radius, 200, |c, v| c.corner_radius = v)}
168                            {number_field(project, history, "Shadow", bg.shadow, 100, |c, v| c.shadow = v)}
169                        }
170                    }}
171                </div>
172            </div>
173        </div>
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn presets_include_default_and_a_color() {
183        let p = background_presets();
184        assert!(p.len() >= 4);
185        assert!(p.iter().any(|(n, _)| *n == "Aurora"));
186        assert!(
187            p.iter()
188                .any(|(_, s)| matches!(s, BackgroundSource::Color { .. }))
189        );
190    }
191
192    #[test]
193    fn source_css_renders_gradient_and_color() {
194        let g = source_css(&BackgroundSource::Gradient {
195            from: [1, 2, 3],
196            to: [4, 5, 6],
197            angle_deg: 90.0,
198        });
199        assert_eq!(g, "linear-gradient(90deg, rgb(1,2,3), rgb(4,5,6))");
200        assert_eq!(
201            source_css(&BackgroundSource::Color { rgb: [10, 20, 30] }),
202            "rgb(10,20,30)"
203        );
204    }
205
206    #[test]
207    fn parse_u32_clamps_and_defaults() {
208        assert_eq!(parse_u32_field("64", 400), 64);
209        assert_eq!(parse_u32_field("999", 400), 400); // clamp to max
210        assert_eq!(parse_u32_field("abc", 400), 0); // non-numeric
211        assert_eq!(parse_u32_field("-5", 400), 0); // negative → parse fail → 0
212    }
213
214    #[test]
215    fn active_source_matches_equal_presets() {
216        let a = BackgroundSource::Color { rgb: [1, 1, 1] };
217        let b = BackgroundSource::Color { rgb: [1, 1, 1] };
218        let c = BackgroundSource::Color { rgb: [2, 2, 2] };
219        assert!(is_active_source(&a, &b));
220        assert!(!is_active_source(&a, &c));
221    }
222}