Skip to main content

maolan_engine/
history.rs

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