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