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