Skip to main content

phosphor_app/state/
menu.rs

1//! Menu state — SpaceMenu, FxMenu, InstrumentModal, FX types.
2
3// ── FX System ──
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq)]
6pub enum FxType {
7    Reverb,
8    Delay,
9    Gate,
10    Eq,
11    Limiter,
12    Compressor,
13}
14
15impl FxType {
16    pub fn label(self) -> &'static str {
17        match self {
18            Self::Reverb => "reverb",
19            Self::Delay => "delay",
20            Self::Gate => "gate",
21            Self::Eq => "eq",
22            Self::Limiter => "limiter",
23            Self::Compressor => "comp",
24        }
25    }
26
27    pub const ALL: &[FxType] = &[
28        Self::Reverb, Self::Delay, Self::Gate, Self::Eq, Self::Limiter, Self::Compressor,
29    ];
30}
31
32/// An FX instance on a track.
33#[derive(Debug, Clone)]
34pub struct FxInstance {
35    pub fx_type: FxType,
36    pub enabled: bool,
37    /// Placeholder parameter values (0.0..1.0).
38    pub params: Vec<(String, f32)>,
39}
40
41impl FxInstance {
42    pub fn new(fx_type: FxType) -> Self {
43        let params = match fx_type {
44            FxType::Reverb => vec![
45                ("mix".into(), 0.3), ("decay".into(), 0.5), ("size".into(), 0.6),
46            ],
47            FxType::Delay => vec![
48                ("time".into(), 0.4), ("feedback".into(), 0.3), ("mix".into(), 0.25),
49            ],
50            FxType::Gate => vec![
51                ("thresh".into(), 0.5), ("attack".into(), 0.1), ("release".into(), 0.3),
52            ],
53            FxType::Eq => vec![
54                ("low".into(), 0.5), ("mid".into(), 0.5), ("high".into(), 0.5),
55            ],
56            FxType::Limiter => vec![
57                ("thresh".into(), 0.8), ("release".into(), 0.2),
58            ],
59            FxType::Compressor => vec![
60                ("thresh".into(), 0.6), ("ratio".into(), 0.4), ("attack".into(), 0.1),
61                ("release".into(), 0.3),
62            ],
63        };
64        Self { fx_type, enabled: true, params }
65    }
66}
67
68/// FX menu state (opened when pressing Enter on fx button).
69#[derive(Debug)]
70pub struct FxMenu {
71    pub open: bool,
72    pub cursor: usize,
73}
74
75impl Default for FxMenu {
76    fn default() -> Self { Self::new() }
77}
78
79impl FxMenu {
80    pub fn new() -> Self {
81        Self { open: false, cursor: 0 }
82    }
83
84    pub fn item_count(&self) -> usize {
85        FxType::ALL.len()
86    }
87
88    pub fn move_up(&mut self) {
89        if self.cursor > 0 { self.cursor -= 1; }
90    }
91
92    pub fn move_down(&mut self) {
93        if self.cursor + 1 < self.item_count() { self.cursor += 1; }
94    }
95}
96
97// ── Instrument Selection Modal ──
98
99#[derive(Debug, Clone, Copy, PartialEq, Eq)]
100pub enum InstrumentType {
101    Synth,
102    DrumRack,
103    DX7,
104    Jupiter8,
105    Odyssey,
106    Juno60,
107    Sampler,
108}
109
110impl InstrumentType {
111    pub fn label(self) -> &'static str {
112        match self {
113            Self::Synth => "Phosphor Synth",
114            Self::DrumRack => "Drum Rack",
115            Self::DX7 => "DX7",
116            Self::Jupiter8 => "Jupiter-8",
117            Self::Odyssey => "Odyssey",
118            Self::Juno60 => "Juno-60",
119            Self::Sampler => "Sampler",
120        }
121    }
122
123    pub fn description(self) -> &'static str {
124        match self {
125            Self::Synth => "polyphonic subtractive synthesizer",
126            Self::DrumRack => "drum machine with sample pads",
127            Self::DX7 => "6-operator FM synthesizer",
128            Self::Jupiter8 => "dual-VCO analog poly synthesizer",
129            Self::Odyssey => "duophonic synth with 3 filter types",
130            Self::Juno60 => "single-DCO poly with BBD chorus",
131            Self::Sampler => "sample-based instrument",
132        }
133    }
134
135    pub const ALL: &[InstrumentType] = &[Self::Synth, Self::DrumRack, Self::DX7, Self::Jupiter8, Self::Odyssey, Self::Juno60, Self::Sampler];
136}
137
138#[derive(Debug)]
139pub struct InstrumentModal {
140    pub open: bool,
141    pub cursor: usize,
142}
143
144impl Default for InstrumentModal {
145    fn default() -> Self { Self::new() }
146}
147
148impl InstrumentModal {
149    pub fn new() -> Self {
150        Self { open: false, cursor: 0 }
151    }
152
153    pub fn move_up(&mut self) {
154        if self.cursor > 0 { self.cursor -= 1; }
155    }
156
157    pub fn move_down(&mut self) {
158        if self.cursor + 1 < InstrumentType::ALL.len() { self.cursor += 1; }
159    }
160
161    pub fn selected(&self) -> InstrumentType {
162        InstrumentType::ALL[self.cursor]
163    }
164}
165
166// ── Preset Browser Modal ──
167
168/// The user-preset browser for the track under the cursor.
169///
170/// A modal rather than extra entries on the patch knob: the patch selector
171/// stores a normalised fraction, so lengthening the bank it indexes would
172/// remap every value already saved in a session. Browsing presets in their own
173/// list moves nothing.
174#[derive(Debug)]
175pub struct PresetModal {
176    pub open: bool,
177    /// Whose bank this is. `None` until the modal is opened on a track.
178    pub instrument: Option<InstrumentType>,
179    /// The track the bank was opened for. Held so a load lands on that track
180    /// even if something moved the cursor while the modal was up.
181    pub track_idx: usize,
182    pub cursor: usize,
183    /// Preset names in bank order, read when the modal opened.
184    pub entries: Vec<String>,
185    /// Why the bank could not be read, when it could not.
186    pub error: Option<String>,
187    /// Name waiting on an overwrite confirmation.
188    pub pending_name: String,
189}
190
191impl Default for PresetModal {
192    fn default() -> Self { Self::new() }
193}
194
195impl PresetModal {
196    /// Row 0 is always "save the current panel"; the presets follow it.
197    pub const SAVE_ROW: usize = 0;
198
199    pub fn new() -> Self {
200        Self {
201            open: false,
202            instrument: None,
203            track_idx: 0,
204            cursor: 0,
205            entries: Vec::new(),
206            error: None,
207            pending_name: String::new(),
208        }
209    }
210
211    pub fn show(&mut self, instrument: InstrumentType, track_idx: usize, entries: Vec<String>) {
212        self.open = true;
213        self.instrument = Some(instrument);
214        self.track_idx = track_idx;
215        self.cursor = 0;
216        self.entries = entries;
217        self.error = None;
218        self.pending_name.clear();
219    }
220
221    pub fn close(&mut self) {
222        self.open = false;
223        self.entries.clear();
224        self.error = None;
225        self.pending_name.clear();
226        self.cursor = 0;
227    }
228
229    /// Rows in the list: the save row plus one per preset.
230    pub fn item_count(&self) -> usize { self.entries.len() + 1 }
231
232    pub fn move_up(&mut self) {
233        if self.cursor > 0 { self.cursor -= 1; }
234    }
235
236    pub fn move_down(&mut self) {
237        if self.cursor + 1 < self.item_count() { self.cursor += 1; }
238    }
239
240    /// Index into the bank for the selected row, or `None` on the save row.
241    pub fn selected_preset(&self) -> Option<usize> {
242        self.cursor.checked_sub(1).filter(|i| *i < self.entries.len())
243    }
244
245    /// Name of the selected preset, or `None` on the save row.
246    pub fn selected_name(&self) -> Option<&str> {
247        self.selected_preset().map(|i| self.entries[i].as_str())
248    }
249
250    /// Replace the list after a save or delete, keeping the cursor on
251    /// something that exists.
252    pub fn set_entries(&mut self, entries: Vec<String>) {
253        self.entries = entries;
254        let max = self.item_count() - 1;
255        if self.cursor > max { self.cursor = max; }
256    }
257}
258
259// ── Space Menu ──
260
261/// Actions that can be triggered from the space menu.
262#[derive(Debug, Clone, Copy, PartialEq, Eq)]
263pub enum SpaceAction {
264    PlayPause,
265    ToggleRecord,
266    ToggleLoop,
267    ToggleMetronome,
268    Panic,
269    Save,
270    Open,
271    AddInstrument,
272    Delete,
273    CycleTheme,
274    NewTrack,
275    EditMode,
276    Quantize,
277    Presets,
278}
279
280// ── Confirmation Modal ──
281
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub enum ConfirmKind {
284    DeleteTrack,
285    DeleteClip,
286    DeletePreset,
287    /// Saving over a preset name the bank already holds.
288    OverwritePreset,
289}
290
291#[derive(Debug)]
292pub struct ConfirmModal {
293    pub open: bool,
294    pub kind: ConfirmKind,
295    pub message: String,
296}
297
298impl Default for ConfirmModal {
299    fn default() -> Self { Self::new() }
300}
301
302impl ConfirmModal {
303    pub fn new() -> Self {
304        Self { open: false, kind: ConfirmKind::DeleteTrack, message: String::new() }
305    }
306
307    pub fn show(&mut self, kind: ConfirmKind, message: &str) {
308        self.open = true;
309        self.kind = kind;
310        self.message = message.to_string();
311    }
312
313    pub fn close(&mut self) {
314        self.open = false;
315        self.message.clear();
316    }
317}
318
319// ── Input Modal (for file path entry) ──
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
322pub enum InputModalKind {
323    SaveAs,
324    Open,
325    /// Naming a user preset from the preset browser.
326    PresetName,
327}
328
329#[derive(Debug)]
330pub struct InputModal {
331    pub open: bool,
332    pub kind: InputModalKind,
333    pub buffer: String,
334    pub cursor: usize,
335}
336
337impl Default for InputModal {
338    fn default() -> Self { Self::new() }
339}
340
341impl InputModal {
342    pub fn new() -> Self {
343        Self { open: false, kind: InputModalKind::SaveAs, buffer: String::new(), cursor: 0 }
344    }
345
346    pub fn open_save(&mut self, default_name: &str) {
347        self.open = true;
348        self.kind = InputModalKind::SaveAs;
349        self.buffer = format!("sessions/{default_name}");
350        self.cursor = self.buffer.len();
351    }
352
353    pub fn open_load(&mut self) {
354        self.open = true;
355        self.kind = InputModalKind::Open;
356        self.buffer = "sessions/".to_string();
357        self.cursor = self.buffer.len();
358    }
359
360    /// Name a user preset. Starts empty rather than on a suggestion, because
361    /// a suggestion the player accepts by reflex is how a bank fills up with
362    /// eight sounds called "juno".
363    pub fn open_preset_name(&mut self) {
364        self.open = true;
365        self.kind = InputModalKind::PresetName;
366        self.buffer.clear();
367        self.cursor = 0;
368    }
369
370    pub fn type_char(&mut self, ch: char) {
371        self.buffer.insert(self.cursor, ch);
372        self.cursor += 1;
373    }
374
375    pub fn backspace(&mut self) {
376        if self.cursor > 0 {
377            self.cursor -= 1;
378            self.buffer.remove(self.cursor);
379        }
380    }
381
382    pub fn delete(&mut self) {
383        if self.cursor < self.buffer.len() {
384            self.buffer.remove(self.cursor);
385        }
386    }
387
388    pub fn move_left(&mut self) {
389        if self.cursor > 0 { self.cursor -= 1; }
390    }
391
392    pub fn move_right(&mut self) {
393        if self.cursor < self.buffer.len() { self.cursor += 1; }
394    }
395
396    pub fn move_home(&mut self) {
397        self.cursor = 0;
398    }
399
400    pub fn move_end(&mut self) {
401        self.cursor = self.buffer.len();
402    }
403
404    pub fn close(&mut self) {
405        self.open = false;
406        self.buffer.clear();
407        self.cursor = 0;
408    }
409
410    pub fn value(&self) -> &str {
411        &self.buffer
412    }
413}
414
415/// The space menu: press Space to open, Space again to close.
416/// Shows all Space+key shortcuts, actions, and help topics.
417#[derive(Debug)]
418pub struct SpaceMenu {
419    pub open: bool,
420    pub cursor: usize,
421    /// Which section is active.
422    pub section: SpaceMenuSection,
423}
424
425#[derive(Debug, Clone, Copy, PartialEq, Eq)]
426pub enum SpaceMenuSection {
427    /// Main shortcuts list.
428    Actions,
429    /// Help topics.
430    Help,
431}
432
433impl Default for SpaceMenu {
434    fn default() -> Self { Self::new() }
435}
436
437impl SpaceMenu {
438    pub fn new() -> Self {
439        Self { open: false, cursor: 0, section: SpaceMenuSection::Actions }
440    }
441
442    pub fn toggle(&mut self) {
443        self.open = !self.open;
444        if self.open { self.cursor = 0; self.section = SpaceMenuSection::Actions; }
445    }
446
447    pub fn move_up(&mut self) {
448        if self.cursor > 0 { self.cursor -= 1; }
449    }
450
451    pub fn move_down(&mut self) {
452        let max = self.item_count();
453        if self.cursor + 1 < max { self.cursor += 1; }
454    }
455
456    pub fn switch_section(&mut self) {
457        self.section = match self.section {
458            SpaceMenuSection::Actions => SpaceMenuSection::Help,
459            SpaceMenuSection::Help => SpaceMenuSection::Actions,
460        };
461        self.cursor = 0;
462    }
463
464    fn item_count(&self) -> usize {
465        match self.section {
466            SpaceMenuSection::Actions => SPACE_ACTIONS.len(),
467            SpaceMenuSection::Help => HELP_TOPICS.len(),
468        }
469    }
470}
471
472/// Space menu action entries: (key, label, description).
473pub const SPACE_ACTIONS: &[(&str, &str, &str)] = &[
474    ("spc+1", "transport", "focus transport controls"),
475    ("spc+2", "tracks",    "focus the tracks panel"),
476    ("spc+3", "clip view", "focus clip / piano roll panel"),
477    ("spc+p", "play/pause","toggle transport playback"),
478    ("spc+r", "record",    "toggle global recording"),
479    ("spc+l", "loop",      "edit loop region"),
480    ("spc+m", "metronome", "toggle click track"),
481    ("spc+!", "panic",     "kill all sound immediately"),
482    ("spc+a", "add instr", "add instrument track"),
483    ("spc+s", "save",      "save project"),
484    ("spc+o", "open",      "open project"),
485    ("spc+d", "delete",    "delete selected track/clip"),
486    ("spc+e", "edit mode", "note-level piano roll editing"),
487    ("spc+q", "quantize",  "snap notes to grid"),
488    ("spc+w", "presets",   "save / load instrument presets"),
489    ("spc+v", "vibe",      "cycle color theme"),
490    ("spc+h", "help",      "open help topics"),
491];
492
493// ── Quantize Modal ──
494
495use super::clip_view::GridResolution;
496
497#[derive(Debug)]
498pub struct QuantizeModal {
499    pub open: bool,
500    pub grid: GridResolution,
501    pub strength: u8,
502    pub cursor: usize,
503}
504
505impl Default for QuantizeModal {
506    fn default() -> Self { Self::new() }
507}
508
509impl QuantizeModal {
510    pub fn new() -> Self {
511        Self { open: false, grid: GridResolution::Eighth, strength: 100, cursor: 0 }
512    }
513    pub fn open_with(&mut self, grid: GridResolution) {
514        self.open = true;
515        self.grid = grid;
516        self.strength = 100;
517        self.cursor = 0;
518    }
519    pub fn close(&mut self) { self.open = false; }
520    pub fn move_up(&mut self) { if self.cursor > 0 { self.cursor -= 1; } }
521    pub fn move_down(&mut self) { if self.cursor < 2 { self.cursor += 1; } }
522    pub fn adjust(&mut self, direction: i32) {
523        match self.cursor {
524            0 => { if direction > 0 { self.grid = self.grid.next(); } else { self.grid = self.grid.prev(); } }
525            1 => { self.strength = (self.strength as i32 + direction * 25).clamp(25, 100) as u8; }
526            _ => {}
527        }
528    }
529}
530
531/// Help topic entries: (title, short description).
532pub const HELP_TOPICS: &[(&str, &str)] = &[
533    ("navigation",  "moving between tracks, clips, and panes"),
534    ("transport",   "play, pause, stop, record, loop, BPM"),
535    ("tracks",      "mute, solo, arm, fx, volume, routing"),
536    ("clips",       "selecting, jumping, clip-level fx"),
537    ("piano roll",  "editing MIDI notes, velocity, quantize"),
538    ("fx & mixing", "adding effects, sends, master bus"),
539    ("shortcuts",   "full keyboard shortcut reference"),
540    ("plugins",     "loading and managing plugins"),
541];