Skip to main content

ui_storybook/components/editor/
inspector_panel.rs

1//! `InspectorPanel` + property primitives (M-UI.18 / AUT-138).
2//!
3//! Right-pane inspector used by both the editor (UI-16/17) and the
4//! cursor studio (UI-20/21). Composes a tab strip + zero-or-more
5//! `PropertySection` rows. Tabs and active state are controlled
6//! props; the component owns no state.
7
8use leptos::prelude::*;
9
10/// Which inspector tab is active.
11#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
12pub enum InspectorTab {
13    /// Visual / typographic style.
14    #[default]
15    Style,
16    /// Cursor appearance and motion.
17    Cursor,
18    /// Audio tracks + ducking.
19    Audio,
20    /// Generated captions.
21    Captions,
22    /// AI tools (B-roll suggestions, etc.).
23    Ai,
24}
25
26impl InspectorTab {
27    /// Display label.
28    #[must_use]
29    pub fn label(self) -> &'static str {
30        match self {
31            InspectorTab::Style => "Style",
32            InspectorTab::Cursor => "Cursor",
33            InspectorTab::Audio => "Audio",
34            InspectorTab::Captions => "Captions",
35            InspectorTab::Ai => "AI",
36        }
37    }
38
39    /// Stable kebab-case slug.
40    #[must_use]
41    pub fn slug(self) -> &'static str {
42        match self {
43            InspectorTab::Style => "style",
44            InspectorTab::Cursor => "cursor",
45            InspectorTab::Audio => "audio",
46            InspectorTab::Captions => "captions",
47            InspectorTab::Ai => "ai",
48        }
49    }
50}
51
52/// One property row inside a section.
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub struct PropertyRowView {
55    /// Display label.
56    pub label: &'static str,
57    /// Optional pre-formatted value text shown right-aligned.
58    pub value: Option<&'static str>,
59    /// `true` to dim and disable the row.
60    pub disabled: bool,
61    /// Which built-in control to render. Custom controls land via
62    /// composition in the parent.
63    pub control: PropertyControlView,
64}
65
66/// Built-in property-row controls.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub enum PropertyControlView {
69    /// Read-only — just the value.
70    ValueOnly,
71    /// Slider with a `0..=100` percent. Renders the value column too.
72    SliderPercent {
73        /// Current value.
74        percent: u8,
75    },
76    /// Toggle switch.
77    Toggle {
78        /// `true` when on.
79        on: bool,
80    },
81    /// Inline color swatch group. Each `&'static str` is a CSS color.
82    ColorSwatches {
83        /// Color strings in order.
84        swatches: Vec<&'static str>,
85        /// Index of the selected swatch.
86        selected: usize,
87    },
88    /// Pill-style select. Renders the value as a button with chevron.
89    SelectPill {
90        /// Pre-formatted current label.
91        current_label: &'static str,
92    },
93}
94
95/// One section with a heading and rows.
96#[derive(Clone, Debug, PartialEq, Eq)]
97pub struct PropertySectionView {
98    /// Uppercase section title ("APPEARANCE", "MOTION").
99    pub title: &'static str,
100    /// Rows in display order.
101    pub rows: Vec<PropertyRowView>,
102}
103
104/// Inspector view-model.
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct InspectorPanelView {
107    /// Tabs in display order.
108    pub tabs: Vec<InspectorTab>,
109    /// Active tab.
110    pub active: InspectorTab,
111    /// Sections rendered under the active tab.
112    pub sections: Vec<PropertySectionView>,
113}
114
115#[component]
116pub fn InspectorTabs(
117    /// Available tabs.
118    tabs: Vec<InspectorTab>,
119    /// Active tab.
120    active: InspectorTab,
121) -> impl IntoView {
122    let chips: Vec<_> = tabs
123        .into_iter()
124        .map(|t| {
125            let mut class = String::from("inspector-tab");
126            if t == active {
127                class.push_str(" inspector-tab-active");
128            }
129            let slug = t.slug();
130            let label = t.label();
131            let pressed = t == active;
132            view! {
133                <button class=class data-tab=slug aria-pressed=pressed>{label}</button>
134            }
135        })
136        .collect();
137    view! {
138        <div class="inspector-tabs" role="tablist" aria-label="Inspector tabs">{chips}</div>
139    }
140}
141
142#[component]
143pub fn PropertySection(section: PropertySectionView) -> impl IntoView {
144    let rows: Vec<_> = section.rows.into_iter().map(render_row).collect();
145    view! {
146        <section class="property-section">
147            <span class="property-section-heading">{section.title}</span>
148            <ul class="property-section-rows" role="list">{rows}</ul>
149        </section>
150    }
151}
152
153#[component]
154pub fn InspectorPanel(view: InspectorPanelView) -> impl IntoView {
155    let sections: Vec<_> = view
156        .sections
157        .into_iter()
158        .map(|s| view! { <PropertySection section=s /> })
159        .collect();
160    view! {
161        <aside class="inspector-panel" data-tab=view.active.slug() aria-label="Inspector">
162            <InspectorTabs tabs=view.tabs active=view.active />
163            <div class="inspector-body">{sections}</div>
164        </aside>
165    }
166}
167
168fn render_row(row: PropertyRowView) -> impl IntoView {
169    let mut class = String::from("property-row");
170    if row.disabled {
171        class.push_str(" property-row-disabled");
172    }
173    let control = render_control(row.control);
174    view! {
175        <li class=class>
176            <span class="property-row-label">{row.label}</span>
177            <span class="property-row-control">{control}</span>
178            {row.value.map(|v| view! { <span class="property-row-value">{v}</span> })}
179        </li>
180    }
181}
182
183fn render_control(control: PropertyControlView) -> AnyView {
184    match control {
185        PropertyControlView::ValueOnly => view! { <span class="property-control-value"></span> }.into_any(),
186        PropertyControlView::SliderPercent { percent } => {
187            let p = percent.min(100);
188            let style = format!("width: {p}%;");
189            view! {
190                <span class="property-slider" role="slider" aria-valuemin="0" aria-valuemax="100" aria-valuenow=p>
191                    <span class="property-slider-fill" style=style></span>
192                </span>
193            }
194            .into_any()
195        }
196        PropertyControlView::Toggle { on } => view! {
197            <span class=if on { "property-toggle property-toggle-on" } else { "property-toggle" } aria-pressed=on>
198                <span class="property-toggle-knob"></span>
199            </span>
200        }
201        .into_any(),
202        PropertyControlView::ColorSwatches { swatches, selected } => {
203            let chips: Vec<_> = swatches
204                .iter()
205                .enumerate()
206                .map(|(i, color)| {
207                    let style = format!("background: {color};");
208                    let is_sel = i == selected;
209                    let class = if is_sel {
210                        "property-swatch property-swatch-selected"
211                    } else {
212                        "property-swatch"
213                    };
214                    view! {
215                        <button class=class style=style aria-pressed=is_sel></button>
216                    }
217                })
218                .collect();
219            view! { <span class="property-swatches">{chips}</span> }.into_any()
220        }
221        PropertyControlView::SelectPill { current_label } => view! {
222            <button class="property-pill">{current_label} " ▾"</button>
223        }
224        .into_any(),
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231
232    #[test]
233    fn tab_slugs_unique_kebab() {
234        let slugs = [
235            InspectorTab::Style.slug(),
236            InspectorTab::Cursor.slug(),
237            InspectorTab::Audio.slug(),
238            InspectorTab::Captions.slug(),
239            InspectorTab::Ai.slug(),
240        ];
241        let mut sorted = slugs.to_vec();
242        sorted.sort_unstable();
243        sorted.dedup();
244        assert_eq!(sorted.len(), slugs.len());
245    }
246}