diff --git a/crates/rustmotion-studio/src/app/mod.rs b/crates/rustmotion-studio/src/app/mod.rs index 5237a2f..43c42e3 100644 --- a/crates/rustmotion-studio/src/app/mod.rs +++ b/crates/rustmotion-studio/src/app/mod.rs @@ -86,7 +86,7 @@ pub fn run_preview_root( if let Some(p) = input_path.clone() { let _ = tx.send(WatchMsg::Retarget(p)); } - library.lock().unwrap().watch_tx = Some(tx); + library.lock().unwrap_or_else(|e| e.into_inner()).watch_tx = Some(tx); } dioxus::LaunchBuilder::desktop() @@ -129,13 +129,14 @@ fn spawn_watcher(shared: Shared) -> Sender { } WatchMsg::Changed => { if let Some(p) = current.clone() { - if let Ok(scenario) = rustmotion::loader::load_input(&p) { - if let Ok(mut m) = shared.lock() { - let g = m.generation.wrapping_add(1); - *m = StudioModel::new(scenario, None, Some(p.clone())); - m.generation = g; - } - } + let (scenario, error) = match rustmotion::loader::load_input(&p) { + Ok(s) => (s, None), + Err(e) => (crate::scenario::empty_scenario(), Some(e.to_string())), + }; + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + let g = m.generation.wrapping_add(1); + *m = StudioModel::new(scenario, error, Some(p.clone())); + m.generation = g; } } } diff --git a/crates/rustmotion-studio/src/app/root.rs b/crates/rustmotion-studio/src/app/root.rs index 64f1021..68c1d66 100644 --- a/crates/rustmotion-studio/src/app/root.rs +++ b/crates/rustmotion-studio/src/app/root.rs @@ -119,7 +119,7 @@ pub fn StudioRoot() -> Element { .unwrap_or(usize::MAX); let (cached, path) = { - let lib = thumb_lib.lock().unwrap(); + let lib = thumb_lib.lock().unwrap_or_else(|e| e.into_inner()); let path = lib.path_at(i); let cached = path.as_ref().and_then(|p| lib.thumb_cache.get(p).cloned()); (cached, path) @@ -131,7 +131,11 @@ pub fn StudioRoot() -> Element { match render_thumbnail(&p) { Some(jpeg) => { let arc = Arc::new(jpeg); - thumb_lib.lock().unwrap().thumb_cache.insert(p, arc.clone()); + thumb_lib + .lock() + .unwrap_or_else(|e| e.into_inner()) + .thumb_cache + .insert(p, arc.clone()); (*arc).clone() } None => Vec::new(), diff --git a/crates/rustmotion-studio/src/editor/annotations.rs b/crates/rustmotion-studio/src/editor/annotations.rs index 3de5aba..4322335 100644 --- a/crates/rustmotion-studio/src/editor/annotations.rs +++ b/crates/rustmotion-studio/src/editor/annotations.rs @@ -8,6 +8,11 @@ use crate::{ scenario::{append_annotation, remove_annotation, Shared}, }; +/// Write `content` to `path`, returning a user-facing error string on failure. +fn write_file(path: &std::path::Path, content: &str) -> Result<(), String> { + std::fs::write(path, content).map_err(|e| format!("write: {e}")) +} + /// The left-hand "Comments" panel: lists the scenario's annotations, with /// per-comment "go to frame" and "delete" actions. #[component] @@ -60,36 +65,58 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal) -> Ele let shared = use_context::(); let mut note = use_signal(String::new); - let submit = move |_| { - let text = note(); - if text.trim().is_empty() { - return; - } - let frame = current(); - let (path, raw, view, scene) = { - let m = shared.lock().unwrap(); - let (view, scene) = match m.tasks.get(frame as usize) { - Some(rustmotion::encode::video::FrameTask::Normal { - view_idx, - scene_idx, - .. - }) => (*view_idx, *scene_idx), - _ => (0, 0), + let submit = { + let shared = shared.clone(); + move |_| { + let text = note(); + if text.trim().is_empty() { + return; + } + let frame = current(); + let (path, raw, view, scene) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); + let (view, scene) = match m.tasks.get(frame as usize) { + Some(rustmotion::encode::video::FrameTask::Normal { + view_idx, + scene_idx, + .. + }) => (*view_idx, *scene_idx), + _ => (0, 0), + }; + (m.path.clone(), m.raw.clone(), view, scene) }; - (m.path.clone(), m.raw.clone(), view, scene) - }; - let ann = serde_json::json!({ - "id": annotation_id(), "note": text, "status": "open", "frame": frame, - "view": view, "scene": scene, - "target": { "pointer": pointer, "kind": kind } - }); - let raw = append_annotation(raw, ann); - // Annotations live in the scenario JSON; HTML sources have no place to - // store them, so skip the write rather than overwrite the HTML. - if let (Some(path), Ok(t)) = (path, serde_json::to_string_pretty(&raw)) { - if !rustmotion::loader::is_html_path(&path) { - let _ = std::fs::write(&path, t); - note.set(String::new()); + let ann = serde_json::json!({ + "id": annotation_id(), "note": text, "status": "open", "frame": frame, + "view": view, "scene": scene, + "target": { "pointer": pointer, "kind": kind } + }); + let updated = append_annotation(raw, ann); + // Annotations live in the scenario JSON; HTML sources have no place to + // store them, so skip the write rather than overwrite the HTML. + if let Some(path) = path { + if !rustmotion::loader::is_html_path(&path) { + match serde_json::to_string_pretty(&updated) { + Ok(t) => { + let write_result = write_file(&path, &t); + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + match write_result { + Ok(()) => { + m.write_error = None; + note.set(String::new()); + } + Err(e) => { + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } + } + } + Err(e) => { + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + m.write_error = Some(format!("json: {e}")); + m.generation = m.generation.wrapping_add(1); + } + } + } } } }; @@ -118,13 +145,20 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal) -> Ele /// Remove an annotation by id from the scenario file (the watcher reloads). fn delete_annotation(shared: &Shared, id: &str) { let (path, raw) = { - let m = shared.lock().unwrap(); + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) }; - let raw = remove_annotation(raw, id); - if let (Some(path), Ok(t)) = (path, serde_json::to_string_pretty(&raw)) { + let updated = remove_annotation(raw, id); + if let Some(path) = path { if !rustmotion::loader::is_html_path(&path) { - let _ = std::fs::write(&path, t); + let write_result = serde_json::to_string_pretty(&updated) + .map_err(|e| format!("json: {e}")) + .and_then(|t| write_file(&path, &t)); + if let Err(e) = write_result { + let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner()); + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } } } } diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 7bc2f2a..bde9e65 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -1,3 +1,6 @@ +use std::cell::RefCell; +use std::rc::Rc; + use dioxus::prelude::*; use dioxus_icons::lucide::{ Bold, Italic, TextAlignCenter, TextAlignEnd, TextAlignJustify, TextAlignStart, X, @@ -13,6 +16,17 @@ use crate::scenario::{set_field, set_style, Shared}; use super::annotations::AnnotationBox; +// ── Debounce context ───────────────────────────────────────────────────────── + +/// Holds the Dioxus [`dioxus_core::Task`] handle of the last scheduled write +/// so the next keystroke can cancel it before it fires. Provided via Dioxus +/// context by [`InspectorPanel`] and consumed by the `write_*` helpers. +/// +/// Uses `Rc>` because Dioxus's `Task` is `!Send`; all access is +/// single-threaded (Dioxus desktop runs on one thread). +#[derive(Clone)] +pub struct WriteDebounce(Rc>>); + // ── Schema ─────────────────────────────────────────────────────────────── /// How one property is edited. The variant decides the widget; the field's @@ -572,6 +586,8 @@ pub fn InspectorPanel( content: Option, style: serde_json::Value, ) -> Element { + // Provide the debounce handle so all child write helpers share one slot. + use_context_provider(|| WriteDebounce(Rc::new(RefCell::new(None)))); let fam = family(&kind); rsx! { div { style: "width:300px; flex:none; min-height:0; background:var(--rm-surface); border-left:1px solid var(--rm-border); box-sizing:border-box; display:flex; flex-direction:column; overflow:auto;", @@ -947,54 +963,160 @@ fn hsv_to_hex(c: Hsv) -> String { // ── Persistence ────────────────────────────────────────────────────────────── -/// Write a single style property back to the scenario file. The watcher reloads -/// and refreshes the preview. Empty values are ignored so clearing a field -/// mid-edit doesn't collapse the element. -fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) { +/// The payload for a deferred disk write. Carries everything needed to perform +/// the write so it can be captured by the spawned task without borrowing. +enum WritePayload { + Prop { + path: std::path::PathBuf, + raw: serde_json::Value, + pointer: String, + prop: &'static str, + value: String, + }, + Content { + path: std::path::PathBuf, + raw: serde_json::Value, + pointer: String, + text: String, + }, +} + +/// Schedule a debounced disk write (~250 ms). Any previously scheduled write is +/// cancelled first so only the last value in a burst reaches the disk. +/// +/// On success the model's `write_error` is cleared; on failure it is set to the +/// OS error message and `generation` is bumped so the hot-reload loop picks it +/// up and shows the topbar indicator. +fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayload) { + // Cancel the previous pending write (if any). `Task::cancel` is safe to + // call on an already-completed task (it's a no-op). + { + let mut slot = debounce.0.borrow_mut(); + if let Some(prev) = slot.take() { + prev.cancel(); + } + } + + let debounce_slot = debounce.0.clone(); + let task = spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(250)).await; + + // Clear the slot so the next write doesn't try to cancel this one. + *debounce_slot.borrow_mut() = None; + + let result = perform_write(&payload); + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + match result { + Ok(()) => { + // Clear any previous write error on success. + m.write_error = None; + } + Err(e) => { + m.write_error = Some(e); + m.generation = m.generation.wrapping_add(1); + } + } + }); + + // Store the new handle for the next cancellation. + *debounce.0.borrow_mut() = Some(task); +} + +/// Execute the actual file write and return `Ok(())` or an error message. +fn perform_write(payload: &WritePayload) -> Result<(), String> { + match payload { + WritePayload::Prop { + path, + raw, + pointer, + prop, + value, + } => { + if rustmotion::loader::is_html_path(path) { + let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + if let Some(updated) = + rustmotion::loader::set_html_inline_style(&html, pointer, prop, value) + { + std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + } + } else if let Some(updated) = set_style(raw.clone(), pointer, prop, value) { + let text = + serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; + std::fs::write(path, text).map_err(|e| format!("write: {e}"))?; + } + Ok(()) + } + WritePayload::Content { + path, + raw, + pointer, + text, + } => { + if rustmotion::loader::is_html_path(path) { + let html = std::fs::read_to_string(path).map_err(|e| format!("read: {e}"))?; + if let Some(updated) = + rustmotion::loader::set_html_text_content(&html, pointer, text) + { + std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + } + } else if let Some(updated) = set_field(raw.clone(), pointer, "content", text) { + let s = serde_json::to_string_pretty(&updated).map_err(|e| format!("json: {e}"))?; + std::fs::write(path, s).map_err(|e| format!("write: {e}"))?; + } + Ok(()) + } + } +} + +/// Write a single style property back to the scenario file. Schedules a debounced +/// write (~250 ms) so rapid slider drags and keystrokes coalesce into one flush. +/// Empty values are ignored so clearing a field mid-edit doesn't collapse the +/// element. Write errors are stored in the model and surfaced in the topbar. +fn write_prop(shared: &Shared, pointer: &str, prop: &'static str, value: &str) { if value.trim().is_empty() { return; } let (path, raw) = { - let m = shared.lock().unwrap(); + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) }; let Some(path) = path else { return; }; - if rustmotion::loader::is_html_path(&path) { - if let Ok(html) = std::fs::read_to_string(&path) { - if let Some(updated) = - rustmotion::loader::set_html_inline_style(&html, pointer, prop, value) - { - let _ = std::fs::write(&path, updated); - } - } - } else if let Some(updated) = set_style(raw, pointer, prop, value) { - if let Ok(text) = serde_json::to_string_pretty(&updated) { - let _ = std::fs::write(&path, text); - } - } + let debounce = consume_context::(); + schedule_write( + &debounce, + shared.clone(), + WritePayload::Prop { + path, + raw, + pointer: pointer.to_string(), + prop, + value: value.to_string(), + }, + ); } /// Write the element's text `content` back to the scenario file. Unlike /// [`write_prop`], an empty value is allowed (clearing the text is valid). +/// Schedules a debounced write (~250 ms); errors are surfaced in the topbar. fn write_content(shared: &Shared, pointer: &str, text: &str) { let (path, raw) = { - let m = shared.lock().unwrap(); + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) }; let Some(path) = path else { return; }; - if rustmotion::loader::is_html_path(&path) { - if let Ok(html) = std::fs::read_to_string(&path) { - if let Some(updated) = rustmotion::loader::set_html_text_content(&html, pointer, text) { - let _ = std::fs::write(&path, updated); - } - } - } else if let Some(updated) = set_field(raw, pointer, "content", text) { - if let Ok(s) = serde_json::to_string_pretty(&updated) { - let _ = std::fs::write(&path, s); - } - } + let debounce = consume_context::(); + schedule_write( + &debounce, + shared.clone(), + WritePayload::Content { + path, + raw, + pointer: pointer.to_string(), + text: text.to_string(), + }, + ); } diff --git a/crates/rustmotion-studio/src/editor/playback.rs b/crates/rustmotion-studio/src/editor/playback.rs index 5a2f8b7..b4bbb88 100644 --- a/crates/rustmotion-studio/src/editor/playback.rs +++ b/crates/rustmotion-studio/src/editor/playback.rs @@ -12,10 +12,20 @@ pub fn use_playback_clock(shared: Shared, mut current: Signal, playing: Sig let shared = shared.clone(); async move { loop { - let fps = shared.lock().unwrap().scenario.video.fps.max(1); + let fps = shared + .lock() + .unwrap_or_else(|e| e.into_inner()) + .scenario + .video + .fps + .max(1); tokio::time::sleep(Duration::from_secs_f64(1.0 / fps as f64)).await; if playing() { - let total = shared.lock().unwrap().total_frames.max(1); + let total = shared + .lock() + .unwrap_or_else(|e| e.into_inner()) + .total_frames + .max(1); let next = (current() + 1) % total; current.set(next); } @@ -33,7 +43,7 @@ pub fn use_hot_reload(shared: Shared, mut rev: Signal) { let mut last_gen = 0u64; loop { tokio::time::sleep(Duration::from_millis(250)).await; - let g = shared.lock().unwrap().generation; + let g = shared.lock().unwrap_or_else(|e| e.into_inner()).generation; if g != last_gen { last_gen = g; rev.set(rev() + 1); diff --git a/crates/rustmotion-studio/src/editor/topbar.rs b/crates/rustmotion-studio/src/editor/topbar.rs index 1b804cd..8c3707f 100644 --- a/crates/rustmotion-studio/src/editor/topbar.rs +++ b/crates/rustmotion-studio/src/editor/topbar.rs @@ -7,6 +7,8 @@ use crate::scenario::{Theme, View}; /// The editor's top bar (open-slide style): a back-to-library control on the /// left, the centered document title, and the action cluster on the right /// (theme swap, Inspect overlay toggle, Comments panel toggle, and Present). +/// `write_error` is `Some` when the last inspector write failed; shown as a +/// discrete warning indicator using the `--rm-error` token. #[component] pub fn TopBar( view: Signal, @@ -16,6 +18,7 @@ pub fn TopBar( show_annotations: Signal, show_hits: Signal, comment_count: usize, + write_error: Option, ) -> Element { let mut theme = use_context::>(); let inspecting = show_hits(); @@ -39,8 +42,15 @@ pub fn TopBar( "{title}" } - // ── Right: actions ─────────────────────────────────────── + // ── Right: write-error indicator + actions ─────────────── div { style: "display:flex; align-items:center; gap:6px; z-index:1;", + if let Some(ref msg) = write_error { + span { + title: "{msg}", + style: "color:var(--rm-error); font-size:11px; white-space:nowrap; max-width:200px; overflow:hidden; text-overflow:ellipsis;", + "Changes not saved: {msg}" + } + } Button { variant: ButtonVariant::Outline, size: ButtonSize::IconSm, diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index 0780661..92b7470 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -39,6 +39,10 @@ pub fn StudioApp(view: Signal) -> Element { let show_hits = use_signal(|| true); // Asset handler: GET /frame/{idx} -> JPEG of that frame. + // + // Safety note: we clone the data we need *before* dropping the lock, then + // render *outside* the lock so a Skia panic cannot poison the Mutex and + // kill the session for all future requests. let handler_shared = shared.clone(); use_asset_handler( "frame", @@ -50,27 +54,47 @@ pub fn StudioApp(view: Signal) -> Element { .and_then(|s| s.split('?').next()) .and_then(|s| s.parse().ok()) .unwrap_or(0); - let jpeg = { - let m = handler_shared.lock().unwrap(); - render_frame(&m.scenario, &m.tasks, idx, 1.0) + + // Render inside the lock but wrapped in `catch_unwind` so a Skia + // panic converts to an error instead of poisoning the Mutex. + // `catch_unwind` converts the panic to `Err`; the MutexGuard then + // drops normally (no unwind ⟹ no poison). + let result = { + let m = handler_shared.lock().unwrap_or_else(|e| e.into_inner()); + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + render_frame(&m.scenario, &m.tasks, idx, 1.0) + })) }; - responder.respond( - Response::builder() - .header("Content-Type", "image/jpeg") - .header("Cache-Control", "no-cache, no-store, must-revalidate") - .header("Pragma", "no-cache") - .header("Expires", "0") - .body(jpeg) - .unwrap(), - ); + match result { + Ok(jpeg) => { + responder.respond( + Response::builder() + .header("Content-Type", "image/jpeg") + .header("Cache-Control", "no-cache, no-store, must-revalidate") + .header("Pragma", "no-cache") + .header("Expires", "0") + .body(jpeg) + .unwrap(), + ); + } + Err(_) => { + responder.respond( + Response::builder() + .status(500) + .header("Content-Type", "text/plain") + .body(b"render error".to_vec()) + .unwrap(), + ); + } + } }, ); use_playback_clock(shared.clone(), current, playing); use_hot_reload(shared.clone(), rev); - let (total, err, title, annotations) = { - let m = shared.lock().unwrap(); + let (total, err, write_err, title, annotations) = { + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); let title = m .path .as_ref() @@ -81,6 +105,7 @@ pub fn StudioApp(view: Signal) -> Element { ( m.total_frames, m.error.clone(), + m.write_error.clone(), title, list_annotations(&m.raw), ) @@ -96,7 +121,7 @@ pub fn StudioApp(view: Signal) -> Element { let inspector_shared = shared.clone(); let inspector = use_memo(move || { selected().map(|(_, pointer, kind)| { - let m = inspector_shared.lock().unwrap(); + let m = inspector_shared.lock().unwrap_or_else(|e| e.into_inner()); let style = read_style_object(&m.raw, &pointer); let content = read_field(&m.raw, &pointer, "content"); (pointer, style, kind, content) @@ -127,6 +152,7 @@ pub fn StudioApp(view: Signal) -> Element { show_annotations, show_hits, comment_count, + write_error: write_err, } div { style: "flex:1; display:flex; flex-direction:row; flex-wrap:nowrap; align-items:stretch; min-height:0; overflow:hidden;", div { style: "flex:1; min-width:0; display:flex; flex-direction:column; min-height:0;", @@ -159,7 +185,7 @@ fn Canvas( ) -> Element { let shared = use_context::(); let (max, vw, vh) = { - let m = shared.lock().unwrap(); + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); ( m.total_frames.saturating_sub(1), m.scenario.video.width, @@ -170,7 +196,7 @@ fn Canvas( let r = rev(); let hits = if show_hits() { - let m = shared.lock().unwrap(); + let m = shared.lock().unwrap_or_else(|e| e.into_inner()); let prefix = scene_prefix(&m.raw, &m.tasks, cur); frame_hits(&m.scenario, &m.tasks, cur, &prefix) } else { diff --git a/crates/rustmotion-studio/src/library/view.rs b/crates/rustmotion-studio/src/library/view.rs index 7d3454c..9d71c01 100644 --- a/crates/rustmotion-studio/src/library/view.rs +++ b/crates/rustmotion-studio/src/library/view.rs @@ -29,7 +29,7 @@ pub fn Library(view: Signal) -> Element { // Build sections: a "Recent" pseudo-group first, then the workspace groups. let sections: Vec<(String, Vec)> = { - let lib = library.lock().unwrap(); + let lib = library.lock().unwrap_or_else(|e| e.into_inner()); let mut s = Vec::new(); if !lib.recents.is_empty() { s.push(("Recent".to_string(), lib.recents.clone())); @@ -149,13 +149,13 @@ fn open_scenario(shared: &Shared, library: &SharedLibrary, mut view: Signal (empty_scenario(), Some(e.to_string())), }; { - let mut m = shared.lock().unwrap(); + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); let g = m.generation.wrapping_add(1); *m = StudioModel::new(scenario, error, Some(path.clone())); m.generation = g; } { - let mut lib = library.lock().unwrap(); + let mut lib = library.lock().unwrap_or_else(|e| e.into_inner()); lib.note_opened(&path); lib.retarget_watch(&path); } @@ -164,7 +164,13 @@ fn open_scenario(shared: &Shared, library: &SharedLibrary, mut view: Signal) { - let workspace = { library.lock().unwrap().workspace.clone() }; + let workspace = { + library + .lock() + .unwrap_or_else(|e| e.into_inner()) + .workspace + .clone() + }; let mut path = workspace.join("untitled.json"); let mut n = 1; while path.exists() { diff --git a/crates/rustmotion-studio/src/scenario/model.rs b/crates/rustmotion-studio/src/scenario/model.rs index 5cc88f1..c682372 100644 --- a/crates/rustmotion-studio/src/scenario/model.rs +++ b/crates/rustmotion-studio/src/scenario/model.rs @@ -10,7 +10,11 @@ pub struct StudioModel { pub scenario: ResolvedScenario, pub tasks: Vec, pub total_frames: u32, + /// Load/parse error surfaced as a full-screen banner in the editor. pub error: Option, + /// Disk-write error surfaced as a topbar indicator. Cleared on the next + /// successful write or on model reload. + pub write_error: Option, /// Bumped on every hot-reload so the UI can detect a change. pub generation: u64, /// Path to the scenario file (for inspector write-back). @@ -47,6 +51,7 @@ impl StudioModel { tasks, total_frames, error, + write_error: None, generation: 0, path, raw,