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