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