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