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::VecDeque;
9use std::sync::Arc;
10
11fn audio_clip_to_data(clip: &crate::audio::clip::AudioClip) -> crate::message::AudioClipData {
12    crate::message::AudioClipData {
13        name: clip.name.clone(),
14        start: clip.start,
15        length: clip.end.saturating_sub(clip.start).max(1),
16        offset: clip.offset,
17        input_channel: clip.input_channel,
18        muted: clip.muted,
19        peaks_file: clip.peaks_file.clone(),
20        fade_enabled: clip.fade_enabled,
21        fade_in_samples: clip.fade_in_samples,
22        fade_out_samples: clip.fade_out_samples,
23        preview_name: clip.pitch_correction_preview_name.clone(),
24        source_name: clip.pitch_correction_source_name.clone(),
25        source_offset: clip.pitch_correction_source_offset,
26        source_length: clip.pitch_correction_source_length,
27        pitch_correction_points: clip.pitch_correction_points.clone(),
28        pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
29        pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
30        pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
31        plugin_graph_json: clip.plugin_graph_json.clone(),
32        grouped_clips: clip.grouped_clips.iter().map(audio_clip_to_data).collect(),
33    }
34}
35
36fn midi_clip_to_data(clip: &crate::midi::clip::MIDIClip) -> crate::message::MidiClipData {
37    crate::message::MidiClipData {
38        name: clip.name.clone(),
39        start: clip.start,
40        length: clip.end.saturating_sub(clip.start).max(1),
41        offset: clip.offset,
42        input_channel: clip.input_channel,
43        muted: clip.muted,
44        grouped_clips: clip.grouped_clips.iter().map(midi_clip_to_data).collect(),
45    }
46}
47
48#[derive(Clone, Debug)]
49pub struct UndoEntry {
50    pub forward_actions: Vec<Action>,
51    pub inverse_actions: Vec<Action>,
52}
53
54pub struct History {
55    undo_stack: VecDeque<UndoEntry>,
56    redo_stack: VecDeque<UndoEntry>,
57    max_history: usize,
58}
59
60impl History {
61    pub fn new(max_history: usize) -> Self {
62        Self {
63            undo_stack: VecDeque::new(),
64            redo_stack: VecDeque::new(),
65            max_history,
66        }
67    }
68
69    pub fn record(&mut self, entry: UndoEntry) {
70        self.undo_stack.push_back(entry);
71        self.redo_stack.clear(); // Clear redo stack on new action
72
73        // Limit history size
74        if self.undo_stack.len() > self.max_history {
75            self.undo_stack.pop_front();
76        }
77    }
78
79    pub fn undo(&mut self) -> Option<Vec<Action>> {
80        self.undo_stack.pop_back().map(|entry| {
81            let inverse = entry.inverse_actions.clone();
82            self.redo_stack.push_back(entry);
83            inverse
84        })
85    }
86
87    pub fn redo(&mut self) -> Option<Vec<Action>> {
88        self.redo_stack.pop_back().map(|entry| {
89            let forward = entry.forward_actions.clone();
90            self.undo_stack.push_back(entry);
91            forward
92        })
93    }
94
95    pub fn clear(&mut self) {
96        self.undo_stack.clear();
97        self.redo_stack.clear();
98    }
99}
100
101impl Default for History {
102    fn default() -> Self {
103        Self::new(100)
104    }
105}
106
107/// Check if an action should be recorded in history
108pub fn should_record(action: &Action) -> bool {
109    match action {
110        Action::SetTempo(_)
111        | Action::SetLoopEnabled(_)
112        | Action::SetLoopRange(_)
113        | Action::SetPunchEnabled(_)
114        | Action::SetPunchRange(_)
115        | Action::SetMetronomeEnabled(_)
116        | Action::SetTimeSignature { .. }
117        | Action::AddTrack { .. }
118        | Action::RemoveTrack(_)
119        | Action::RenameTrack { .. }
120        | Action::TrackLevel(_, _)
121        | Action::TrackBalance(_, _)
122        | Action::TrackToggleArm(_)
123        | Action::TrackToggleMute(_)
124        | Action::TrackTogglePhase(_)
125        | Action::TrackToggleSolo(_)
126        | Action::TrackToggleInputMonitor(_)
127        | Action::TrackToggleDiskMonitor(_)
128        | Action::TrackSetMidiLearnBinding { .. }
129        | Action::SetGlobalMidiLearnBinding { .. }
130        | Action::TrackSetVcaMaster { .. }
131        | Action::TrackSetFrozen { .. }
132        | Action::TrackAddAudioInput(_)
133        | Action::TrackAddAudioOutput(_)
134        | Action::TrackRemoveAudioInput(_)
135        | Action::TrackRemoveAudioOutput(_)
136        | Action::AddClip { .. }
137        | Action::AddGroupedClip { .. }
138        | Action::RemoveClip { .. }
139        | Action::RenameClip { .. }
140        | Action::ClipMove { .. }
141        | Action::SetClipFade { .. }
142        | Action::SetClipBounds { .. }
143        | Action::SetClipMuted { .. }
144        | Action::ClearAllMidiLearnBindings
145        | Action::Connect { .. }
146        | Action::Disconnect { .. }
147        | Action::TrackConnectVst3Audio { .. }
148        | Action::TrackDisconnectVst3Audio { .. }
149        | Action::TrackLoadClapPlugin { .. }
150        | Action::TrackUnloadClapPlugin { .. }
151        | Action::TrackLoadVst3Plugin { .. }
152        | Action::TrackUnloadVst3PluginInstance { .. }
153        | Action::TrackSetClapParameter { .. }
154        | Action::TrackSetVst3Parameter { .. }
155        | Action::TrackSetPluginBypassed { .. }
156        | Action::ModifyMidiNotes { .. }
157        | Action::ModifyMidiControllers { .. }
158        | Action::DeleteMidiControllers { .. }
159        | Action::InsertMidiControllers { .. }
160        | Action::DeleteMidiNotes { .. }
161        | Action::InsertMidiNotes { .. }
162        | Action::SetMidiSysExEvents { .. } => true,
163        #[cfg(unix)]
164        Action::TrackConnectPluginAudio { .. }
165        | Action::TrackDisconnectPluginAudio { .. }
166        | Action::TrackConnectPluginMidi { .. }
167        | Action::TrackDisconnectPluginMidi { .. } => true,
168        #[cfg(all(unix, not(target_os = "macos")))]
169        Action::TrackLoadLv2Plugin { .. }
170        | Action::TrackUnloadLv2PluginInstance { .. }
171        | Action::TrackSetLv2ControlValue { .. } => true,
172        _ => false,
173    }
174}
175
176/// Create an inverse action that will undo the given action
177/// Returns None if the action cannot be inverted
178pub fn create_inverse_action(action: &Action, state: &State) -> Option<Action> {
179    match action {
180        Action::AddTrack { name, .. } => Some(Action::RemoveTrack(name.clone())),
181
182        Action::RemoveTrack(name) => {
183            // Find the track to capture its data
184            let track = state.tracks.get(name)?;
185            let track_lock = track.lock();
186            Some(Action::AddTrack {
187                name: track_lock.name.clone(),
188                audio_ins: track_lock.primary_audio_ins(),
189                midi_ins: track_lock.midi.ins.len(),
190                audio_outs: track_lock.primary_audio_outs(),
191                midi_outs: track_lock.midi.outs.len(),
192            })
193        }
194
195        Action::RenameTrack { old_name, new_name } => Some(Action::RenameTrack {
196            old_name: new_name.clone(),
197            new_name: old_name.clone(),
198        }),
199
200        Action::TrackLevel(name, _new_level) => {
201            // Find current level
202            let track = state.tracks.get(name)?;
203            let track_lock = track.lock();
204            Some(Action::TrackLevel(name.clone(), track_lock.level))
205        }
206
207        Action::TrackBalance(name, _new_balance) => {
208            // Find current balance
209            let track = state.tracks.get(name)?;
210            let track_lock = track.lock();
211            Some(Action::TrackBalance(name.clone(), track_lock.balance))
212        }
213
214        Action::TrackToggleArm(name) => Some(Action::TrackToggleArm(name.clone())),
215        Action::TrackToggleMute(name) => Some(Action::TrackToggleMute(name.clone())),
216        Action::TrackTogglePhase(name) => Some(Action::TrackTogglePhase(name.clone())),
217        Action::TrackToggleSolo(name) => Some(Action::TrackToggleSolo(name.clone())),
218        Action::TrackToggleInputMonitor(name) => {
219            Some(Action::TrackToggleInputMonitor(name.clone()))
220        }
221        Action::TrackToggleDiskMonitor(name) => Some(Action::TrackToggleDiskMonitor(name.clone())),
222        Action::TrackSetMidiLearnBinding {
223            track_name, target, ..
224        } => {
225            let track = state.tracks.get(track_name)?;
226            let track_lock = track.lock();
227            let binding = match target {
228                crate::message::TrackMidiLearnTarget::Volume => {
229                    track_lock.midi_learn_volume.clone()
230                }
231                crate::message::TrackMidiLearnTarget::Balance => {
232                    track_lock.midi_learn_balance.clone()
233                }
234                crate::message::TrackMidiLearnTarget::Mute => track_lock.midi_learn_mute.clone(),
235                crate::message::TrackMidiLearnTarget::Solo => track_lock.midi_learn_solo.clone(),
236                crate::message::TrackMidiLearnTarget::Arm => track_lock.midi_learn_arm.clone(),
237                crate::message::TrackMidiLearnTarget::InputMonitor => {
238                    track_lock.midi_learn_input_monitor.clone()
239                }
240                crate::message::TrackMidiLearnTarget::DiskMonitor => {
241                    track_lock.midi_learn_disk_monitor.clone()
242                }
243            };
244            Some(Action::TrackSetMidiLearnBinding {
245                track_name: track_name.clone(),
246                target: *target,
247                binding,
248            })
249        }
250        Action::TrackSetVcaMaster { track_name, .. } => {
251            let track = state.tracks.get(track_name)?;
252            let track_lock = track.lock();
253            Some(Action::TrackSetVcaMaster {
254                track_name: track_name.clone(),
255                master_track: track_lock.vca_master(),
256            })
257        }
258        Action::TrackSetFrozen { track_name, .. } => {
259            let track = state.tracks.get(track_name)?;
260            let track_lock = track.lock();
261            Some(Action::TrackSetFrozen {
262                track_name: track_name.clone(),
263                frozen: track_lock.frozen(),
264            })
265        }
266        Action::TrackAddAudioInput(name) => Some(Action::TrackRemoveAudioInput(name.clone())),
267        Action::TrackAddAudioOutput(name) => Some(Action::TrackRemoveAudioOutput(name.clone())),
268        Action::TrackRemoveAudioInput(name) => Some(Action::TrackAddAudioInput(name.clone())),
269        Action::TrackRemoveAudioOutput(name) => Some(Action::TrackAddAudioOutput(name.clone())),
270
271        Action::AddClip {
272            track_name, kind, ..
273        } => {
274            // To undo adding a clip, we need to know which index it will have
275            let track = state.tracks.get(track_name)?;
276            let track_lock = track.lock();
277            let clip_index = match kind {
278                Kind::Audio => track_lock.audio.clips.len(),
279                Kind::MIDI => track_lock.midi.clips.len(),
280            };
281            Some(Action::RemoveClip {
282                track_name: track_name.clone(),
283                kind: *kind,
284                clip_indices: vec![clip_index],
285            })
286        }
287
288        Action::AddGroupedClip {
289            track_name, kind, ..
290        } => {
291            let track = state.tracks.get(track_name)?;
292            let track_lock = track.lock();
293            let clip_index = match kind {
294                Kind::Audio => track_lock.audio.clips.len(),
295                Kind::MIDI => track_lock.midi.clips.len(),
296            };
297            Some(Action::RemoveClip {
298                track_name: track_name.clone(),
299                kind: *kind,
300                clip_indices: vec![clip_index],
301            })
302        }
303
304        Action::RemoveClip {
305            track_name,
306            kind,
307            clip_indices,
308        } => {
309            // To undo removing clips, we need to capture their data
310            let track = state.tracks.get(track_name)?;
311            let track_lock = track.lock();
312
313            // For now, we only support undoing single clip removal
314            if clip_indices.len() != 1 {
315                return None;
316            }
317
318            let clip_idx = clip_indices[0];
319            match kind {
320                Kind::Audio => {
321                    let clip = track_lock.audio.clips.get(clip_idx)?;
322                    if clip.grouped_clips.is_empty() {
323                        let length = clip.end.saturating_sub(clip.start);
324                        Some(Action::AddClip {
325                            name: clip.name.clone(),
326                            track_name: track_name.clone(),
327                            start: clip.start,
328                            length,
329                            offset: clip.offset,
330                            input_channel: clip.input_channel,
331                            muted: clip.muted,
332                            peaks_file: clip.peaks_file.clone(),
333                            kind: Kind::Audio,
334                            fade_enabled: clip.fade_enabled,
335                            fade_in_samples: clip.fade_in_samples,
336                            fade_out_samples: clip.fade_out_samples,
337                            source_name: clip.pitch_correction_source_name.clone(),
338                            source_offset: clip.pitch_correction_source_offset,
339                            source_length: clip.pitch_correction_source_length,
340                            preview_name: clip.pitch_correction_preview_name.clone(),
341                            pitch_correction_points: clip.pitch_correction_points.clone(),
342                            pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
343                            pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
344                            pitch_correction_formant_compensation: clip
345                                .pitch_correction_formant_compensation,
346                            plugin_graph_json: clip.plugin_graph_json.clone(),
347                        })
348                    } else {
349                        Some(Action::AddGroupedClip {
350                            track_name: track_name.clone(),
351                            kind: Kind::Audio,
352                            audio_clip: Some(audio_clip_to_data(clip)),
353                            midi_clip: None,
354                        })
355                    }
356                }
357                Kind::MIDI => {
358                    let clip = track_lock.midi.clips.get(clip_idx)?;
359                    if clip.grouped_clips.is_empty() {
360                        let length = clip.end.saturating_sub(clip.start);
361                        Some(Action::AddClip {
362                            name: clip.name.clone(),
363                            track_name: track_name.clone(),
364                            start: clip.start,
365                            length,
366                            offset: clip.offset,
367                            input_channel: clip.input_channel,
368                            muted: clip.muted,
369                            peaks_file: None,
370                            kind: Kind::MIDI,
371                            fade_enabled: true,
372                            fade_in_samples: 240,
373                            fade_out_samples: 240,
374                            source_name: None,
375                            source_offset: None,
376                            source_length: None,
377                            preview_name: None,
378                            pitch_correction_points: vec![],
379                            pitch_correction_frame_likeness: None,
380                            pitch_correction_inertia_ms: None,
381                            pitch_correction_formant_compensation: None,
382                            plugin_graph_json: None,
383                        })
384                    } else {
385                        Some(Action::AddGroupedClip {
386                            track_name: track_name.clone(),
387                            kind: Kind::MIDI,
388                            audio_clip: None,
389                            midi_clip: Some(midi_clip_to_data(clip)),
390                        })
391                    }
392                }
393            }
394        }
395
396        Action::RenameClip {
397            track_name,
398            kind,
399            clip_index,
400            new_name: _,
401        } => {
402            // Find current name
403            let track = state.tracks.get(track_name)?;
404            let track_lock = track.lock();
405            let old_name = match kind {
406                Kind::Audio => track_lock.audio.clips.get(*clip_index)?.name.clone(),
407                Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.name.clone(),
408            };
409            Some(Action::RenameClip {
410                track_name: track_name.clone(),
411                kind: *kind,
412                clip_index: *clip_index,
413                new_name: old_name,
414            })
415        }
416
417        Action::ClipMove {
418            kind,
419            from,
420            to,
421            copy,
422        } => {
423            let (original_start, original_input_channel) = {
424                let source_track = state.tracks.get(&from.track_name)?;
425                let source_lock = source_track.lock();
426                match kind {
427                    Kind::Audio => {
428                        let clip = source_lock.audio.clips.get(from.clip_index)?;
429                        (clip.start, clip.input_channel)
430                    }
431                    Kind::MIDI => {
432                        let clip = source_lock.midi.clips.get(from.clip_index)?;
433                        (clip.start, clip.input_channel)
434                    }
435                }
436            };
437
438            if *copy {
439                // If it was a copy, we need to remove the newly created clip
440                let dest_track = state.tracks.get(&to.track_name)?;
441                let dest_lock = dest_track.lock();
442                let clip_idx = match kind {
443                    Kind::Audio => dest_lock.audio.clips.len(),
444                    Kind::MIDI => dest_lock.midi.clips.len(),
445                };
446                Some(Action::RemoveClip {
447                    track_name: to.track_name.clone(),
448                    kind: *kind,
449                    clip_indices: vec![clip_idx],
450                })
451            } else {
452                // If it was a move, reverse the move from the destination track.
453                let dest_track = state.tracks.get(&to.track_name)?;
454                let dest_lock = dest_track.lock();
455                let dest_len = match kind {
456                    Kind::Audio => {
457                        if dest_lock.audio.clips.is_empty() {
458                            return None;
459                        }
460                        dest_lock.audio.clips.len()
461                    }
462                    Kind::MIDI => {
463                        if dest_lock.midi.clips.is_empty() {
464                            return None;
465                        }
466                        dest_lock.midi.clips.len()
467                    }
468                };
469                let moved_clip_index = if from.track_name == to.track_name {
470                    dest_len.saturating_sub(1)
471                } else {
472                    dest_len
473                };
474                Some(Action::ClipMove {
475                    kind: *kind,
476                    from: ClipMoveFrom {
477                        track_name: to.track_name.clone(),
478                        clip_index: moved_clip_index,
479                    },
480                    to: ClipMoveTo {
481                        track_name: from.track_name.clone(),
482                        sample_offset: original_start,
483                        input_channel: original_input_channel,
484                    },
485                    copy: false,
486                })
487            }
488        }
489
490        Action::SetClipFade {
491            track_name,
492            clip_index,
493            kind,
494            ..
495        } => {
496            // Capture current fade settings
497            let track = state.tracks.get(track_name)?;
498            let track_lock = track.lock();
499            match kind {
500                Kind::Audio => {
501                    let clip = track_lock.audio.clips.get(*clip_index)?;
502                    Some(Action::SetClipFade {
503                        track_name: track_name.clone(),
504                        clip_index: *clip_index,
505                        kind: *kind,
506                        fade_enabled: clip.fade_enabled,
507                        fade_in_samples: clip.fade_in_samples,
508                        fade_out_samples: clip.fade_out_samples,
509                    })
510                }
511                Kind::MIDI => {
512                    // MIDI clips don't have fade fields in engine, use defaults
513                    Some(Action::SetClipFade {
514                        track_name: track_name.clone(),
515                        clip_index: *clip_index,
516                        kind: *kind,
517                        fade_enabled: true,
518                        fade_in_samples: 240,
519                        fade_out_samples: 240,
520                    })
521                }
522            }
523        }
524        Action::SetClipBounds {
525            track_name,
526            clip_index,
527            kind,
528            ..
529        } => {
530            let track = state.tracks.get(track_name)?;
531            let track_lock = track.lock();
532            match kind {
533                Kind::Audio => {
534                    let clip = track_lock.audio.clips.get(*clip_index)?;
535                    Some(Action::SetClipBounds {
536                        track_name: track_name.clone(),
537                        clip_index: *clip_index,
538                        kind: *kind,
539                        start: clip.start,
540                        length: clip.end.max(1),
541                        offset: clip.offset,
542                    })
543                }
544                Kind::MIDI => {
545                    let clip = track_lock.midi.clips.get(*clip_index)?;
546                    Some(Action::SetClipBounds {
547                        track_name: track_name.clone(),
548                        clip_index: *clip_index,
549                        kind: *kind,
550                        start: clip.start,
551                        length: clip.end.max(1),
552                        offset: clip.offset,
553                    })
554                }
555            }
556        }
557        Action::SetClipMuted {
558            track_name,
559            clip_index,
560            kind,
561            ..
562        } => {
563            let track = state.tracks.get(track_name)?;
564            let track_lock = track.lock();
565            let muted = match kind {
566                Kind::Audio => track_lock.audio.clips.get(*clip_index)?.muted,
567                Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.muted,
568            };
569            Some(Action::SetClipMuted {
570                track_name: track_name.clone(),
571                clip_index: *clip_index,
572                kind: *kind,
573                muted,
574            })
575        }
576        Action::SetClipPitchCorrection {
577            track_name,
578            clip_index,
579            ..
580        } => {
581            let track = state.tracks.get(track_name)?;
582            let track_lock = track.lock();
583            let clip = track_lock.audio.clips.get(*clip_index)?;
584            Some(Action::SetClipPitchCorrection {
585                track_name: track_name.clone(),
586                clip_index: *clip_index,
587                preview_name: clip.pitch_correction_preview_name.clone(),
588                source_name: clip.pitch_correction_source_name.clone(),
589                source_offset: clip.pitch_correction_source_offset,
590                source_length: clip.pitch_correction_source_length,
591                pitch_correction_points: clip.pitch_correction_points.clone(),
592                pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
593                pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
594                pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
595            })
596        }
597        Action::Connect {
598            from_track,
599            from_port,
600            to_track,
601            to_port,
602            kind,
603        } => Some(Action::Disconnect {
604            from_track: from_track.clone(),
605            from_port: *from_port,
606            to_track: to_track.clone(),
607            to_port: *to_port,
608            kind: *kind,
609        }),
610
611        Action::Disconnect {
612            from_track,
613            from_port,
614            to_track,
615            to_port,
616            kind,
617        } => Some(Action::Connect {
618            from_track: from_track.clone(),
619            from_port: *from_port,
620            to_track: to_track.clone(),
621            to_port: *to_port,
622            kind: *kind,
623        }),
624        Action::TrackConnectVst3Audio {
625            track_name,
626            from_node,
627            from_port,
628            to_node,
629            to_port,
630        } => Some(Action::TrackDisconnectVst3Audio {
631            track_name: track_name.clone(),
632            from_node: from_node.clone(),
633            from_port: *from_port,
634            to_node: to_node.clone(),
635            to_port: *to_port,
636        }),
637        Action::TrackDisconnectVst3Audio {
638            track_name,
639            from_node,
640            from_port,
641            to_node,
642            to_port,
643        } => Some(Action::TrackConnectVst3Audio {
644            track_name: track_name.clone(),
645            from_node: from_node.clone(),
646            from_port: *from_port,
647            to_node: to_node.clone(),
648            to_port: *to_port,
649        }),
650        #[cfg(unix)]
651        Action::TrackConnectPluginAudio {
652            track_name,
653            from_node,
654            from_port,
655            to_node,
656            to_port,
657        } => Some(Action::TrackDisconnectPluginAudio {
658            track_name: track_name.clone(),
659            from_node: from_node.clone(),
660            from_port: *from_port,
661            to_node: to_node.clone(),
662            to_port: *to_port,
663        }),
664        #[cfg(unix)]
665        Action::TrackDisconnectPluginAudio {
666            track_name,
667            from_node,
668            from_port,
669            to_node,
670            to_port,
671        } => Some(Action::TrackConnectPluginAudio {
672            track_name: track_name.clone(),
673            from_node: from_node.clone(),
674            from_port: *from_port,
675            to_node: to_node.clone(),
676            to_port: *to_port,
677        }),
678        #[cfg(unix)]
679        Action::TrackConnectPluginMidi {
680            track_name,
681            from_node,
682            from_port,
683            to_node,
684            to_port,
685        } => Some(Action::TrackDisconnectPluginMidi {
686            track_name: track_name.clone(),
687            from_node: from_node.clone(),
688            from_port: *from_port,
689            to_node: to_node.clone(),
690            to_port: *to_port,
691        }),
692        #[cfg(unix)]
693        Action::TrackDisconnectPluginMidi {
694            track_name,
695            from_node,
696            from_port,
697            to_node,
698            to_port,
699        } => Some(Action::TrackConnectPluginMidi {
700            track_name: track_name.clone(),
701            from_node: from_node.clone(),
702            from_port: *from_port,
703            to_node: to_node.clone(),
704            to_port: *to_port,
705        }),
706
707        Action::TrackLoadClapPlugin {
708            track_name,
709            plugin_path,
710        } => Some(Action::TrackUnloadClapPlugin {
711            track_name: track_name.clone(),
712            plugin_path: plugin_path.clone(),
713        }),
714
715        Action::TrackUnloadClapPlugin {
716            track_name,
717            plugin_path,
718        } => Some(Action::TrackLoadClapPlugin {
719            track_name: track_name.clone(),
720            plugin_path: plugin_path.clone(),
721        }),
722        #[cfg(all(unix, not(target_os = "macos")))]
723        Action::TrackLoadLv2Plugin {
724            track_name,
725            plugin_uri: _,
726        } => {
727            let track = state.tracks.get(track_name)?;
728            let track = track.lock();
729            Some(Action::TrackUnloadLv2PluginInstance {
730                track_name: track_name.clone(),
731                instance_id: track.next_lv2_instance_id,
732            })
733        }
734        #[cfg(all(unix, not(target_os = "macos")))]
735        Action::TrackUnloadLv2PluginInstance {
736            track_name,
737            instance_id,
738        } => {
739            let track = state.tracks.get(track_name)?;
740            let track = track.lock();
741            let plugin_uri = track
742                .loaded_lv2_instances()
743                .into_iter()
744                .find(|(id, _)| *id == *instance_id)
745                .map(|(_, uri)| uri)?;
746            Some(Action::TrackLoadLv2Plugin {
747                track_name: track_name.clone(),
748                plugin_uri,
749            })
750        }
751        Action::TrackLoadVst3Plugin {
752            track_name,
753            plugin_path: _,
754        } => {
755            let track = state.tracks.get(track_name)?;
756            let track = track.lock();
757            Some(Action::TrackUnloadVst3PluginInstance {
758                track_name: track_name.clone(),
759                instance_id: track.next_plugin_instance_id,
760            })
761        }
762        Action::TrackUnloadVst3PluginInstance {
763            track_name,
764            instance_id,
765        } => {
766            let track = state.tracks.get(track_name)?;
767            let track = track.lock();
768            let plugin_path = track
769                .loaded_vst3_instances()
770                .into_iter()
771                .find(|(id, _, _)| *id == *instance_id)
772                .map(|(_, path, _)| path)?;
773            Some(Action::TrackLoadVst3Plugin {
774                track_name: track_name.clone(),
775                plugin_path,
776            })
777        }
778        Action::TrackSetClapParameter {
779            track_name,
780            instance_id,
781            ..
782        } => {
783            let track = state.tracks.get(track_name)?;
784            let track = track.lock();
785            let snapshot = track.clap_snapshot_state(*instance_id).ok()?;
786            Some(Action::TrackClapRestoreState {
787                track_name: track_name.clone(),
788                instance_id: *instance_id,
789                state: snapshot,
790            })
791        }
792        Action::TrackSetVst3Parameter {
793            track_name,
794            instance_id,
795            ..
796        } => {
797            let track = state.tracks.get(track_name)?;
798            let track = track.lock();
799            let snapshot = track.vst3_snapshot_state(*instance_id).ok()?;
800            Some(Action::TrackVst3RestoreState {
801                track_name: track_name.clone(),
802                instance_id: *instance_id,
803                state: snapshot,
804            })
805        }
806        Action::TrackSetPluginBypassed {
807            track_name,
808            instance_id,
809            format,
810            bypassed,
811        } => {
812            let track = state.tracks.get(track_name)?;
813            let track = track.lock();
814            let current_bypassed = match format.as_str() {
815                "CLAP" => track
816                    .clap_plugins
817                    .iter()
818                    .find(|i| i.id == *instance_id)
819                    .map(|i| i.processor.is_bypassed()),
820                "VST3" => track
821                    .vst3_processors
822                    .iter()
823                    .find(|i| i.id == *instance_id)
824                    .map(|i| i.processor.is_bypassed()),
825                #[cfg(all(unix, not(target_os = "macos")))]
826                "LV2" => track
827                    .lv2_processors
828                    .iter()
829                    .find(|i| i.id == *instance_id)
830                    .map(|i| i.processor.is_bypassed()),
831                _ => None,
832            };
833            Some(Action::TrackSetPluginBypassed {
834                track_name: track_name.clone(),
835                instance_id: *instance_id,
836                format: format.clone(),
837                bypassed: current_bypassed.unwrap_or(!*bypassed),
838            })
839        }
840        #[cfg(all(unix, not(target_os = "macos")))]
841        Action::TrackSetLv2ControlValue {
842            track_name,
843            instance_id,
844            ..
845        } => {
846            let track = state.tracks.get(track_name)?;
847            let track = track.lock();
848            let snapshot = track.lv2_snapshot_state(*instance_id).ok()?;
849            Some(Action::TrackSetLv2PluginState {
850                track_name: track_name.clone(),
851                instance_id: *instance_id,
852                state: snapshot,
853            })
854        }
855        Action::ModifyMidiNotes {
856            track_name,
857            clip_index,
858            note_indices,
859            new_notes,
860            old_notes,
861        } => Some(Action::ModifyMidiNotes {
862            track_name: track_name.clone(),
863            clip_index: *clip_index,
864            note_indices: note_indices.clone(),
865            new_notes: old_notes.clone(),
866            old_notes: new_notes.clone(),
867        }),
868        Action::ModifyMidiControllers {
869            track_name,
870            clip_index,
871            controller_indices,
872            new_controllers,
873            old_controllers,
874        } => Some(Action::ModifyMidiControllers {
875            track_name: track_name.clone(),
876            clip_index: *clip_index,
877            controller_indices: controller_indices.clone(),
878            new_controllers: old_controllers.clone(),
879            old_controllers: new_controllers.clone(),
880        }),
881        Action::DeleteMidiControllers {
882            track_name,
883            clip_index,
884            deleted_controllers,
885            ..
886        } => Some(Action::InsertMidiControllers {
887            track_name: track_name.clone(),
888            clip_index: *clip_index,
889            controllers: deleted_controllers.clone(),
890        }),
891        Action::InsertMidiControllers {
892            track_name,
893            clip_index,
894            controllers,
895        } => {
896            let mut controller_indices: Vec<usize> =
897                controllers.iter().map(|(idx, _)| *idx).collect();
898            controller_indices.sort_unstable_by(|a, b| b.cmp(a));
899            Some(Action::DeleteMidiControllers {
900                track_name: track_name.clone(),
901                clip_index: *clip_index,
902                controller_indices,
903                deleted_controllers: controllers.clone(),
904            })
905        }
906
907        Action::DeleteMidiNotes {
908            track_name,
909            clip_index,
910            deleted_notes,
911            ..
912        } => Some(Action::InsertMidiNotes {
913            track_name: track_name.clone(),
914            clip_index: *clip_index,
915            notes: deleted_notes.clone(),
916        }),
917
918        Action::InsertMidiNotes {
919            track_name,
920            clip_index,
921            notes,
922        } => {
923            let mut note_indices: Vec<usize> = notes.iter().map(|(idx, _)| *idx).collect();
924            note_indices.sort_unstable_by(|a, b| b.cmp(a));
925            Some(Action::DeleteMidiNotes {
926                track_name: track_name.clone(),
927                clip_index: *clip_index,
928                note_indices,
929                deleted_notes: notes.clone(),
930            })
931        }
932        Action::SetMidiSysExEvents {
933            track_name,
934            clip_index,
935            new_sysex_events,
936            old_sysex_events,
937        } => Some(Action::SetMidiSysExEvents {
938            track_name: track_name.clone(),
939            clip_index: *clip_index,
940            new_sysex_events: old_sysex_events.clone(),
941            old_sysex_events: new_sysex_events.clone(),
942        }),
943
944        // These are more complex and would need additional state tracking
945        _ => None,
946    }
947}
948
949pub fn create_inverse_actions(action: &Action, state: &State) -> Option<Vec<Action>> {
950    if let Action::ClearAllMidiLearnBindings = action {
951        let mut actions = Vec::<Action>::new();
952        for (track_name, track) in &state.tracks {
953            let t = track.lock();
954            let mut push_if_some =
955                |target: crate::message::TrackMidiLearnTarget,
956                 binding: Option<crate::message::MidiLearnBinding>| {
957                    if binding.is_some() {
958                        actions.push(Action::TrackSetMidiLearnBinding {
959                            track_name: track_name.clone(),
960                            target,
961                            binding,
962                        });
963                    }
964                };
965            push_if_some(
966                crate::message::TrackMidiLearnTarget::Volume,
967                t.midi_learn_volume.clone(),
968            );
969            push_if_some(
970                crate::message::TrackMidiLearnTarget::Balance,
971                t.midi_learn_balance.clone(),
972            );
973            push_if_some(
974                crate::message::TrackMidiLearnTarget::Mute,
975                t.midi_learn_mute.clone(),
976            );
977            push_if_some(
978                crate::message::TrackMidiLearnTarget::Solo,
979                t.midi_learn_solo.clone(),
980            );
981            push_if_some(
982                crate::message::TrackMidiLearnTarget::Arm,
983                t.midi_learn_arm.clone(),
984            );
985            push_if_some(
986                crate::message::TrackMidiLearnTarget::InputMonitor,
987                t.midi_learn_input_monitor.clone(),
988            );
989            push_if_some(
990                crate::message::TrackMidiLearnTarget::DiskMonitor,
991                t.midi_learn_disk_monitor.clone(),
992            );
993        }
994        return Some(actions);
995    }
996
997    if let Action::RemoveTrack(track_name) = action {
998        let mut actions = Vec::new();
999        {
1000            let track = state.tracks.get(track_name)?;
1001            let track = track.lock();
1002            actions.push(Action::AddTrack {
1003                name: track.name.clone(),
1004                audio_ins: track.primary_audio_ins(),
1005                midi_ins: track.midi.ins.len(),
1006                audio_outs: track.primary_audio_outs(),
1007                midi_outs: track.midi.outs.len(),
1008            });
1009            for _ in track.primary_audio_ins()..track.audio.ins.len() {
1010                actions.push(Action::TrackAddAudioInput(track.name.clone()));
1011            }
1012            for _ in track.primary_audio_outs()..track.audio.outs.len() {
1013                actions.push(Action::TrackAddAudioOutput(track.name.clone()));
1014            }
1015
1016            if track.level != 0.0 {
1017                actions.push(Action::TrackLevel(track.name.clone(), track.level));
1018            }
1019            if track.balance != 0.0 {
1020                actions.push(Action::TrackBalance(track.name.clone(), track.balance));
1021            }
1022            if track.armed {
1023                actions.push(Action::TrackToggleArm(track.name.clone()));
1024            }
1025            if track.muted {
1026                actions.push(Action::TrackToggleMute(track.name.clone()));
1027            }
1028            if track.soloed {
1029                actions.push(Action::TrackToggleSolo(track.name.clone()));
1030            }
1031            if track.input_monitor {
1032                actions.push(Action::TrackToggleInputMonitor(track.name.clone()));
1033            }
1034            if !track.disk_monitor {
1035                actions.push(Action::TrackToggleDiskMonitor(track.name.clone()));
1036            }
1037            if track.midi_learn_volume.is_some() {
1038                actions.push(Action::TrackSetMidiLearnBinding {
1039                    track_name: track.name.clone(),
1040                    target: crate::message::TrackMidiLearnTarget::Volume,
1041                    binding: track.midi_learn_volume.clone(),
1042                });
1043            }
1044            if track.midi_learn_balance.is_some() {
1045                actions.push(Action::TrackSetMidiLearnBinding {
1046                    track_name: track.name.clone(),
1047                    target: crate::message::TrackMidiLearnTarget::Balance,
1048                    binding: track.midi_learn_balance.clone(),
1049                });
1050            }
1051            if track.midi_learn_mute.is_some() {
1052                actions.push(Action::TrackSetMidiLearnBinding {
1053                    track_name: track.name.clone(),
1054                    target: crate::message::TrackMidiLearnTarget::Mute,
1055                    binding: track.midi_learn_mute.clone(),
1056                });
1057            }
1058            if track.midi_learn_solo.is_some() {
1059                actions.push(Action::TrackSetMidiLearnBinding {
1060                    track_name: track.name.clone(),
1061                    target: crate::message::TrackMidiLearnTarget::Solo,
1062                    binding: track.midi_learn_solo.clone(),
1063                });
1064            }
1065            if track.midi_learn_arm.is_some() {
1066                actions.push(Action::TrackSetMidiLearnBinding {
1067                    track_name: track.name.clone(),
1068                    target: crate::message::TrackMidiLearnTarget::Arm,
1069                    binding: track.midi_learn_arm.clone(),
1070                });
1071            }
1072            if track.midi_learn_input_monitor.is_some() {
1073                actions.push(Action::TrackSetMidiLearnBinding {
1074                    track_name: track.name.clone(),
1075                    target: crate::message::TrackMidiLearnTarget::InputMonitor,
1076                    binding: track.midi_learn_input_monitor.clone(),
1077                });
1078            }
1079            if track.midi_learn_disk_monitor.is_some() {
1080                actions.push(Action::TrackSetMidiLearnBinding {
1081                    track_name: track.name.clone(),
1082                    target: crate::message::TrackMidiLearnTarget::DiskMonitor,
1083                    binding: track.midi_learn_disk_monitor.clone(),
1084                });
1085            }
1086            if track.vca_master.is_some() {
1087                actions.push(Action::TrackSetVcaMaster {
1088                    track_name: track.name.clone(),
1089                    master_track: track.vca_master(),
1090                });
1091            }
1092            for (other_name, other_track_handle) in &state.tracks {
1093                if other_name == track_name {
1094                    continue;
1095                }
1096                let other_track = other_track_handle.lock();
1097                if other_track.vca_master.as_deref() == Some(track_name.as_str()) {
1098                    actions.push(Action::TrackSetVcaMaster {
1099                        track_name: other_name.clone(),
1100                        master_track: Some(track_name.clone()),
1101                    });
1102                }
1103            }
1104
1105            for clip in &track.audio.clips {
1106                let length = clip.end.saturating_sub(clip.start).max(1);
1107                actions.push(Action::AddClip {
1108                    name: clip.name.clone(),
1109                    track_name: track.name.clone(),
1110                    start: clip.start,
1111                    length,
1112                    offset: clip.offset,
1113                    input_channel: clip.input_channel,
1114                    muted: clip.muted,
1115                    peaks_file: clip.peaks_file.clone(),
1116                    kind: Kind::Audio,
1117                    fade_enabled: clip.fade_enabled,
1118                    fade_in_samples: clip.fade_in_samples,
1119                    fade_out_samples: clip.fade_out_samples,
1120                    source_name: clip.pitch_correction_source_name.clone(),
1121                    source_offset: clip.pitch_correction_source_offset,
1122                    source_length: clip.pitch_correction_source_length,
1123                    preview_name: clip.pitch_correction_preview_name.clone(),
1124                    pitch_correction_points: clip.pitch_correction_points.clone(),
1125                    pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
1126                    pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
1127                    pitch_correction_formant_compensation: clip
1128                        .pitch_correction_formant_compensation,
1129                    plugin_graph_json: clip.plugin_graph_json.clone(),
1130                });
1131            }
1132            for clip in &track.midi.clips {
1133                let length = clip.end.saturating_sub(clip.start).max(1);
1134                actions.push(Action::AddClip {
1135                    name: clip.name.clone(),
1136                    track_name: track.name.clone(),
1137                    start: clip.start,
1138                    length,
1139                    offset: clip.offset,
1140                    input_channel: clip.input_channel,
1141                    muted: clip.muted,
1142                    peaks_file: None,
1143                    kind: Kind::MIDI,
1144                    fade_enabled: true,
1145                    fade_in_samples: 240,
1146                    fade_out_samples: 240,
1147                    source_name: None,
1148                    source_offset: None,
1149                    source_length: None,
1150                    preview_name: None,
1151                    pitch_correction_points: vec![],
1152                    pitch_correction_frame_likeness: None,
1153                    pitch_correction_inertia_ms: None,
1154                    pitch_correction_formant_compensation: None,
1155                    plugin_graph_json: None,
1156                });
1157            }
1158        }
1159
1160        let mut seen_audio = std::collections::HashSet::<(String, usize, String, usize)>::new();
1161        let mut seen_midi = std::collections::HashSet::<(String, usize, String, usize)>::new();
1162
1163        for (from_name, from_track_handle) in &state.tracks {
1164            let from_track = from_track_handle.lock();
1165            for (from_port, out) in from_track.audio.outs.iter().enumerate() {
1166                let conns: Vec<Arc<AudioIO>> = out.connections.lock().to_vec();
1167                for conn in conns {
1168                    for (to_name, to_track_handle) in &state.tracks {
1169                        let to_track = to_track_handle.lock();
1170                        for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
1171                            if Arc::ptr_eq(&conn, to_in)
1172                                && (from_name == track_name || to_name == track_name)
1173                                && seen_audio.insert((
1174                                    from_name.clone(),
1175                                    from_port,
1176                                    to_name.clone(),
1177                                    to_port,
1178                                ))
1179                            {
1180                                actions.push(Action::Connect {
1181                                    from_track: from_name.clone(),
1182                                    from_port,
1183                                    to_track: to_name.clone(),
1184                                    to_port,
1185                                    kind: Kind::Audio,
1186                                });
1187                            }
1188                        }
1189                    }
1190                }
1191            }
1192
1193            for (from_port, out) in from_track.midi.outs.iter().enumerate() {
1194                let conns: Vec<Arc<crate::mutex::UnsafeMutex<Box<MIDIIO>>>> =
1195                    out.lock().connections.to_vec();
1196                for conn in conns {
1197                    for (to_name, to_track_handle) in &state.tracks {
1198                        let to_track = to_track_handle.lock();
1199                        for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
1200                            if Arc::ptr_eq(&conn, to_in)
1201                                && (from_name == track_name || to_name == track_name)
1202                                && seen_midi.insert((
1203                                    from_name.clone(),
1204                                    from_port,
1205                                    to_name.clone(),
1206                                    to_port,
1207                                ))
1208                            {
1209                                actions.push(Action::Connect {
1210                                    from_track: from_name.clone(),
1211                                    from_port,
1212                                    to_track: to_name.clone(),
1213                                    to_port,
1214                                    kind: Kind::MIDI,
1215                                });
1216                            }
1217                        }
1218                    }
1219                }
1220            }
1221        }
1222
1223        for (to_name, to_track_handle) in &state.tracks {
1224            if to_name != track_name {
1225                continue;
1226            }
1227            let to_track = to_track_handle.lock();
1228            for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
1229                for (from_name, from_track_handle) in &state.tracks {
1230                    let from_track = from_track_handle.lock();
1231                    for (from_port, out) in from_track.audio.outs.iter().enumerate() {
1232                        let conns: Vec<Arc<AudioIO>> = out.connections.lock().to_vec();
1233                        if conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
1234                            && seen_audio.insert((
1235                                from_name.clone(),
1236                                from_port,
1237                                to_name.clone(),
1238                                to_port,
1239                            ))
1240                        {
1241                            actions.push(Action::Connect {
1242                                from_track: from_name.clone(),
1243                                from_port,
1244                                to_track: to_name.clone(),
1245                                to_port,
1246                                kind: Kind::Audio,
1247                            });
1248                        }
1249                    }
1250                }
1251            }
1252            for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
1253                for (from_name, from_track_handle) in &state.tracks {
1254                    let from_track = from_track_handle.lock();
1255                    for (from_port, out) in from_track.midi.outs.iter().enumerate() {
1256                        let conns: Vec<Arc<crate::mutex::UnsafeMutex<Box<MIDIIO>>>> =
1257                            out.lock().connections.to_vec();
1258                        if conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
1259                            && seen_midi.insert((
1260                                from_name.clone(),
1261                                from_port,
1262                                to_name.clone(),
1263                                to_port,
1264                            ))
1265                        {
1266                            actions.push(Action::Connect {
1267                                from_track: from_name.clone(),
1268                                from_port,
1269                                to_track: to_name.clone(),
1270                                to_port,
1271                                kind: Kind::MIDI,
1272                            });
1273                        }
1274                    }
1275                }
1276            }
1277        }
1278
1279        return Some(actions);
1280    }
1281
1282    create_inverse_action(action, state).map(|a| vec![a])
1283}
1284
1285#[cfg(test)]
1286mod tests {
1287    use super::*;
1288    use crate::audio::clip::AudioClip;
1289    use crate::kind::Kind;
1290    #[cfg(all(unix, not(target_os = "macos")))]
1291    use crate::message::Lv2PluginState;
1292    use crate::message::{MidiLearnBinding, TrackMidiLearnTarget};
1293    use crate::mutex::UnsafeMutex;
1294    use crate::track::Track;
1295    use crate::vst3::Vst3PluginState;
1296    use std::sync::Arc;
1297
1298    fn make_state_with_track(track: Track) -> State {
1299        let mut state = State::default();
1300        state.tracks.insert(
1301            track.name.clone(),
1302            Arc::new(UnsafeMutex::new(Box::new(track))),
1303        );
1304        state
1305    }
1306
1307    fn binding(cc: u8) -> MidiLearnBinding {
1308        MidiLearnBinding {
1309            device: Some("midi".to_string()),
1310            channel: 1,
1311            cc,
1312        }
1313    }
1314
1315    #[test]
1316    fn history_record_limits_size_and_clears_redo_on_new_entry() {
1317        let mut history = History::new(2);
1318        let a = UndoEntry {
1319            forward_actions: vec![Action::SetTempo(120.0)],
1320            inverse_actions: vec![Action::SetTempo(110.0)],
1321        };
1322        let b = UndoEntry {
1323            forward_actions: vec![Action::SetLoopEnabled(true)],
1324            inverse_actions: vec![Action::SetLoopEnabled(false)],
1325        };
1326        let c = UndoEntry {
1327            forward_actions: vec![Action::SetMetronomeEnabled(true)],
1328            inverse_actions: vec![Action::SetMetronomeEnabled(false)],
1329        };
1330
1331        history.record(a);
1332        history.record(b.clone());
1333        history.record(c.clone());
1334
1335        let undo = history.undo().unwrap();
1336        assert!(matches!(
1337            undo.as_slice(),
1338            [Action::SetMetronomeEnabled(false)]
1339        ));
1340
1341        let redo = history.redo().unwrap();
1342        assert!(matches!(
1343            redo.as_slice(),
1344            [Action::SetMetronomeEnabled(true)]
1345        ));
1346
1347        history.undo();
1348        history.record(UndoEntry {
1349            forward_actions: vec![Action::SetClipPlaybackEnabled(true)],
1350            inverse_actions: vec![Action::SetClipPlaybackEnabled(false)],
1351        });
1352
1353        assert!(history.redo().is_none());
1354        let undo = history.undo().unwrap();
1355        assert!(matches!(
1356            undo.as_slice(),
1357            [Action::SetClipPlaybackEnabled(false)]
1358        ));
1359        let undo = history.undo().unwrap();
1360        assert!(matches!(undo.as_slice(), [Action::SetLoopEnabled(false)]));
1361        assert!(history.undo().is_none());
1362    }
1363
1364    #[test]
1365    fn history_clear_removes_pending_undo_and_redo_entries() {
1366        let mut history = History::new(4);
1367        history.record(UndoEntry {
1368            forward_actions: vec![Action::SetTempo(120.0)],
1369            inverse_actions: vec![Action::SetTempo(100.0)],
1370        });
1371        history.record(UndoEntry {
1372            forward_actions: vec![Action::SetLoopEnabled(true)],
1373            inverse_actions: vec![Action::SetLoopEnabled(false)],
1374        });
1375
1376        assert!(history.undo().is_some());
1377        assert!(history.redo().is_some());
1378
1379        history.clear();
1380
1381        assert!(history.undo().is_none());
1382        assert!(history.redo().is_none());
1383    }
1384
1385    #[test]
1386    fn history_with_zero_capacity_discards_recorded_entries() {
1387        let mut history = History::new(0);
1388        history.record(UndoEntry {
1389            forward_actions: vec![Action::SetTempo(120.0)],
1390            inverse_actions: vec![Action::SetTempo(100.0)],
1391        });
1392
1393        assert!(history.undo().is_none());
1394        assert!(history.redo().is_none());
1395    }
1396
1397    #[test]
1398    fn should_record_covers_recent_transport_and_lv2_actions() {
1399        assert!(should_record(&Action::SetLoopEnabled(true)));
1400        assert!(should_record(&Action::SetLoopRange(Some((64, 128)))));
1401        assert!(should_record(&Action::SetPunchEnabled(true)));
1402        assert!(should_record(&Action::SetPunchRange(Some((32, 96)))));
1403        assert!(should_record(&Action::SetMetronomeEnabled(true)));
1404        assert!(!should_record(&Action::SetClipPlaybackEnabled(false)));
1405        assert!(!should_record(&Action::SetRecordEnabled(true)));
1406        assert!(should_record(&Action::SetClipBounds {
1407            track_name: "t".to_string(),
1408            clip_index: 0,
1409            kind: Kind::Audio,
1410            start: 64,
1411            length: 32,
1412            offset: 16,
1413        }));
1414        assert!(should_record(&Action::TrackLoadVst3Plugin {
1415            track_name: "t".to_string(),
1416            plugin_path: "/tmp/test.vst3".to_string(),
1417        }));
1418        #[cfg(all(unix, not(target_os = "macos")))]
1419        {
1420            assert!(should_record(&Action::TrackLoadLv2Plugin {
1421                track_name: "t".to_string(),
1422                plugin_uri: "urn:test".to_string(),
1423            }));
1424            assert!(should_record(&Action::TrackSetLv2ControlValue {
1425                track_name: "t".to_string(),
1426                instance_id: 0,
1427                index: 1,
1428                value: 0.5,
1429            }));
1430            assert!(!should_record(&Action::TrackSetLv2PluginState {
1431                track_name: "t".to_string(),
1432                instance_id: 0,
1433                state: Lv2PluginState {
1434                    port_values: vec![],
1435                    properties: vec![],
1436                },
1437            }));
1438        }
1439        assert!(!should_record(&Action::TrackVst3RestoreState {
1440            track_name: "t".to_string(),
1441            instance_id: 0,
1442            state: Vst3PluginState {
1443                plugin_id: "id".to_string(),
1444                component_state: vec![],
1445                controller_state: vec![],
1446            },
1447        }));
1448    }
1449
1450    #[test]
1451    fn create_inverse_action_for_add_clip_targets_next_clip_index() {
1452        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1453        track
1454            .audio
1455            .clips
1456            .push(AudioClip::new("existing".to_string(), 0, 16));
1457        let state = make_state_with_track(track);
1458
1459        let inverse = create_inverse_action(
1460            &Action::AddClip {
1461                name: "new".to_string(),
1462                track_name: "t".to_string(),
1463                start: 32,
1464                length: 16,
1465                offset: 0,
1466                input_channel: 0,
1467                muted: false,
1468                peaks_file: None,
1469                kind: Kind::Audio,
1470                fade_enabled: false,
1471                fade_in_samples: 0,
1472                fade_out_samples: 0,
1473                source_name: None,
1474                source_offset: None,
1475                source_length: None,
1476                preview_name: None,
1477                pitch_correction_points: vec![],
1478                pitch_correction_frame_likeness: None,
1479                pitch_correction_inertia_ms: None,
1480                pitch_correction_formant_compensation: None,
1481                plugin_graph_json: None,
1482            },
1483            &state,
1484        )
1485        .unwrap();
1486
1487        match inverse {
1488            Action::RemoveClip {
1489                track_name,
1490                kind,
1491                clip_indices,
1492            } => {
1493                assert_eq!(track_name, "t");
1494                assert_eq!(kind, Kind::Audio);
1495                assert_eq!(clip_indices, vec![1]);
1496            }
1497            other => panic!("unexpected inverse action: {other:?}"),
1498        }
1499    }
1500
1501    #[test]
1502    fn create_inverse_action_for_set_clip_bounds_restores_previous_audio_bounds() {
1503        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1504        let mut clip = AudioClip::new("clip".to_string(), 10, 30);
1505        clip.offset = 7;
1506        track.audio.clips.push(clip);
1507        let state = make_state_with_track(track);
1508
1509        let inverse = create_inverse_action(
1510            &Action::SetClipBounds {
1511                track_name: "t".to_string(),
1512                clip_index: 0,
1513                kind: Kind::Audio,
1514                start: 14,
1515                length: 22,
1516                offset: 11,
1517            },
1518            &state,
1519        )
1520        .expect("inverse action");
1521
1522        match inverse {
1523            Action::SetClipBounds {
1524                track_name,
1525                clip_index,
1526                kind,
1527                start,
1528                length,
1529                offset,
1530            } => {
1531                assert_eq!(track_name, "t");
1532                assert_eq!(clip_index, 0);
1533                assert_eq!(kind, Kind::Audio);
1534                assert_eq!(start, 10);
1535                assert_eq!(length, 30);
1536                assert_eq!(offset, 7);
1537            }
1538            other => panic!("unexpected inverse action: {other:?}"),
1539        }
1540    }
1541
1542    #[test]
1543    fn create_inverse_action_for_set_clip_bounds_restores_previous_midi_bounds() {
1544        let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1545        track.midi.clips.push(crate::midi::clip::MIDIClip {
1546            name: "pattern.mid".to_string(),
1547            start: 24,
1548            end: 120,
1549            offset: 9,
1550            ..Default::default()
1551        });
1552        let state = make_state_with_track(track);
1553
1554        let inverse = create_inverse_action(
1555            &Action::SetClipBounds {
1556                track_name: "t".to_string(),
1557                clip_index: 0,
1558                kind: Kind::MIDI,
1559                start: 32,
1560                length: 48,
1561                offset: 4,
1562            },
1563            &state,
1564        )
1565        .expect("inverse action");
1566
1567        match inverse {
1568            Action::SetClipBounds {
1569                track_name,
1570                clip_index,
1571                kind,
1572                start,
1573                length,
1574                offset,
1575            } => {
1576                assert_eq!(track_name, "t");
1577                assert_eq!(clip_index, 0);
1578                assert_eq!(kind, Kind::MIDI);
1579                assert_eq!(start, 24);
1580                assert_eq!(length, 120);
1581                assert_eq!(offset, 9);
1582            }
1583            other => panic!("unexpected inverse action: {other:?}"),
1584        }
1585    }
1586
1587    #[test]
1588    fn create_inverse_action_for_set_clip_muted_restores_audio_and_midi_flags() {
1589        let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
1590        let mut audio_clip = AudioClip::new("audio.wav".to_string(), 0, 16);
1591        audio_clip.muted = true;
1592        track.audio.clips.push(audio_clip);
1593        let midi_clip = crate::midi::clip::MIDIClip {
1594            name: "pattern.mid".to_string(),
1595            muted: false,
1596            ..Default::default()
1597        };
1598        track.midi.clips.push(midi_clip);
1599        let state = make_state_with_track(track);
1600
1601        let audio_inverse = create_inverse_action(
1602            &Action::SetClipMuted {
1603                track_name: "t".to_string(),
1604                clip_index: 0,
1605                kind: Kind::Audio,
1606                muted: false,
1607            },
1608            &state,
1609        )
1610        .expect("audio inverse");
1611        let midi_inverse = create_inverse_action(
1612            &Action::SetClipMuted {
1613                track_name: "t".to_string(),
1614                clip_index: 0,
1615                kind: Kind::MIDI,
1616                muted: true,
1617            },
1618            &state,
1619        )
1620        .expect("midi inverse");
1621
1622        assert!(matches!(
1623            audio_inverse,
1624            Action::SetClipMuted {
1625                muted: true,
1626                kind: Kind::Audio,
1627                ..
1628            }
1629        ));
1630        assert!(matches!(
1631            midi_inverse,
1632            Action::SetClipMuted {
1633                muted: false,
1634                kind: Kind::MIDI,
1635                ..
1636            }
1637        ));
1638    }
1639
1640    #[test]
1641    fn create_inverse_action_for_rename_clip_restores_previous_name() {
1642        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1643        track
1644            .audio
1645            .clips
1646            .push(AudioClip::new("before.wav".to_string(), 0, 16));
1647        let state = make_state_with_track(track);
1648
1649        let inverse = create_inverse_action(
1650            &Action::RenameClip {
1651                track_name: "t".to_string(),
1652                kind: Kind::Audio,
1653                clip_index: 0,
1654                new_name: "after.wav".to_string(),
1655            },
1656            &state,
1657        )
1658        .expect("inverse action");
1659
1660        assert!(matches!(
1661            inverse,
1662            Action::RenameClip { new_name, kind: Kind::Audio, .. } if new_name == "before.wav"
1663        ));
1664    }
1665
1666    #[test]
1667    fn create_inverse_action_for_track_set_vca_master_restores_none() {
1668        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1669        let state = make_state_with_track(track);
1670
1671        let inverse = create_inverse_action(
1672            &Action::TrackSetVcaMaster {
1673                track_name: "t".to_string(),
1674                master_track: Some("bus".to_string()),
1675            },
1676            &state,
1677        )
1678        .expect("inverse action");
1679
1680        assert!(matches!(
1681            inverse,
1682            Action::TrackSetVcaMaster { track_name, master_track: None } if track_name == "t"
1683        ));
1684    }
1685
1686    #[test]
1687    fn create_inverse_action_for_remove_audio_clip_restores_peaks_file() {
1688        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1689        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
1690        clip.offset = 12;
1691        clip.input_channel = 0;
1692        clip.muted = true;
1693        clip.peaks_file = Some("peaks/clip.json".to_string());
1694        track.audio.clips.push(clip);
1695        let state = make_state_with_track(track);
1696
1697        let inverse = create_inverse_action(
1698            &Action::RemoveClip {
1699                track_name: "t".to_string(),
1700                kind: Kind::Audio,
1701                clip_indices: vec![0],
1702            },
1703            &state,
1704        )
1705        .expect("inverse action");
1706
1707        match inverse {
1708            Action::AddClip {
1709                name,
1710                track_name,
1711                start,
1712                length,
1713                offset,
1714                input_channel,
1715                muted,
1716                peaks_file,
1717                kind,
1718                ..
1719            } => {
1720                assert_eq!(name, "audio/clip.wav");
1721                assert_eq!(track_name, "t");
1722                assert_eq!(start, 48);
1723                assert_eq!(length, 96);
1724                assert_eq!(offset, 12);
1725                assert_eq!(input_channel, 0);
1726                assert!(muted);
1727                assert_eq!(peaks_file.as_deref(), Some("peaks/clip.json"));
1728                assert_eq!(kind, Kind::Audio);
1729            }
1730            other => panic!("unexpected inverse action: {other:?}"),
1731        }
1732    }
1733
1734    #[test]
1735    fn create_inverse_action_for_remove_grouped_audio_clip_restores_group() {
1736        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1737        let mut group = AudioClip::new("Group".to_string(), 48, 144);
1738        group
1739            .grouped_clips
1740            .push(AudioClip::new("child.wav".to_string(), 0, 32));
1741        track.audio.clips.push(group);
1742        let state = make_state_with_track(track);
1743
1744        let inverse = create_inverse_action(
1745            &Action::RemoveClip {
1746                track_name: "t".to_string(),
1747                kind: Kind::Audio,
1748                clip_indices: vec![0],
1749            },
1750            &state,
1751        )
1752        .expect("inverse action");
1753
1754        match inverse {
1755            Action::AddGroupedClip {
1756                track_name,
1757                kind,
1758                audio_clip,
1759                midi_clip,
1760            } => {
1761                assert_eq!(track_name, "t");
1762                assert_eq!(kind, Kind::Audio);
1763                assert!(midi_clip.is_none());
1764                let audio_clip = audio_clip.expect("audio clip payload");
1765                assert_eq!(audio_clip.name, "Group");
1766                assert_eq!(audio_clip.grouped_clips.len(), 1);
1767                assert_eq!(audio_clip.grouped_clips[0].name, "child.wav");
1768            }
1769            other => panic!("unexpected inverse action: {other:?}"),
1770        }
1771    }
1772
1773    #[test]
1774    fn create_inverse_action_for_remove_midi_clip_restores_clip() {
1775        let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1776        track.midi.clips.push(crate::midi::clip::MIDIClip {
1777            name: "pattern.mid".to_string(),
1778            start: 48,
1779            end: 144,
1780            offset: 12,
1781            input_channel: 3,
1782            muted: true,
1783            ..Default::default()
1784        });
1785        let state = make_state_with_track(track);
1786
1787        let inverse = create_inverse_action(
1788            &Action::RemoveClip {
1789                track_name: "t".to_string(),
1790                kind: Kind::MIDI,
1791                clip_indices: vec![0],
1792            },
1793            &state,
1794        )
1795        .expect("inverse action");
1796
1797        match inverse {
1798            Action::AddClip {
1799                name,
1800                track_name,
1801                start,
1802                length,
1803                offset,
1804                input_channel,
1805                muted,
1806                kind,
1807                ..
1808            } => {
1809                assert_eq!(name, "pattern.mid");
1810                assert_eq!(track_name, "t");
1811                assert_eq!(start, 48);
1812                assert_eq!(length, 96);
1813                assert_eq!(offset, 12);
1814                assert_eq!(input_channel, 3);
1815                assert!(muted);
1816                assert_eq!(kind, Kind::MIDI);
1817            }
1818            other => panic!("unexpected inverse action: {other:?}"),
1819        }
1820    }
1821
1822    #[test]
1823    fn create_inverse_action_for_remove_grouped_midi_clip_restores_group() {
1824        let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1825        let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
1826        group.grouped_clips.push(crate::midi::clip::MIDIClip::new(
1827            "child.mid".to_string(),
1828            0,
1829            48,
1830        ));
1831        track.midi.clips.push(group);
1832        let state = make_state_with_track(track);
1833
1834        let inverse = create_inverse_action(
1835            &Action::RemoveClip {
1836                track_name: "t".to_string(),
1837                kind: Kind::MIDI,
1838                clip_indices: vec![0],
1839            },
1840            &state,
1841        )
1842        .expect("inverse action");
1843
1844        match inverse {
1845            Action::AddGroupedClip {
1846                track_name,
1847                kind,
1848                audio_clip,
1849                midi_clip,
1850            } => {
1851                assert_eq!(track_name, "t");
1852                assert_eq!(kind, Kind::MIDI);
1853                assert!(audio_clip.is_none());
1854                let midi_clip = midi_clip.expect("midi clip payload");
1855                assert_eq!(midi_clip.name, "Group");
1856                assert_eq!(midi_clip.grouped_clips.len(), 1);
1857                assert_eq!(midi_clip.grouped_clips[0].name, "child.mid");
1858            }
1859            other => panic!("unexpected inverse action: {other:?}"),
1860        }
1861    }
1862
1863    #[test]
1864    fn create_inverse_action_for_remove_grouped_audio_clip_preserves_child_metadata() {
1865        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1866        let mut child = AudioClip::new("child.wav".to_string(), 4, 40);
1867        child.peaks_file = Some("peaks/child.json".to_string());
1868        child.pitch_correction_source_name = Some("source.wav".to_string());
1869        child.pitch_correction_source_offset = Some(8);
1870        child.pitch_correction_source_length = Some(24);
1871        child.pitch_correction_preview_name = Some("preview.wav".to_string());
1872        child.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
1873            start_sample: 1,
1874            length_samples: 2,
1875            detected_midi_pitch: 60.0,
1876            target_midi_pitch: 62.0,
1877            clarity: 0.75,
1878        }];
1879        child.pitch_correction_frame_likeness = Some(0.25);
1880        child.pitch_correction_inertia_ms = Some(100);
1881        child.pitch_correction_formant_compensation = Some(true);
1882        child.plugin_graph_json = Some(serde_json::json!({"plugins":[],"connections":[]}));
1883        let mut group = AudioClip::new("Group".to_string(), 48, 144);
1884        group.grouped_clips.push(child);
1885        track.audio.clips.push(group);
1886        let state = make_state_with_track(track);
1887
1888        let inverse = create_inverse_action(
1889            &Action::RemoveClip {
1890                track_name: "t".to_string(),
1891                kind: Kind::Audio,
1892                clip_indices: vec![0],
1893            },
1894            &state,
1895        )
1896        .expect("inverse action");
1897
1898        match inverse {
1899            Action::AddGroupedClip {
1900                audio_clip: Some(audio_clip),
1901                ..
1902            } => {
1903                let child = &audio_clip.grouped_clips[0];
1904                assert_eq!(child.peaks_file.as_deref(), Some("peaks/child.json"));
1905                assert_eq!(child.source_name.as_deref(), Some("source.wav"));
1906                assert_eq!(child.source_offset, Some(8));
1907                assert_eq!(child.source_length, Some(24));
1908                assert_eq!(child.preview_name.as_deref(), Some("preview.wav"));
1909                assert_eq!(child.pitch_correction_points.len(), 1);
1910                assert_eq!(child.pitch_correction_frame_likeness, Some(0.25));
1911                assert_eq!(child.pitch_correction_inertia_ms, Some(100));
1912                assert_eq!(child.pitch_correction_formant_compensation, Some(true));
1913                assert!(child.plugin_graph_json.is_some());
1914            }
1915            other => panic!("unexpected inverse action: {other:?}"),
1916        }
1917    }
1918
1919    #[test]
1920    fn create_inverse_action_for_remove_grouped_midi_clip_preserves_child_structure() {
1921        let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1922        let child = crate::midi::clip::MIDIClip::new("child.mid".to_string(), 0, 48);
1923        let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
1924        group.grouped_clips.push(child);
1925        track.midi.clips.push(group);
1926        let state = make_state_with_track(track);
1927
1928        let inverse = create_inverse_action(
1929            &Action::RemoveClip {
1930                track_name: "t".to_string(),
1931                kind: Kind::MIDI,
1932                clip_indices: vec![0],
1933            },
1934            &state,
1935        )
1936        .expect("inverse action");
1937
1938        match inverse {
1939            Action::AddGroupedClip {
1940                midi_clip: Some(midi_clip),
1941                ..
1942            } => {
1943                let child = &midi_clip.grouped_clips[0];
1944                assert_eq!(child.name, "child.mid");
1945                assert_eq!(child.start, 0);
1946                assert_eq!(child.length, 48);
1947            }
1948            other => panic!("unexpected inverse action: {other:?}"),
1949        }
1950    }
1951
1952    #[test]
1953    fn create_inverse_action_for_set_clip_pitch_correction_restores_previous_values() {
1954        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1955        let mut clip = AudioClip::new("audio.wav".to_string(), 0, 128);
1956        clip.pitch_correction_preview_name = Some("audio_preview.wav".to_string());
1957        clip.pitch_correction_source_name = Some("audio_source.wav".to_string());
1958        clip.pitch_correction_source_offset = Some(12);
1959        clip.pitch_correction_source_length = Some(96);
1960        clip.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
1961            start_sample: 4,
1962            length_samples: 32,
1963            detected_midi_pitch: 60.2,
1964            target_midi_pitch: 61.0,
1965            clarity: 0.8,
1966        }];
1967        clip.pitch_correction_frame_likeness = Some(0.4);
1968        clip.pitch_correction_inertia_ms = Some(250);
1969        clip.pitch_correction_formant_compensation = Some(false);
1970        track.audio.clips.push(clip);
1971        let state = make_state_with_track(track);
1972
1973        let inverse = create_inverse_action(
1974            &Action::SetClipPitchCorrection {
1975                track_name: "t".to_string(),
1976                clip_index: 0,
1977                preview_name: None,
1978                source_name: None,
1979                source_offset: None,
1980                source_length: None,
1981                pitch_correction_points: vec![],
1982                pitch_correction_frame_likeness: None,
1983                pitch_correction_inertia_ms: None,
1984                pitch_correction_formant_compensation: None,
1985            },
1986            &state,
1987        )
1988        .expect("inverse action");
1989
1990        match inverse {
1991            Action::SetClipPitchCorrection {
1992                track_name,
1993                clip_index,
1994                preview_name,
1995                source_name,
1996                source_offset,
1997                source_length,
1998                pitch_correction_points,
1999                pitch_correction_frame_likeness,
2000                pitch_correction_inertia_ms,
2001                pitch_correction_formant_compensation,
2002            } => {
2003                assert_eq!(track_name, "t");
2004                assert_eq!(clip_index, 0);
2005                assert_eq!(preview_name.as_deref(), Some("audio_preview.wav"));
2006                assert_eq!(source_name.as_deref(), Some("audio_source.wav"));
2007                assert_eq!(source_offset, Some(12));
2008                assert_eq!(source_length, Some(96));
2009                assert_eq!(pitch_correction_points.len(), 1);
2010                assert_eq!(pitch_correction_points[0].target_midi_pitch, 61.0);
2011                assert_eq!(pitch_correction_frame_likeness, Some(0.4));
2012                assert_eq!(pitch_correction_inertia_ms, Some(250));
2013                assert_eq!(pitch_correction_formant_compensation, Some(false));
2014            }
2015            other => panic!("unexpected inverse action: {other:?}"),
2016        }
2017    }
2018
2019    #[test]
2020    fn create_inverse_action_for_clip_copy_targets_new_destination_clip() {
2021        let mut source = Track::new("src".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2022        source
2023            .audio
2024            .clips
2025            .push(AudioClip::new("source.wav".to_string(), 12, 48));
2026        let mut dest = Track::new("dst".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2027        dest.audio
2028            .clips
2029            .push(AudioClip::new("existing.wav".to_string(), 0, 24));
2030
2031        let mut state = State::default();
2032        state.tracks.insert(
2033            source.name.clone(),
2034            Arc::new(UnsafeMutex::new(Box::new(source))),
2035        );
2036        state.tracks.insert(
2037            dest.name.clone(),
2038            Arc::new(UnsafeMutex::new(Box::new(dest))),
2039        );
2040
2041        let inverse = create_inverse_action(
2042            &Action::ClipMove {
2043                kind: Kind::Audio,
2044                from: ClipMoveFrom {
2045                    track_name: "src".to_string(),
2046                    clip_index: 0,
2047                },
2048                to: ClipMoveTo {
2049                    track_name: "dst".to_string(),
2050                    sample_offset: 96,
2051                    input_channel: 0,
2052                },
2053                copy: true,
2054            },
2055            &state,
2056        )
2057        .expect("inverse action");
2058
2059        match inverse {
2060            Action::RemoveClip {
2061                track_name,
2062                kind,
2063                clip_indices,
2064            } => {
2065                assert_eq!(track_name, "dst");
2066                assert_eq!(kind, Kind::Audio);
2067                assert_eq!(clip_indices, vec![1]);
2068            }
2069            other => panic!("unexpected inverse action: {other:?}"),
2070        }
2071    }
2072
2073    #[test]
2074    fn create_inverse_action_for_same_track_clip_move_reverses_last_destination_clip() {
2075        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2076        let mut original = AudioClip::new("clip.wav".to_string(), 20, 40);
2077        original.input_channel = 2;
2078        let moved = AudioClip::new("moved.wav".to_string(), 80, 32);
2079        track.audio.clips.push(original);
2080        track.audio.clips.push(moved);
2081        let state = make_state_with_track(track);
2082
2083        let inverse = create_inverse_action(
2084            &Action::ClipMove {
2085                kind: Kind::Audio,
2086                from: ClipMoveFrom {
2087                    track_name: "t".to_string(),
2088                    clip_index: 0,
2089                },
2090                to: ClipMoveTo {
2091                    track_name: "t".to_string(),
2092                    sample_offset: 80,
2093                    input_channel: 1,
2094                },
2095                copy: false,
2096            },
2097            &state,
2098        )
2099        .expect("inverse action");
2100
2101        match inverse {
2102            Action::ClipMove {
2103                kind,
2104                from,
2105                to,
2106                copy,
2107            } => {
2108                assert_eq!(kind, Kind::Audio);
2109                assert_eq!(from.track_name, "t");
2110                assert_eq!(from.clip_index, 1);
2111                assert_eq!(to.track_name, "t");
2112                assert_eq!(to.sample_offset, 20);
2113                assert_eq!(to.input_channel, 2);
2114                assert!(!copy);
2115            }
2116            other => panic!("unexpected inverse action: {other:?}"),
2117        }
2118    }
2119
2120    #[test]
2121    fn create_inverse_action_for_track_midi_binding_restores_previous_binding() {
2122        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2123        track.midi_learn_volume = Some(binding(7));
2124        let state = make_state_with_track(track);
2125
2126        let inverse = create_inverse_action(
2127            &Action::TrackSetMidiLearnBinding {
2128                track_name: "t".to_string(),
2129                target: TrackMidiLearnTarget::Volume,
2130                binding: Some(binding(9)),
2131            },
2132            &state,
2133        )
2134        .unwrap();
2135
2136        match inverse {
2137            Action::TrackSetMidiLearnBinding {
2138                track_name,
2139                target,
2140                binding,
2141            } => {
2142                assert_eq!(track_name, "t");
2143                assert_eq!(target, TrackMidiLearnTarget::Volume);
2144                assert_eq!(binding.unwrap().cc, 7);
2145            }
2146            other => panic!("unexpected inverse action: {other:?}"),
2147        }
2148    }
2149
2150    #[test]
2151    fn create_inverse_action_for_vst3_load_uses_next_runtime_instance_id() {
2152        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2153        track.next_plugin_instance_id = 42;
2154        let state = make_state_with_track(track);
2155
2156        let inverse = create_inverse_action(
2157            &Action::TrackLoadVst3Plugin {
2158                track_name: "t".to_string(),
2159                plugin_path: "/tmp/test.vst3".to_string(),
2160            },
2161            &state,
2162        )
2163        .unwrap();
2164
2165        match inverse {
2166            Action::TrackUnloadVst3PluginInstance {
2167                track_name,
2168                instance_id,
2169            } => {
2170                assert_eq!(track_name, "t");
2171                assert_eq!(instance_id, 42);
2172            }
2173            other => panic!("unexpected inverse action: {other:?}"),
2174        }
2175    }
2176
2177    #[test]
2178    #[cfg(all(unix, not(target_os = "macos")))]
2179    fn create_inverse_action_for_lv2_load_uses_next_runtime_instance_id() {
2180        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2181        track.next_lv2_instance_id = 5;
2182        let state = make_state_with_track(track);
2183
2184        let inverse = create_inverse_action(
2185            &Action::TrackLoadLv2Plugin {
2186                track_name: "t".to_string(),
2187                plugin_uri: "urn:test".to_string(),
2188            },
2189            &state,
2190        )
2191        .unwrap();
2192
2193        match inverse {
2194            Action::TrackUnloadLv2PluginInstance {
2195                track_name,
2196                instance_id,
2197            } => {
2198                assert_eq!(track_name, "t");
2199                assert_eq!(instance_id, 5);
2200            }
2201            other => panic!("unexpected inverse action: {other:?}"),
2202        }
2203    }
2204
2205    #[test]
2206    fn create_inverse_actions_for_clear_all_midi_learn_bindings_restores_only_existing_bindings() {
2207        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2208        track.midi_learn_volume = Some(binding(7));
2209        track.midi_learn_disk_monitor = Some(binding(64));
2210        let state = make_state_with_track(track);
2211
2212        let inverses = create_inverse_actions(&Action::ClearAllMidiLearnBindings, &state).unwrap();
2213
2214        assert_eq!(inverses.len(), 2);
2215        assert!(inverses.iter().any(|action| {
2216            matches!(
2217                action,
2218                Action::TrackSetMidiLearnBinding {
2219                    target: TrackMidiLearnTarget::Volume,
2220                    binding: Some(MidiLearnBinding { cc: 7, .. }),
2221                    ..
2222                }
2223            )
2224        }));
2225        assert!(inverses.iter().any(|action| {
2226            matches!(
2227                action,
2228                Action::TrackSetMidiLearnBinding {
2229                    target: TrackMidiLearnTarget::DiskMonitor,
2230                    binding: Some(MidiLearnBinding { cc: 64, .. }),
2231                    ..
2232                }
2233            )
2234        }));
2235    }
2236
2237    #[test]
2238    fn create_inverse_actions_for_remove_track_restores_io_flags_and_bindings() {
2239        let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
2240        track.level = -3.0;
2241        track.balance = 0.25;
2242        track.armed = true;
2243        track.muted = true;
2244        track.soloed = true;
2245        track.input_monitor = true;
2246        track.disk_monitor = false;
2247        track.midi_learn_volume = Some(binding(10));
2248        track.vca_master = Some("bus".to_string());
2249        track.audio.ins.push(Arc::new(AudioIO::new(64)));
2250        track.audio.outs.push(Arc::new(AudioIO::new(64)));
2251        let state = make_state_with_track(track);
2252
2253        let inverses =
2254            create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2255
2256        assert!(matches!(
2257            inverses.first(),
2258            Some(Action::AddTrack {
2259                name,
2260                audio_ins: 1,
2261                audio_outs: 1,
2262                midi_ins: 1,
2263                midi_outs: 1,
2264            }) if name == "t"
2265        ));
2266        assert!(
2267            inverses
2268                .iter()
2269                .any(|action| matches!(action, Action::TrackAddAudioInput(name) if name == "t"))
2270        );
2271        assert!(
2272            inverses
2273                .iter()
2274                .any(|action| matches!(action, Action::TrackAddAudioOutput(name) if name == "t"))
2275        );
2276        assert!(
2277            inverses.iter().any(
2278                |action| matches!(action, Action::TrackToggleInputMonitor(name) if name == "t")
2279            )
2280        );
2281        assert!(
2282            inverses.iter().any(
2283                |action| matches!(action, Action::TrackToggleDiskMonitor(name) if name == "t")
2284            )
2285        );
2286        assert!(inverses.iter().any(|action| {
2287            matches!(
2288                action,
2289                Action::TrackSetMidiLearnBinding {
2290                    target: TrackMidiLearnTarget::Volume,
2291                    binding: Some(MidiLearnBinding { cc: 10, .. }),
2292                    ..
2293                }
2294            )
2295        }));
2296        assert!(inverses.iter().any(|action| {
2297            matches!(
2298                action,
2299                Action::TrackSetVcaMaster {
2300                    track_name,
2301                    master_track: Some(master),
2302                } if track_name == "t" && master == "bus"
2303            )
2304        }));
2305    }
2306}