Skip to main content

maolan_engine/
history.rs

1use crate::{
2    audio::io::AudioIO,
3    engine::parse_automation_lanes,
4    kind::Kind,
5    message::{Action, ClipMoveFrom, ClipMoveTo},
6    midi::io::MIDIIO,
7    state::State,
8};
9use std::collections::{HashSet, VecDeque};
10use std::sync::Arc;
11use std::sync::atomic::Ordering;
12
13fn audio_clip_to_data(clip: &crate::audio::clip::AudioClip) -> crate::message::AudioClipData {
14    crate::message::AudioClipData {
15        id: clip.id.clone(),
16        name: clip.name.clone(),
17        start: clip.start,
18        length: clip.end.saturating_sub(clip.start).max(1),
19        offset: clip.offset,
20        input_channel: clip.input_channel,
21        muted: clip.muted,
22        peaks_file: clip.peaks_file.clone(),
23        fade_enabled: clip.fade_enabled,
24        fade_in_samples: clip.fade_in_samples,
25        fade_out_samples: clip.fade_out_samples,
26        preview_name: clip.pitch_correction_preview_name.clone(),
27        source_name: clip.pitch_correction_source_name.clone(),
28        source_offset: clip.pitch_correction_source_offset,
29        source_length: clip.pitch_correction_source_length,
30        pitch_correction_points: clip.pitch_correction_points.clone(),
31        pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
32        pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
33        pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
34        plugin_graph_json: clip.plugin_graph_json.clone(),
35        grouped_clips: clip.grouped_clips.iter().map(audio_clip_to_data).collect(),
36    }
37}
38
39fn midi_clip_to_data(clip: &crate::midi::clip::MIDIClip) -> crate::message::MidiClipData {
40    crate::message::MidiClipData {
41        id: clip.id.clone(),
42        name: clip.name.clone(),
43        start: clip.start,
44        length: clip.end.saturating_sub(clip.start).max(1),
45        offset: clip.offset,
46        input_channel: clip.input_channel,
47        muted: clip.muted,
48        grouped_clips: clip.grouped_clips.iter().map(midi_clip_to_data).collect(),
49    }
50}
51
52#[derive(Clone, Debug)]
53pub struct UndoEntry {
54    pub forward_actions: Vec<Action>,
55    pub inverse_actions: Vec<Action>,
56}
57
58pub struct History {
59    undo_stack: VecDeque<UndoEntry>,
60    redo_stack: VecDeque<UndoEntry>,
61    max_history: usize,
62    save_point: Option<usize>,
63}
64
65impl History {
66    pub fn new(max_history: usize) -> Self {
67        Self {
68            undo_stack: VecDeque::new(),
69            redo_stack: VecDeque::new(),
70            max_history,
71            save_point: None,
72        }
73    }
74
75    pub fn mark_save_point(&mut self) {
76        self.save_point = Some(self.undo_stack.len());
77    }
78
79    pub fn is_dirty(&self) -> bool {
80        match self.save_point {
81            Some(point) => self.undo_stack.len() != point,
82            None => !self.undo_stack.is_empty(),
83        }
84    }
85
86    pub fn record(&mut self, entry: UndoEntry) {
87        self.undo_stack.push_back(entry);
88        self.redo_stack.clear();
89
90        if self.undo_stack.len() > self.max_history {
91            self.undo_stack.pop_front();
92        }
93    }
94
95    pub fn undo(&mut self) -> Option<Vec<Action>> {
96        self.undo_stack.pop_back().map(|entry| {
97            let inverse = entry.inverse_actions.clone();
98            self.redo_stack.push_back(entry);
99            inverse
100        })
101    }
102
103    pub fn redo(&mut self) -> Option<Vec<Action>> {
104        self.redo_stack.pop_back().map(|entry| {
105            let forward = entry.forward_actions.clone();
106            self.undo_stack.push_back(entry);
107            forward
108        })
109    }
110
111    pub fn clear(&mut self) {
112        self.undo_stack.clear();
113        self.redo_stack.clear();
114    }
115}
116
117impl Default for History {
118    fn default() -> Self {
119        Self::new(100)
120    }
121}
122
123pub fn should_record(action: &Action) -> bool {
124    match action {
125        Action::SetTempo(_)
126        | Action::SetLoopEnabled(_)
127        | Action::SetLoopRange(_)
128        | Action::SetPunchEnabled(_)
129        | Action::SetPunchRange(_)
130        | Action::SetMetronomeEnabled(_)
131        | Action::SetTimeSignature { .. }
132        | Action::SetTempoMap { .. }
133        | Action::SetModulators(_)
134        | Action::SetTrackAutomationLanes { .. }
135        | Action::TrackAutomationToggleLane { .. }
136        | Action::TrackAutomationInsertPoint { .. }
137        | Action::TrackAutomationDeletePoint { .. }
138        | Action::TrackAutomationSetMode { .. }
139        | Action::AddTrack { .. }
140        | Action::RemoveTrack(_)
141        | Action::RenameTrack { .. }
142        | Action::TrackLevel(_, _)
143        | Action::TrackBalance(_, _)
144        | Action::TrackToggleArm(_)
145        | Action::TrackToggleMute(_)
146        | Action::TrackTogglePhase(_)
147        | Action::TrackToggleSolo(_)
148        | Action::TrackToggleInputMonitor { .. }
149        | Action::TrackToggleDiskMonitor { .. }
150        | Action::TrackToggleMidiInputMonitor { .. }
151        | Action::TrackToggleMidiDiskMonitor { .. }
152        | Action::TrackSetColor { .. }
153        | Action::TrackSetMidiLearnBinding { .. }
154        | Action::SetGlobalMidiLearnBinding { .. }
155        | Action::SetSessionMidiLearnBinding { .. }
156        | Action::TrackSetFrozen { .. }
157        | Action::TrackSetSessionSlot { .. }
158        | Action::TrackSetSessionSlotPlayEnabled { .. }
159        | Action::TrackSetFolder { .. }
160        | Action::TrackSetParent { .. }
161        | Action::TrackToggleFolder { .. }
162        | Action::TrackToggleMaster(_)
163        | Action::TrackAddAudioInput(_)
164        | Action::TrackAddAudioOutput(_)
165        | Action::TrackRemoveAudioInput(_)
166        | Action::TrackRemoveAudioOutput(_)
167        | Action::AddClip { .. }
168        | Action::AddGroupedClip { .. }
169        | Action::RemoveClip { .. }
170        | Action::RenameClip { .. }
171        | Action::ClipMove { .. }
172        | Action::SetClipFade { .. }
173        | Action::SetClipBounds { .. }
174        | Action::SetClipMuted { .. }
175        | Action::SetClipSourceName { .. }
176        | Action::SetClipPluginGraphJson { .. }
177        | Action::SetClipPitchCorrection { .. }
178        | Action::ClearAllMidiLearnBindings
179        | Action::Connect { .. }
180        | Action::Disconnect { .. }
181        | Action::TrackConnectVst3Audio { .. }
182        | Action::TrackDisconnectVst3Audio { .. }
183        | Action::TrackLoadClapPlugin { .. }
184        | Action::TrackUnloadClapPlugin { .. }
185        | Action::TrackUnloadClapPluginInstance { .. }
186        | Action::TrackLoadVst3Plugin { .. }
187        | Action::TrackUnloadVst3PluginInstance { .. }
188        | Action::TrackSetClapParameter { .. }
189        | Action::ClipSetClapParameter { .. }
190        | Action::TrackSetVst3Parameter { .. }
191        | Action::TrackSetPluginBypassed { .. }
192        | Action::ModifyMidiNotes { .. }
193        | Action::ModifyMidiControllers { .. }
194        | Action::DeleteMidiControllers { .. }
195        | Action::InsertMidiControllers { .. }
196        | Action::DeleteMidiNotes { .. }
197        | Action::InsertMidiNotes { .. }
198        | Action::SetMidiSysExEvents { .. } => true,
199        Action::TrackConnectPluginAudio { .. }
200        | Action::TrackDisconnectPluginAudio { .. }
201        | Action::TrackConnectPluginMidi { .. }
202        | Action::TrackDisconnectPluginMidi { .. }
203        | Action::TrackConnectAudio { .. }
204        | Action::TrackDisconnectAudio { .. }
205        | Action::TrackConnectMidi { .. }
206        | Action::TrackDisconnectMidi { .. } => true,
207        #[cfg(all(unix, not(target_os = "macos")))]
208        Action::TrackLoadLv2Plugin { .. }
209        | Action::TrackUnloadLv2PluginInstance { .. }
210        | Action::TrackSetLv2ControlValue { .. } => true,
211        _ => false,
212    }
213}
214
215pub fn create_inverse_action(action: &Action, state: &State) -> Option<Action> {
216    match action {
217        Action::AddTrack { name, .. } => Some(Action::RemoveTrack(name.clone())),
218
219        Action::RemoveTrack(name) => {
220            let track = state.tracks.get(name)?;
221            let track_lock = track.lock();
222            Some(Action::AddTrack {
223                name: track_lock.name.clone(),
224                audio_ins: track_lock.primary_audio_ins(),
225                midi_ins: track_lock.midi.ins.len(),
226                audio_outs: track_lock.primary_audio_outs(),
227                midi_outs: track_lock.midi.outs.len(),
228                folder: track_lock.is_folder,
229            })
230        }
231
232        Action::RenameTrack { old_name, new_name } => Some(Action::RenameTrack {
233            old_name: new_name.clone(),
234            new_name: old_name.clone(),
235        }),
236
237        Action::TrackLevel(name, _new_level) => {
238            let track = state.tracks.get(name)?;
239            let track_lock = track.lock();
240            Some(Action::TrackLevel(name.clone(), track_lock.level()))
241        }
242
243        Action::TrackBalance(name, _new_balance) => {
244            let track = state.tracks.get(name)?;
245            let track_lock = track.lock();
246            Some(Action::TrackBalance(name.clone(), track_lock.balance()))
247        }
248
249        Action::TrackToggleArm(name) => Some(Action::TrackToggleArm(name.clone())),
250        Action::TrackToggleMute(name) => Some(Action::TrackToggleMute(name.clone())),
251        Action::TrackTogglePhase(name) => Some(Action::TrackTogglePhase(name.clone())),
252        Action::TrackToggleSolo(name) => Some(Action::TrackToggleSolo(name.clone())),
253        Action::TrackToggleInputMonitor { track_name, lane } => {
254            Some(Action::TrackToggleInputMonitor {
255                track_name: track_name.clone(),
256                lane: *lane,
257            })
258        }
259        Action::TrackToggleDiskMonitor { track_name, lane } => {
260            Some(Action::TrackToggleDiskMonitor {
261                track_name: track_name.clone(),
262                lane: *lane,
263            })
264        }
265        Action::TrackToggleMidiInputMonitor { track_name, lane } => {
266            Some(Action::TrackToggleMidiInputMonitor {
267                track_name: track_name.clone(),
268                lane: *lane,
269            })
270        }
271        Action::TrackToggleMidiDiskMonitor { track_name, lane } => {
272            Some(Action::TrackToggleMidiDiskMonitor {
273                track_name: track_name.clone(),
274                lane: *lane,
275            })
276        }
277        Action::TrackSetColor {
278            track_name,
279            color: _,
280        } => {
281            let track = state.tracks.get(track_name)?;
282            let track_lock = track.lock();
283            Some(Action::TrackSetColor {
284                track_name: track_name.clone(),
285                color: track_lock.color,
286            })
287        }
288        Action::TrackSetMidiLearnBinding {
289            track_name, target, ..
290        } => {
291            let track = state.tracks.get(track_name)?;
292            let track_lock = track.lock();
293            let binding = match target {
294                crate::message::TrackMidiLearnTarget::Volume => {
295                    track_lock.midi_learn_volume.clone()
296                }
297                crate::message::TrackMidiLearnTarget::Balance => {
298                    track_lock.midi_learn_balance.clone()
299                }
300                crate::message::TrackMidiLearnTarget::Mute => track_lock.midi_learn_mute.clone(),
301                crate::message::TrackMidiLearnTarget::Solo => track_lock.midi_learn_solo.clone(),
302                crate::message::TrackMidiLearnTarget::Arm => track_lock.midi_learn_arm.clone(),
303                crate::message::TrackMidiLearnTarget::InputMonitor => {
304                    track_lock.midi_learn_input_monitor.clone()
305                }
306                crate::message::TrackMidiLearnTarget::DiskMonitor => {
307                    track_lock.midi_learn_disk_monitor.clone()
308                }
309            };
310            Some(Action::TrackSetMidiLearnBinding {
311                track_name: track_name.clone(),
312                target: *target,
313                binding,
314            })
315        }
316        Action::TrackSetFrozen { track_name, .. } => {
317            let track = state.tracks.get(track_name)?;
318            let track_lock = track.lock();
319            Some(Action::TrackSetFrozen {
320                track_name: track_name.clone(),
321                frozen: track_lock.frozen(),
322            })
323        }
324        Action::TrackSetSessionSlot {
325            track_name,
326            scene_index,
327            clip_id: _,
328        } => {
329            let track = state.tracks.get(track_name)?;
330            let track_lock = track.lock();
331            Some(Action::TrackSetSessionSlot {
332                track_name: track_name.clone(),
333                scene_index: *scene_index,
334                clip_id: track_lock
335                    .rt
336                    .session_slots
337                    .get(scene_index)
338                    .map(|slot| slot.clip_id.clone()),
339            })
340        }
341        Action::TrackSetSessionSlotPlayEnabled {
342            track_name,
343            scene_index,
344            enabled: _,
345        } => {
346            let track = state.tracks.get(track_name)?;
347            let track_lock = track.lock();
348            let enabled = track_lock
349                .rt
350                .session_slots
351                .get(scene_index)
352                .map(|slot| slot.play_enabled)
353                .unwrap_or(true);
354            Some(Action::TrackSetSessionSlotPlayEnabled {
355                track_name: track_name.clone(),
356                scene_index: *scene_index,
357                enabled,
358            })
359        }
360        Action::TrackSetFolder {
361            track_name,
362            is_folder: _,
363        } => {
364            let track = state.tracks.get(track_name)?;
365            let track_lock = track.lock();
366            Some(Action::TrackSetFolder {
367                track_name: track_name.clone(),
368                is_folder: track_lock.is_folder,
369            })
370        }
371        Action::TrackSetParent {
372            track_name,
373            parent_name: _,
374        } => {
375            let track = state.tracks.get(track_name)?;
376            let track_lock = track.lock();
377            Some(Action::TrackSetParent {
378                track_name: track_name.clone(),
379                parent_name: track_lock.parent_track.clone(),
380            })
381        }
382        Action::TrackToggleFolder { track_name } => Some(Action::TrackToggleFolder {
383            track_name: track_name.clone(),
384        }),
385        Action::TrackToggleMaster(track_name) => {
386            Some(Action::TrackToggleMaster(track_name.clone()))
387        }
388        Action::TrackAddAudioInput(name) => Some(Action::TrackRemoveAudioInput(name.clone())),
389        Action::TrackAddAudioOutput(name) => Some(Action::TrackRemoveAudioOutput(name.clone())),
390        Action::TrackRemoveAudioInput(name) => Some(Action::TrackAddAudioInput(name.clone())),
391        Action::TrackRemoveAudioOutput(name) => Some(Action::TrackAddAudioOutput(name.clone())),
392
393        Action::AddClip {
394            track_name, kind, ..
395        } => {
396            let track = state.tracks.get(track_name)?;
397            let track_lock = track.lock();
398            let clip_index = match kind {
399                Kind::Audio => track_lock.audio.clips().len(),
400                Kind::MIDI => track_lock.midi.clips().len(),
401            };
402            Some(Action::RemoveClip {
403                track_name: track_name.clone(),
404                kind: *kind,
405                clip_indices: vec![clip_index],
406            })
407        }
408
409        Action::AddGroupedClip {
410            track_name, kind, ..
411        } => {
412            let track = state.tracks.get(track_name)?;
413            let track_lock = track.lock();
414            let clip_index = match kind {
415                Kind::Audio => track_lock.audio.clips().len(),
416                Kind::MIDI => track_lock.midi.clips().len(),
417            };
418            Some(Action::RemoveClip {
419                track_name: track_name.clone(),
420                kind: *kind,
421                clip_indices: vec![clip_index],
422            })
423        }
424
425        Action::RemoveClip {
426            track_name,
427            kind,
428            clip_indices,
429        } => {
430            let track = state.tracks.get(track_name)?;
431            let track_lock = track.lock();
432
433            if clip_indices.len() != 1 {
434                return None;
435            }
436
437            let clip_idx = clip_indices[0];
438            match kind {
439                Kind::Audio => {
440                    let clip = track_lock.audio.clips().get(clip_idx).cloned()?;
441                    if clip.grouped_clips.is_empty() {
442                        let length = clip.end.saturating_sub(clip.start);
443                        Some(Action::AddClip {
444                            clip_id: clip.id.clone(),
445                            name: clip.name.clone(),
446                            track_name: track_name.clone(),
447                            start: clip.start,
448                            length,
449                            offset: clip.offset,
450                            input_channel: clip.input_channel,
451                            muted: clip.muted,
452                            peaks_file: clip.peaks_file.clone(),
453                            kind: Kind::Audio,
454                            fade_enabled: clip.fade_enabled,
455                            fade_in_samples: clip.fade_in_samples,
456                            fade_out_samples: clip.fade_out_samples,
457                            source_name: clip.pitch_correction_source_name.clone(),
458                            source_offset: clip.pitch_correction_source_offset,
459                            source_length: clip.pitch_correction_source_length,
460                            preview_name: clip.pitch_correction_preview_name.clone(),
461                            pitch_correction_points: clip.pitch_correction_points.clone(),
462                            pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
463                            pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
464                            pitch_correction_formant_compensation: clip
465                                .pitch_correction_formant_compensation,
466                            plugin_graph_json: clip.plugin_graph_json.clone(),
467                        })
468                    } else {
469                        Some(Action::AddGroupedClip {
470                            track_name: track_name.clone(),
471                            kind: Kind::Audio,
472                            audio_clip: Some(audio_clip_to_data(&clip)),
473                            midi_clip: None,
474                        })
475                    }
476                }
477                Kind::MIDI => {
478                    let clip = track_lock.midi.clips().get(clip_idx).cloned()?;
479                    if clip.grouped_clips.is_empty() {
480                        let length = clip.end.saturating_sub(clip.start);
481                        Some(Action::AddClip {
482                            clip_id: clip.id.clone(),
483                            name: clip.name.clone(),
484                            track_name: track_name.clone(),
485                            start: clip.start,
486                            length,
487                            offset: clip.offset,
488                            input_channel: clip.input_channel,
489                            muted: clip.muted,
490                            peaks_file: None,
491                            kind: Kind::MIDI,
492                            fade_enabled: true,
493                            fade_in_samples: 240,
494                            fade_out_samples: 240,
495                            source_name: None,
496                            source_offset: None,
497                            source_length: None,
498                            preview_name: None,
499                            pitch_correction_points: vec![],
500                            pitch_correction_frame_likeness: None,
501                            pitch_correction_inertia_ms: None,
502                            pitch_correction_formant_compensation: None,
503                            plugin_graph_json: None,
504                        })
505                    } else {
506                        Some(Action::AddGroupedClip {
507                            track_name: track_name.clone(),
508                            kind: Kind::MIDI,
509                            audio_clip: None,
510                            midi_clip: Some(midi_clip_to_data(&clip)),
511                        })
512                    }
513                }
514            }
515        }
516
517        Action::RenameClip {
518            track_name,
519            kind,
520            clip_index,
521            new_name: _,
522        } => {
523            let track = state.tracks.get(track_name)?;
524            let track_lock = track.lock();
525            let old_name = match kind {
526                Kind::Audio => track_lock
527                    .audio
528                    .clips()
529                    .get(*clip_index)
530                    .cloned()?
531                    .name
532                    .clone(),
533                Kind::MIDI => track_lock
534                    .midi
535                    .clips()
536                    .get(*clip_index)
537                    .cloned()?
538                    .name
539                    .clone(),
540            };
541            Some(Action::RenameClip {
542                track_name: track_name.clone(),
543                kind: *kind,
544                clip_index: *clip_index,
545                new_name: old_name,
546            })
547        }
548
549        Action::ClipMove {
550            kind,
551            from,
552            to,
553            copy,
554        } => {
555            let (original_start, original_input_channel) = {
556                let source_track = state.tracks.get(&from.track_name)?;
557                let source_lock = source_track.lock();
558                match kind {
559                    Kind::Audio => {
560                        let clip = source_lock.audio.clips().get(from.clip_index).cloned()?;
561                        (clip.start, clip.input_channel)
562                    }
563                    Kind::MIDI => {
564                        let clip = source_lock.midi.clips().get(from.clip_index).cloned()?;
565                        (clip.start, clip.input_channel)
566                    }
567                }
568            };
569
570            if *copy {
571                let dest_track = state.tracks.get(&to.track_name)?;
572                let dest_lock = dest_track.lock();
573                let clip_idx = match kind {
574                    Kind::Audio => dest_lock.audio.clips().len(),
575                    Kind::MIDI => dest_lock.midi.clips().len(),
576                };
577                Some(Action::RemoveClip {
578                    track_name: to.track_name.clone(),
579                    kind: *kind,
580                    clip_indices: vec![clip_idx],
581                })
582            } else {
583                let dest_track = state.tracks.get(&to.track_name)?;
584                let dest_lock = dest_track.lock();
585                let dest_len = match kind {
586                    Kind::Audio => {
587                        if dest_lock.audio.clips().is_empty() {
588                            return None;
589                        }
590                        dest_lock.audio.clips().len()
591                    }
592                    Kind::MIDI => {
593                        if dest_lock.midi.clips().is_empty() {
594                            return None;
595                        }
596                        dest_lock.midi.clips().len()
597                    }
598                };
599                let moved_clip_index = if from.track_name == to.track_name {
600                    dest_len.saturating_sub(1)
601                } else {
602                    dest_len
603                };
604                Some(Action::ClipMove {
605                    kind: *kind,
606                    from: ClipMoveFrom {
607                        track_name: to.track_name.clone(),
608                        clip_index: moved_clip_index,
609                    },
610                    to: ClipMoveTo {
611                        track_name: from.track_name.clone(),
612                        sample_offset: original_start,
613                        input_channel: original_input_channel,
614                    },
615                    copy: false,
616                })
617            }
618        }
619
620        Action::SetClipFade {
621            track_name,
622            clip_index,
623            kind,
624            ..
625        } => {
626            let track = state.tracks.get(track_name)?;
627            let track_lock = track.lock();
628            match kind {
629                Kind::Audio => {
630                    let clip = track_lock.audio.clips().get(*clip_index).cloned()?;
631                    Some(Action::SetClipFade {
632                        track_name: track_name.clone(),
633                        clip_index: *clip_index,
634                        kind: *kind,
635                        fade_enabled: clip.fade_enabled,
636                        fade_in_samples: clip.fade_in_samples,
637                        fade_out_samples: clip.fade_out_samples,
638                    })
639                }
640                Kind::MIDI => Some(Action::SetClipFade {
641                    track_name: track_name.clone(),
642                    clip_index: *clip_index,
643                    kind: *kind,
644                    fade_enabled: true,
645                    fade_in_samples: 240,
646                    fade_out_samples: 240,
647                }),
648            }
649        }
650        Action::SetClipBounds {
651            track_name,
652            clip_index,
653            kind,
654            ..
655        } => {
656            let track = state.tracks.get(track_name)?;
657            let track_lock = track.lock();
658            match kind {
659                Kind::Audio => {
660                    let clip = track_lock.audio.clips().get(*clip_index).cloned()?;
661                    Some(Action::SetClipBounds {
662                        track_name: track_name.clone(),
663                        clip_index: *clip_index,
664                        kind: *kind,
665                        start: clip.start,
666                        length: clip.end.saturating_sub(clip.start).max(1),
667                        offset: clip.offset,
668                    })
669                }
670                Kind::MIDI => {
671                    let clip = track_lock.midi.clips().get(*clip_index).cloned()?;
672                    Some(Action::SetClipBounds {
673                        track_name: track_name.clone(),
674                        clip_index: *clip_index,
675                        kind: *kind,
676                        start: clip.start,
677                        length: clip.end.saturating_sub(clip.start).max(1),
678                        offset: clip.offset,
679                    })
680                }
681            }
682        }
683        Action::SetClipMuted {
684            track_name,
685            clip_index,
686            kind,
687            ..
688        } => {
689            let track = state.tracks.get(track_name)?;
690            let track_lock = track.lock();
691            let muted = match kind {
692                Kind::Audio => track_lock.audio.clips().get(*clip_index).cloned()?.muted,
693                Kind::MIDI => track_lock.midi.clips().get(*clip_index).cloned()?.muted,
694            };
695            Some(Action::SetClipMuted {
696                track_name: track_name.clone(),
697                clip_index: *clip_index,
698                kind: *kind,
699                muted,
700            })
701        }
702        Action::SetClipSourceName {
703            track_name,
704            clip_index,
705            kind,
706            ..
707        } => {
708            let track = state.tracks.get(track_name)?;
709            let track_lock = track.lock();
710            let name = match kind {
711                Kind::Audio => track_lock
712                    .audio
713                    .clips()
714                    .get(*clip_index)
715                    .cloned()?
716                    .name
717                    .clone(),
718                Kind::MIDI => track_lock
719                    .midi
720                    .clips()
721                    .get(*clip_index)
722                    .cloned()?
723                    .name
724                    .clone(),
725            };
726            Some(Action::SetClipSourceName {
727                track_name: track_name.clone(),
728                kind: *kind,
729                clip_index: *clip_index,
730                name,
731            })
732        }
733        Action::SetClipPitchCorrection {
734            track_name,
735            clip_index,
736            ..
737        } => {
738            let track = state.tracks.get(track_name)?;
739            let track_lock = track.lock();
740            let clip = track_lock.audio.clips().get(*clip_index).cloned()?;
741            Some(Action::SetClipPitchCorrection {
742                track_name: track_name.clone(),
743                clip_index: *clip_index,
744                preview_name: clip.pitch_correction_preview_name.clone(),
745                source_name: clip.pitch_correction_source_name.clone(),
746                source_offset: clip.pitch_correction_source_offset,
747                source_length: clip.pitch_correction_source_length,
748                pitch_correction_points: clip.pitch_correction_points.clone(),
749                pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
750                pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
751                pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
752            })
753        }
754        Action::SetClipPluginGraphJson {
755            track_name,
756            clip_index,
757            plugin_graph_json: _,
758        } => {
759            let track = state.tracks.get(track_name)?;
760            let track_lock = track.lock();
761            let plugin_graph_json = track_lock
762                .audio
763                .clips()
764                .get(*clip_index)
765                .map(|clip| clip.plugin_graph_json.clone())
766                .unwrap_or_default();
767            Some(Action::SetClipPluginGraphJson {
768                track_name: track_name.clone(),
769                clip_index: *clip_index,
770                plugin_graph_json,
771            })
772        }
773        Action::SetTrackAutomationLanes {
774            track_name,
775            lanes: _,
776            mode: _,
777        } => {
778            let track = state.tracks.get(track_name)?;
779            let track_lock = track.lock();
780            Some(Action::SetTrackAutomationLanes {
781                track_name: track_name.clone(),
782                lanes: track_lock.automation_lanes.clone(),
783                mode: track_lock.automation_mode(),
784            })
785        }
786        Action::TrackAutomationToggleLane { track_name, target } => {
787            let track = state.tracks.get(track_name)?;
788            let track_lock = track.lock();
789            let _lanes = parse_automation_lanes(&track_lock.automation_lanes);
790            Some(Action::TrackAutomationToggleLane {
791                track_name: track_name.clone(),
792                target: target.clone(),
793            })
794        }
795        Action::TrackAutomationInsertPoint {
796            track_name,
797            target,
798            sample,
799            value: _,
800        } => {
801            let track = state.tracks.get(track_name)?;
802            let track_lock = track.lock();
803            let lanes = parse_automation_lanes(&track_lock.automation_lanes);
804            let old_value = lanes
805                .iter()
806                .find(|lane| lane.target == *target)
807                .and_then(|lane| lane.points.iter().find(|p| p.sample == *sample))
808                .map(|p| p.value);
809            match old_value {
810                Some(value) => Some(Action::TrackAutomationInsertPoint {
811                    track_name: track_name.clone(),
812                    target: target.clone(),
813                    sample: *sample,
814                    value,
815                }),
816                None => Some(Action::TrackAutomationDeletePoint {
817                    track_name: track_name.clone(),
818                    target: target.clone(),
819                    sample: *sample,
820                }),
821            }
822        }
823        Action::TrackAutomationDeletePoint {
824            track_name,
825            target,
826            sample,
827        } => {
828            let track = state.tracks.get(track_name)?;
829            let track_lock = track.lock();
830            let lanes = parse_automation_lanes(&track_lock.automation_lanes);
831            let old_value = lanes
832                .iter()
833                .find(|lane| lane.target == *target)
834                .and_then(|lane| lane.points.iter().find(|p| p.sample == *sample))
835                .map(|p| p.value);
836            old_value.map(|value| Action::TrackAutomationInsertPoint {
837                track_name: track_name.clone(),
838                target: target.clone(),
839                sample: *sample,
840                value,
841            })
842        }
843        Action::TrackAutomationSetMode {
844            track_name,
845            mode: _,
846        } => {
847            let track = state.tracks.get(track_name)?;
848            let track_lock = track.lock();
849            Some(Action::TrackAutomationSetMode {
850                track_name: track_name.clone(),
851                mode: track_lock.automation_mode(),
852            })
853        }
854        Action::Connect {
855            from_track,
856            from_port,
857            to_track,
858            to_port,
859            kind,
860        } => Some(Action::Disconnect {
861            from_track: from_track.clone(),
862            from_port: *from_port,
863            to_track: to_track.clone(),
864            to_port: *to_port,
865            kind: *kind,
866        }),
867
868        Action::Disconnect {
869            from_track,
870            from_port,
871            to_track,
872            to_port,
873            kind,
874        } => Some(Action::Connect {
875            from_track: from_track.clone(),
876            from_port: *from_port,
877            to_track: to_track.clone(),
878            to_port: *to_port,
879            kind: *kind,
880        }),
881        Action::TrackConnectVst3Audio {
882            track_name,
883            from_node,
884            from_port,
885            to_node,
886            to_port,
887        } => Some(Action::TrackDisconnectVst3Audio {
888            track_name: track_name.clone(),
889            from_node: from_node.clone(),
890            from_port: *from_port,
891            to_node: to_node.clone(),
892            to_port: *to_port,
893        }),
894        Action::TrackDisconnectVst3Audio {
895            track_name,
896            from_node,
897            from_port,
898            to_node,
899            to_port,
900        } => Some(Action::TrackConnectVst3Audio {
901            track_name: track_name.clone(),
902            from_node: from_node.clone(),
903            from_port: *from_port,
904            to_node: to_node.clone(),
905            to_port: *to_port,
906        }),
907        Action::TrackConnectPluginAudio {
908            track_name,
909            from_node,
910            from_port,
911            to_node,
912            to_port,
913        } => Some(Action::TrackDisconnectPluginAudio {
914            track_name: track_name.clone(),
915            from_node: from_node.clone(),
916            from_port: *from_port,
917            to_node: to_node.clone(),
918            to_port: *to_port,
919        }),
920        Action::TrackDisconnectPluginAudio {
921            track_name,
922            from_node,
923            from_port,
924            to_node,
925            to_port,
926        } => Some(Action::TrackConnectPluginAudio {
927            track_name: track_name.clone(),
928            from_node: from_node.clone(),
929            from_port: *from_port,
930            to_node: to_node.clone(),
931            to_port: *to_port,
932        }),
933        Action::TrackConnectPluginMidi {
934            track_name,
935            from_node,
936            from_port,
937            to_node,
938            to_port,
939        } => Some(Action::TrackDisconnectPluginMidi {
940            track_name: track_name.clone(),
941            from_node: from_node.clone(),
942            from_port: *from_port,
943            to_node: to_node.clone(),
944            to_port: *to_port,
945        }),
946        Action::TrackDisconnectPluginMidi {
947            track_name,
948            from_node,
949            from_port,
950            to_node,
951            to_port,
952        } => Some(Action::TrackConnectPluginMidi {
953            track_name: track_name.clone(),
954            from_node: from_node.clone(),
955            from_port: *from_port,
956            to_node: to_node.clone(),
957            to_port: *to_port,
958        }),
959        Action::TrackConnectAudio {
960            track_name,
961            from,
962            from_port,
963            to,
964            to_port,
965        } => Some(Action::TrackDisconnectAudio {
966            track_name: track_name.clone(),
967            from: from.clone(),
968            from_port: *from_port,
969            to: to.clone(),
970            to_port: *to_port,
971        }),
972        Action::TrackDisconnectAudio {
973            track_name,
974            from,
975            from_port,
976            to,
977            to_port,
978        } => Some(Action::TrackConnectAudio {
979            track_name: track_name.clone(),
980            from: from.clone(),
981            from_port: *from_port,
982            to: to.clone(),
983            to_port: *to_port,
984        }),
985        Action::TrackConnectMidi {
986            track_name,
987            from,
988            from_port,
989            to,
990            to_port,
991        } => Some(Action::TrackDisconnectMidi {
992            track_name: track_name.clone(),
993            from: from.clone(),
994            from_port: *from_port,
995            to: to.clone(),
996            to_port: *to_port,
997        }),
998        Action::TrackDisconnectMidi {
999            track_name,
1000            from,
1001            from_port,
1002            to,
1003            to_port,
1004        } => Some(Action::TrackConnectMidi {
1005            track_name: track_name.clone(),
1006            from: from.clone(),
1007            from_port: *from_port,
1008            to: to.clone(),
1009            to_port: *to_port,
1010        }),
1011
1012        Action::TrackLoadClapPlugin { track_name, .. } => {
1013            let track = state.tracks.get(track_name)?;
1014            let track = track.lock();
1015            Some(Action::TrackUnloadClapPluginInstance {
1016                track_name: track_name.clone(),
1017                instance_id: track.next_clap_instance_id.load(Ordering::Relaxed),
1018            })
1019        }
1020        Action::TrackUnloadClapPlugin {
1021            track_name,
1022            plugin_id,
1023        } => Some(Action::TrackLoadClapPlugin {
1024            track_name: track_name.clone(),
1025            plugin_id: plugin_id.clone(),
1026            instance_id: None,
1027        }),
1028
1029        Action::TrackLoadVst3Plugin { track_name, .. } => {
1030            let track = state.tracks.get(track_name)?;
1031            let track = track.lock();
1032            Some(Action::TrackUnloadVst3PluginInstance {
1033                track_name: track_name.clone(),
1034                instance_id: track.next_vst3_instance_id.load(Ordering::Relaxed),
1035            })
1036        }
1037        #[cfg(all(unix, not(target_os = "macos")))]
1038        Action::TrackLoadLv2Plugin { track_name, .. } => {
1039            let track = state.tracks.get(track_name)?;
1040            let track = track.lock();
1041            Some(Action::TrackUnloadLv2PluginInstance {
1042                track_name: track_name.clone(),
1043                instance_id: track.next_lv2_instance_id.load(Ordering::Relaxed),
1044            })
1045        }
1046        Action::TrackSetClapParameter {
1047            track_name,
1048            instance_id,
1049            ..
1050        } => {
1051            let track = state.tracks.get(track_name)?;
1052            let track = track.lock();
1053            let snapshot = track.clap_snapshot_state(*instance_id).ok()?;
1054            Some(Action::TrackClapRestoreState {
1055                track_name: track_name.clone(),
1056                instance_id: *instance_id,
1057                state: snapshot,
1058            })
1059        }
1060        Action::ClipSetClapParameter {
1061            track_name,
1062            clip_idx,
1063            instance_id,
1064            ..
1065        } => {
1066            let track = state.tracks.get(track_name)?;
1067            let mut track = track.lock();
1068            let (_, snapshot) = track
1069                .clip_clap_snapshot_state(*clip_idx, *instance_id)
1070                .ok()?;
1071            Some(Action::ClipClapRestoreState {
1072                track_name: track_name.clone(),
1073                clip_idx: *clip_idx,
1074                instance_id: *instance_id,
1075                state: snapshot,
1076            })
1077        }
1078        Action::TrackSetVst3Parameter {
1079            track_name,
1080            instance_id,
1081            ..
1082        } => {
1083            let track = state.tracks.get(track_name)?;
1084            let track = track.lock();
1085            let snapshot = track.vst3_snapshot_state(*instance_id).ok()?;
1086            Some(Action::TrackVst3RestoreState {
1087                track_name: track_name.clone(),
1088                instance_id: *instance_id,
1089                state: snapshot,
1090            })
1091        }
1092        Action::TrackSetPluginBypassed {
1093            track_name,
1094            instance_id,
1095            format,
1096            bypassed,
1097        } => {
1098            let track = state.tracks.get(track_name)?;
1099            let track = track.lock();
1100            let current_bypassed = match format.as_str() {
1101                "CLAP" => track
1102                    .clap_plugins
1103                    .iter()
1104                    .find(|i| i.id == *instance_id)
1105                    .map(|i| i.processor.is_bypassed()),
1106                "VST3" => track
1107                    .vst3_plugins
1108                    .iter()
1109                    .find(|i| i.id == *instance_id)
1110                    .map(|i| i.processor.is_bypassed()),
1111                #[cfg(all(unix, not(target_os = "macos")))]
1112                "LV2" => track
1113                    .lv2_plugins
1114                    .iter()
1115                    .find(|i| i.id == *instance_id)
1116                    .map(|i| i.processor.is_bypassed()),
1117                _ => None,
1118            };
1119            Some(Action::TrackSetPluginBypassed {
1120                track_name: track_name.clone(),
1121                instance_id: *instance_id,
1122                format: format.clone(),
1123                bypassed: current_bypassed.unwrap_or(!*bypassed),
1124            })
1125        }
1126        #[cfg(all(unix, not(target_os = "macos")))]
1127        Action::TrackSetLv2ControlValue { .. } => None,
1128        Action::ModifyMidiNotes {
1129            track_name,
1130            clip_index,
1131            note_indices,
1132            new_notes,
1133            old_notes,
1134        } => Some(Action::ModifyMidiNotes {
1135            track_name: track_name.clone(),
1136            clip_index: *clip_index,
1137            note_indices: note_indices.clone(),
1138            new_notes: old_notes.clone(),
1139            old_notes: new_notes.clone(),
1140        }),
1141        Action::ModifyMidiControllers {
1142            track_name,
1143            clip_index,
1144            controller_indices,
1145            new_controllers,
1146            old_controllers,
1147        } => Some(Action::ModifyMidiControllers {
1148            track_name: track_name.clone(),
1149            clip_index: *clip_index,
1150            controller_indices: controller_indices.clone(),
1151            new_controllers: old_controllers.clone(),
1152            old_controllers: new_controllers.clone(),
1153        }),
1154        Action::DeleteMidiControllers {
1155            track_name,
1156            clip_index,
1157            deleted_controllers,
1158            ..
1159        } => Some(Action::InsertMidiControllers {
1160            track_name: track_name.clone(),
1161            clip_index: *clip_index,
1162            controllers: deleted_controllers.clone(),
1163        }),
1164        Action::InsertMidiControllers {
1165            track_name,
1166            clip_index,
1167            controllers,
1168        } => {
1169            let mut controller_indices: Vec<usize> =
1170                controllers.iter().map(|(idx, _)| *idx).collect();
1171            controller_indices.sort_unstable_by(|a, b| b.cmp(a));
1172            Some(Action::DeleteMidiControllers {
1173                track_name: track_name.clone(),
1174                clip_index: *clip_index,
1175                controller_indices,
1176                deleted_controllers: controllers.clone(),
1177            })
1178        }
1179
1180        Action::DeleteMidiNotes {
1181            track_name,
1182            clip_index,
1183            deleted_notes,
1184            ..
1185        } => Some(Action::InsertMidiNotes {
1186            track_name: track_name.clone(),
1187            clip_index: *clip_index,
1188            notes: deleted_notes.clone(),
1189        }),
1190
1191        Action::InsertMidiNotes {
1192            track_name,
1193            clip_index,
1194            notes,
1195        } => {
1196            let mut note_indices: Vec<usize> = notes.iter().map(|(idx, _)| *idx).collect();
1197            note_indices.sort_unstable_by(|a, b| b.cmp(a));
1198            Some(Action::DeleteMidiNotes {
1199                track_name: track_name.clone(),
1200                clip_index: *clip_index,
1201                note_indices,
1202                deleted_notes: notes.clone(),
1203            })
1204        }
1205        Action::SetMidiSysExEvents {
1206            track_name,
1207            clip_index,
1208            new_sysex_events,
1209            old_sysex_events,
1210        } => Some(Action::SetMidiSysExEvents {
1211            track_name: track_name.clone(),
1212            clip_index: *clip_index,
1213            new_sysex_events: old_sysex_events.clone(),
1214            old_sysex_events: new_sysex_events.clone(),
1215        }),
1216
1217        _ => None,
1218    }
1219}
1220
1221pub fn create_inverse_actions(action: &Action, state: &State) -> Option<Vec<Action>> {
1222    if let Action::ClearAllMidiLearnBindings = action {
1223        let mut actions = Vec::<Action>::new();
1224        for (track_name, track) in &state.tracks {
1225            let t = track.lock();
1226            let mut push_if_some =
1227                |target: crate::message::TrackMidiLearnTarget,
1228                 binding: Option<crate::message::MidiLearnBinding>| {
1229                    if binding.is_some() {
1230                        actions.push(Action::TrackSetMidiLearnBinding {
1231                            track_name: track_name.clone(),
1232                            target,
1233                            binding,
1234                        });
1235                    }
1236                };
1237            push_if_some(
1238                crate::message::TrackMidiLearnTarget::Volume,
1239                t.midi_learn_volume.clone(),
1240            );
1241            push_if_some(
1242                crate::message::TrackMidiLearnTarget::Balance,
1243                t.midi_learn_balance.clone(),
1244            );
1245            push_if_some(
1246                crate::message::TrackMidiLearnTarget::Mute,
1247                t.midi_learn_mute.clone(),
1248            );
1249            push_if_some(
1250                crate::message::TrackMidiLearnTarget::Solo,
1251                t.midi_learn_solo.clone(),
1252            );
1253            push_if_some(
1254                crate::message::TrackMidiLearnTarget::Arm,
1255                t.midi_learn_arm.clone(),
1256            );
1257            push_if_some(
1258                crate::message::TrackMidiLearnTarget::InputMonitor,
1259                t.midi_learn_input_monitor.clone(),
1260            );
1261            push_if_some(
1262                crate::message::TrackMidiLearnTarget::DiskMonitor,
1263                t.midi_learn_disk_monitor.clone(),
1264            );
1265        }
1266        return Some(actions);
1267    }
1268
1269    if let Action::TrackUnloadClapPlugin {
1270        track_name,
1271        plugin_id,
1272    } = action
1273    {
1274        let track = state.tracks.get(track_name)?;
1275        let track = track.lock();
1276        let instance = track
1277            .clap_plugins
1278            .iter()
1279            .find(|p| p.processor.plugin_id() == plugin_id)?;
1280        let id = instance.id;
1281        let state_snapshot = instance.processor.snapshot_state().ok()?;
1282        return Some(vec![
1283            Action::TrackLoadClapPlugin {
1284                track_name: track_name.clone(),
1285                plugin_id: plugin_id.clone(),
1286                instance_id: Some(id),
1287            },
1288            Action::TrackClapRestoreState {
1289                track_name: track_name.clone(),
1290                instance_id: id,
1291                state: state_snapshot,
1292            },
1293        ]);
1294    }
1295
1296    if let Action::TrackUnloadClapPluginInstance {
1297        track_name,
1298        instance_id,
1299    } = action
1300    {
1301        let track = state.tracks.get(track_name)?;
1302        let track = track.lock();
1303        let instance = track.clap_plugins.iter().find(|p| p.id == *instance_id)?;
1304        let plugin_id = instance.processor.plugin_id().to_string();
1305        let state_snapshot = instance.processor.snapshot_state().ok()?;
1306        return Some(vec![
1307            Action::TrackLoadClapPlugin {
1308                track_name: track_name.clone(),
1309                plugin_id,
1310                instance_id: Some(*instance_id),
1311            },
1312            Action::TrackClapRestoreState {
1313                track_name: track_name.clone(),
1314                instance_id: *instance_id,
1315                state: state_snapshot,
1316            },
1317        ]);
1318    }
1319
1320    if let Action::TrackUnloadVst3PluginInstance {
1321        track_name,
1322        instance_id,
1323    } = action
1324    {
1325        let track = state.tracks.get(track_name)?;
1326        let track = track.lock();
1327        let instance = track.vst3_plugins.iter().find(|p| p.id == *instance_id)?;
1328        let plugin_id = instance.processor.plugin_id().to_string();
1329        let state_snapshot = instance.processor.snapshot_state().ok()?;
1330        return Some(vec![
1331            Action::TrackLoadVst3Plugin {
1332                track_name: track_name.clone(),
1333                plugin_id,
1334                instance_id: Some(*instance_id),
1335            },
1336            Action::TrackVst3RestoreState {
1337                track_name: track_name.clone(),
1338                instance_id: *instance_id,
1339                state: state_snapshot,
1340            },
1341        ]);
1342    }
1343
1344    #[cfg(all(unix, not(target_os = "macos")))]
1345    if let Action::TrackUnloadLv2PluginInstance {
1346        track_name,
1347        instance_id,
1348    } = action
1349    {
1350        let track = state.tracks.get(track_name)?;
1351        let track = track.lock();
1352        let instance = track.lv2_plugins.iter().find(|p| p.id == *instance_id)?;
1353        let uri = instance.processor.uri().to_string();
1354        let state_snapshot = instance.processor.snapshot_state().ok()?;
1355        return Some(vec![
1356            Action::TrackLoadLv2Plugin {
1357                track_name: track_name.clone(),
1358                plugin_uri: uri,
1359                instance_id: Some(*instance_id),
1360            },
1361            Action::TrackSetLv2PluginState {
1362                track_name: track_name.clone(),
1363                instance_id: *instance_id,
1364                state: state_snapshot,
1365            },
1366        ]);
1367    }
1368
1369    if let Action::RemoveTrack(track_name) = action {
1370        let mut actions = Vec::new();
1371        {
1372            let track = state.tracks.get(track_name)?;
1373            let track = track.lock();
1374            actions.push(Action::AddTrack {
1375                name: track.name.clone(),
1376                audio_ins: track.primary_audio_ins(),
1377                midi_ins: track.midi.ins.len(),
1378                audio_outs: track.primary_audio_outs(),
1379                midi_outs: track.midi.outs.len(),
1380                folder: track.is_folder,
1381            });
1382            if let Some(parent_name) = track.parent_track.as_ref() {
1383                actions.push(Action::TrackSetParent {
1384                    track_name: track.name.clone(),
1385                    parent_name: Some(parent_name.clone()),
1386                });
1387            }
1388            for _ in track.primary_audio_ins()..track.audio.ins.len() {
1389                actions.push(Action::TrackAddAudioInput(track.name.clone()));
1390            }
1391            for _ in track.primary_audio_outs()..track.audio.outs.len() {
1392                actions.push(Action::TrackAddAudioOutput(track.name.clone()));
1393            }
1394
1395            let level = track.level();
1396            if level != 0.0 {
1397                actions.push(Action::TrackLevel(track.name.clone(), level));
1398            }
1399            let balance = track.balance();
1400            if balance != 0.0 {
1401                actions.push(Action::TrackBalance(track.name.clone(), balance));
1402            }
1403            if track.armed() {
1404                actions.push(Action::TrackToggleArm(track.name.clone()));
1405            }
1406            if track.muted() {
1407                actions.push(Action::TrackToggleMute(track.name.clone()));
1408            }
1409            if track.soloed() {
1410                actions.push(Action::TrackToggleSolo(track.name.clone()));
1411            }
1412            for (lane, &monitor) in track.input_monitor().iter().enumerate() {
1413                if monitor {
1414                    actions.push(Action::TrackToggleInputMonitor {
1415                        track_name: track.name.clone(),
1416                        lane,
1417                    });
1418                }
1419            }
1420            for (lane, &monitor) in track.disk_monitor().iter().enumerate() {
1421                if !monitor {
1422                    actions.push(Action::TrackToggleDiskMonitor {
1423                        track_name: track.name.clone(),
1424                        lane,
1425                    });
1426                }
1427            }
1428            for (lane, &monitor) in track.midi_input_monitor().iter().enumerate() {
1429                if monitor {
1430                    actions.push(Action::TrackToggleMidiInputMonitor {
1431                        track_name: track.name.clone(),
1432                        lane,
1433                    });
1434                }
1435            }
1436            for (lane, &monitor) in track.midi_disk_monitor().iter().enumerate() {
1437                if !monitor {
1438                    actions.push(Action::TrackToggleMidiDiskMonitor {
1439                        track_name: track.name.clone(),
1440                        lane,
1441                    });
1442                }
1443            }
1444            if let Some(color) = track.color {
1445                actions.push(Action::TrackSetColor {
1446                    track_name: track.name.clone(),
1447                    color: Some(color),
1448                });
1449            }
1450            if track.midi_learn_volume.is_some() {
1451                actions.push(Action::TrackSetMidiLearnBinding {
1452                    track_name: track.name.clone(),
1453                    target: crate::message::TrackMidiLearnTarget::Volume,
1454                    binding: track.midi_learn_volume.clone(),
1455                });
1456            }
1457            if track.midi_learn_balance.is_some() {
1458                actions.push(Action::TrackSetMidiLearnBinding {
1459                    track_name: track.name.clone(),
1460                    target: crate::message::TrackMidiLearnTarget::Balance,
1461                    binding: track.midi_learn_balance.clone(),
1462                });
1463            }
1464            if track.midi_learn_mute.is_some() {
1465                actions.push(Action::TrackSetMidiLearnBinding {
1466                    track_name: track.name.clone(),
1467                    target: crate::message::TrackMidiLearnTarget::Mute,
1468                    binding: track.midi_learn_mute.clone(),
1469                });
1470            }
1471            if track.midi_learn_solo.is_some() {
1472                actions.push(Action::TrackSetMidiLearnBinding {
1473                    track_name: track.name.clone(),
1474                    target: crate::message::TrackMidiLearnTarget::Solo,
1475                    binding: track.midi_learn_solo.clone(),
1476                });
1477            }
1478            if track.midi_learn_arm.is_some() {
1479                actions.push(Action::TrackSetMidiLearnBinding {
1480                    track_name: track.name.clone(),
1481                    target: crate::message::TrackMidiLearnTarget::Arm,
1482                    binding: track.midi_learn_arm.clone(),
1483                });
1484            }
1485            if track.midi_learn_input_monitor.is_some() {
1486                actions.push(Action::TrackSetMidiLearnBinding {
1487                    track_name: track.name.clone(),
1488                    target: crate::message::TrackMidiLearnTarget::InputMonitor,
1489                    binding: track.midi_learn_input_monitor.clone(),
1490                });
1491            }
1492            if track.midi_learn_disk_monitor.is_some() {
1493                actions.push(Action::TrackSetMidiLearnBinding {
1494                    track_name: track.name.clone(),
1495                    target: crate::message::TrackMidiLearnTarget::DiskMonitor,
1496                    binding: track.midi_learn_disk_monitor.clone(),
1497                });
1498            }
1499            for clip in track.audio.clips().iter() {
1500                let length = clip.end.saturating_sub(clip.start).max(1);
1501                actions.push(Action::AddClip {
1502                    clip_id: clip.id.clone(),
1503                    name: clip.name.clone(),
1504                    track_name: track.name.clone(),
1505                    start: clip.start,
1506                    length,
1507                    offset: clip.offset,
1508                    input_channel: clip.input_channel,
1509                    muted: clip.muted,
1510                    peaks_file: clip.peaks_file.clone(),
1511                    kind: Kind::Audio,
1512                    fade_enabled: clip.fade_enabled,
1513                    fade_in_samples: clip.fade_in_samples,
1514                    fade_out_samples: clip.fade_out_samples,
1515                    source_name: clip.pitch_correction_source_name.clone(),
1516                    source_offset: clip.pitch_correction_source_offset,
1517                    source_length: clip.pitch_correction_source_length,
1518                    preview_name: clip.pitch_correction_preview_name.clone(),
1519                    pitch_correction_points: clip.pitch_correction_points.clone(),
1520                    pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
1521                    pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
1522                    pitch_correction_formant_compensation: clip
1523                        .pitch_correction_formant_compensation,
1524                    plugin_graph_json: clip.plugin_graph_json.clone(),
1525                });
1526            }
1527            for clip in track.midi.clips().iter() {
1528                let length = clip.end.saturating_sub(clip.start).max(1);
1529                actions.push(Action::AddClip {
1530                    clip_id: clip.id.clone(),
1531                    name: clip.name.clone(),
1532                    track_name: track.name.clone(),
1533                    start: clip.start,
1534                    length,
1535                    offset: clip.offset,
1536                    input_channel: clip.input_channel,
1537                    muted: clip.muted,
1538                    peaks_file: None,
1539                    kind: Kind::MIDI,
1540                    fade_enabled: true,
1541                    fade_in_samples: 240,
1542                    fade_out_samples: 240,
1543                    source_name: None,
1544                    source_offset: None,
1545                    source_length: None,
1546                    preview_name: None,
1547                    pitch_correction_points: vec![],
1548                    pitch_correction_frame_likeness: None,
1549                    pitch_correction_inertia_ms: None,
1550                    pitch_correction_formant_compensation: None,
1551                    plugin_graph_json: None,
1552                });
1553            }
1554
1555            for instance in &track.vst3_plugins {
1556                let id = instance.id;
1557                let plugin_id = instance.processor.plugin_id().to_string();
1558                if let Ok(state) = instance.processor.snapshot_state() {
1559                    actions.push(Action::TrackLoadVst3Plugin {
1560                        track_name: track.name.clone(),
1561                        plugin_id,
1562                        instance_id: Some(id),
1563                    });
1564                    actions.push(Action::TrackVst3RestoreState {
1565                        track_name: track.name.clone(),
1566                        instance_id: id,
1567                        state,
1568                    });
1569                }
1570            }
1571
1572            for (id, plugin_id, state) in track.clap_snapshot_all_states() {
1573                actions.push(Action::TrackLoadClapPlugin {
1574                    track_name: track.name.clone(),
1575                    plugin_id,
1576                    instance_id: Some(id),
1577                });
1578                actions.push(Action::TrackClapRestoreState {
1579                    track_name: track.name.clone(),
1580                    instance_id: id,
1581                    state,
1582                });
1583            }
1584
1585            #[cfg(all(unix, not(target_os = "macos")))]
1586            for instance in &track.lv2_plugins {
1587                let id = instance.id;
1588                let uri = instance.processor.uri().to_string();
1589                if let Ok(state) = instance.processor.snapshot_state() {
1590                    actions.push(Action::TrackLoadLv2Plugin {
1591                        track_name: track.name.clone(),
1592                        plugin_uri: uri,
1593                        instance_id: Some(id),
1594                    });
1595                    actions.push(Action::TrackSetLv2PluginState {
1596                        track_name: track.name.clone(),
1597                        instance_id: id,
1598                        state,
1599                    });
1600                }
1601            }
1602
1603            for conn in &track.plugin_midi_connections {
1604                actions.push(Action::TrackConnectPluginMidi {
1605                    track_name: track.name.clone(),
1606                    from_node: conn.from_node.clone(),
1607                    from_port: conn.from_port,
1608                    to_node: conn.to_node.clone(),
1609                    to_port: conn.to_port,
1610                });
1611            }
1612        }
1613
1614        let mut seen_audio = std::collections::HashSet::<(String, usize, String, usize)>::new();
1615        let mut seen_midi = std::collections::HashSet::<(String, usize, String, usize)>::new();
1616
1617        // Parent/child wiring is restored by TrackSetParent, so don't also capture it as a
1618        // generic Connect action (that would create duplicate connections on undo).
1619        let (parent_name, child_names) = {
1620            let track = state.tracks.get(track_name)?;
1621            let track = track.lock();
1622            let parent = track.parent_track.clone();
1623            let children: HashSet<String> = track
1624                .child_tracks
1625                .iter()
1626                .map(|c| c.lock().name.clone())
1627                .collect();
1628            (parent, children)
1629        };
1630        let is_family =
1631            |other: &str| parent_name.as_deref() == Some(other) || child_names.contains(other);
1632
1633        for (from_name, from_track_handle) in &state.tracks {
1634            let from_track = from_track_handle.lock();
1635            for (from_port, out) in from_track.audio.outs.iter().enumerate() {
1636                let conns: Vec<Arc<AudioIO>> = out.connections().to_vec();
1637                for conn in conns {
1638                    for (to_name, to_track_handle) in &state.tracks {
1639                        let to_track = to_track_handle.lock();
1640                        for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
1641                            let other_name = if from_name == track_name {
1642                                to_name
1643                            } else {
1644                                from_name
1645                            };
1646                            if from_name != to_name
1647                                && Arc::ptr_eq(&conn, to_in)
1648                                && (from_name == track_name || to_name == track_name)
1649                                && !is_family(other_name)
1650                                && seen_audio.insert((
1651                                    from_name.to_string(),
1652                                    from_port,
1653                                    to_name.to_string(),
1654                                    to_port,
1655                                ))
1656                            {
1657                                actions.push(Action::Connect {
1658                                    from_track: from_name.to_string(),
1659                                    from_port,
1660                                    to_track: to_name.to_string(),
1661                                    to_port,
1662                                    kind: Kind::Audio,
1663                                });
1664                            }
1665                        }
1666                    }
1667                }
1668            }
1669
1670            for (from_port, out) in from_track.midi.outs.iter().enumerate() {
1671                let conns: Vec<Arc<MIDIIO>> = out.connections();
1672                for conn in conns {
1673                    for (to_name, to_track_handle) in &state.tracks {
1674                        let to_track = to_track_handle.lock();
1675                        for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
1676                            let other_name = if from_name == track_name {
1677                                to_name
1678                            } else {
1679                                from_name
1680                            };
1681                            if from_name != to_name
1682                                && Arc::ptr_eq(&conn, to_in)
1683                                && (from_name == track_name || to_name == track_name)
1684                                && !is_family(other_name)
1685                                && seen_midi.insert((
1686                                    from_name.to_string(),
1687                                    from_port,
1688                                    to_name.to_string(),
1689                                    to_port,
1690                                ))
1691                            {
1692                                actions.push(Action::Connect {
1693                                    from_track: from_name.to_string(),
1694                                    from_port,
1695                                    to_track: to_name.to_string(),
1696                                    to_port,
1697                                    kind: Kind::MIDI,
1698                                });
1699                            }
1700                        }
1701                    }
1702                }
1703            }
1704        }
1705
1706        for (to_name, to_track_handle) in &state.tracks {
1707            if to_name != track_name {
1708                continue;
1709            }
1710            let to_track = to_track_handle.lock();
1711            for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
1712                for (from_name, from_track_handle) in &state.tracks {
1713                    let from_track = from_track_handle.lock();
1714                    for (from_port, out) in from_track.audio.outs.iter().enumerate() {
1715                        let conns: Vec<Arc<AudioIO>> = out.connections().to_vec();
1716                        if from_name != to_name
1717                            && conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
1718                            && !is_family(from_name)
1719                            && seen_audio.insert((
1720                                from_name.to_string(),
1721                                from_port,
1722                                to_name.to_string(),
1723                                to_port,
1724                            ))
1725                        {
1726                            actions.push(Action::Connect {
1727                                from_track: from_name.to_string(),
1728                                from_port,
1729                                to_track: to_name.to_string(),
1730                                to_port,
1731                                kind: Kind::Audio,
1732                            });
1733                        }
1734                    }
1735                }
1736            }
1737            for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
1738                for (from_name, from_track_handle) in &state.tracks {
1739                    let from_track = from_track_handle.lock();
1740                    for (from_port, out) in from_track.midi.outs.iter().enumerate() {
1741                        let conns: Vec<Arc<MIDIIO>> = out.connections();
1742                        if from_name != to_name
1743                            && conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
1744                            && !is_family(from_name)
1745                            && seen_midi.insert((
1746                                from_name.to_string(),
1747                                from_port,
1748                                to_name.to_string(),
1749                                to_port,
1750                            ))
1751                        {
1752                            actions.push(Action::Connect {
1753                                from_track: from_name.to_string(),
1754                                from_port,
1755                                to_track: to_name.to_string(),
1756                                to_port,
1757                                kind: Kind::MIDI,
1758                            });
1759                        }
1760                    }
1761                }
1762            }
1763        }
1764
1765        return Some(actions);
1766    }
1767
1768    create_inverse_action(action, state).map(|a| vec![a])
1769}
1770
1771#[cfg(test)]
1772mod tests {
1773    use super::*;
1774    use crate::audio::clip::AudioClip;
1775    use crate::kind::Kind;
1776
1777    use crate::message::{MidiLearnBinding, TrackMidiLearnTarget};
1778    use crate::plugins::types::Vst3PluginState;
1779    use crate::track::Track;
1780    use std::sync::Arc;
1781
1782    fn make_state_with_track(track: Track) -> State {
1783        let mut state = State::default();
1784        state.tracks.insert(track.name.clone(), Arc::new(track));
1785        state
1786    }
1787
1788    fn binding(cc: u8) -> MidiLearnBinding {
1789        MidiLearnBinding {
1790            device: Some("midi".to_string()),
1791            channel: 1,
1792            cc,
1793        }
1794    }
1795
1796    #[test]
1797    fn history_record_limits_size_and_clears_redo_on_new_entry() {
1798        let mut history = History::new(2);
1799        let a = UndoEntry {
1800            forward_actions: vec![Action::SetTempo(120.0)],
1801            inverse_actions: vec![Action::SetTempo(110.0)],
1802        };
1803        let b = UndoEntry {
1804            forward_actions: vec![Action::SetLoopEnabled(true)],
1805            inverse_actions: vec![Action::SetLoopEnabled(false)],
1806        };
1807        let c = UndoEntry {
1808            forward_actions: vec![Action::SetMetronomeEnabled(true)],
1809            inverse_actions: vec![Action::SetMetronomeEnabled(false)],
1810        };
1811
1812        history.record(a);
1813        history.record(b.clone());
1814        history.record(c.clone());
1815
1816        let undo = history.undo().unwrap();
1817        assert!(matches!(
1818            undo.as_slice(),
1819            [Action::SetMetronomeEnabled(false)]
1820        ));
1821
1822        let redo = history.redo().unwrap();
1823        assert!(matches!(
1824            redo.as_slice(),
1825            [Action::SetMetronomeEnabled(true)]
1826        ));
1827
1828        history.undo();
1829        history.record(UndoEntry {
1830            forward_actions: vec![Action::SetClipPlaybackEnabled(true)],
1831            inverse_actions: vec![Action::SetClipPlaybackEnabled(false)],
1832        });
1833
1834        assert!(history.redo().is_none());
1835        let undo = history.undo().unwrap();
1836        assert!(matches!(
1837            undo.as_slice(),
1838            [Action::SetClipPlaybackEnabled(false)]
1839        ));
1840        let undo = history.undo().unwrap();
1841        assert!(matches!(undo.as_slice(), [Action::SetLoopEnabled(false)]));
1842        assert!(history.undo().is_none());
1843    }
1844
1845    #[test]
1846    fn history_clear_removes_pending_undo_and_redo_entries() {
1847        let mut history = History::new(4);
1848        history.record(UndoEntry {
1849            forward_actions: vec![Action::SetTempo(120.0)],
1850            inverse_actions: vec![Action::SetTempo(100.0)],
1851        });
1852        history.record(UndoEntry {
1853            forward_actions: vec![Action::SetLoopEnabled(true)],
1854            inverse_actions: vec![Action::SetLoopEnabled(false)],
1855        });
1856
1857        assert!(history.undo().is_some());
1858        assert!(history.redo().is_some());
1859
1860        history.clear();
1861
1862        assert!(history.undo().is_none());
1863        assert!(history.redo().is_none());
1864    }
1865
1866    #[test]
1867    fn history_with_zero_capacity_discards_recorded_entries() {
1868        let mut history = History::new(0);
1869        history.record(UndoEntry {
1870            forward_actions: vec![Action::SetTempo(120.0)],
1871            inverse_actions: vec![Action::SetTempo(100.0)],
1872        });
1873
1874        assert!(history.undo().is_none());
1875        assert!(history.redo().is_none());
1876    }
1877
1878    #[test]
1879    fn should_record_covers_recent_transport_and_lv2_actions() {
1880        assert!(should_record(&Action::SetLoopEnabled(true)));
1881        assert!(should_record(&Action::SetLoopRange(Some((64, 128)))));
1882        assert!(should_record(&Action::SetPunchEnabled(true)));
1883        assert!(should_record(&Action::SetPunchRange(Some((32, 96)))));
1884        assert!(should_record(&Action::SetMetronomeEnabled(true)));
1885        assert!(!should_record(&Action::SetClipPlaybackEnabled(false)));
1886        assert!(!should_record(&Action::SetRecordEnabled(true)));
1887        assert!(should_record(&Action::SetClipBounds {
1888            track_name: "t".to_string(),
1889            clip_index: 0,
1890            kind: Kind::Audio,
1891            start: 64,
1892            length: 32,
1893            offset: 16,
1894        }));
1895        assert!(should_record(&Action::TrackLoadVst3Plugin {
1896            track_name: "t".to_string(),
1897            plugin_id: "/tmp/test.vst3".to_string(),
1898            instance_id: None,
1899        }));
1900        #[cfg(all(unix, not(target_os = "macos")))]
1901        {
1902            assert!(should_record(&Action::TrackLoadLv2Plugin {
1903                track_name: "t".to_string(),
1904                plugin_uri: "urn:test".to_string(),
1905                instance_id: None,
1906            }));
1907            assert!(should_record(&Action::TrackSetLv2ControlValue {
1908                track_name: "t".to_string(),
1909                instance_id: 0,
1910                index: 1,
1911                value: 0.5,
1912            }));
1913            assert!(!should_record(&Action::TrackSetLv2PluginState {
1914                track_name: "t".to_string(),
1915                instance_id: 0,
1916                state: vec![],
1917            }));
1918        }
1919        assert!(!should_record(&Action::TrackVst3RestoreState {
1920            track_name: "t".to_string(),
1921            instance_id: 0,
1922            state: Vst3PluginState {
1923                plugin_id: "id".to_string(),
1924                component_state: vec![],
1925                controller_state: vec![],
1926            },
1927        }));
1928    }
1929
1930    #[test]
1931    fn create_inverse_action_for_add_clip_targets_next_clip_index() {
1932        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1933        track
1934            .audio
1935            .push_clip(AudioClip::new("existing".to_string(), 0, 16));
1936        let state = make_state_with_track(track);
1937
1938        let inverse = create_inverse_action(
1939            &Action::AddClip {
1940                clip_id: String::new(),
1941                name: "new".to_string(),
1942                track_name: "t".to_string(),
1943                start: 32,
1944                length: 16,
1945                offset: 0,
1946                input_channel: 0,
1947                muted: false,
1948                peaks_file: None,
1949                kind: Kind::Audio,
1950                fade_enabled: false,
1951                fade_in_samples: 0,
1952                fade_out_samples: 0,
1953                source_name: None,
1954                source_offset: None,
1955                source_length: None,
1956                preview_name: None,
1957                pitch_correction_points: vec![],
1958                pitch_correction_frame_likeness: None,
1959                pitch_correction_inertia_ms: None,
1960                pitch_correction_formant_compensation: None,
1961                plugin_graph_json: None,
1962            },
1963            &state,
1964        )
1965        .unwrap();
1966
1967        match inverse {
1968            Action::RemoveClip {
1969                track_name,
1970                kind,
1971                clip_indices,
1972            } => {
1973                assert_eq!(track_name, "t");
1974                assert_eq!(kind, Kind::Audio);
1975                assert_eq!(clip_indices, vec![1]);
1976            }
1977            other => panic!("unexpected inverse action: {other:?}"),
1978        }
1979    }
1980
1981    #[test]
1982    fn create_inverse_action_for_set_clip_bounds_restores_previous_audio_bounds() {
1983        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1984        let mut clip = AudioClip::new("clip".to_string(), 10, 30);
1985        clip.offset = 7;
1986        track.audio.push_clip(clip);
1987        let state = make_state_with_track(track);
1988
1989        let inverse = create_inverse_action(
1990            &Action::SetClipBounds {
1991                track_name: "t".to_string(),
1992                clip_index: 0,
1993                kind: Kind::Audio,
1994                start: 14,
1995                length: 22,
1996                offset: 11,
1997            },
1998            &state,
1999        )
2000        .expect("inverse action");
2001
2002        match inverse {
2003            Action::SetClipBounds {
2004                track_name,
2005                clip_index,
2006                kind,
2007                start,
2008                length,
2009                offset,
2010            } => {
2011                assert_eq!(track_name, "t");
2012                assert_eq!(clip_index, 0);
2013                assert_eq!(kind, Kind::Audio);
2014                assert_eq!(start, 10);
2015                assert_eq!(length, 20);
2016                assert_eq!(offset, 7);
2017            }
2018            other => panic!("unexpected inverse action: {other:?}"),
2019        }
2020    }
2021
2022    #[test]
2023    fn create_inverse_action_for_set_clip_bounds_restores_previous_midi_bounds() {
2024        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2025        track.midi.push_clip(crate::midi::clip::MIDIClip {
2026            name: "pattern.mid".to_string(),
2027            start: 24,
2028            end: 120,
2029            offset: 9,
2030            ..Default::default()
2031        });
2032        let state = make_state_with_track(track);
2033
2034        let inverse = create_inverse_action(
2035            &Action::SetClipBounds {
2036                track_name: "t".to_string(),
2037                clip_index: 0,
2038                kind: Kind::MIDI,
2039                start: 32,
2040                length: 48,
2041                offset: 4,
2042            },
2043            &state,
2044        )
2045        .expect("inverse action");
2046
2047        match inverse {
2048            Action::SetClipBounds {
2049                track_name,
2050                clip_index,
2051                kind,
2052                start,
2053                length,
2054                offset,
2055            } => {
2056                assert_eq!(track_name, "t");
2057                assert_eq!(clip_index, 0);
2058                assert_eq!(kind, Kind::MIDI);
2059                assert_eq!(start, 24);
2060                assert_eq!(length, 96);
2061                assert_eq!(offset, 9);
2062            }
2063            other => panic!("unexpected inverse action: {other:?}"),
2064        }
2065    }
2066
2067    #[test]
2068    fn create_inverse_action_for_set_clip_muted_restores_audio_and_midi_flags() {
2069        let track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
2070        let mut audio_clip = AudioClip::new("audio.wav".to_string(), 0, 16);
2071        audio_clip.muted = true;
2072        track.audio.push_clip(audio_clip);
2073        let midi_clip = crate::midi::clip::MIDIClip {
2074            name: "pattern.mid".to_string(),
2075            muted: false,
2076            ..Default::default()
2077        };
2078        track.midi.push_clip(midi_clip);
2079        let state = make_state_with_track(track);
2080
2081        let audio_inverse = create_inverse_action(
2082            &Action::SetClipMuted {
2083                track_name: "t".to_string(),
2084                clip_index: 0,
2085                kind: Kind::Audio,
2086                muted: false,
2087            },
2088            &state,
2089        )
2090        .expect("audio inverse");
2091        let midi_inverse = create_inverse_action(
2092            &Action::SetClipMuted {
2093                track_name: "t".to_string(),
2094                clip_index: 0,
2095                kind: Kind::MIDI,
2096                muted: true,
2097            },
2098            &state,
2099        )
2100        .expect("midi inverse");
2101
2102        assert!(matches!(
2103            audio_inverse,
2104            Action::SetClipMuted {
2105                muted: true,
2106                kind: Kind::Audio,
2107                ..
2108            }
2109        ));
2110        assert!(matches!(
2111            midi_inverse,
2112            Action::SetClipMuted {
2113                muted: false,
2114                kind: Kind::MIDI,
2115                ..
2116            }
2117        ));
2118    }
2119
2120    #[test]
2121    fn create_inverse_action_for_rename_clip_restores_previous_name() {
2122        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2123        track
2124            .audio
2125            .push_clip(AudioClip::new("before.wav".to_string(), 0, 16));
2126        let state = make_state_with_track(track);
2127
2128        let inverse = create_inverse_action(
2129            &Action::RenameClip {
2130                track_name: "t".to_string(),
2131                kind: Kind::Audio,
2132                clip_index: 0,
2133                new_name: "after.wav".to_string(),
2134            },
2135            &state,
2136        )
2137        .expect("inverse action");
2138
2139        assert!(matches!(
2140            inverse,
2141            Action::RenameClip { new_name, kind: Kind::Audio, .. } if new_name == "before.wav"
2142        ));
2143    }
2144
2145    #[test]
2146    fn create_inverse_action_for_remove_audio_clip_restores_peaks_file() {
2147        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2148        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
2149        clip.offset = 12;
2150        clip.input_channel = 0;
2151        clip.muted = true;
2152        clip.peaks_file = Some("peaks/clip.json".to_string());
2153        track.audio.push_clip(clip);
2154        let state = make_state_with_track(track);
2155
2156        let inverse = create_inverse_action(
2157            &Action::RemoveClip {
2158                track_name: "t".to_string(),
2159                kind: Kind::Audio,
2160                clip_indices: vec![0],
2161            },
2162            &state,
2163        )
2164        .expect("inverse action");
2165
2166        match inverse {
2167            Action::AddClip {
2168                name,
2169                track_name,
2170                start,
2171                length,
2172                offset,
2173                input_channel,
2174                muted,
2175                peaks_file,
2176                kind,
2177                ..
2178            } => {
2179                assert_eq!(name, "audio/clip.wav");
2180                assert_eq!(track_name, "t");
2181                assert_eq!(start, 48);
2182                assert_eq!(length, 96);
2183                assert_eq!(offset, 12);
2184                assert_eq!(input_channel, 0);
2185                assert!(muted);
2186                assert_eq!(peaks_file.as_deref(), Some("peaks/clip.json"));
2187                assert_eq!(kind, Kind::Audio);
2188            }
2189            other => panic!("unexpected inverse action: {other:?}"),
2190        }
2191    }
2192
2193    #[test]
2194    fn create_inverse_action_for_remove_grouped_audio_clip_restores_group() {
2195        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2196        let mut group = AudioClip::new("Group".to_string(), 48, 144);
2197        group
2198            .grouped_clips
2199            .push(AudioClip::new("child.wav".to_string(), 0, 32));
2200        track.audio.push_clip(group);
2201        let state = make_state_with_track(track);
2202
2203        let inverse = create_inverse_action(
2204            &Action::RemoveClip {
2205                track_name: "t".to_string(),
2206                kind: Kind::Audio,
2207                clip_indices: vec![0],
2208            },
2209            &state,
2210        )
2211        .expect("inverse action");
2212
2213        match inverse {
2214            Action::AddGroupedClip {
2215                track_name,
2216                kind,
2217                audio_clip,
2218                midi_clip,
2219            } => {
2220                assert_eq!(track_name, "t");
2221                assert_eq!(kind, Kind::Audio);
2222                assert!(midi_clip.is_none());
2223                let audio_clip = audio_clip.expect("audio clip payload");
2224                assert_eq!(audio_clip.name, "Group");
2225                assert_eq!(audio_clip.grouped_clips.len(), 1);
2226                assert_eq!(audio_clip.grouped_clips[0].name, "child.wav");
2227            }
2228            other => panic!("unexpected inverse action: {other:?}"),
2229        }
2230    }
2231
2232    #[test]
2233    fn create_inverse_action_for_remove_midi_clip_restores_clip() {
2234        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2235        track.midi.push_clip(crate::midi::clip::MIDIClip {
2236            name: "pattern.mid".to_string(),
2237            start: 48,
2238            end: 144,
2239            offset: 12,
2240            input_channel: 3,
2241            muted: true,
2242            ..Default::default()
2243        });
2244        let state = make_state_with_track(track);
2245
2246        let inverse = create_inverse_action(
2247            &Action::RemoveClip {
2248                track_name: "t".to_string(),
2249                kind: Kind::MIDI,
2250                clip_indices: vec![0],
2251            },
2252            &state,
2253        )
2254        .expect("inverse action");
2255
2256        match inverse {
2257            Action::AddClip {
2258                name,
2259                track_name,
2260                start,
2261                length,
2262                offset,
2263                input_channel,
2264                muted,
2265                kind,
2266                ..
2267            } => {
2268                assert_eq!(name, "pattern.mid");
2269                assert_eq!(track_name, "t");
2270                assert_eq!(start, 48);
2271                assert_eq!(length, 96);
2272                assert_eq!(offset, 12);
2273                assert_eq!(input_channel, 3);
2274                assert!(muted);
2275                assert_eq!(kind, Kind::MIDI);
2276            }
2277            other => panic!("unexpected inverse action: {other:?}"),
2278        }
2279    }
2280
2281    #[test]
2282    fn create_inverse_action_for_remove_grouped_midi_clip_restores_group() {
2283        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2284        let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
2285        group.grouped_clips.push(crate::midi::clip::MIDIClip::new(
2286            "child.mid".to_string(),
2287            0,
2288            48,
2289        ));
2290        track.midi.push_clip(group);
2291        let state = make_state_with_track(track);
2292
2293        let inverse = create_inverse_action(
2294            &Action::RemoveClip {
2295                track_name: "t".to_string(),
2296                kind: Kind::MIDI,
2297                clip_indices: vec![0],
2298            },
2299            &state,
2300        )
2301        .expect("inverse action");
2302
2303        match inverse {
2304            Action::AddGroupedClip {
2305                track_name,
2306                kind,
2307                audio_clip,
2308                midi_clip,
2309            } => {
2310                assert_eq!(track_name, "t");
2311                assert_eq!(kind, Kind::MIDI);
2312                assert!(audio_clip.is_none());
2313                let midi_clip = midi_clip.expect("midi clip payload");
2314                assert_eq!(midi_clip.name, "Group");
2315                assert_eq!(midi_clip.grouped_clips.len(), 1);
2316                assert_eq!(midi_clip.grouped_clips[0].name, "child.mid");
2317            }
2318            other => panic!("unexpected inverse action: {other:?}"),
2319        }
2320    }
2321
2322    #[test]
2323    fn create_inverse_action_for_remove_grouped_audio_clip_preserves_child_metadata() {
2324        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2325        let mut child = AudioClip::new("child.wav".to_string(), 4, 40);
2326        child.peaks_file = Some("peaks/child.json".to_string());
2327        child.pitch_correction_source_name = Some("source.wav".to_string());
2328        child.pitch_correction_source_offset = Some(8);
2329        child.pitch_correction_source_length = Some(24);
2330        child.pitch_correction_preview_name = Some("preview.wav".to_string());
2331        child.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
2332            start_sample: 1,
2333            length_samples: 2,
2334            detected_midi_pitch: 60.0,
2335            target_midi_pitch: 62.0,
2336            clarity: 0.75,
2337        }];
2338        child.pitch_correction_frame_likeness = Some(0.25);
2339        child.pitch_correction_inertia_ms = Some(100);
2340        child.pitch_correction_formant_compensation = Some(true);
2341        child.plugin_graph_json = Some(serde_json::json!({"plugins":[],"connections":[]}));
2342        let mut group = AudioClip::new("Group".to_string(), 48, 144);
2343        group.grouped_clips.push(child);
2344        track.audio.push_clip(group);
2345        let state = make_state_with_track(track);
2346
2347        let inverse = create_inverse_action(
2348            &Action::RemoveClip {
2349                track_name: "t".to_string(),
2350                kind: Kind::Audio,
2351                clip_indices: vec![0],
2352            },
2353            &state,
2354        )
2355        .expect("inverse action");
2356
2357        match inverse {
2358            Action::AddGroupedClip {
2359                audio_clip: Some(audio_clip),
2360                ..
2361            } => {
2362                let child = &audio_clip.grouped_clips[0];
2363                assert_eq!(child.peaks_file.as_deref(), Some("peaks/child.json"));
2364                assert_eq!(child.source_name.as_deref(), Some("source.wav"));
2365                assert_eq!(child.source_offset, Some(8));
2366                assert_eq!(child.source_length, Some(24));
2367                assert_eq!(child.preview_name.as_deref(), Some("preview.wav"));
2368                assert_eq!(child.pitch_correction_points.len(), 1);
2369                assert_eq!(child.pitch_correction_frame_likeness, Some(0.25));
2370                assert_eq!(child.pitch_correction_inertia_ms, Some(100));
2371                assert_eq!(child.pitch_correction_formant_compensation, Some(true));
2372                assert!(child.plugin_graph_json.is_some());
2373            }
2374            other => panic!("unexpected inverse action: {other:?}"),
2375        }
2376    }
2377
2378    #[test]
2379    fn create_inverse_action_for_remove_grouped_midi_clip_preserves_child_structure() {
2380        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2381        let child = crate::midi::clip::MIDIClip::new("child.mid".to_string(), 0, 48);
2382        let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
2383        group.grouped_clips.push(child);
2384        track.midi.push_clip(group);
2385        let state = make_state_with_track(track);
2386
2387        let inverse = create_inverse_action(
2388            &Action::RemoveClip {
2389                track_name: "t".to_string(),
2390                kind: Kind::MIDI,
2391                clip_indices: vec![0],
2392            },
2393            &state,
2394        )
2395        .expect("inverse action");
2396
2397        match inverse {
2398            Action::AddGroupedClip {
2399                midi_clip: Some(midi_clip),
2400                ..
2401            } => {
2402                let child = &midi_clip.grouped_clips[0];
2403                assert_eq!(child.name, "child.mid");
2404                assert_eq!(child.start, 0);
2405                assert_eq!(child.length, 48);
2406            }
2407            other => panic!("unexpected inverse action: {other:?}"),
2408        }
2409    }
2410
2411    #[test]
2412    fn create_inverse_action_for_set_clip_pitch_correction_restores_previous_values() {
2413        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2414        let mut clip = AudioClip::new("audio.wav".to_string(), 0, 128);
2415        clip.pitch_correction_preview_name = Some("audio_preview.wav".to_string());
2416        clip.pitch_correction_source_name = Some("audio_source.wav".to_string());
2417        clip.pitch_correction_source_offset = Some(12);
2418        clip.pitch_correction_source_length = Some(96);
2419        clip.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
2420            start_sample: 4,
2421            length_samples: 32,
2422            detected_midi_pitch: 60.2,
2423            target_midi_pitch: 61.0,
2424            clarity: 0.8,
2425        }];
2426        clip.pitch_correction_frame_likeness = Some(0.4);
2427        clip.pitch_correction_inertia_ms = Some(250);
2428        clip.pitch_correction_formant_compensation = Some(false);
2429        track.audio.push_clip(clip);
2430        let state = make_state_with_track(track);
2431
2432        let inverse = create_inverse_action(
2433            &Action::SetClipPitchCorrection {
2434                track_name: "t".to_string(),
2435                clip_index: 0,
2436                preview_name: None,
2437                source_name: None,
2438                source_offset: None,
2439                source_length: None,
2440                pitch_correction_points: vec![],
2441                pitch_correction_frame_likeness: None,
2442                pitch_correction_inertia_ms: None,
2443                pitch_correction_formant_compensation: None,
2444            },
2445            &state,
2446        )
2447        .expect("inverse action");
2448
2449        match inverse {
2450            Action::SetClipPitchCorrection {
2451                track_name,
2452                clip_index,
2453                preview_name,
2454                source_name,
2455                source_offset,
2456                source_length,
2457                pitch_correction_points,
2458                pitch_correction_frame_likeness,
2459                pitch_correction_inertia_ms,
2460                pitch_correction_formant_compensation,
2461            } => {
2462                assert_eq!(track_name, "t");
2463                assert_eq!(clip_index, 0);
2464                assert_eq!(preview_name.as_deref(), Some("audio_preview.wav"));
2465                assert_eq!(source_name.as_deref(), Some("audio_source.wav"));
2466                assert_eq!(source_offset, Some(12));
2467                assert_eq!(source_length, Some(96));
2468                assert_eq!(pitch_correction_points.len(), 1);
2469                assert_eq!(pitch_correction_points[0].target_midi_pitch, 61.0);
2470                assert_eq!(pitch_correction_frame_likeness, Some(0.4));
2471                assert_eq!(pitch_correction_inertia_ms, Some(250));
2472                assert_eq!(pitch_correction_formant_compensation, Some(false));
2473            }
2474            other => panic!("unexpected inverse action: {other:?}"),
2475        }
2476    }
2477
2478    #[test]
2479    fn create_inverse_action_for_clip_copy_targets_new_destination_clip() {
2480        let source = Track::new("src".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2481        source
2482            .audio
2483            .push_clip(AudioClip::new("source.wav".to_string(), 12, 48));
2484        let dest = Track::new("dst".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2485        dest.audio
2486            .push_clip(AudioClip::new("existing.wav".to_string(), 0, 24));
2487
2488        let mut state = State::default();
2489        state.tracks.insert(source.name.clone(), Arc::new(source));
2490        state.tracks.insert(dest.name.clone(), Arc::new(dest));
2491
2492        let inverse = create_inverse_action(
2493            &Action::ClipMove {
2494                kind: Kind::Audio,
2495                from: ClipMoveFrom {
2496                    track_name: "src".to_string(),
2497                    clip_index: 0,
2498                },
2499                to: ClipMoveTo {
2500                    track_name: "dst".to_string(),
2501                    sample_offset: 96,
2502                    input_channel: 0,
2503                },
2504                copy: true,
2505            },
2506            &state,
2507        )
2508        .expect("inverse action");
2509
2510        match inverse {
2511            Action::RemoveClip {
2512                track_name,
2513                kind,
2514                clip_indices,
2515            } => {
2516                assert_eq!(track_name, "dst");
2517                assert_eq!(kind, Kind::Audio);
2518                assert_eq!(clip_indices, vec![1]);
2519            }
2520            other => panic!("unexpected inverse action: {other:?}"),
2521        }
2522    }
2523
2524    #[test]
2525    fn create_inverse_action_for_same_track_clip_move_reverses_last_destination_clip() {
2526        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2527        let mut original = AudioClip::new("clip.wav".to_string(), 20, 40);
2528        original.input_channel = 2;
2529        let moved = AudioClip::new("moved.wav".to_string(), 80, 32);
2530        track.audio.push_clip(original);
2531        track.audio.push_clip(moved);
2532        let state = make_state_with_track(track);
2533
2534        let inverse = create_inverse_action(
2535            &Action::ClipMove {
2536                kind: Kind::Audio,
2537                from: ClipMoveFrom {
2538                    track_name: "t".to_string(),
2539                    clip_index: 0,
2540                },
2541                to: ClipMoveTo {
2542                    track_name: "t".to_string(),
2543                    sample_offset: 80,
2544                    input_channel: 1,
2545                },
2546                copy: false,
2547            },
2548            &state,
2549        )
2550        .expect("inverse action");
2551
2552        match inverse {
2553            Action::ClipMove {
2554                kind,
2555                from,
2556                to,
2557                copy,
2558            } => {
2559                assert_eq!(kind, Kind::Audio);
2560                assert_eq!(from.track_name, "t");
2561                assert_eq!(from.clip_index, 1);
2562                assert_eq!(to.track_name, "t");
2563                assert_eq!(to.sample_offset, 20);
2564                assert_eq!(to.input_channel, 2);
2565                assert!(!copy);
2566            }
2567            other => panic!("unexpected inverse action: {other:?}"),
2568        }
2569    }
2570
2571    #[test]
2572    fn create_inverse_action_for_track_midi_binding_restores_previous_binding() {
2573        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2574        track.midi_learn_volume = Some(binding(7));
2575        let state = make_state_with_track(track);
2576
2577        let inverse = create_inverse_action(
2578            &Action::TrackSetMidiLearnBinding {
2579                track_name: "t".to_string(),
2580                target: TrackMidiLearnTarget::Volume,
2581                binding: Some(binding(9)),
2582            },
2583            &state,
2584        )
2585        .unwrap();
2586
2587        match inverse {
2588            Action::TrackSetMidiLearnBinding {
2589                track_name,
2590                target,
2591                binding,
2592            } => {
2593                assert_eq!(track_name, "t");
2594                assert_eq!(target, TrackMidiLearnTarget::Volume);
2595                assert_eq!(binding.unwrap().cc, 7);
2596            }
2597            other => panic!("unexpected inverse action: {other:?}"),
2598        }
2599    }
2600
2601    #[test]
2602    fn create_inverse_action_for_vst3_load_uses_next_runtime_instance_id() {
2603        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2604        track.next_vst3_instance_id.store(42, Ordering::Relaxed);
2605        let state = make_state_with_track(track);
2606
2607        let inverse = create_inverse_action(
2608            &Action::TrackLoadVst3Plugin {
2609                track_name: "t".to_string(),
2610                plugin_id: "/tmp/test.vst3".to_string(),
2611                instance_id: None,
2612            },
2613            &state,
2614        )
2615        .unwrap();
2616
2617        match inverse {
2618            Action::TrackUnloadVst3PluginInstance {
2619                track_name,
2620                instance_id,
2621            } => {
2622                assert_eq!(track_name, "t");
2623                assert_eq!(instance_id, 42);
2624            }
2625            other => panic!("unexpected inverse action: {other:?}"),
2626        }
2627    }
2628
2629    #[test]
2630    #[cfg(all(unix, not(target_os = "macos")))]
2631    fn create_inverse_action_for_lv2_load_uses_next_runtime_instance_id() {
2632        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2633        track.next_lv2_instance_id.store(5, Ordering::Relaxed);
2634        let state = make_state_with_track(track);
2635
2636        let inverse = create_inverse_action(
2637            &Action::TrackLoadLv2Plugin {
2638                track_name: "t".to_string(),
2639                plugin_uri: "urn:test".to_string(),
2640                instance_id: None,
2641            },
2642            &state,
2643        )
2644        .unwrap();
2645
2646        match inverse {
2647            Action::TrackUnloadLv2PluginInstance {
2648                track_name,
2649                instance_id,
2650            } => {
2651                assert_eq!(track_name, "t");
2652                assert_eq!(instance_id, 5);
2653            }
2654            other => panic!("unexpected inverse action: {other:?}"),
2655        }
2656    }
2657
2658    #[test]
2659    fn create_inverse_actions_for_clear_all_midi_learn_bindings_restores_only_existing_bindings() {
2660        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2661        track.midi_learn_volume = Some(binding(7));
2662        track.midi_learn_disk_monitor = Some(binding(64));
2663        let state = make_state_with_track(track);
2664
2665        let inverses = create_inverse_actions(&Action::ClearAllMidiLearnBindings, &state).unwrap();
2666
2667        assert_eq!(inverses.len(), 2);
2668        assert!(inverses.iter().any(|action| {
2669            matches!(
2670                action,
2671                Action::TrackSetMidiLearnBinding {
2672                    target: TrackMidiLearnTarget::Volume,
2673                    binding: Some(MidiLearnBinding { cc: 7, .. }),
2674                    ..
2675                }
2676            )
2677        }));
2678        assert!(inverses.iter().any(|action| {
2679            matches!(
2680                action,
2681                Action::TrackSetMidiLearnBinding {
2682                    target: TrackMidiLearnTarget::DiskMonitor,
2683                    binding: Some(MidiLearnBinding { cc: 64, .. }),
2684                    ..
2685                }
2686            )
2687        }));
2688    }
2689
2690    #[test]
2691    fn create_inverse_actions_for_remove_track_restores_io_flags_and_bindings() {
2692        let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
2693        track.set_level(-3.0);
2694        track.set_balance(0.25);
2695        track.armed.store(true, Ordering::Relaxed);
2696        track.set_muted(true);
2697        track.soloed.store(true, Ordering::Relaxed);
2698        track.set_input_monitor(vec![true]);
2699        track.set_disk_monitor(vec![false]);
2700        track.midi_learn_volume = Some(binding(10));
2701        track.audio.ins.push(Arc::new(AudioIO::new(64)));
2702        track.audio.outs.push(Arc::new(AudioIO::new(64)));
2703        let state = make_state_with_track(track);
2704
2705        let inverses =
2706            create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2707
2708        assert!(matches!(
2709            inverses.first(),
2710            Some(Action::AddTrack {
2711                name,
2712                audio_ins: 1,
2713                audio_outs: 1,
2714                midi_ins: 1,
2715                midi_outs: 1,
2716                folder: false,
2717            }) if name == "t"
2718        ));
2719        assert!(
2720            inverses
2721                .iter()
2722                .any(|action| matches!(action, Action::TrackAddAudioInput(name) if name == "t"))
2723        );
2724        assert!(
2725            inverses
2726                .iter()
2727                .any(|action| matches!(action, Action::TrackAddAudioOutput(name) if name == "t"))
2728        );
2729        assert!(
2730            inverses.iter().any(
2731                |action| matches!(action, Action::TrackToggleInputMonitor { track_name, .. } if track_name == "t")
2732            )
2733        );
2734        assert!(
2735            inverses.iter().any(
2736                |action| matches!(action, Action::TrackToggleDiskMonitor { track_name, .. } if track_name == "t")
2737            )
2738        );
2739        assert!(inverses.iter().any(|action| {
2740            matches!(
2741                action,
2742                Action::TrackSetMidiLearnBinding {
2743                    target: TrackMidiLearnTarget::Volume,
2744                    binding: Some(MidiLearnBinding { cc: 10, .. }),
2745                    ..
2746                }
2747            )
2748        }));
2749    }
2750
2751    #[test]
2752    fn create_inverse_actions_for_remove_track_omits_internal_passthrough() {
2753        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2754        track.ensure_default_audio_passthrough();
2755        track.ensure_default_midi_passthrough();
2756        let state = make_state_with_track(track);
2757
2758        let inverses =
2759            create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2760
2761        assert!(
2762            !inverses.iter().any(|action| matches!(
2763                action,
2764                Action::Connect {
2765                    from_track,
2766                    to_track,
2767                    ..
2768                } if from_track == to_track
2769            )),
2770            "internal passthrough should not be captured as a track-to-track Connect action"
2771        );
2772    }
2773
2774    #[test]
2775    fn create_inverse_actions_for_remove_folder_track_restores_parent_and_omits_child_wiring() {
2776        let mut state = State::default();
2777        let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2778        let child = Track::new("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2779        state.tracks.insert("folder".to_string(), Arc::new(folder));
2780        state.tracks.insert("child".to_string(), Arc::new(child));
2781        {
2782            let folder_arc = state.tracks.get("folder").unwrap().clone();
2783            let child_arc = state.tracks.get("child").unwrap().clone();
2784            child_arc.lock().parent_track = Some("folder".to_string());
2785            folder_arc.lock().child_tracks.push(child_arc.clone());
2786
2787            // Simulate the implicit wiring TrackSetParent creates.
2788            let (folder_in, folder_out) = {
2789                let folder = folder_arc.lock();
2790                (folder.audio.ins[0].clone(), folder.audio.outs[0].clone())
2791            };
2792            let (child_in, child_out) = {
2793                let child = child_arc.lock();
2794                (child.audio.ins[0].clone(), child.audio.outs[0].clone())
2795            };
2796            AudioIO::connect(&folder_in, &child_in);
2797            AudioIO::connect(&child_out, &folder_out);
2798        }
2799
2800        let mut inverses =
2801            create_inverse_actions(&Action::RemoveTrack("folder".to_string()), &state).unwrap();
2802        inverses.extend(
2803            create_inverse_actions(&Action::RemoveTrack("child".to_string()), &state).unwrap(),
2804        );
2805
2806        assert!(
2807            inverses.iter().any(|action| matches!(
2808                action,
2809                Action::TrackSetParent {
2810                    track_name,
2811                    parent_name: Some(parent_name),
2812                } if track_name == "child" && parent_name == "folder"
2813            )),
2814            "inverse should restore the child-to-folder parent relationship"
2815        );
2816        assert!(
2817            !inverses.iter().any(|action| matches!(
2818                action,
2819                Action::Connect { from_track, to_track, .. }
2820                if (from_track == "folder" && to_track == "child")
2821                    || (from_track == "child" && to_track == "folder")
2822            )),
2823            "implicit folder-to-child wiring should not be captured as a generic Connect action"
2824        );
2825    }
2826
2827    #[test]
2828    fn create_inverse_action_for_track_set_session_slot_restores_previous_clip() {
2829        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2830        track
2831            .rt
2832            .session_slots
2833            .insert(0, crate::track::SessionSlot::new("old-clip".to_string()));
2834        let state = make_state_with_track(track);
2835
2836        let inverse = create_inverse_action(
2837            &Action::TrackSetSessionSlot {
2838                track_name: "t".to_string(),
2839                scene_index: 0,
2840                clip_id: Some("new-clip".to_string()),
2841            },
2842            &state,
2843        )
2844        .unwrap();
2845
2846        match inverse {
2847            Action::TrackSetSessionSlot {
2848                track_name,
2849                scene_index,
2850                clip_id,
2851            } => {
2852                assert_eq!(track_name, "t");
2853                assert_eq!(scene_index, 0);
2854                assert_eq!(clip_id, Some("old-clip".to_string()));
2855            }
2856            other => panic!("unexpected inverse action: {other:?}"),
2857        }
2858    }
2859
2860    #[test]
2861    fn create_inverse_action_for_track_set_session_slot_clear_restores_none() {
2862        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2863        track
2864            .rt
2865            .session_slots
2866            .insert(0, crate::track::SessionSlot::new("old-clip".to_string()));
2867        let state = make_state_with_track(track);
2868
2869        let inverse = create_inverse_action(
2870            &Action::TrackSetSessionSlot {
2871                track_name: "t".to_string(),
2872                scene_index: 0,
2873                clip_id: None,
2874            },
2875            &state,
2876        )
2877        .unwrap();
2878
2879        match inverse {
2880            Action::TrackSetSessionSlot {
2881                track_name,
2882                scene_index,
2883                clip_id,
2884            } => {
2885                assert_eq!(track_name, "t");
2886                assert_eq!(scene_index, 0);
2887                assert_eq!(clip_id, Some("old-clip".to_string()));
2888            }
2889            other => panic!("unexpected inverse action: {other:?}"),
2890        }
2891    }
2892
2893    #[test]
2894    fn create_inverse_action_for_track_set_session_slot_play_enabled_restores_previous_flag() {
2895        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2896        track
2897            .rt
2898            .session_slots
2899            .insert(0, crate::track::SessionSlot::new("clip".to_string()));
2900        track.rt.session_slots.get_mut(&0).unwrap().play_enabled = false;
2901        let state = make_state_with_track(track);
2902
2903        let inverse = create_inverse_action(
2904            &Action::TrackSetSessionSlotPlayEnabled {
2905                track_name: "t".to_string(),
2906                scene_index: 0,
2907                enabled: true,
2908            },
2909            &state,
2910        )
2911        .unwrap();
2912
2913        match inverse {
2914            Action::TrackSetSessionSlotPlayEnabled {
2915                track_name,
2916                scene_index,
2917                enabled,
2918            } => {
2919                assert_eq!(track_name, "t");
2920                assert_eq!(scene_index, 0);
2921                assert!(!enabled);
2922            }
2923            other => panic!("unexpected inverse action: {other:?}"),
2924        }
2925    }
2926}