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
167 changes: 149 additions & 18 deletions src/app/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1487,17 +1487,13 @@ impl VardaApp {
}

// ── History ───────────────────────────────────────────
// Restore is shared with the windowed runner via `history_undo` /
// `history_redo` on the unified timeline. The headless/API path has
// no UI layout, so it uses `history_snapshot_default()` for the
// "current" state pushed onto the opposite stack.
EngineCommand::Undo => {
let current = self.history_snapshot_default();
if let Some(snap) = self.session.history.undo(current) {
let rw = self.render_width;
let rh = self.render_height;
let (warnings, _) = self.apply_scene_diff(&snap.scene, rw, rh);
self.apply_stage_diff(&snap.stage);
self.mixer.clear_sub_mix_cache();
for w in &warnings {
log::warn!("Undo warning: {}", w);
}
if self.history_undo(current).is_some() {
CommandResult::Ok
} else {
CommandResult::Err {
Expand All @@ -1508,15 +1504,7 @@ impl VardaApp {
}
EngineCommand::Redo => {
let current = self.history_snapshot_default();
if let Some(snap) = self.session.history.redo(current) {
let rw = self.render_width;
let rh = self.render_height;
let (warnings, _) = self.apply_scene_diff(&snap.scene, rw, rh);
self.apply_stage_diff(&snap.stage);
self.mixer.clear_sub_mix_cache();
for w in &warnings {
log::warn!("Redo warning: {}", w);
}
if self.history_redo(current).is_some() {
CommandResult::Ok
} else {
CommandResult::Err {
Expand All @@ -1534,3 +1522,146 @@ impl VardaApp {
}
}
}

/// Whether a bus-driven command should record an undo/redo snapshot before it
/// executes. This is what makes API / WebSocket / CLI edits undoable on the
/// same timeline the windowed UI uses (see [undo-redo.md](/spec/undo-redo.md)).
///
/// The predicate is an explicit **denylist** of live-control, transient, and
/// non-authored commands; everything else defaults to undoable. New commands
/// are therefore undoable unless added here — when introducing a live control
/// (transport, device toggle, output-window lifecycle) or a transient action,
/// add it below so it does not pollute the undo timeline. This mirrors
/// `UIActions::has_undoable_action` / `has_undoable_stage_action`, which are
/// the equivalent gate for the windowed consumer.
pub(crate) fn command_is_undoable(cmd: &EngineCommand) -> bool {
use EngineCommand as C;
!matches!(
cmd,
// Live crossfader control (spec: ⚠️ live, excluded).
C::SetCrossfader(..)
| C::AutoCrossfade { .. }
| C::BeatCrossfade { .. }
// Audio device lifecycle / scanning.
| C::OpenAudioSource { .. }
| C::CloseAudioSource { .. }
| C::ScanAudioDevices
| C::RescanAudio
| C::ToggleAudioSource { .. }
// Video transport (temporal, not structural).
| C::VideoTogglePlay { .. }
| C::VideoSeek { .. }
| C::VideoSetSpeed { .. }
| C::VideoSetLoopMode { .. }
| C::VideoSetInPoint { .. }
| C::VideoSetOutPoint { .. }
| C::VideoClearInOutPoints { .. }
// ADSR live triggers.
| C::TriggerAdsr { .. }
| C::ReleaseAdsr { .. }
// Sequence playback transport (authoring steps stay undoable).
| C::PlaySequence { .. }
| C::StopSequence { .. }
| C::ToggleSequence { .. }
// HTML transient window / reload.
| C::OpenHtmlInteractive { .. }
| C::CloseHtmlInteractive
| C::ReloadHtmlDeck { .. }
// Stream library config (not scene state).
| C::AddStreamLibraryEntry { .. }
| C::RemoveStreamLibraryEntry { .. }
| C::AddHlsLibraryEntry { .. }
| C::RemoveHlsLibraryEntry { .. }
| C::AddDashLibraryEntry { .. }
| C::RemoveDashLibraryEntry { .. }
| C::AddRtmpLibraryEntry { .. }
| C::RemoveRtmpLibraryEntry { .. }
// Output-window lifecycle / device config (spec: ❌, excluded).
// Surface→output *assignments* remain undoable (default true).
| C::CreateOutput
| C::CreateHeadlessOutput { .. }
| C::CloseOutput { .. }
| C::SetOutputDisplay { .. }
| C::SetOutputTarget { .. }
| C::StartOutput { .. }
| C::StopOutput { .. }
| C::SetCalibrationMode { .. }
| C::SetOutputRotation { .. }
| C::SetEdgeBlend { .. }
| C::SetEdgeBlendMode { .. }
// Surface auto-detection produces preview contours only; the scene
// is not mutated until ConfirmDetectedContours (which is undoable).
| C::DetectFromImage { .. }
| C::DetectFromSvg { .. }
| C::DetectFromDxf { .. }
| C::DetectFromCamera { .. }
// Analyzer instance lifecycle (runtime, not SceneConfig state).
| C::RequestAnalyzer { .. }
| C::ReleaseAnalyzer { .. }
| C::AddAnalyzerModSource { .. }
| C::UpdateAnalyzerSmoothing { .. }
// Device scanning / MIDI mappings (device config, not scene).
| C::RescanNdi
| C::RescanSyphon
| C::RescanCameras
| C::RescanMidi
| C::SetMidiDeviceEnabled { .. }
| C::ClearMidiMappings
| C::RemoveMidiMapping { .. }
// Clock preference / manual BPM (live sync config).
| C::SetClockPreference { .. }
| C::SetManualBpm { .. }
// Global engine settings / profiling.
| C::SetRenderResolution { .. }
| C::SetTargetFps { .. }
| C::StartPerfProfile { .. }
// Persistence, history control, and shutdown are never undoable.
| C::SaveWorkspace
| C::LoadWorkspace
| C::Undo
| C::Redo
| C::Shutdown
)
}

#[cfg(test)]
mod tests {
use super::command_is_undoable;
use crate::engine::EngineCommand as C;

#[test]
fn authoring_commands_are_undoable() {
assert!(command_is_undoable(&C::AddChannel));
assert!(command_is_undoable(&C::RemoveChannel { channel_idx: 0 }));
assert!(command_is_undoable(&C::SetChannelOpacity {
channel_idx: 0,
opacity: 0.5,
}));
assert!(command_is_undoable(&C::SetParam {
path: "deck/abc/opacity".into(),
value: crate::engine::ParamValue::Float(0.5),
}));
assert!(command_is_undoable(&C::RemoveSurface { uuid: "s".into() }));
// Surface→output assignment is authoring and must be undoable.
assert!(command_is_undoable(&C::AssignSurfaceToOutputByIdx {
output_idx: 0,
surface_uuid: "s".into(),
}));
}

#[test]
fn live_and_transient_commands_are_not_undoable() {
assert!(!command_is_undoable(&C::SetCrossfader(0.5)));
assert!(!command_is_undoable(&C::VideoTogglePlay {
channel_idx: 0,
deck_idx: 0,
}));
assert!(!command_is_undoable(&C::PlaySequence { idx: 0 }));
assert!(!command_is_undoable(&C::StartOutput { idx: 0 }));
assert!(!command_is_undoable(&C::CreateOutput));
assert!(!command_is_undoable(&C::Undo));
assert!(!command_is_undoable(&C::Redo));
assert!(!command_is_undoable(&C::SaveWorkspace));
assert!(!command_is_undoable(&C::Shutdown));
}
}
14 changes: 14 additions & 0 deletions src/app/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,20 @@ impl HistoryManager {
}
}

/// Result of a successful undo/redo restore, returned to the caller.
///
/// A windowed consumer uses `structural_changed` to decide whether to
/// re-register GPU preview textures and reads dome layout flags off the
/// restored `snapshot`'s stage half. The headless/API consumer only needs to
/// know a restore happened (`Some(_)` vs `None`).
pub struct HistoryRestore {
/// The state that was restored onto live engine state.
pub snapshot: HistorySnapshot,
/// True if the scene diff changed deck/channel structure (GPU resources
/// were rebuilt, so preview textures must be re-registered).
pub structural_changed: bool,
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down
73 changes: 66 additions & 7 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,15 @@ impl VardaApp {
/// is handled. Adding a new variant requires wiring it here.
pub fn process_commands(&mut self) {
while let Ok((cmd, reply_tx)) = self.bus.command_rx.try_recv() {
// Record pre-mutation state so bus-driven (HTTP API / WebSocket /
// CLI / MIDI-issued) edits are undoable on the same timeline the
// windowed UI uses. In-process UI mutations do NOT flow through the
// bus — the windowed runner records those itself — so there is no
// double-record here. See commands::command_is_undoable.
if commands::command_is_undoable(&cmd) {
let snapshot = self.history_snapshot_default();
self.push_history(snapshot);
}
let result = self.execute_command(cmd);
if let Some(tx) = reply_tx {
let _ = tx.send(result);
Expand Down Expand Up @@ -1064,23 +1073,73 @@ mod tests {
assert!((state.mixer.channels[0].opacity - 0.5).abs() < 1e-5);
}

/// Regression test for the previously-dead API undo/redo path: bus-driven
/// (HTTP API / headless) commands must record onto the shared timeline so
/// `Undo`/`Redo` sent over the same bus actually restore state.
#[test]
fn smoke_undo_redo_roundtrip() {
fn api_command_undo_redo_roundtrip() {
let Some(mut app) = headless_app() else {
return;
};
let tx = app.command_sender();
// Set crossfader to 0.5 (will trigger history push)
tx.send((crate::engine::EngineCommand::SetCrossfader(0.5), None))
.unwrap();

// Baseline: no history yet, nothing to undo.
assert!(!app.history_can_undo(), "fresh app has empty undo stack");
let original = app.build_engine_state().mixer.channels[0].opacity;

// An undoable, bus-driven edit records a pre-mutation snapshot.
let new_opacity = if (original - 0.5).abs() < 1e-3 {
0.25
} else {
0.5
};
tx.send((
crate::engine::EngineCommand::SetChannelOpacity {
channel_idx: 0,
opacity: new_opacity,
},
None,
))
.unwrap();
app.process_commands();
// Undo
assert!(
app.history_can_undo(),
"undoable API command should record history"
);
assert!((app.build_engine_state().mixer.channels[0].opacity - new_opacity).abs() < 1e-5);

// Undo over the bus restores the pre-edit opacity.
tx.send((crate::engine::EngineCommand::Undo, None)).unwrap();
app.process_commands();
// Redo
assert!(
(app.build_engine_state().mixer.channels[0].opacity - original).abs() < 1e-5,
"API undo must restore the pre-command state"
);
assert!(app.history_can_redo(), "after undo, redo is available");

// Redo re-applies the edit.
tx.send((crate::engine::EngineCommand::Redo, None)).unwrap();
app.process_commands();
// Just verify no crash — undo/redo correctness is tested in history.rs
assert!(
(app.build_engine_state().mixer.channels[0].opacity - new_opacity).abs() < 1e-5,
"API redo must re-apply the command"
);
}

/// Live-control commands (crossfader) must NOT pollute the undo timeline.
#[test]
fn live_control_commands_do_not_record_history() {
let Some(mut app) = headless_app() else {
return;
};
let tx = app.command_sender();
tx.send((crate::engine::EngineCommand::SetCrossfader(0.5), None))
.unwrap();
app.process_commands();
assert!(
!app.history_can_undo(),
"crossfader is a live control and must not record history"
);
}

#[test]
Expand Down
2 changes: 2 additions & 0 deletions src/app/snapshot.rs
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,8 @@ pub(crate) fn build_engine_state(app: &VardaApp) -> EngineState {
syphon_available: false,
stream_receivers: build_stream_receiver_snapshots(app),
analyzers: app.available_analyzers(),
can_undo: app.history_can_undo(),
can_redo: app.history_can_redo(),
}
}

Expand Down
Loading