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