Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions crates/rustmotion-studio/src/editor/annotations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,20 @@ pub fn AnnotationBox(pointer: String, kind: String, current: Signal<u32>) -> Ele
let updated = append_annotation(raw, ann);
match serde_json::to_string_pretty(&updated) {
Ok(t) => {
// Pre-write state for the undo history (JSON annotation
// writes rewrite the scenario file, so they must be in
// the same linear history as inspector edits).
let snapshot = std::fs::read_to_string(&path).ok();
let write_result = write_file(&path, &t);
if write_result.is_ok() {
if let Some(s) = snapshot {
crate::scenario::record_edit(
&crate::scenario::history_slot(),
&path,
s,
);
}
}
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
match write_result {
Ok(()) => {
Expand Down Expand Up @@ -190,13 +203,23 @@ fn delete_annotation(shared: &Shared, id: &str) {
}
} else {
let updated = remove_annotation(raw, id);
// Pre-write state for the undo history (same linear history as
// inspector edits — this rewrites the scenario file).
let snapshot = std::fs::read_to_string(&path).ok();
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);
match write_result {
Ok(()) => {
if let Some(s) = snapshot {
crate::scenario::record_edit(&crate::scenario::history_slot(), &path, s);
}
}
Err(e) => {
let mut m = shared.lock().unwrap_or_else(|e2| e2.into_inner());
m.write_error = Some(e);
m.generation = m.generation.wrapping_add(1);
}
}
}
}
Expand Down
44 changes: 36 additions & 8 deletions crates/rustmotion-studio/src/editor/inspector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -981,12 +981,22 @@ enum WritePayload {
},
}

impl WritePayload {
fn path(&self) -> &std::path::Path {
match self {
WritePayload::Prop { path, .. } | WritePayload::Content { path, .. } => path,
}
}
}

/// 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.
/// On success the model's `write_error` is cleared and the pre-write file state
/// is pushed onto the undo history; on failure `write_error` is set to the OS
/// error message and `generation` is bumped so the hot-reload loop picks it up
/// and shows the topbar indicator. The pending window is surfaced as the
/// "Saving…" indicator via the history slot.
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).
Expand All @@ -996,6 +1006,7 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa
prev.cancel();
}
}
crate::scenario::set_saving(&crate::scenario::history_slot(), true);

