Skip to content

Commit bb14797

Browse files
authored
fix(studio): surface write/reload errors, debounce edits, survive render panics (#30)
Closes #13. Write failures visible in the topbar, reload parse errors in the banner, 250ms write debounce restored, catch_unwind around frame rendering and poison-recovering locks throughout.
1 parent 107e781 commit bb14797

9 files changed

Lines changed: 316 additions & 98 deletions

File tree

crates/rustmotion-studio/src/app/mod.rs

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,7 @@ pub fn run_preview_root(
8686
if let Some(p) = input_path.clone() {
8787
let _ = tx.send(WatchMsg::Retarget(p));
8888
}
89-
library.lock().unwrap().watch_tx = Some(tx);
89+
library.lock().unwrap_or_else(|e| e.into_inner()).watch_tx = Some(tx);
9090
}
9191

9292
dioxus::LaunchBuilder::desktop()
@@ -129,13 +129,14 @@ fn spawn_watcher(shared: Shared) -> Sender<WatchMsg> {
129129
}
130130
WatchMsg::Changed => {
131131
if let Some(p) = current.clone() {
132-
if let Ok(scenario) = rustmotion::loader::load_input(&p) {
133-
if let Ok(mut m) = shared.lock() {
134-
let g = m.generation.wrapping_add(1);
135-
*m = StudioModel::new(scenario, None, Some(p.clone()));
136-
m.generation = g;
137-
}
138-
}
132+
let (scenario, error) = match rustmotion::loader::load_input(&p) {
133+
Ok(s) => (s, None),
134+
Err(e) => (crate::scenario::empty_scenario(), Some(e.to_string())),
135+
};
136+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
137+
let g = m.generation.wrapping_add(1);
138+
*m = StudioModel::new(scenario, error, Some(p.clone()));
139+
m.generation = g;
139140
}
140141
}
141142
}

