Skip to main content

maolan_engine/
history.rs

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