let debounce_slot = debounce.0.clone();
let task = spawn(async move {
Expand All @@ -1004,10 +1015,21 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa
// Clear the slot so the next write doesn't try to cancel this one.
*debounce_slot.borrow_mut() = None;

// Capture the file state BEFORE the write: one history entry per
// effective disk write.
let snapshot = std::fs::read_to_string(payload.path()).ok();
let result = perform_write(&payload);
crate::scenario::set_saving(&crate::scenario::history_slot(), false);
if let (Ok(true), Some(snapshot)) = (&result, snapshot) {
crate::scenario::record_edit(
&crate::scenario::history_slot(),
payload.path(),
snapshot,
);
}
let mut m = shared.lock().unwrap_or_else(|e| e.into_inner());
match result {
Ok(()) => {
Ok(_) => {
// Clear any previous write error on success.
m.write_error = None;
}
Expand All @@ -1022,8 +1044,10 @@ fn schedule_write(debounce: &WriteDebounce, shared: Shared, payload: WritePayloa
*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> {
/// 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.
fn perform_write(payload: &WritePayload) -> Result<bool, String> {
match payload {
WritePayload::Prop {
path,
Expand All @@ -1038,13 +1062,15 @@ fn perform_write(payload: &WritePayload) -> Result<(), String> {
rustmotion::loader::set_html_inline_style(&html, pointer, prop, value)
{
std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?;
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}"))?;
return Ok(true);
}
Ok(())
Ok(false)
}
WritePayload::Content {
path,
Expand All @@ -1058,12 +1084,14 @@ fn perform_write(payload: &WritePayload) -> Result<(), String> {
rustmotion::loader::set_html_text_content(&html, pointer, text)
{
std::fs::write(path, updated).map_err(|e| format!("write: {e}"))?;
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}"))?;
return Ok(true);
}
Ok(())
Ok(false)
}
}
}
Expand Down
82 changes: 80 additions & 2 deletions crates/rustmotion-studio/src/editor/topbar.rs
Original file line number Diff line number Diff line change
@@ -1,11 +1,54 @@
use std::time::Duration;

use dioxus::prelude::*;
use dioxus_icons::lucide::{ChevronLeft, Download, Eye, MessageSquare, Monitor, Moon, Play, Sun};
use dioxus_icons::lucide::{
ChevronLeft, Download, Eye, MessageSquare, Monitor, Moon, Play, Redo2, Sun, Undo2,
};

use crate::components::button::{Button, ButtonSize, ButtonVariant};
use crate::scenario::{Shared, Theme, View};
use crate::scenario::{history_slot, redo, undo, Shared, SharedHistory, Theme, View};

use super::export::{export_label, export_slot, start_export, use_export_poll, ExportStatus};

/// Snapshot of the history slot for the topbar UI (polled, set-on-change).
#[derive(Clone, PartialEq, Default)]
struct HistoryUi {
can_undo: bool,
can_redo: bool,
saving: bool,
}

/// Poll the history slot into a signal (~150 ms), same pattern as the export
/// poll. Undo/redo availability only counts when the slot belongs to the
/// currently open file.
fn use_history_poll(shared: Shared, slot: SharedHistory, mut sig: Signal<HistoryUi>) {
use_future(move || {
let shared = shared.clone();
let slot = slot.clone();
async move {
loop {
tokio::time::sleep(Duration::from_millis(150)).await;
let path = {
let m = shared.lock().unwrap_or_else(|e| e.into_inner());
m.path.clone()
};
let ui = {
let st = slot.lock().unwrap_or_else(|e| e.into_inner());
let matches = path.is_some() && st.path == path;
HistoryUi {
can_undo: matches && st.history.can_undo(),
can_redo: matches && st.history.can_redo(),
saving: st.saving,
}
};
if ui != sig() {
sig.set(ui);
}
}
}
});
}

/// 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, Export, and
Expand Down Expand Up @@ -38,6 +81,12 @@ pub fn TopBar(
let status = export_status();
let exporting = status.is_running();

// Undo/redo + pending-write ("Saving…") state, polled from the history slot.
let history = use_hook(history_slot);
let history_ui = use_signal(HistoryUi::default);
use_history_poll(shared.clone(), history.clone(), history_ui);
let hist = history_ui();

rsx! {
div { style: "position:relative; display:flex; align-items:center; justify-content:space-between; height:40px; padding:0 12px; border-bottom:1px solid var(--rm-border); background:var(--rm-surface-2); flex:none;",
div { style: "display:flex; align-items:center; gap:8px; z-index:1;",
Expand All @@ -57,6 +106,11 @@ pub fn TopBar(

// ── Right: indicators + actions ──────────────────────────
div { style: "display:flex; align-items:center; gap:6px; z-index:1;",
if hist.saving {
span { style: "color:var(--rm-text-muted); font-size:11px; white-space:nowrap;",
"Saving…"
}
}
if let Some(ref msg) = write_error {
span {
title: "{msg}",
Expand All @@ -81,6 +135,30 @@ pub fn TopBar(
},
_ => rsx! {},
}
Button {
variant: ButtonVariant::Ghost,
size: ButtonSize::IconSm,
title: "Undo (Cmd+Z)",
disabled: !hist.can_undo,
onclick: {
let shared = shared.clone();
let history = history.clone();
move |_| undo(&shared, &history)
},
Undo2 { size: 15, stroke: if hist.can_undo { "var(--rm-text)" } else { "var(--rm-text-muted)" } }
}
Button {
variant: ButtonVariant::Ghost,
size: ButtonSize::IconSm,
title: "Redo (Shift+Cmd+Z)",
disabled: !hist.can_redo,
onclick: {
let shared = shared.clone();
let history = history.clone();
move |_| redo(&shared, &history)
},
Redo2 { size: 15, stroke: if hist.can_redo { "var(--rm-text)" } else { "var(--rm-text-muted)" } }
}
Button {
variant: ButtonVariant::Outline,
size: ButtonSize::IconSm,
Expand Down
46 changes: 43 additions & 3 deletions crates/rustmotion-studio/src/editor/view.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ use dioxus::desktop::{
};
use dioxus::prelude::*;

use crate::scenario::{list_annotations, read_field, read_style_object, Shared, View};
use crate::scenario::{
history_slot, list_annotations, read_field, read_style_object, redo, undo, Shared, View,
};

use super::annotations::AnnotationsPanel;
use super::frames::{frame_hits, render_frame, scene_prefix, HitPct};
Expand Down Expand Up @@ -142,8 +144,42 @@ pub fn StudioApp(view: Signal<View>) -> Element {
let panel = inspector();
let panel_w = if panel.is_some() { "300px" } else { "0px" };

// Undo/redo keyboard shortcuts: the root div is focusable (and focused on
// mount) so Cmd+Z / Shift+Cmd+Z (Ctrl on non-mac) reach it — key events in
// the subtree bubble up to it. The inspector slot stops keydown propagation
// so text fields keep their native editing undo.
let history = use_hook(history_slot);
let on_shortcut = {
let shared = shared.clone();
let history = history.clone();
move |evt: KeyboardEvent| {
let mods = evt.modifiers();
if !(mods.meta() || mods.ctrl()) {
return;
}
let is_z = matches!(evt.key(), Key::Character(ref c) if c.eq_ignore_ascii_case("z"));
if !is_z {
return;
}
evt.prevent_default();
if mods.shift() {
redo(&shared, &history);
} else {
undo(&shared, &history);
}
}
};

rsx! {
div { style: "margin:0; background:var(--rm-bg); height:100vh; overflow:hidden; color:var(--rm-text); font:13px -apple-system,sans-serif; display:flex; flex-direction:column;",
div {
style: "margin:0; background:var(--rm-bg); height:100vh; overflow:hidden; color:var(--rm-text); font:13px -apple-system,sans-serif; display:flex; flex-direction:column; outline:none;",
tabindex: "0",
onmounted: move |evt| {
spawn(async move {
let _ = evt.set_focus(true).await;
});
},
onkeydown: on_shortcut,
TopBar {
view,
title,
Expand All @@ -159,7 +195,11 @@ pub fn StudioApp(view: Signal<View>) -> Element {
Canvas { current, rev, show_hits, selected }
PlaybackBar { current, playing, total }
}
div { style: "flex:none; display:flex; overflow:hidden; transition:width 220ms ease; width:{panel_w};",
div {
style: "flex:none; display:flex; overflow:hidden; transition:width 220ms ease; width:{panel_w};",
// Keep Cmd+Z inside inspector inputs/textareas native
// (text-field undo), not intercepted by the editor.
onkeydown: move |evt: KeyboardEvent| evt.stop_propagation(),
if let Some((pointer, style, kind, content)) = panel {
InspectorPanel { selected, pointer, kind, current, content, style }
}
Expand Down
Loading
Loading