crates/rustmotion-studio/src/app/root.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ pub fn StudioRoot() -> Element {
119119
.unwrap_or(usize::MAX);
120120

121121
let (cached, path) = {
122-
let lib = thumb_lib.lock().unwrap();
122+
let lib = thumb_lib.lock().unwrap_or_else(|e| e.into_inner());
123123
let path = lib.path_at(i);
124124
let cached = path.as_ref().and_then(|p| lib.thumb_cache.get(p).cloned());
125125
(cached, path)
@@ -131,7 +131,11 @@ pub fn StudioRoot() -> Element {
131131
match render_thumbnail(&p) {
132132
Some(jpeg) => {
133133
let arc = Arc::new(jpeg);
134-
thumb_lib.lock().unwrap().thumb_cache.insert(p, arc.clone());
134+
thumb_lib
135+
.lock()
136+
.unwrap_or_else(|e| e.into_inner())
137+
.thumb_cache
138+
.insert(p, arc.clone());
135139
(*arc).clone()
136140
}
137141
None => Vec::new(),

crates/rustmotion-studio/src/editor/annotations.rs

Lines changed: 67 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ use crate::{
88
scenario::{append_annotation, remove_annotation, Shared},
99
};
1010

11+
/// Write `content` to `path`, returning a user-facing error string on failure.
12+
fn write_file(path: &std::path::Path, content: &str) -> Result<(), String> {
13+
std::fs::write(path, content).map_err(|e| format!("write: {e}"))
14+
}
15+
1116
/// The left-hand "Comments" panel: lists the scenario's annotations, with
1217
/// per-comment "go to frame" and "delete" actions.
1318
#[component]
@@ -60,36 +65,58 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
6065
let shared = use_context::<Shared>();
6166
let mut note = use_signal(String::new);
6267

63-
let submit = move |_| {
64-
let text = note();
65-
if text.trim().is_empty() {
66-
return;
67-
}
68-
let frame = current();
69-
let (path, raw, view, scene) = {
70-
let m = shared.lock().unwrap();
71-
let (view, scene) = match m.tasks.get(frame as usize) {
72-
Some(rustmotion::encode::video::FrameTask::Normal {
73-
view_idx,
74-
scene_idx,
75-
..
76-
}) => (*view_idx, *scene_idx),
77-
_ => (0, 0),
68+
let submit = {
69+
let shared = shared.clone();
70+
move |_| {
71+
let text = note();
72+
if text.trim().is_empty() {
73+
return;
74+
}
75+
let frame = current();
76+
let (path, raw, view, scene) = {
77+
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
78+
let (view, scene) = match m.tasks.get(frame as usize) {
79+
Some(rustmotion::encode::video::FrameTask::Normal {
80+
view_idx,
81+
scene_idx,
82+
..
83+
}) => (*view_idx, *scene_idx),
84+
_ => (0, 0),
85+
};
86+
(m.path.clone(), m.raw.clone(), view, scene)
7887
};
79-
(m.path.clone(), m.raw.clone(), view, scene)
80-
};
81-
let ann = serde_json::json!({
82-
"id": annotation_id(), "note": text, "status": "open", "frame": frame,
83-
"view": view, "scene": scene,
84-
"target": { "pointer": pointer, "kind": kind }
85-
});
86-
let raw = append_annotation(raw, ann);
87-
// Annotations live in the scenario JSON; HTML sources have no place to
88-
// store them, so skip the write rather than overwrite the HTML.
89-
if let (Some(path), Ok(t)) = (path, serde_json::to_string_pretty(&raw)) {
90-
if !rustmotion::loader::is_html_path(&path) {
91-
let _ = std::fs::write(&path, t);
92-
note.set(String::new());
88+
let ann = serde_json::json!({
89+
"id": annotation_id(), "note": text, "status": "open", "frame": frame,
90+
"view": view, "scene": scene,
91+
"target": { "pointer": pointer, "kind": kind }
92+
});
93+
let updated = append_annotation(raw, ann);
94+
// Annotations live in the scenario JSON; HTML sources have no place to
95+
// store them, so skip the write rather than overwrite the HTML.
96+
if let Some(path) = path {
97+
if !rustmotion::loader::is_html_path(&path) {
98+
match serde_json::to_string_pretty(&updated) {
99+
Ok(t) => {
100+
let write_result = write_file(&path, &t);
101+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
102+
match write_result {
103+
Ok(()) => {
104+
m.write_error = None;
105+
note.set(String::new());
106+
}
107+
Err(e) => {
108+
m.write_error = Some(e);
109+
m.generation = m.generation.wrapping_add(1);
110+
}
111+
}
112+
}
113+
Err(e) => {
114+
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
115+
m.write_error = Some(format!("json: {e}"));
116+
m.generation = m.generation.wrapping_add(1);
117+
}
118+
}
119+
}
93120
}
94121
}
95122
};
@@ -118,13 +145,20 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
118145
/// Remove an annotation by id from the scenario file (the watcher reloads).
119146
fn delete_annotation(shared: &Shared, id: &str) {
120147
let (path, raw) = {
121-
let m = shared.lock().unwrap();
148+
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
122149
(m.path.clone(), m.raw.clone())
123150
};
124-
let raw = remove_annotation(raw, id);
125-
if let (Some(path), Ok(t)) = (path, serde_json::to_string_pretty(&raw)) {
151+
let updated = remove_annotation(raw, id);
152+
if let Some(path) = path {
126153
if !rustmotion::loader::is_html_path(&path) {
127-
let _ = std::fs::write(&path, t);
154+
let write_result = serde_json::to_string_pretty(&updated)
155+
.map_err(|e| format!("json: {e}"))
156+
.and_then(|t| write_file(&path, &t));
157+
if let Err(e) = write_result {
158+
let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
159+
m.write_error = Some(e);
160+
m.generation = m.generation.wrapping_add(1);
161+
}
128162
}
129163
}
130164
}

0 commit comments

Comments
 (0)