Skip to main content

app_ui/
app_shell_mount.rs

1//! `AppShellRoot` — root component for the tray-launched main window
2//! (M-TRAY.3 / AUT-252) and the in-app `NavigationRail` surface
3//! routing (M-TRAY.4 / AUT-253).
4//!
5//! Owns the `RwSignal<AppSection>` that drives both the rail's
6//! `active` prop and the right-pane `match` over surface placeholders.
7//! Clicking a rail item flips the signal (via the `on_select`
8//! callback added in M-TRAY.2 / AUT-251) and the URL is rewritten to
9//! match via `history.replaceState`.
10//!
11//! Per the M-TRAY.1 audit doc, `AppShell` itself stays pure
12//! slot-composition — the section signal lives here in the consumer
13//! crate, not inside `ui_storybook::components::shell::AppShell`.
14
15use leptos::prelude::*;
16use ui_storybook::components::shell::{
17    AppSection, AppShell, NavigationRail, StatusBar, StatusKind,
18};
19use ui_storybook::fixtures::shell::{sample_nav_items, sample_user_avatar, sample_workspace_badge};
20
21#[component]
22pub fn AppShellRoot(initial: AppSection) -> impl IntoView {
23    let active = RwSignal::new(initial);
24
25    // ED.5 / M-EDIT — the loaded editor project, shared with the editor
26    // surface via context. The Record→Edit handoff (`open_in_editor`)
27    // pushes a project here; an effect jumps to the editor when one loads.
28    let editor_project = RwSignal::new(None::<edit::EditProject>);
29    provide_context(editor_project);
30    crate::editor_ipc::install_editor_project_listener(editor_project);
31    Effect::new(move |_| {
32        if editor_project.get().is_some() {
33            active.set(AppSection::Editor);
34        }
35    });
36
37    // Drop a video anywhere in the app → open it in the editor (the
38    // `editor-project` reply trips the effect above and switches tabs).
39    // `editor_drag_active` drives the editor drop zone's drag-over
40    // highlight; provided via context for the editor surface to read.
41    crate::editor_ipc::install_file_drop_to_editor_listener();
42    let editor_drag_active = RwSignal::new(false);
43    provide_context(editor_drag_active);
44    crate::editor_ipc::install_drag_active_listeners(editor_drag_active);
45
46    // ED.7 — the playhead status from the backend editor session, plus a
47    // perpetual host-injected tick that advances the clock while playing.
48    // Created once here (the app root), so editor-surface re-mounts can't
49    // spawn duplicate tick loops; leaked because it has app lifetime.
50    let editor_status = RwSignal::new(crate::editor_ipc::EditorStatus::default());
51    provide_context(editor_status);
52    crate::editor_ipc::install_editor_status_listener(editor_status);
53    gloo_timers::callback::Interval::new(33, move || {
54        if editor_status.get_untracked().playing {
55            crate::editor_ipc::editor_transport(&crate::editor_ipc::TransportAction::Tick {
56                dt_ms: 33,
57            });
58        }
59    })
60    .forget();
61
62    // ED.9 — the selected clip index, shared with the video filmstrip and
63    // (ED.18) the inspector.
64    let editor_selection = RwSignal::new(None::<usize>);
65    provide_context(editor_selection);
66
67    // ED.10 — audio waveform peak buckets (populated when the source audio
68    // is decoded; empty renders a quiet baseline).
69    let editor_peaks = RwSignal::new(Vec::<crate::waveform::WaveBucket>::new());
70    provide_context(editor_peaks);
71
72    // ED.11 — the edit history (undo/redo stacks + current project state),
73    // resolved against the loaded clip on first edit.
74    let editor_history = StoredValue::new(None::<edit::History>);
75    provide_context(editor_history);
76
77    // ED.12 — which zoom region is selected on the zoom lane.
78    let editor_zoom_selection = RwSignal::new(None::<edit::zoom::ZoomId>);
79    provide_context(editor_zoom_selection);
80
81    // ED.22 — export lifecycle state, fed by the export event bridge.
82    let editor_export = RwSignal::new(crate::editor_ipc::ExportUiState::Idle);
83    provide_context(editor_export);
84    crate::editor_ipc::install_editor_export_listeners(editor_export);
85
86    // ED.23 — last-saved `.screenproj` path, set by the `editor-saved` event.
87    let editor_saved = RwSignal::new(None::<String>);
88    provide_context(editor_saved);
89    crate::editor_ipc::install_editor_saved_listener(editor_saved);
90
91    // ED.24 — recordings library entries, fed by the `recordings-listed` event.
92    let editor_recordings = RwSignal::new(Vec::<crate::editor_ipc::RecordingEntry>::new());
93    provide_context(editor_recordings);
94    crate::editor_ipc::install_recordings_listener(editor_recordings);
95
96    // NavigationRail click → flip the signal + rewrite the URL so a
97    // page-reload restores the last visited surface within the
98    // current Tauri session.
99    let on_select = Callback::new(move |section: AppSection| {
100        active.set(section);
101        push_surface_query(section);
102    });
103
104    let nav_items = sample_nav_items(false);
105    let workspace = sample_workspace_badge();
106    let user = sample_user_avatar();
107
108    view! {
109        <AppShell
110            rail=ToChildren::to_children(move || view! {
111                <NavigationRail
112                    items=nav_items.clone()
113                    active=active.get()
114                    workspace=workspace.clone()
115                    user=user.clone()
116                    on_select=on_select
117                />
118            })
119            main=ToChildren::to_children(move || view! {
120                <SurfacePane active=active />
121            })
122            footer=ToChildren::to_children(move || view! {
123                <StatusBar
124                    fps=60.0_f32
125                    encoder="—"
126                    file_bytes=0_u64
127                    kind=StatusKind::Ready
128                />
129            })
130        />
131    }
132}
133
134/// Render the content for the currently-active surface. Record, Library,
135/// and Editor are live ([`RecorderPage`](crate::recorder_page::RecorderPage),
136/// [`RecordingsLibrary`](crate::recordings_library::RecordingsLibrary),
137/// [`EditorSurface`](crate::editor_surface::EditorSurface)); Cursor and
138/// Preferences are still placeholders.
139#[component]
140fn SurfacePane(active: RwSignal<AppSection>) -> impl IntoView {
141    view! {
142        <Show
143            when=move || matches!(active.get(), AppSection::Record)
144            fallback=move || view! { <NonRecorderSurfaces active=active /> }
145        >
146            <section class="app-surface app-surface--recorder">
147                <crate::recorder_page::RecorderPage />
148            </section>
149        </Show>
150    }
151}
152
153#[component]
154fn NonRecorderSurfaces(active: RwSignal<AppSection>) -> impl IntoView {
155    view! {
156        <Show
157            when=move || matches!(active.get(), AppSection::Library)
158            fallback=move || view! { <EditorOrLater active=active /> }
159        >
160            <section class="app-surface app-surface--library">
161                <crate::recordings_library::RecordingsLibrary active=active />
162            </section>
163        </Show>
164    }
165}
166
167#[component]
168fn EditorOrLater(active: RwSignal<AppSection>) -> impl IntoView {
169    view! {
170        <Show
171            when=move || matches!(active.get(), AppSection::Editor)
172            fallback=move || view! { <CursorOrPrefs active=active /> }
173        >
174            <crate::editor_surface::EditorSurface />
175        </Show>
176    }
177}
178
179#[component]
180fn CursorOrPrefs(active: RwSignal<AppSection>) -> impl IntoView {
181    view! {
182        <Show
183            when=move || matches!(active.get(), AppSection::Cursor)
184            fallback=move || view! {
185                <section class="app-surface app-surface--prefs">
186                    <h1>"Preferences"</h1>
187                    <p>"App preferences. Future ticket."</p>
188                </section>
189            }
190        >
191            <section class="app-surface app-surface--cursor">
192                <h1>"Cursor Studio"</h1>
193                <p>"Cursor style picker. Future ticket: AUT-140."</p>
194            </section>
195        </Show>
196    }
197}
198
199/// Push `?surface=<slug>` onto the URL via `history.replaceState` so
200/// a reload reopens at the same surface. Used by [`AppShellRoot`]'s
201/// `on_select` callback (M-TRAY.4 / AUT-253).
202fn push_surface_query(section: AppSection) {
203    let Some(window) = web_sys::window() else {
204        return;
205    };
206    let Ok(history) = window.history() else {
207        return;
208    };
209    let slug = crate::surface_to_query(section);
210    let new_url = format!("?surface={slug}");
211    // Empty state value, empty title → just rewrite the query.
212    let _ = history.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&new_url));
213}
214
215#[cfg(test)]
216mod tests {
217    // M-TRAY.3 / M-TRAY.4 unit tests live on the pure-Rust helpers in
218    // `crate::lib` — `parse_surface_from_query` + `surface_to_query`.
219    // See those modules for the round-trip coverage.
220}