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    ToggleRecord,
294    ToggleLoop,
295    ToggleMetronome,
296    Panic,
297    Save,
298    Open,
299    AddInstrument,
300    Delete,
301    CycleTheme,
302    NewTrack,
303    EditMode,
304    Quantize,
305    Presets,
306}
307
308// ── Confirmation Modal ──
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq)]
311pub enum ConfirmKind {
312    DeleteTrack,
313    DeleteClip,
314    DeletePreset,
315    /// Saving over a preset name the bank already holds.
316    OverwritePreset,
317}
318
319#[derive(Debug)]
320pub struct ConfirmModal {
321    pub open: bool,
322    pub kind: ConfirmKind,
323    pub message: String,
324}
325
326impl Default for ConfirmModal {
327    fn default() -> Self { Self::new() }
328}
329
330impl ConfirmModal {
331    pub fn new() -> Self {
332        Self { open: false, kind: ConfirmKind::DeleteTrack, message: String::new() }
333    }
334
335    pub fn show(&mut self, kind: ConfirmKind, message: &str) {
336        self.open = true;
337        self.kind = kind;
338        self.message = message.to_string();
339    }
340
341    pub fn close(&mut self) {
342        self.open = false;
343        self.message.clear();
344    }
345}
346
347// ── Input Modal (for file path entry) ──
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350pub enum InputModalKind {
351    SaveAs,
352    Open,
353    /// Naming a user preset from the preset browser.
354    PresetName,
355}
356
357#[derive(Debug)]
358pub struct InputModal {
359    pub open: bool,
360    pub kind: InputModalKind,
361    pub buffer: String,
362    pub cursor: usize,
363}
364
365impl Default for InputModal {
366    fn default() -> Self { Self::new() }
367}
368
369impl InputModal {
370    pub fn new() -> Self {
371        Self { open: false, kind: InputModalKind::SaveAs, buffer: String::new(), cursor: 0 }
372    }
373
374    /// Ask for a filename to save under.
375    ///
376    /// The field starts in `sessions/` when the working directory has one —
377    /// a checkout being run from its own root, which is where every session
378    /// on disk already is — and in the absolute `<app dir>/sessions/`
379    /// otherwise. A bare `sessions/` resolves against wherever the process was
380    /// started, so a shortcut, an alias or a desktop launcher would write the
381    /// file successfully into a directory nobody is going to look in again.
382    /// See [`crate::paths::session_prompt_dir`].
383    pub fn open_save(&mut self, default_name: &str) {
384        self.open = true;
385        self.kind = InputModalKind::SaveAs;
386        self.buffer = format!("{}{default_name}", crate::paths::session_prompt_dir());
387        self.cursor = self.buffer.len();
388    }
389
390    /// Ask for a file to open. Same starting directory as [`Self::open_save`];
391    /// a relative path typed here is also looked for under the application
392    /// directory, so the way a checkout spells a session keeps working from
393    /// anywhere. See [`crate::paths::find_session`].
394    pub fn open_load(&mut self) {
395        self.open = true;
396        self.kind = InputModalKind::Open;
397        self.buffer = crate::paths::session_prompt_dir();
398        self.cursor = self.buffer.len();
399    }
400
401    /// Name a user preset. Starts empty rather than on a suggestion, because
402    /// a suggestion the player accepts by reflex is how a bank fills up with
403    /// eight sounds called "juno".
404    pub fn open_preset_name(&mut self) {
405        self.open = true;
406        self.kind = InputModalKind::PresetName;
407        self.buffer.clear();
408        self.cursor = 0;
409    }
410
411    pub fn type_char(&mut self, ch: char) {
412        self.buffer.insert(self.cursor, ch);
413        self.cursor += 1;
414    }
415
416    pub fn backspace(&mut self) {
417        if self.cursor > 0 {
418            self.cursor -= 1;
419            self.buffer.remove(self.cursor);
420        }
421    }
422
423    pub fn delete(&mut self) {
424        if self.cursor < self.buffer.len() {
425            self.buffer.remove(self.cursor);
426        }
427    }
428
429    pub fn move_left(&mut self) {
430        if self.cursor > 0 { self.cursor -= 1; }
431    }
432
433    pub fn move_right(&mut self) {
434        if self.cursor < self.buffer.len() { self.cursor += 1; }
435    }
436
437    pub fn move_home(&mut self) {
438        self.cursor = 0;
439    }
440
441    pub fn move_end(&mut self) {
442        self.cursor = self.buffer.len();
443    }
444
445    pub fn close(&mut self) {
446        self.open = false;
447        self.buffer.clear();
448        self.cursor = 0;
449    }
450
451    pub fn value(&self) -> &str {
452        &self.buffer
453    }
454}
455
456/// The space menu: press Space to open, Space again to close.
457/// Shows all Space+key shortcuts, actions, and help topics.
458#[derive(Debug)]
459pub struct SpaceMenu {
460    pub open: bool,
461    pub cursor: usize,
462    /// Which section is active.
463    pub section: SpaceMenuSection,
464}
465
466#[derive(Debug, Clone, Copy, PartialEq, Eq)]
467pub enum SpaceMenuSection {
468    /// Main shortcuts list.
469    Actions,
470    /// Help topics.
471    Help,
472}
473
474impl Default for SpaceMenu {
475    fn default() -> Self { Self::new() }
476}
477
478impl SpaceMenu {
479    pub fn new() -> Self {
480        Self { open: false, cursor: 0, section: SpaceMenuSection::Actions }
481    }
482
483    pub fn toggle(&mut self) {
484        self.open = !self.open;
485        if self.open { self.cursor = 0; self.section = SpaceMenuSection::Actions; }
486    }
487
488    pub fn move_up(&mut self) {
489        if self.cursor > 0 { self.cursor -= 1; }
490    }
491
492    pub fn move_down(&mut self) {
493        let max = self.item_count();
494        if self.cursor + 1 < max { self.cursor += 1; }
495    }
496
497    pub fn switch_section(&mut self) {
498        self.section = match self.section {
499            SpaceMenuSection::Actions => SpaceMenuSection::Help,
500            SpaceMenuSection::Help => SpaceMenuSection::Actions,
501        };
502        self.cursor = 0;
503    }
504
505    fn item_count(&self) -> usize {
506        match self.section {
507            SpaceMenuSection::Actions => SPACE_ACTIONS.len(),
508            SpaceMenuSection::Help => HELP_TOPICS.len(),
509        }
510    }
511}
512
513/// Space menu action entries: (key, label, description).
514pub const SPACE_ACTIONS: &[(&str, &str, &str)] = &[
515    ("spc+1", "transport", "focus transport controls"),
516    ("spc+2", "tracks",    "focus the tracks panel"),
517    ("spc+3", "clip view", "focus clip / piano roll panel"),
518    ("spc+p", "play/pause","toggle transport playback"),
519    ("spc+r", "record",    "toggle global recording"),
520    ("spc+l", "loop",      "edit loop region"),
521    ("spc+m", "metronome", "toggle click track"),
522    ("spc+!", "panic",     "kill all sound immediately"),
523    ("spc+a", "add instr", "add instrument track"),
524    ("spc+s", "save",      "save project"),
525    ("spc+o", "open",      "open project"),
526    ("spc+d", "delete",    "delete selected track/clip"),
527    ("spc+e", "edit mode", "note-level piano roll editing"),
528    ("spc+q", "quantize",  "snap notes to grid"),
529    ("spc+w", "presets",   "save / load instrument presets"),
530    ("spc+v", "vibe",      "cycle color theme"),
531    ("spc+h", "help",      "open help topics"),
532];
533
534// ── Quantize Modal ──
535
536use super::clip_view::GridResolution;
537
538#[derive(Debug)]
539pub struct QuantizeModal {
540    pub open: bool,
541    pub grid: GridResolution,
542    pub strength: u8,
543    pub cursor: usize,
544}
545
546impl Default for QuantizeModal {
547    fn default() -> Self { Self::new() }
548}
549
550impl QuantizeModal {
551    pub fn new() -> Self {
552        Self { open: false, grid: GridResolution::Eighth, strength: 100, cursor: 0 }
553    }
554    pub fn open_with(&mut self, grid: GridResolution) {
555        self.open = true;
556        self.grid = grid;
557        self.strength = 100;
558        self.cursor = 0;
559    }
560    pub fn close(&mut self) { self.open = false; }
561    pub fn move_up(&mut self) { if self.cursor > 0 { self.cursor -= 1; } }
562    pub fn move_down(&mut self) { if self.cursor < 2 { self.cursor += 1; } }
563    pub fn adjust(&mut self, direction: i32) {
564        match self.cursor {
565            0 => { if direction > 0 { self.grid = self.grid.next(); } else { self.grid = self.grid.prev(); } }
566            1 => { self.strength = (self.strength as i32 + direction * 25).clamp(25, 100) as u8; }
567            _ => {}
568        }
569    }
570}
571
572/// Help topic entries: (title, short description).
573pub const HELP_TOPICS: &[(&str, &str)] = &[
574    ("navigation",  "moving between tracks, clips, and panes"),
575    ("transport",   "play, pause, stop, record, loop, BPM"),
576    ("tracks",      "mute, solo, arm, fx, volume, routing"),
577    ("clips",       "selecting, jumping, clip-level fx"),
578    ("piano roll",  "editing MIDI notes, velocity, quantize"),
579    ("fx & mixing", "adding effects, sends, master bus"),
580    ("shortcuts",   "full keyboard shortcut reference"),
581    ("plugins",     "loading and managing plugins"),
582];