diff --git a/crates/rustmotion-studio/src/app/mod.rs b/crates/rustmotion-studio/src/app/mod.rs index 93a355b..423f6b2 100644 --- a/crates/rustmotion-studio/src/app/mod.rs +++ b/crates/rustmotion-studio/src/app/mod.rs @@ -130,6 +130,21 @@ fn spawn_watcher(shared: Shared) -> Sender { } WatchMsg::Changed => { if let Some(p) = current.clone() { + // Self-write skip: if the disk content is exactly what + // this process last wrote (debounced write, undo), the + // in-memory model is already up to date — and possibly + // NEWER under continuous typing. Reloading would + // clobber it, so skip. External edits hash differently + // and reload normally. + if let Ok(content) = std::fs::read_to_string(&p) { + if crate::scenario::is_self_write( + &crate::scenario::self_write_slot(), + &p, + &content, + ) { + continue; + } + } let (scenario, error) = match rustmotion::loader::load_input(&p) { Ok(s) => (s, None), Err(e) => (crate::scenario::empty_scenario(), Some(e.to_string())), diff --git a/crates/rustmotion-studio/src/components/color_picker/component.rs b/crates/rustmotion-studio/src/components/color_picker/component.rs index 446e58e..10b4350 100644 --- a/crates/rustmotion-studio/src/components/color_picker/component.rs +++ b/crates/rustmotion-studio/src/components/color_picker/component.rs @@ -57,17 +57,35 @@ pub struct ColorPickerRootProps { pub fn ColorPickerRoot(props: ColorPickerRootProps) -> Element { let (open, set_open) = use_controlled(props.open, props.default_open, props.on_open_change); + // Local mirror of the color: the picker internals (area drag, hue slider, + // hex field) read/write THIS state, so every selection — including + // mid-drag moves — previews live in the popover AND propagates through + // `on_color_change` immediately, independent of whether the parent echoes + // the new color back through the `color` prop. + let mut local = use_signal(|| (props.color)()); + use_effect(move || { + let external = (props.color)(); + if external != *local.peek() { + local.set(external); + } + }); + let forward = props.on_color_change; + let on_change = move |c: Hsv| { + local.set(c); + forward.call(c); + }; + use_context_provider(|| ColorPickerRootContext { open, disabled: props.disabled, - color: props.color, + color: local.into(), }); rsx! { color_picker::ColorPicker { class: "dx-color-picker", - color: props.color, - on_color_change: props.on_color_change, + color: local(), + on_color_change: on_change, disabled: props.disabled, attributes: props.attributes, popover::PopoverRoot { @@ -190,11 +208,43 @@ pub struct ColorPickerPopoverProps { #[component] pub fn ColorPickerPopover(props: ColorPickerPopoverProps) -> Element { + let ctx = use_context::(); + // The inspector docks the picker against the right window edge, so the + // popover opens LEFT of the swatch (CSS `right:0`). Vertically it flips + // above the swatch when its bottom would clip the window: measured when + // the popover opens. + let mut node = use_signal(|| None::>); + let mut flip_up = use_signal(|| false); + use_effect(move || { + if (ctx.open)() { + if let Some(n) = node() { + spawn(async move { + if let Ok(rect) = n.get_client_rect().await { + let win = dioxus::desktop::window(); + let vh = win + .inner_size() + .to_logical::(win.scale_factor()) + .height; + flip_up.set(rect.origin.y + rect.size.height > vh - 8.0); + } + }); + } + } + }); + let class = if flip_up() { + "dx-color-picker-popover flip-up" + } else { + "dx-color-picker-popover" + }; + rsx! { popover::PopoverContent { - class: "dx-color-picker-popover".to_string(), + class: class.to_string(), attributes: props.attributes, - {props.children} + div { + onmounted: move |e| node.set(Some(e.data())), + {props.children} + } } } } diff --git a/crates/rustmotion-studio/src/components/color_picker/style.css b/crates/rustmotion-studio/src/components/color_picker/style.css index 0dfeffb..e30a9fc 100644 --- a/crates/rustmotion-studio/src/components/color_picker/style.css +++ b/crates/rustmotion-studio/src/components/color_picker/style.css @@ -176,7 +176,10 @@ position: absolute; z-index: 1000; top: 100%; - left: 0; + /* The inspector docks against the right window edge: anchor the popover's + right edge to the swatch so it opens LEFTWARD instead of clipping. */ + right: 0; + left: auto; display: block; border-radius: 0.5rem; margin-top: 4px; @@ -240,3 +243,12 @@ cursor: not-allowed; opacity: 0.5; } + +/* Near the bottom of the window the popover flips above the swatch + (measured at open time by ColorPickerPopover). */ +.dx-color-picker-popover.flip-up { + top: auto; + bottom: 100%; + margin-top: 0; + margin-bottom: 4px; +} diff --git a/crates/rustmotion-studio/src/editor/inspector.rs b/crates/rustmotion-studio/src/editor/inspector.rs index 8272147..19499c9 100644 --- a/crates/rustmotion-studio/src/editor/inspector.rs +++ b/crates/rustmotion-studio/src/editor/inspector.rs @@ -13,9 +13,12 @@ use crate::components::select::{Select, SelectOption}; use crate::components::slider::Slider; use crate::components::switch::Switch; use crate::scenario::{ - scene_duration_for_pointer, set_field, set_field_value, set_style, set_style_value, Shared, + apply_optimistic, scene_duration_for_pointer, set_field, set_field_value, set_style, + set_style_value, Mutation, Shared, }; +use super::view::RevSignal; + use super::annotations::AnnotationBox; use super::properties::{ component_props, css_family, css_section_props, display_number, effective_element, @@ -422,6 +425,13 @@ fn family(kind: &str) -> Family { } } +/// Text-family elements edit their content first: the CONTENT section renders +/// ABOVE Properties. Other families keep Properties on top (and have no +/// content editor). +fn content_before_properties(kind: &str) -> bool { + family(kind) == Family::Text +} + const TEXT_SECTIONS: &[Section] = &[ Section { title: "Typography", @@ -618,10 +628,10 @@ pub fn InspectorPanel( "✕" } } - RootPropsSection { pointer: pointer.clone(), kind: kind.clone(), element } - if fam == Family::Text { + if content_before_properties(&kind) { ContentEditor { pointer: pointer.clone(), content: content.clone().unwrap_or_default() } } + RootPropsSection { pointer: pointer.clone(), kind: kind.clone(), element } for section in sections(fam) { SectionView { key: "{section.title}", @@ -1812,6 +1822,14 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa *debounce.0.borrow_mut() = Some(task); } +/// Write scenario-file content and record it in the self-write ledger so the +/// watcher skips the resulting event (the in-memory model is already ahead). +fn write_and_note(path: &std::path::Path, content: &str) -> Result<(), String> { + std::fs::write(path, content).map_err(|e| format!("write: {e}"))?; + crate::scenario::note_self_write(&crate::scenario::self_write_slot(), path, content); + Ok(()) +} + /// Execute the actual file write. Returns `Ok(true)` when the file was /// written, `Ok(false)` when the edit was a no-op (nothing to record in the /// undo history), or an error message. @@ -1829,13 +1847,13 @@ fn perform_write(payload: &WritePayload) -> Result { 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}"))?; + write_and_note(path, &updated)?; return Ok(true); } } 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}"))?; + write_and_note(path, &text)?; return Ok(true); } Ok(false) @@ -1851,12 +1869,12 @@ fn perform_write(payload: &WritePayload) -> Result { if let Some(updated) = rustmotion::loader::set_html_text_content(&html, pointer, text) { - std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + write_and_note(path, &updated)?; return Ok(true); } } 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}"))?; + write_and_note(path, &s)?; return Ok(true); } Ok(false) @@ -1874,14 +1892,14 @@ fn perform_write(payload: &WritePayload) -> Result { if let Some(updated) = rustmotion::loader::set_html_attribute(&html, pointer, field, &attr) { - std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + write_and_note(path, &updated)?; return Ok(true); } } else if let Some(updated) = set_field_value(raw.clone(), pointer, field, value.clone()) { 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}"))?; + write_and_note(path, &s)?; return Ok(true); } Ok(false) @@ -1897,14 +1915,14 @@ fn perform_write(payload: &WritePayload) -> Result { if let Some(updated) = rustmotion::loader::remove_html_inline_style(&html, pointer, prop) { - std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?; + write_and_note(path, &updated)?; return Ok(true); } } else if let Some(updated) = set_style_value(raw.clone(), pointer, prop, serde_json::Value::Null) { 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}"))?; + write_and_note(path, &s)?; return Ok(true); } Ok(false) @@ -1912,6 +1930,19 @@ fn perform_write(payload: &WritePayload) -> Result { } } +/// Apply an edit to the in-memory model immediately (canvas refreshes in ~one +/// render) and nudge the hot-reload signal. Rebuild failures are transient +/// (mid-typing) and silently keep the previous model — the disk write path +/// has its own guards. +fn optimistic(shared: &Shared, mutation: Mutation) { + if apply_optimistic(shared, &mutation).is_ok() { + if let Some(rev) = try_consume_context::() { + let mut r = rev.0; + r.set(r() + 1); + } + } +} + /// 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 @@ -1921,6 +1952,14 @@ fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) { if value.trim().is_empty() { return; } + optimistic( + shared, + Mutation::Style { + pointer: pointer.to_string(), + prop: prop.to_string(), + value: serde_json::Value::String(value.to_string()), + }, + ); let (path, raw) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) @@ -1946,6 +1985,14 @@ fn write_prop(shared: &Shared, pointer: &str, prop: &str, value: &str) { /// `Value::Null` removes the field (JSON) / the attribute (HTML). Debounced /// with the same guarantees as [`write_prop`]. fn write_root_field(shared: &Shared, pointer: &str, field: &str, value: serde_json::Value) { + optimistic( + shared, + Mutation::Field { + pointer: pointer.to_string(), + field: field.to_string(), + value: value.clone(), + }, + ); let (path, raw) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) @@ -1970,6 +2017,14 @@ fn write_root_field(shared: &Shared, pointer: &str, field: &str, value: serde_js /// Remove one style property (an emptied generic control unsets the key / /// declaration rather than writing an empty string). Debounced. fn write_style_removal(shared: &Shared, pointer: &str, prop: &str) { + optimistic( + shared, + Mutation::Style { + pointer: pointer.to_string(), + prop: prop.to_string(), + value: serde_json::Value::Null, + }, + ); let (path, raw) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) @@ -1994,6 +2049,14 @@ fn write_style_removal(shared: &Shared, pointer: &str, prop: &str) { /// [`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) { + optimistic( + shared, + Mutation::Field { + pointer: pointer.to_string(), + field: "content".to_string(), + value: serde_json::Value::String(text.to_string()), + }, + ); let (path, raw) = { let m = shared.lock().unwrap_or_else(|e| e.into_inner()); (m.path.clone(), m.raw.clone()) @@ -2013,3 +2076,17 @@ fn write_content(shared: &Shared, pointer: &str, text: &str) { }, ); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn text_family_shows_content_before_properties() { + assert!(content_before_properties("text")); + assert!(content_before_properties("caption")); + // Non-text components keep the current order (no content editor). + assert!(!content_before_properties("gauge")); + assert!(!content_before_properties("card")); + } +} diff --git a/crates/rustmotion-studio/src/editor/view.rs b/crates/rustmotion-studio/src/editor/view.rs index dc33009..a12d3e5 100644 --- a/crates/rustmotion-studio/src/editor/view.rs +++ b/crates/rustmotion-studio/src/editor/view.rs @@ -18,6 +18,12 @@ use super::playback::{ use super::prefetch::{frame_cache, use_prefetch_publisher, FrameKey}; use super::topbar::TopBar; +/// The hot-reload revision signal, exposed via context so the optimistic edit +/// path can nudge the canvas immediately instead of waiting for the 250 ms +/// generation poll. +#[derive(Clone, Copy)] +pub struct RevSignal(pub Signal); + /// Overlay element boxes: invisible by default. Only the single hovered element /// (`.hov`) gets a dashed outline, and the selected element (`.sel`) a solid /// blue box. "Hovered" is tracked explicitly (one node at a time) rather than @@ -131,6 +137,7 @@ pub fn StudioApp(view: Signal) -> Element { use_playback_clock(shared.clone(), current, playing); use_hot_reload(shared.clone(), rev); + use_context_provider(|| RevSignal(rev)); // Publish the playhead/side/model snapshot for the background prefetcher. use_prefetch_publisher( shared.clone(), diff --git a/crates/rustmotion-studio/src/scenario/history.rs b/crates/rustmotion-studio/src/scenario/history.rs index 73c5af4..d827c2f 100644 --- a/crates/rustmotion-studio/src/scenario/history.rs +++ b/crates/rustmotion-studio/src/scenario/history.rs @@ -145,7 +145,19 @@ fn step(shared: &Shared, slot: &SharedHistory, is_undo: bool) { return Ok(false); }; match std::fs::write(&path, &target) { - Ok(()) => Ok(true), + Ok(()) => { + // Record the self-write so the watcher skips the reload, + // and adopt the restored state in memory ourselves. If + // adoption fails (shouldn't: disk states were valid when + // captured), clear the note so the watcher reloads + // normally instead of leaving stale memory. + super::optimistic::note_self_write( + &super::optimistic::self_write_slot(), + &path, + &target, + ); + Ok(true) + } Err(e) => { // Roll the stacks back so the failed step isn't lost: the // inverse operation with `target` restores both stacks @@ -164,7 +176,12 @@ fn step(shared: &Shared, slot: &SharedHistory, is_undo: bool) { match outcome { Ok(true) => { - // The watcher picks up the write and reloads the model. + // Adopt the restored state in memory (the watcher will skip the + // self-write). Failure → clear the note so the watcher reloads. + let disk = std::fs::read_to_string(&path).unwrap_or_default(); + if super::optimistic::adopt_source(shared, &path, &disk).is_err() { + super::optimistic::clear_self_write(&super::optimistic::self_write_slot(), &path); + } let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); m.write_error = None; } diff --git a/crates/rustmotion-studio/src/scenario/mod.rs b/crates/rustmotion-studio/src/scenario/mod.rs index ac3751e..231be62 100644 --- a/crates/rustmotion-studio/src/scenario/mod.rs +++ b/crates/rustmotion-studio/src/scenario/mod.rs @@ -6,6 +6,7 @@ mod diff; mod edit; mod history; mod model; +mod optimistic; mod sidecar; pub use baseline::{baseline_slot, get_baseline, set_baseline}; @@ -16,6 +17,7 @@ pub use edit::{ }; pub use history::{history_slot, record_edit, redo, set_saving, undo, SharedHistory}; pub use model::{empty_scenario, Shared, StudioModel}; +pub use optimistic::{apply_optimistic, is_self_write, note_self_write, self_write_slot, Mutation}; pub use sidecar::{append_sidecar_annotation, remove_sidecar_annotation}; /// Which top-level view is shown (library home vs. the editor). diff --git a/crates/rustmotion-studio/src/scenario/model.rs b/crates/rustmotion-studio/src/scenario/model.rs index 824b8cc..403025b 100644 --- a/crates/rustmotion-studio/src/scenario/model.rs +++ b/crates/rustmotion-studio/src/scenario/model.rs @@ -26,6 +26,9 @@ pub struct StudioModel { pub path: Option, /// Raw parsed scenario JSON (for reading/editing element props by pointer). pub raw: serde_json::Value, + /// In-memory HTML source for `.html` scenarios (kept in sync by the + /// optimistic edit path; `None` for JSON sources). + pub html_source: Option, } pub type Shared = Arc>; @@ -39,6 +42,7 @@ impl StudioModel { // For HTML sources the raw JSON is the transpiled scenario, plus the // annotations sidecar merged in so `list_annotations` and the comments // panel work unchanged; the inspector reads element props by pointer. + let mut html_source = None; let raw = path .as_ref() .and_then(|p| { @@ -49,6 +53,7 @@ impl StudioModel { // loader (error banner); a read failure here can only be a // race, so fall back to the bare transpile. let annotations = super::sidecar::read_sidecar(p).unwrap_or_default(); + html_source = Some(s.clone()); super::sidecar::merge_annotations(raw, annotations) } else { serde_json::from_str(&s).ok()? @@ -70,6 +75,7 @@ impl StudioModel { generation: 0, path, raw, + html_source, } } } diff --git a/crates/rustmotion-studio/src/scenario/optimistic.rs b/crates/rustmotion-studio/src/scenario/optimistic.rs new file mode 100644 index 0000000..3d350be --- /dev/null +++ b/crates/rustmotion-studio/src/scenario/optimistic.rs @@ -0,0 +1,373 @@ +//! Optimistic in-memory edits: apply every edit event to the live model +//! immediately (rebuild scenario + tasks from memory, bump generation) so the +//! canvas refreshes in ~one render, while the DISK keeps the existing +//! debounced write path untouched (250 ms, history/undo, write_error). +//! +//! Also owns the self-write ledger: the debounced writer and undo/redo record +//! a hash of what they wrote; the watcher skips reloads whose disk content +//! matches the last self-write (the in-memory model is already up to date — +//! and possibly NEWER under continuous typing, so the skip is a correctness +//! fix, not just an optimization). External edits (agent, editor) hash +//! differently and reload normally. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use serde_json::Value; + +use rustmotion::schema::ResolvedScenario; + +use super::{set_field_value, set_style_value, Shared}; + +/// One in-memory edit, mirroring the debounced write payloads. +/// `Value::Null` removes the property / field. +#[derive(Debug, Clone)] +pub enum Mutation { + Style { + pointer: String, + prop: String, + value: Value, + }, + Field { + pointer: String, + field: String, + value: Value, + }, +} + +/// Apply a mutation to the in-memory model: mutate the raw (JSON) or the +/// in-memory HTML source (retranspiled), rebuild scenario/tasks (fresh Arcs — +/// the prefetcher follows), bump generation. On rebuild failure (transiently +/// invalid edit, e.g. mid-typing in a JSON area) the model is left UNTOUCHED +/// and no write_error is raised — the disk is only ever written by the +/// debounced path, which has its own guards. +pub fn apply_optimistic(shared: &Shared, mutation: &Mutation) -> Result<(), String> { + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + let Some(path) = m.path.clone() else { + return Err("no open file".into()); + }; + + if rustmotion::loader::is_html_path(&path) { + // HTML: mutate the in-memory source, retranspile, rebuild. + let source = match &m.html_source { + Some(s) => s.clone(), + None => std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?, + }; + let new_source = apply_to_html(&source, mutation).ok_or("mutation didn't apply")?; + let transpiled = rustmotion::loader::html_to_scenario_json(&new_source) + .map_err(|e| format!("transpile: {e}"))?; + let annotations = super::sidecar::read_sidecar(&path).unwrap_or_default(); + let new_raw = super::sidecar::merge_annotations(transpiled, annotations); + let scenario = rebuild_from_value(&new_raw)?; + commit(&mut m, scenario, new_raw, Some(new_source)); + } else { + // JSON: mutate the raw value, rebuild. + let new_raw = apply_to_raw(m.raw.clone(), mutation).ok_or("mutation didn't apply")?; + let scenario = rebuild_from_value(&new_raw)?; + commit(&mut m, scenario, new_raw, None); + } + Ok(()) +} + +/// Adopt a full source text as the new in-memory state (undo/redo: they +/// rewrite the disk themselves and the watcher skips the self-write, so the +/// memory must be updated here). Rebuilds like `apply_optimistic`. +pub fn adopt_source(shared: &Shared, path: &Path, source: &str) -> Result<(), String> { + let mut m = shared.lock().unwrap_or_else(|e| e.into_inner()); + if rustmotion::loader::is_html_path(path) { + let transpiled = rustmotion::loader::html_to_scenario_json(source) + .map_err(|e| format!("transpile: {e}"))?; + let annotations = super::sidecar::read_sidecar(path).unwrap_or_default(); + let new_raw = super::sidecar::merge_annotations(transpiled, annotations); + let scenario = rebuild_from_value(&new_raw)?; + commit(&mut m, scenario, new_raw, Some(source.to_string())); + } else { + let new_raw: Value = serde_json::from_str(source).map_err(|e| format!("parse: {e}"))?; + let scenario = rebuild_from_value(&new_raw)?; + commit(&mut m, scenario, new_raw, None); + } + Ok(()) +} + +/// Swap the rebuilt state into the model: fresh Arcs (the prefetcher follows), +/// new totals, generation bump. +fn commit( + m: &mut super::StudioModel, + scenario: ResolvedScenario, + raw: Value, + html_source: Option, +) { + let tasks = rustmotion::encode::build_frame_tasks(&scenario); + m.total_frames = tasks.len() as u32; + m.scenario = Arc::new(scenario); + m.tasks = Arc::new(tasks); + m.raw = raw; + if html_source.is_some() { + m.html_source = html_source; + } + m.generation = m.generation.wrapping_add(1); +} + +/// Apply a mutation to a raw JSON document (pure). +fn apply_to_raw(raw: Value, mutation: &Mutation) -> Option { + match mutation { + Mutation::Style { + pointer, + prop, + value, + } => set_style_value(raw, pointer, prop, value.clone()), + Mutation::Field { + pointer, + field, + value, + } => set_field_value(raw, pointer, field, value.clone()), + } +} + +/// Apply a mutation to an HTML source string (pure). `content` maps to the +/// element's text node; other fields are attributes. +fn apply_to_html(source: &str, mutation: &Mutation) -> Option { + match mutation { + Mutation::Style { + pointer, + prop, + value, + } => match value { + Value::Null => rustmotion::loader::remove_html_inline_style(source, pointer, prop), + Value::String(s) => rustmotion::loader::set_html_inline_style(source, pointer, prop, s), + other => { + rustmotion::loader::set_html_inline_style(source, pointer, prop, &other.to_string()) + } + }, + Mutation::Field { + pointer, + field, + value, + } => { + if field == "content" { + let text = match value { + Value::String(s) => s.clone(), + Value::Null => String::new(), + other => other.to_string(), + }; + rustmotion::loader::set_html_text_content(source, pointer, &text) + } else { + let attr = match value { + Value::Null => String::new(), + Value::String(s) => s.clone(), + other => other.to_string(), + }; + rustmotion::loader::set_html_attribute(source, pointer, field, &attr) + } + } + } +} + +// ── Self-write ledger ──────────────────────────────────────────────────────── + +pub type SelfWrites = Arc>>; + +/// App-global ledger: path → hash of the last content this process wrote. +pub fn self_write_slot() -> SelfWrites { + static SLOT: OnceLock = OnceLock::new(); + SLOT.get_or_init(|| Arc::new(Mutex::new(HashMap::new()))) + .clone() +} + +fn content_hash(content: &str) -> u64 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + content.hash(&mut h); + h.finish() +} + +/// Record that this process wrote `content` to `path`. +pub fn note_self_write(slot: &SelfWrites, path: &Path, content: &str) { + let mut map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.insert(path.to_path_buf(), content_hash(content)); +} + +/// Forget the note for `path` (adoption failed → let the watcher reload). +pub fn clear_self_write(slot: &SelfWrites, path: &Path) { + let mut map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.remove(path); +} + +/// Whether `content` on disk is exactly the last self-write for `path` +/// (watcher: true → skip the reload). +pub fn is_self_write(slot: &SelfWrites, path: &Path, content: &str) -> bool { + let map = slot.lock().unwrap_or_else(|e| e.into_inner()); + map.get(path) == Some(&content_hash(content)) +} + +// ── Rebuild ────────────────────────────────────────────────────────────────── + +/// Build a `ResolvedScenario` from a raw scenario JSON value — the same +/// pipeline as the loader (variable defaults + include resolution; includes +/// resolve as Inline, so file-relative includes are a known limitation shared +/// with the diff baseline render). +fn rebuild_from_value(raw: &Value) -> Result { + let json = serde_json::to_string(raw).map_err(|e| format!("serialize: {e}"))?; + rustmotion::loader::load_scenario_from_source(None, Some(&json)).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::scenario::{empty_scenario, StudioModel}; + use serde_json::json; + + fn temp_json(tag: &str, content: &str) -> PathBuf { + let p = std::env::temp_dir().join(format!("rm_opt_{tag}_{}.json", std::process::id())); + std::fs::write(&p, content).unwrap(); + p + } + + fn model_for(path: &Path) -> Shared { + Arc::new(Mutex::new(StudioModel::new( + empty_scenario(), + None, + Some(path.to_path_buf()), + ))) + } + + const DOC: &str = r##"{ "video": { "width": 640, "height": 360, "background": "#101418" }, + "scenes": [ { "duration": 1.0, "children": [ + { "type": "text", "content": "Hi", "style": { "font-size": 48 } } + ] } ] }"##; + + // ── Self-write skip decision ──────────────────────────────────────── + + #[test] + fn self_write_skip_decision() { + let slot: SelfWrites = Arc::new(Mutex::new(HashMap::new())); + let a = Path::new("/w/a.json"); + let b = Path::new("/w/b.json"); + // Nothing recorded → reload (not a self-write). + assert!(!is_self_write(&slot, a, "content")); + note_self_write(&slot, a, "content"); + // Identical content → skip. + assert!(is_self_write(&slot, a, "content")); + // Different content (external edit) → reload. + assert!(!is_self_write(&slot, a, "external change")); + // Same content on a DIFFERENT path → reload. + assert!(!is_self_write(&slot, b, "content")); + // Cleared → reload again. + note_self_write(&slot, a, "content"); + clear_self_write(&slot, a); + assert!(!is_self_write(&slot, a, "content")); + } + + // ── Optimistic rebuild (JSON) ─────────────────────────────────────── + + #[test] + fn optimistic_style_mutation_rebuilds_the_scenario() { + let path = temp_json("style", DOC); + let shared = model_for(&path); + let gen_before = shared.lock().unwrap().generation; + + let m = Mutation::Style { + pointer: "/scenes/0/children/0".into(), + prop: "font-size".into(), + value: json!(64), + }; + apply_optimistic(&shared, &m).expect("valid mutation applies"); + + let model = shared.lock().unwrap(); + assert_eq!( + model.raw["scenes"][0]["children"][0]["style"]["font-size"], + json!(64) + ); + // The rebuilt scenario carries the new value too (children are raw values). + assert_eq!( + model.scenario.views[0].scenes[0].children[0]["style"]["font-size"], + json!(64) + ); + assert!(model.generation > gen_before, "generation bumped"); + assert!(model.total_frames > 0); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn invalid_optimistic_mutation_leaves_the_model_untouched() { + let path = temp_json("invalid", DOC); + let shared = model_for(&path); + let (gen_before, raw_before) = { + let m = shared.lock().unwrap(); + (m.generation, m.raw.clone()) + }; + + // width: "abc" breaks the typed Scenario parse → rebuild fails. + let m = Mutation::Field { + pointer: "/video".into(), + field: "width".into(), + value: json!("abc"), + }; + assert!(apply_optimistic(&shared, &m).is_err()); + + let model = shared.lock().unwrap(); + assert_eq!(model.generation, gen_before, "no bump on failure"); + assert_eq!(model.raw, raw_before, "raw untouched on failure"); + let _ = std::fs::remove_file(&path); + } + + // ── Optimistic rebuild (HTML) ─────────────────────────────────────── + + #[test] + fn optimistic_html_mutation_retranspiles_in_memory() { + let p = std::env::temp_dir().join(format!("rm_opt_html_{}.html", std::process::id())); + std::fs::write( + &p, + r##""##, + ) + .unwrap(); + let shared = model_for(&p); + + let m = Mutation::Field { + pointer: "/scenes/0/children/0".into(), + field: "from".into(), + value: json!(250), + }; + apply_optimistic(&shared, &m).expect("html mutation applies"); + + let model = shared.lock().unwrap(); + // Retranspiled + re-typed by the transpiler's coercion. + assert_eq!(model.raw["scenes"][0]["children"][0]["from"], json!(250)); + assert!( + model + .html_source + .as_deref() + .is_some_and(|s| s.contains("from=\"250\"")), + "in-memory HTML source updated" + ); + let _ = std::fs::remove_file(&p); + } + + // ── Undo + self-write flow ────────────────────────────────────────── + + #[test] + fn undo_notes_self_write_and_adopts_in_memory() { + let before = DOC; + let after = DOC.replace("48", "72"); + let path = temp_json("undo_flow", &after); + let shared = model_for(&path); + let hist: crate::scenario::SharedHistory = Arc::new(Mutex::new(Default::default())); + crate::scenario::record_edit(&hist, &path, before.to_string()); + + crate::scenario::undo(&shared, &hist); + + // Disk restored… + let disk = std::fs::read_to_string(&path).unwrap(); + assert_eq!(disk, before); + // …the write is recorded as a self-write (watcher will skip it)… + assert!(is_self_write(&self_write_slot(), &path, &disk)); + // …and the memory adopted the restored state itself. + let model = shared.lock().unwrap(); + assert_eq!( + model.raw["scenes"][0]["children"][0]["style"]["font-size"], + json!(48) + ); + let _ = std::fs::remove_file(&path); + } +}