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    pub fn open_save(&mut self, default_name: &str) {
350        self.open = true;
351        self.kind = InputModalKind::SaveAs;
352        self.buffer = format!("sessions/{default_name}");
353        self.cursor = self.buffer.len();
354    }
355
356    pub fn open_load(&mut self) {
357        self.open = true;
358        self.kind = InputModalKind::Open;
359        self.buffer = "sessions/".to_string();
360        self.cursor = self.buffer.len();
361    }
362
363    /// Name a user preset. Starts empty rather than on a suggestion, because
364    /// a suggestion the player accepts by reflex is how a bank fills up with
365    /// eight sounds called "juno".
366    pub fn open_preset_name(&mut self) {
367        self.open = true;
368        self.kind = InputModalKind::PresetName;
369        self.buffer.clear();
370        self.cursor = 0;
371    }
372
373    pub fn type_char(&mut self, ch: char) {
374        self.buffer.insert(self.cursor, ch);
375        self.cursor += 1;
376    }
377
378    pub fn backspace(&mut self) {
379        if self.cursor > 0 {
380            self.cursor -= 1;
381            self.buffer.remove(self.cursor);
382        }
383    }
384
385    pub fn delete(&mut self) {
386        if self.cursor < self.buffer.len() {
387            self.buffer.remove(self.cursor);
388        }
389    }
390
391    pub fn move_left(&mut self) {
392        if self.cursor > 0 { self.cursor -= 1; }
393    }
394
395    pub fn move_right(&mut self) {
396        if self.cursor < self.buffer.len() { self.cursor += 1; }
397    }
398
399    pub fn move_home(&mut self) {
400        self.cursor = 0;
401    }
402
403    pub fn move_end(&mut self) {
404        self.cursor = self.buffer.len();
405    }
406
407    pub fn close(&mut self) {
408        self.open = false;
409        self.buffer.clear();
410        self.cursor = 0;
411    }
412
413    pub fn value(&self) -> &str {
414        &self.buffer
415    }
416}
417
418/// The space menu: press Space to open, Space again to close.
419/// Shows all Space+key shortcuts, actions, and help topics.
420#[derive(Debug)]
421pub struct SpaceMenu {
422    pub open: bool,
423    pub cursor: usize,
424    /// Which section is active.
425    pub section: SpaceMenuSection,
426}
427
428#[derive(Debug, Clone, Copy, PartialEq, Eq)]
429pub enum SpaceMenuSection {
430    /// Main shortcuts list.
431    Actions,
432    /// Help topics.
433    Help,
434}
435
436impl Default for SpaceMenu {
437    fn default() -> Self { Self::new() }
438}
439
440impl SpaceMenu {
441    pub fn new() -> Self {
442        Self { open: false, cursor: 0, section: SpaceMenuSection::Actions }
443    }
444
445    pub fn toggle(&mut self) {
446        self.open = !self.open;
447        if self.open { self.cursor = 0; self.section = SpaceMenuSection::Actions; }
448    }
449
450    pub fn move_up(&mut self) {
451        if self.cursor > 0 { self.cursor -= 1; }
452    }
453
454    pub fn move_down(&mut self) {
455        let max = self.item_count();
456        if self.cursor + 1 < max { self.cursor += 1; }
457    }
458
459    pub fn switch_section(&mut self) {
460        self.section = match self.section {
461            SpaceMenuSection::Actions => SpaceMenuSection::Help,
462            SpaceMenuSection::Help => SpaceMenuSection::Actions,
463        };
464        self.cursor = 0;
465    }
466
467    fn item_count(&self) -> usize {
468        match self.section {
469            SpaceMenuSection::Actions => SPACE_ACTIONS.len(),
470            SpaceMenuSection::Help => HELP_TOPICS.len(),
471        }
472    }
473}
474
475/// Space menu action entries: (key, label, description).
476pub const SPACE_ACTIONS: &[(&str, &str, &str)] = &[
477    ("spc+1", "transport", "focus transport controls"),
478    ("spc+2", "tracks",    "focus the tracks panel"),
479    ("spc+3", "clip view", "focus clip / piano roll panel"),
480    ("spc+p", "play/pause","toggle transport playback"),
481    ("spc+r", "record",    "toggle global recording"),
482    ("spc+l", "loop",      "edit loop region"),
483    ("spc+m", "metronome", "toggle click track"),
484    ("spc+!", "panic",     "kill all sound immediately"),
485    ("spc+a", "add instr", "add instrument track"),
486    ("spc+s", "save",      "save project"),
487    ("spc+o", "open",      "open project"),
488    ("spc+d", "delete",    "delete selected track/clip"),
489    ("spc+e", "edit mode", "note-level piano roll editing"),
490    ("spc+q", "quantize",  "snap notes to grid"),
491    ("spc+w", "presets",   "save / load instrument presets"),
492    ("spc+v", "vibe",      "cycle color theme"),
493    ("spc+h", "help",      "open help topics"),
494];
495
496// ── Quantize Modal ──
497
498use super::clip_view::GridResolution;
499
500#[derive(Debug)]
501pub struct QuantizeModal {
502    pub open: bool,
503    pub grid: GridResolution,
504    pub strength: u8,
505    pub cursor: usize,
506}
507
508impl Default for QuantizeModal {
509    fn default() -> Self { Self::new() }
510}
511
512impl QuantizeModal {
513    pub fn new() -> Self {
514        Self { open: false, grid: GridResolution::Eighth, strength: 100, cursor: 0 }
515    }
516    pub fn open_with(&mut self, grid: GridResolution) {
517        self.open = true;
518        self.grid = grid;
519        self.strength = 100;
520        self.cursor = 0;
521    }
522    pub fn close(&mut self) { self.open = false; }
523    pub fn move_up(&mut self) { if self.cursor > 0 { self.cursor -= 1; } }
524    pub fn move_down(&mut self) { if self.cursor < 2 { self.cursor += 1; } }
525    pub fn adjust(&mut self, direction: i32) {
526        match self.cursor {
527            0 => { if direction > 0 { self.grid = self.grid.next(); } else { self.grid = self.grid.prev(); } }
528            1 => { self.strength = (self.strength as i32 + direction * 25).clamp(25, 100) as u8; }
529            _ => {}
530        }
531    }
532}
533
534/// Help topic entries: (title, short description).
535pub const HELP_TOPICS: &[(&str, &str)] = &[
536    ("navigation",  "moving between tracks, clips, and panes"),
537    ("transport",   "play, pause, stop, record, loop, BPM"),
538    ("tracks",      "mute, solo, arm, fx, volume, routing"),
539    ("clips",       "selecting, jumping, clip-level fx"),
540    ("piano roll",  "editing MIDI notes, velocity, quantize"),
541    ("fx & mixing", "adding effects, sends, master bus"),
542    ("shortcuts",   "full keyboard shortcut reference"),
543    ("plugins",     "loading and managing plugins"),
544];