Skip to main content

ui_storybook/components/editor/
editor_shell.rs

1//! `EditorShell` + top toolbar (M-UI.16 / AUT-136). Structural shell
2//! for the editor screen — title bar, top toolbar, and slot regions
3//! for the canvas / inspector / timeline.
4//!
5//! All slots are optional `Children` so stories can demonstrate the
6//! empty shell, the no-clip state, and the full composition.
7
8use leptos::prelude::*;
9
10/// View-model for one toolbar action chip.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct ToolbarActionView {
13    /// Stable id ("16:9", "crop", "annotate").
14    pub id: &'static str,
15    /// Display label.
16    pub label: &'static str,
17    /// Leading glyph.
18    pub icon: &'static str,
19    /// `true` when active.
20    pub selected: bool,
21    /// `true` for disabled actions.
22    pub disabled: bool,
23}
24
25/// Shell view-model.
26#[derive(Debug, Clone, PartialEq)]
27pub struct EditorShellView {
28    /// Document title shown center-top.
29    pub document_title: String,
30    /// Optional subtitle ("Captured 2026-05-09 · 1m 24s").
31    pub document_subtitle: Option<String>,
32    /// `false` to render the "no clip" placeholder hint in the title.
33    pub has_clip_loaded: bool,
34    /// Toolbar actions in display order.
35    pub toolbar_actions: Vec<ToolbarActionView>,
36    /// `true` to enable the Export button.
37    pub export_enabled: bool,
38    /// `true` to enable the Share button.
39    pub share_enabled: bool,
40}
41
42#[component]
43pub fn EditorTitleBar(
44    /// Document title.
45    #[prop(into)]
46    title: String,
47    /// Optional subtitle.
48    #[prop(optional, into)]
49    subtitle: String,
50    /// `false` renders a "No clip loaded" hint in place of the title.
51    has_clip_loaded: bool,
52) -> impl IntoView {
53    let title_text = if has_clip_loaded {
54        title
55    } else {
56        "No clip loaded".to_owned()
57    };
58    let class = if has_clip_loaded {
59        "editor-titlebar"
60    } else {
61        "editor-titlebar editor-titlebar-empty"
62    };
63    let subtitle_view = (!subtitle.is_empty() && has_clip_loaded)
64        .then(|| view! { <span class="editor-titlebar-subtitle">{subtitle}</span> });
65    view! {
66        <header class=class>
67            <span class="editor-titlebar-traffic" aria-hidden="true">
68                <span class="traffic-dot traffic-close"></span>
69                <span class="traffic-dot traffic-min"></span>
70                <span class="traffic-dot traffic-max"></span>
71            </span>
72            <span class="editor-titlebar-text">
73                <span class="editor-titlebar-title">{title_text}</span>
74                {subtitle_view}
75            </span>
76        </header>
77    }
78}
79
80#[component]
81pub fn EditorToolbar(
82    /// Toolbar actions.
83    actions: Vec<ToolbarActionView>,
84    /// `true` to enable the Export button.
85    export_enabled: bool,
86    /// `true` to enable the Share button.
87    share_enabled: bool,
88    /// Fired with the chip's `id` when a (non-disabled) toolbar action is
89    /// clicked. The app maps the id to an edit op; the component stays
90    /// presentational (it only emits the id). Omit to leave chips inert.
91    /// Plain `optional` (not `into`) so [`EditorShell`] can forward its own
92    /// already-`Option` value straight through.
93    #[prop(optional)]
94    on_action: Option<Callback<String>>,
95) -> impl IntoView {
96    let action_chips: Vec<_> = actions
97        .into_iter()
98        .map(|a| {
99            let mut class = String::from("editor-action");
100            if a.selected {
101                class.push_str(" editor-action-selected");
102            }
103            if a.disabled {
104                class.push_str(" editor-action-disabled");
105            }
106            let id = a.id;
107            let label = a.label;
108            let icon = a.icon;
109            let disabled = a.disabled;
110            let pressed = a.selected;
111            view! {
112                <button
113                    class=class
114                    data-id=id
115                    disabled=disabled
116                    aria-pressed=pressed
117                    on:click=move |_| {
118                        if let Some(cb) = on_action {
119                            cb.run(id.to_owned());
120                        }
121                    }
122                >
123                    <span class="editor-action-icon" aria-hidden="true">{icon}</span>
124                    <span class="editor-action-label">{label}</span>
125                </button>
126            }
127        })
128        .collect();
129    view! {
130        <div class="editor-toolbar" role="toolbar" aria-label="Editor tools">
131            <div class="editor-toolbar-actions">{action_chips}</div>
132            <div class="editor-toolbar-end">
133                <button class="btn btn-outline btn-sm" disabled=!share_enabled>"Share"</button>
134                <button class="btn btn-default btn-sm" disabled=!export_enabled>"Export"</button>
135            </div>
136        </div>
137    }
138}
139
140#[component]
141pub fn EditorShell(
142    /// View-model.
143    view: EditorShellView,
144    /// Center canvas slot (drop zone, video preview, etc.).
145    #[prop(optional)]
146    canvas: Option<Children>,
147    /// Right inspector slot.
148    #[prop(optional)]
149    inspector: Option<Children>,
150    /// Bottom timeline slot.
151    #[prop(optional)]
152    timeline: Option<Children>,
153    /// Forwarded to [`EditorToolbar`]: fired with a toolbar chip's `id` on
154    /// click. Omit to leave the toolbar inert.
155    #[prop(optional, into)]
156    on_action: Option<Callback<String>>,
157) -> impl IntoView {
158    let EditorShellView {
159        document_title,
160        document_subtitle,
161        has_clip_loaded,
162        toolbar_actions,
163        export_enabled,
164        share_enabled,
165    } = view;
166    view! {
167        <section class="editor-shell" role="main" aria-label="Editor">
168            <EditorTitleBar
169                title=document_title
170                subtitle=document_subtitle.unwrap_or_default()
171                has_clip_loaded=has_clip_loaded
172            />
173            <EditorToolbar
174                actions=toolbar_actions
175                export_enabled=export_enabled
176                share_enabled=share_enabled
177                on_action=on_action.unwrap_or_else(|| Callback::new(|_: String| {}))
178            />
179            <div class="editor-body">
180                <div class="editor-canvas">
181                    {canvas.map(|c| c())}
182                </div>
183                <aside class="editor-inspector" aria-label="Inspector">
184                    {inspector.map(|i| i())}
185                </aside>
186            </div>
187            <footer class="editor-timeline">
188                {timeline.map(|t| t())}
189            </footer>
190        </section>
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn empty_titlebar_is_marked() {
200        let v = EditorShellView {
201            document_title: "Demo".into(),
202            document_subtitle: None,
203            has_clip_loaded: false,
204            toolbar_actions: vec![],
205            export_enabled: false,
206            share_enabled: false,
207        };
208        assert!(!v.has_clip_loaded);
209    }
210}