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    Rhodes,
108    Sampler,
109    LittlePhatty,
110    Prophet6,
111    /// The step sequencer. Not an instrument: it drives one.
112    Sequencer,
113}
114
115impl InstrumentType {
116    pub fn label(self) -> &'static str {
117        match self {
118            Self::Synth => "Phosphor Synth",
119            Self::DrumRack => "Drum Rack",
120            Self::DX7 => "DX7",
121            Self::Jupiter8 => "Jupiter-8",
122            Self::Odyssey => "Odyssey",
123            Self::Juno60 => "Juno-60",
124            Self::Rhodes => "Rhodes",
125            Self::Sampler => "Sampler",
126            Self::LittlePhatty => "Little Phatty",
127            Self::Prophet6 => "Prophet-6",
128            Self::Sequencer => "Step Sequencer",
129        }
130    }
131
132    pub fn description(self) -> &'static str {
133        match self {
134            Self::Synth => "polyphonic subtractive synthesizer",
135            Self::DrumRack => "drum machine with sample pads",
136            Self::DX7 => "6-operator FM synthesizer",
137            Self::Jupiter8 => "dual-VCO analog poly synthesizer",
138            Self::Odyssey => "duophonic synth with 3 filter types",
139            Self::Juno60 => "single-DCO poly with BBD chorus",
140            Self::Rhodes => "modelled tine electric piano",
141            Self::Sampler => "sample-based instrument",
142            Self::LittlePhatty => "monophonic Moog with morphing waves",
143            Self::Prophet6 => "six-voice analog poly with poly mod",
144            Self::Sequencer => "pattern sequencer driving any instrument",
145        }
146    }
147
148    /// Appended to, never reordered: a session stores an instrument by its
149    /// key rather than by position, but the menu's own order is what a player
150    /// has learned, and the preset browser walks this list.
151    pub const ALL: &[InstrumentType] = &[Self::Synth, Self::DrumRack, Self::DX7, Self::Jupiter8, Self::Odyssey, Self::Juno60, Self::Rhodes, Self::Sampler, Self::LittlePhatty, Self::Prophet6, Self::Sequencer];
152
153    /// Whether picking this from the add-track menu builds a step sequencer
154    /// rather than an instrument.
155    ///
156    /// A sequencer track's `instrument_type` is its *child* — the thing in
157    /// the plugin slot making the sound — so this entry never ends up stored
158    /// on a track. It is a choice in a menu, and the track it produces is an
159    /// ordinary instrument track with a pattern player in front of it.
160    #[must_use]
161    pub const fn is_sequencer(self) -> bool {
162        matches!(self, Self::Sequencer)
163    }
164}
165
166#[derive(Debug)]
167pub struct InstrumentModal {
168    pub open: bool,
169    pub cursor: usize,
170}
171
172impl Default for InstrumentModal {
173    fn default() -> Self { Self::new() }
174}
175
176impl InstrumentModal {
177    pub fn new() -> Self {
178        Self { open: false, cursor: 0 }
179    }
180
181    pub fn move_up(&mut self) {
182        if self.cursor > 0 { self.cursor -= 1; }
183    }
184
185    pub fn move_down(&mut self) {
186        if self.cursor + 1 < InstrumentType::ALL.len() { self.cursor += 1; }
187    }
188
189    pub fn selected(&self) -> InstrumentType {
190        InstrumentType::ALL[self.cursor]
191    }
192}
193
194// ── Preset Browser Modal ──
195
196/// The user-preset browser for the track under the cursor.
197///
198/// A modal rather than extra entries on the patch knob: the patch selector
199/// stores a normalised fraction, so lengthening the bank it indexes would
200/// remap every value already saved in a session. Browsing presets in their own
201/// list moves nothing.
202#[derive(Debug)]
203pub struct PresetModal {
204    pub open: bool,
205    /// Whose bank this is. `None` until the modal is opened on a track.
206    pub instrument: Option<InstrumentType>,
207    /// The track the bank was opened for. Held so a load lands on that track
208    /// even if something moved the cursor while the modal was up.
209    pub track_idx: usize,
210    pub cursor: usize,
211    /// Preset names in bank order, read when the modal opened.
212    pub entries: Vec<String>,
213    /// Why the bank could not be read, when it could not.
214    pub error: Option<String>,
215    /// Name waiting on an overwrite confirmation.
216    pub pending_name: String,
217}
218
219impl Default for PresetModal {
220    fn default() -> Self { Self::new() }
221}
222
223impl PresetModal {
224    /// Row 0 is always "save the current panel"; the presets follow it.
225    pub const SAVE_ROW: usize = 0;
226
227    pub fn new() -> Self {
228        Self {
229            open: false,
230            instrument: None,
231            track_idx: 0,
232            cursor: 0,
233            entries: Vec::new(),
234            error: None,
235            pending_name: String::new(),
236        }
237    }
238
239    pub fn show(&mut self, instrument: InstrumentType, track_idx: usize, entries: Vec<String>) {
240        self.open = true;
241        self.instrument = Some(instrument);
242        self.track_idx = track_idx;
243        self.cursor = 0;
244        self.entries = entries;
245        self.error = None;
246        self.pending_name.clear();
247    }
248
249    pub fn close(&mut self) {
250        self.open = false;
251        self.entries.clear();
252        self.error = None;
253        self.pending_name.clear();
254        self.cursor = 0;
255    }
256
257    /// Rows in the list: the save row plus one per preset.
258    pub fn item_count(&self) -> usize { self.entries.len() + 1 }
259
260    pub fn move_up(&mut self) {
261        if self.cursor > 0 { self.cursor -= 1; }
262    }
263
264    pub fn move_down(&mut self) {
265        if self.cursor + 1 < self.item_count() { self.cursor += 1; }
266    }
267
268    /// Index into the bank for the selected row, or `None` on the save row.
269    pub fn selected_preset(&self) -> Option<usize> {
270        self.cursor.checked_sub(1).filter(|i| *i < self.entries.len())
271    }
272
273    /// Name of the selected preset, or `None` on the save row.
274    pub fn selected_name(&self) -> Option<&str> {
275        self.selected_preset().map(|i| self.entries[i].as_str())
276    }
277
278    /// Replace the list after a save or delete, keeping the cursor on
279    /// something that exists.
280    pub fn set_entries(&mut self, entries: Vec<String>) {
281        self.entries = entries;
282        let max = self.item_count() - 1;
283        if self.cursor > max { self.cursor = max; }
284    }
285}
286
287// ── Space Menu ──
288
289/// Actions that can be triggered from the space menu.
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
291pub enum SpaceAction {
292    PlayPause,
293    /// Stop and return the playhead to the top of the song.
294    Stop,
295    ToggleRecord,
296    ToggleLoop,
297    ToggleMetronome,
298    Panic,
299    Save,
300    Open,
301    AddInstrument,
302    Delete,
303    CycleTheme,
304    NewTrack,
305    EditMode,
306    Quantize,
307    Presets,
308}
309
310// ── Confirmation Modal ──
311
312#[derive(Debug, Clone, Copy, PartialEq, Eq)]
313pub enum ConfirmKind {
314    DeleteTrack,
315    DeleteClip,
316    DeletePreset,
317    /// Saving over a preset name the bank already holds.
318    OverwritePreset,
319}
320
321#[derive(Debug)]
322pub struct ConfirmModal {
323    pub open: bool,
324    pub kind: ConfirmKind,
325    pub message: String,
326}
327
328impl Default for ConfirmModal {
329    fn default() -> Self { Self::new() }
330}
331
332impl ConfirmModal {
333    pub fn new() -> Self {
334        Self { open: false, kind: ConfirmKind::DeleteTrack, message: String::new() }
335    }
336
337    pub fn show(&mut self, kind: ConfirmKind, message: &str) {
338        self.open = true;
339        self.kind = kind;
340        self.message = message.to_string();
341    }
342
343    pub fn close(&mut self) {
344        self.open = false;
345        self.message.clear();
346    }
347}
348
349// ── Input Modal (for file path entry) ──
350
351#[derive(Debug, Clone, Copy, PartialEq, Eq)]
352pub enum InputModalKind {
353    SaveAs,
354    Open,
355    /// Naming a user preset from the preset browser.
356    PresetName,
357}
358
359#[derive(Debug)]
360pub struct InputModal {
361    pub open: bool,
362    pub kind: InputModalKind,
363    pub buffer: String,
364    pub cursor: usize,
365}
366
367impl Default for InputModal {
368    fn default() -> Self { Self::new() }
369}
370
371impl InputModal {
372    pub fn new() -> Self {
373        Self { open: false, kind: InputModalKind::SaveAs, buffer: String::new(), cursor: 0 }
374    }
375
376    /// Ask for a filename to save under.
377    ///
378    /// The field starts in `sessions/` when the working directory has one —
379    /// a checkout being run from its own root, which is where every session
380    /// on disk already is — and in the absolute `<app dir>/sessions/`
381    /// otherwise. A bare `sessions/` resolves against wherever the process was
382    /// started, so a shortcut, an alias or a desktop launcher would write the
383    /// file successfully into a directory nobody is going to look in again.
384    /// See [`crate::paths::session_prompt_dir`].
385    pub fn open_save(&mut self, default_name: &str) {
386        self.open = true;
387        self.kind = InputModalKind::SaveAs;
388        self.buffer = format!("{}{default_name}", crate::paths::session_prompt_dir());
389        self.cursor = self.buffer.len();
390    }
391
392    /// Ask for a file to open. Same starting directory as [`Self::open_save`];
393    /// a relative path typed here is also looked for under the application
394    /// directory, so the way a checkout spells a session keeps working from
395    /// anywhere. See [`crate::paths::find_session`].
396    pub fn open_load(&mut self) {
397        self.open = true;
398        self.kind = InputModalKind::Open;
399        self.buffer = crate::paths::session_prompt_dir();
400        self.cursor = self.buffer.len();
401    }
402
403    /// Name a user preset. Starts empty rather than on a suggestion, because
404    /// a suggestion the player accepts by reflex is how a bank fills up with
405    /// eight sounds called "juno".
406    pub fn open_preset_name(&mut self) {
407        self.open = true;
408        self.kind = InputModalKind::PresetName;
409        self.buffer.clear();
410        self.cursor = 0;
411    }
412
413    pub fn type_char(&mut self, ch: char) {
414        self.buffer.insert(self.cursor, ch);
415        self.cursor += 1;
416    }
417
418    pub fn backspace(&mut self) {
419        if self.cursor > 0 {
420            self.cursor -= 1;
421            self.buffer.remove(self.cursor);
422        }
423    }
424
425    pub fn delete(&mut self) {
426        if self.cursor < self.buffer.len() {
427            self.buffer.remove(self.cursor);
428        }
429    }
430
431    pub fn move_left(&mut self) {
432        if self.cursor > 0 { self.cursor -= 1; }
433    }
434
435    pub fn move_right(&mut self) {
436        if self.cursor < self.buffer.len() { self.cursor += 1; }
437    }
438
439    pub fn move_home(&mut self) {
440        self.cursor = 0;
441    }
442
443    pub fn move_end(&mut self) {
444        self.cursor = self.buffer.len();
445    }
446
447    pub fn close(&mut self) {
448        self.open = false;
449        self.buffer.clear();
450        self.cursor = 0;
451    }
452
453    pub fn value(&self) -> &str {
454        &self.buffer
455    }
456}
457
458/// The space menu: press Space to open, Space again to close.
459/// Shows all Space+key shortcuts, actions, and help topics.
460#[derive(Debug)]
461pub struct SpaceMenu {
462    pub open: bool,
463    pub cursor: usize,
464    /// Which section is active.
465    pub section: SpaceMenuSection,
466}
467
468#[derive(Debug, Clone, Copy, PartialEq, Eq)]
469pub enum SpaceMenuSection {
470    /// Main shortcuts list.
471    Actions,
472    /// Help topics.
473    Help,
474}
475
476impl Default for SpaceMenu {
477    fn default() -> Self { Self::new() }
478}
479
480impl SpaceMenu {
481    pub fn new() -> Self {
482        Self { open: false, cursor: 0, section: SpaceMenuSection::Actions }
483    }
484
485    pub fn toggle(&mut self) {
486        self.open = !self.open;
487        if self.open { self.cursor = 0; self.section = SpaceMenuSection::Actions; }
488    }
489
490    pub fn move_up(&mut self) {
491        if self.cursor > 0 { self.cursor -= 1; }
492    }
493
494    pub fn move_down(&mut self) {
495        let max = self.item_count();
496        if self.cursor + 1 < max { self.cursor += 1; }
497    }
498
499    pub fn switch_section(&mut self) {
500        self.section = match self.section {
501            SpaceMenuSection::Actions => SpaceMenuSection::Help,
502            SpaceMenuSection::Help => SpaceMenuSection::Actions,
503        };
504        self.cursor = 0;
505    }
506
507    fn item_count(&self) -> usize {
508        match self.section {
509            SpaceMenuSection::Actions => SPACE_ACTIONS.len(),
510            SpaceMenuSection::Help => HELP_TOPICS.len(),
511        }
512    }
513}
514
515/// Space menu action entries: (key, label, description).
516pub const SPACE_ACTIONS: &[(&str, &str, &str)] = &[
517    ("spc+1", "transport", "focus transport controls"),
518    ("spc+2", "tracks",    "focus the tracks panel"),
519    ("spc+3", "clip view", "focus clip / piano roll panel"),
520    ("spc+p", "play/pause","toggle transport playback"),
521    ("spc+0", "stop",      "stop and return to bar 1"),
522    ("spc+r", "record",    "toggle global recording"),
523    ("spc+l", "loop",      "edit loop region"),
524    ("spc+m", "metronome", "toggle click track"),
525    ("spc+!", "panic",     "kill all sound immediately"),
526    ("spc+a", "add instr", "add instrument track"),
527    ("spc+s", "save",      "save project"),
528    ("spc+o", "open",      "open project"),
529    ("spc+d", "delete",    "delete selected track/clip"),
530    ("spc+e", "edit mode", "note-level piano roll editing"),
531    ("spc+q", "quantize",  "snap notes to grid"),
532    ("spc+w", "presets",   "save / load instrument presets"),
533    ("spc+v", "vibe",      "cycle color theme"),
534    ("spc+h", "help",      "open help topics"),
535];
536
537// ── Quantize Modal ──
538
539use super::clip_view::GridResolution;
540
541#[derive(Debug)]
542pub struct QuantizeModal {
543    pub open: bool,
544    pub grid: GridResolution,
545    pub strength: u8,
546    pub cursor: usize,
547}
548
549impl Default for QuantizeModal {
550    fn default() -> Self { Self::new() }
551}
552
553impl QuantizeModal {
554    pub fn new() -> Self {
555        Self { open: false, grid: GridResolution::Eighth, strength: 100, cursor: 0 }
556    }
557    pub fn open_with(&mut self, grid: GridResolution) {
558        self.open = true;
559        self.grid = grid;
560        self.strength = 100;
561        self.cursor = 0;
562    }
563    pub fn close(&mut self) { self.open = false; }
564    pub fn move_up(&mut self) { if self.cursor > 0 { self.cursor -= 1; } }
565    pub fn move_down(&mut self) { if self.cursor < 2 { self.cursor += 1; } }
566    pub fn adjust(&mut self, direction: i32) {
567        match self.cursor {
568            0 => { if direction > 0 { self.grid = self.grid.next(); } else { self.grid = self.grid.prev(); } }
569            1 => { self.strength = (self.strength as i32 + direction * 25).clamp(25, 100) as u8; }
570            _ => {}
571        }
572    }
573}
574
575/// Help topic entries: (title, short description).
576pub const HELP_TOPICS: &[(&str, &str)] = &[
577    ("navigation",  "moving between tracks, clips, and panes"),
578    ("transport",   "play, pause, stop, record, loop, BPM"),
579    ("tracks",      "mute, solo, arm, fx, volume, routing"),
580    ("clips",       "selecting, jumping, clip-level fx"),
581    ("piano roll",  "editing MIDI notes, velocity, quantize"),
582    ("step grid",   "n hit \u{00B7} jk sound \u{00B7} a accent \u{00B7} t play \u{00B7} b bounce"),
583    ("fx & mixing", "adding effects, sends, master bus"),
584    ("shortcuts",   "full keyboard shortcut reference"),
585    ("plugins",     "loading and managing plugins"),
586];