onedrop_gui/
options_ui.rs

1//! egui-based Options overlay rendered on top of the MilkDrop visuals.
2//!
3//! The overlay shows two tabs — **Visuals** and **Engine** — covering
4//! the V1 scope of the user-facing settings (see `settings.rs`). The
5//! widgets mutate a `Settings` struct in place; this module also
6//! returns an [`OptionsActions`] bundle so the caller can apply the
7//! diff to the live engine (mesh rebuild, profile toggle, …) and
8//! persist it.
9//!
10//! Toggled by the `Tab` key. While open, `Esc` closes the overlay
11//! instead of quitting the app.
12
13use egui_wgpu::{Renderer as EguiRenderer, RendererOptions, ScreenDescriptor};
14use egui_winit::State as EguiState;
15use onedrop_engine::{AudioInput, BeatDetectionMode};
16use winit::event::WindowEvent;
17use winit::window::Window;
18
19use crate::settings::Settings;
20
21/// Per-frame data the HUD needs from `main.rs`. Decoupled from
22/// [`OptionsUi`] so the panel itself stays stateless about engine
23/// internals.
24#[derive(Debug, Clone, Default)]
25pub struct HudData {
26    pub fps: f32,
27    pub preset_name: String,
28    pub mesh_cols: u32,
29    pub mesh_rows: u32,
30}
31
32/// Diff produced by drawing one frame of the Options UI.
33///
34/// `Some(value)` means "the user just changed this — apply it live and
35/// save the settings file". `None` means "no change this frame".
36/// Aggregated and consumed by `main.rs` after the egui pass returns.
37#[derive(Debug, Default, Clone)]
38pub struct OptionsActions {
39    pub mesh_size: Option<(u32, u32)>,
40    pub vsync: Option<bool>,
41    pub transition_duration: Option<f32>,
42    pub enable_per_frame: Option<bool>,
43    pub profile: Option<bool>,
44    /// Beat-detection mode the user just picked. Applied via
45    /// `engine.set_beat_detection_mode`.
46    pub beat_detection_mode: Option<BeatDetectionMode>,
47    /// Audio input device the user just picked. `Some(None)` means
48    /// "use OS default"; `Some(Some(name))` means a specific cpal
49    /// device by name. `None` means no change this frame.
50    pub audio_device: Option<Option<String>>,
51    /// User clicked the "Apply & restart" button. `main.rs` saves the
52    /// settings file and spawns a fresh process with the same CLI args.
53    pub request_restart: bool,
54}
55
56impl OptionsActions {
57    pub fn any(&self) -> bool {
58        self.mesh_size.is_some()
59            || self.vsync.is_some()
60            || self.transition_duration.is_some()
61            || self.enable_per_frame.is_some()
62            || self.profile.is_some()
63            || self.beat_detection_mode.is_some()
64            || self.audio_device.is_some()
65            || self.request_restart
66    }
67}
68
69/// Which tab is currently in front.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum Tab {
72    Visuals,
73    Engine,
74    Playback,
75    Audio,
76    Window,
77}
78
79/// Stateful egui overlay. Owns the renderer + the winit event
80/// translator; the visualiser keeps one of these for the life of the
81/// window.
82pub struct OptionsUi {
83    ctx: egui::Context,
84    state: EguiState,
85    renderer: EguiRenderer,
86    /// Whether the panel is currently drawn. Toggle from main via `Tab`.
87    pub visible: bool,
88    tab: Tab,
89    /// Snapshot of the settings the process booted with. Restart-only
90    /// fields (MSAA, texture format) compare against this to decide
91    /// whether the "Apply & restart" button should be enabled.
92    boot: crate::settings::Settings,
93    /// Cached cpal input-device names, populated on demand by the
94    /// "Refresh" button on the Audio tab. We don't enumerate every
95    /// frame — cpal device enumeration on ALSA is multi-millisecond.
96    audio_devices: Vec<String>,
97    /// Whether the audio-device cache has been populated at least
98    /// once. Drives a lazy first-fill on the user's first visit to
99    /// the Audio tab.
100    audio_devices_fetched: bool,
101}
102
103impl OptionsUi {
104    pub fn new(
105        window: &Window,
106        device: &wgpu::Device,
107        surface_format: wgpu::TextureFormat,
108        boot_settings: Settings,
109    ) -> Self {
110        let ctx = egui::Context::default();
111        let viewport_id = ctx.viewport_id();
112        let state = EguiState::new(
113            ctx.clone(),
114            viewport_id,
115            window,
116            Some(window.scale_factor() as f32),
117            None,
118            None,
119        );
120        // We composite onto the same non-MSAA surface as the blit pass,
121        // so `msaa_samples = 1` (egui treats 0 and 1 as "off").
122        let renderer = EguiRenderer::new(
123            device,
124            surface_format,
125            RendererOptions {
126                msaa_samples: 1,
127                ..Default::default()
128            },
129        );
130        Self {
131            ctx,
132            state,
133            renderer,
134            visible: false,
135            tab: Tab::Visuals,
136            boot: boot_settings,
137            audio_devices: Vec::new(),
138            audio_devices_fetched: false,
139        }
140    }
141
142    /// Feed a winit event into egui. Returns true when egui consumed it
143    /// (e.g. a click on a slider) — the caller should suppress its own
144    /// handling so typing in egui doesn't also flip presets.
145    pub fn on_event(&mut self, window: &Window, event: &WindowEvent) -> bool {
146        let response = self.state.on_window_event(window, event);
147        // Only suppress when the panel is actually drawn AND egui asked
148        // for the event. When the overlay is hidden, egui has no
149        // widgets to claim input.
150        self.visible && response.consumed
151    }
152
153    pub fn toggle(&mut self) {
154        self.visible = !self.visible;
155    }
156
157    /// Run one frame of the UI on top of `surface_view`. Returns the
158    /// changes the user just made; the caller applies them to the live
159    /// engine. The frame is a no-op when neither the options panel nor
160    /// the HUD wants to draw anything.
161    #[allow(clippy::too_many_arguments)]
162    pub fn render(
163        &mut self,
164        device: &wgpu::Device,
165        queue: &wgpu::Queue,
166        window: &Window,
167        encoder: &mut wgpu::CommandEncoder,
168        surface_view: &wgpu::TextureView,
169        surface_size: (u32, u32),
170        settings: &mut Settings,
171        hud_data: &HudData,
172    ) -> OptionsActions {
173        let panel_visible = self.visible;
174        let hud_visible = settings.window.hud;
175        if !panel_visible && !hud_visible {
176            return OptionsActions::default();
177        }
178
179        // Lazy first-fill of the audio device cache: enumerate once on
180        // the user's first visit to the Audio tab so they don't pay
181        // multi-ms cpal-enumeration latency at boot.
182        if panel_visible && self.tab == Tab::Audio && !self.audio_devices_fetched {
183            self.audio_devices = AudioInput::list_input_devices();
184            self.audio_devices_fetched = true;
185        }
186
187        let raw_input = self.state.take_egui_input(window);
188        let mut actions = OptionsActions::default();
189
190        let visible_ptr = &mut self.visible;
191        let tab_ptr = &mut self.tab;
192        let actions_ref = &mut actions;
193        let boot_ref = &self.boot;
194        let audio_devices_ref = &self.audio_devices;
195        let mut refresh_audio = false;
196        let refresh_audio_ref = &mut refresh_audio;
197        // `Context::run` is deprecated in egui 0.34 in favour of `run_ui`,
198        // but `run_ui` hands the closure a `&mut Ui` rather than a
199        // `&Context`. We want the latter so we can host the options
200        // panel inside an `egui::Window`. Stay on `run` until egui
201        // exposes a non-deprecated equivalent.
202        #[allow(deprecated)]
203        let full_output = self.ctx.run(raw_input, |ctx| {
204            if panel_visible {
205                draw_options_window(
206                    ctx,
207                    settings,
208                    boot_ref,
209                    visible_ptr,
210                    tab_ptr,
211                    actions_ref,
212                    audio_devices_ref,
213                    refresh_audio_ref,
214                );
215            }
216            if hud_visible {
217                draw_hud(ctx, hud_data);
218            }
219        });
220
221        if refresh_audio {
222            self.audio_devices = AudioInput::list_input_devices();
223        }
224
225        self.state
226            .handle_platform_output(window, full_output.platform_output);
227
228        let primitives = self
229            .ctx
230            .tessellate(full_output.shapes, full_output.pixels_per_point);
231
232        let screen = ScreenDescriptor {
233            size_in_pixels: [surface_size.0, surface_size.1],
234            pixels_per_point: full_output.pixels_per_point,
235        };
236
237        for (id, delta) in &full_output.textures_delta.set {
238            self.renderer.update_texture(device, queue, *id, delta);
239        }
240        self.renderer
241            .update_buffers(device, queue, encoder, &primitives, &screen);
242
243        {
244            let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
245                label: Some("egui Pass"),
246                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
247                    view: surface_view,
248                    resolve_target: None,
249                    ops: wgpu::Operations {
250                        load: wgpu::LoadOp::Load,
251                        store: wgpu::StoreOp::Store,
252                    },
253                    depth_slice: None,
254                })],
255                depth_stencil_attachment: None,
256                timestamp_writes: None,
257                occlusion_query_set: None,
258                multiview_mask: None,
259            });
260            self.renderer
261                .render(&mut pass.forget_lifetime(), &primitives, &screen);
262        }
263
264        for id in &full_output.textures_delta.free {
265            self.renderer.free_texture(id);
266        }
267
268        actions
269    }
270}
271
272#[allow(clippy::too_many_arguments)]
273fn draw_options_window(
274    ctx: &egui::Context,
275    settings: &mut Settings,
276    boot: &Settings,
277    visible: &mut bool,
278    tab: &mut Tab,
279    actions: &mut OptionsActions,
280    audio_devices: &[String],
281    refresh_audio: &mut bool,
282) {
283    egui::Window::new("Options")
284        .resizable(true)
285        .default_width(460.0)
286        .default_height(440.0)
287        .show(ctx, |ui| {
288            ui.horizontal(|ui| {
289                ui.selectable_value(tab, Tab::Visuals, "Visuals");
290                ui.selectable_value(tab, Tab::Engine, "Engine");
291                ui.selectable_value(tab, Tab::Playback, "Playback");
292                ui.selectable_value(tab, Tab::Audio, "Audio");
293                ui.selectable_value(tab, Tab::Window, "Window");
294                ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
295                    if ui.small_button("Close").clicked() {
296                        *visible = false;
297                    }
298                });
299            });
300            ui.separator();
301
302            match *tab {
303                Tab::Visuals => draw_visuals_tab(ui, settings, boot, actions),
304                Tab::Engine => draw_engine_tab(ui, settings, actions),
305                Tab::Playback => draw_playback_tab(ui, settings, actions),
306                Tab::Audio => draw_audio_tab(ui, settings, actions, audio_devices, refresh_audio),
307                Tab::Window => draw_window_tab(ui, settings),
308            }
309
310            ui.separator();
311            ui.small("Tab toggle · Esc closes overlay");
312        });
313}
314
315/// Translucent corner HUD: FPS + current preset name + mesh size.
316/// Drawn even when the options panel is hidden, gated by
317/// `Settings::window::hud`.
318fn draw_hud(ctx: &egui::Context, data: &HudData) {
319    egui::Area::new(egui::Id::new("onedrop-hud"))
320        .anchor(egui::Align2::RIGHT_TOP, [-12.0, 12.0])
321        .interactable(false)
322        .show(ctx, |ui| {
323            egui::Frame::default()
324                .fill(egui::Color32::from_black_alpha(160))
325                .inner_margin(egui::Margin::symmetric(10, 8))
326                .corner_radius(egui::CornerRadius::same(4))
327                .show(ui, |ui| {
328                    ui.label(
329                        egui::RichText::new(format!("{:>5.1} fps", data.fps))
330                            .color(egui::Color32::WHITE)
331                            .monospace(),
332                    );
333                    ui.label(
334                        egui::RichText::new(format!("mesh {}×{}", data.mesh_cols, data.mesh_rows))
335                            .color(egui::Color32::LIGHT_GRAY)
336                            .monospace()
337                            .small(),
338                    );
339                    if !data.preset_name.is_empty() {
340                        ui.label(
341                            egui::RichText::new(&data.preset_name)
342                                .color(egui::Color32::LIGHT_GRAY)
343                                .small(),
344                        );
345                    }
346                });
347        });
348}
349
350/// Concrete mesh sizes for the named quality presets. Kept in lockstep
351/// with `onedrop_renderer::MeshQuality::size`; we duplicate here so the
352/// dropdown can list "Custom" without forcing a round-trip through the
353/// renderer crate just for labels.
354const MESH_PRESETS: &[(&str, u32, u32)] = &[
355    ("Low (32×24)", 32, 24),
356    ("Medium (48×36)", 48, 36),
357    ("High (64×48)", 64, 48),
358    ("Ultra (96×72)", 96, 72),
359];
360
361fn draw_visuals_tab(
362    ui: &mut egui::Ui,
363    settings: &mut Settings,
364    boot: &Settings,
365    actions: &mut OptionsActions,
366) {
367    let v = &mut settings.visuals;
368
369    ui.heading("Warp mesh");
370    ui.label(
371        "Density of the per-vertex warp grid. Higher = smoother \
372         distortions, scales CPU eval cost roughly linearly.",
373    );
374
375    // Resolve the current size to a preset label, or "Custom".
376    let current_preset = MESH_PRESETS
377        .iter()
378        .find(|(_, c, r)| *c == v.mesh_cols && *r == v.mesh_rows)
379        .map(|(name, _, _)| *name)
380        .unwrap_or("Custom");
381
382    egui::ComboBox::from_label("Quality")
383        .selected_text(current_preset)
384        .show_ui(ui, |ui| {
385            for (name, cols, rows) in MESH_PRESETS {
386                let selected = v.mesh_cols == *cols && v.mesh_rows == *rows;
387                if ui.selectable_label(selected, *name).clicked() && !selected {
388                    v.mesh_cols = *cols;
389                    v.mesh_rows = *rows;
390                    actions.mesh_size = Some((*cols, *rows));
391                }
392            }
393            let is_custom = MESH_PRESETS
394                .iter()
395                .all(|(_, c, r)| *c != v.mesh_cols || *r != v.mesh_rows);
396            // Visual indicator only — the actual custom values are
397            // driven by the sliders below.
398            let _ = ui.selectable_label(is_custom, "Custom…");
399        });
400
401    ui.add_space(4.0);
402    ui.label("Custom override");
403    let mut cols_buf = v.mesh_cols;
404    let mut rows_buf = v.mesh_rows;
405    let resp_cols = ui.add(egui::Slider::new(&mut cols_buf, 8..=192).text("cols"));
406    let resp_rows = ui.add(egui::Slider::new(&mut rows_buf, 6..=96).text("rows"));
407    // Apply only when the slider drag is released so we don't rebuild
408    // the GPU buffer every pixel of slider travel.
409    if (resp_cols.drag_stopped() || resp_cols.lost_focus())
410        || (resp_rows.drag_stopped() || resp_rows.lost_focus())
411    {
412        if cols_buf != v.mesh_cols || rows_buf != v.mesh_rows {
413            v.mesh_cols = cols_buf;
414            v.mesh_rows = rows_buf;
415            actions.mesh_size = Some((cols_buf, rows_buf));
416        }
417    } else {
418        // Reflect live drag in the displayed value but don't apply yet.
419        v.mesh_cols = cols_buf;
420        v.mesh_rows = rows_buf;
421    }
422
423    ui.add_space(8.0);
424    ui.separator();
425    ui.heading("Display");
426
427    // VSync is live-applicable — the surface just needs reconfigure.
428    if ui.checkbox(&mut v.vsync, "VSync").changed() {
429        actions.vsync = Some(v.vsync);
430    }
431    ui.small("Applied immediately. Disabling uncaps frame rate.");
432
433    ui.add(egui::Slider::new(&mut v.target_fps, 0..=240).text("FPS cap (0 = unlimited)"));
434
435    // MSAA changes are pipeline-bound: every render pass we own
436    // hard-codes `MultisampleState::default()`. A live rebuild would
437    // touch ~9 pipelines; the pragmatic V1 path is to persist the new
438    // value and relaunch the process so the next boot picks it up.
439    ui.add(egui::Slider::new(&mut v.msaa_samples, 1..=8).text("MSAA samples"));
440    let msaa_dirty = v.msaa_samples != boot.visuals.msaa_samples;
441    ui.horizontal(|ui| {
442        ui.small(if msaa_dirty {
443            "Pending — restart required to apply."
444        } else {
445            "Matches running configuration."
446        });
447        if ui
448            .add_enabled(msaa_dirty, egui::Button::new("Apply & restart"))
449            .clicked()
450        {
451            actions.request_restart = true;
452        }
453    });
454}
455
456fn draw_engine_tab(ui: &mut egui::Ui, settings: &mut Settings, actions: &mut OptionsActions) {
457    let e = &mut settings.engine;
458
459    ui.heading("Equations");
460
461    if ui
462        .checkbox(&mut e.enable_per_frame, "Per-frame equations")
463        .changed()
464    {
465        actions.enable_per_frame = Some(e.enable_per_frame);
466    }
467    ui.small("Run preset `per_frame` blocks every frame. Off freezes motion.");
468
469    ui.add_space(8.0);
470    ui.separator();
471    ui.heading("Transitions");
472
473    let resp = ui.add(
474        egui::Slider::new(&mut e.transition_duration_s, 0.0..=10.0)
475            .text("Crossfade duration (s)")
476            .suffix(" s"),
477    );
478    if resp.drag_stopped() || resp.lost_focus() {
479        actions.transition_duration = Some(e.transition_duration_s);
480    }
481    ui.small("0 = instant cut. MD2 default is 2.7s.");
482
483    ui.add_space(8.0);
484    ui.separator();
485    ui.heading("Diagnostics");
486
487    if ui.checkbox(&mut e.profile, "Per-phase profiling").changed() {
488        actions.profile = Some(e.profile);
489    }
490    ui.small("Logs wall-clock breakdown of each update phase.");
491}
492
493/// Beat-detection mode variants the picker can offer. Mirrors
494/// `BeatDetectionMode::next()`'s cycle so the dropdown order is
495/// predictable. The `HardCut6` variant has a `special_preset` field
496/// that the GUI never edits — we materialise it with an empty string
497/// when the user selects it, which `BeatDetector` handles identically.
498const BEAT_MODE_NAMES: &[&str] = &[
499    "Off", "HardCut1", "HardCut2", "HardCut3", "HardCut4", "HardCut5", "HardCut6",
500];
501
502fn mode_from_name(name: &str) -> BeatDetectionMode {
503    match name {
504        "HardCut1" => BeatDetectionMode::HardCut1,
505        "HardCut2" => BeatDetectionMode::HardCut2,
506        "HardCut3" => BeatDetectionMode::HardCut3,
507        "HardCut4" => BeatDetectionMode::HardCut4,
508        "HardCut5" => BeatDetectionMode::HardCut5,
509        "HardCut6" => BeatDetectionMode::HardCut6 {
510            special_preset: String::new(),
511        },
512        _ => BeatDetectionMode::Off,
513    }
514}
515
516fn draw_playback_tab(ui: &mut egui::Ui, settings: &mut Settings, actions: &mut OptionsActions) {
517    let p = &mut settings.playback;
518
519    ui.heading("Auto preset change");
520
521    let current = p.beat_detection_mode.clone();
522    egui::ComboBox::from_label("Beat-detection mode")
523        .selected_text(&current)
524        .show_ui(ui, |ui| {
525            for name in BEAT_MODE_NAMES {
526                if ui.selectable_label(current == *name, *name).clicked() && current != *name {
527                    p.beat_detection_mode = (*name).to_string();
528                    actions.beat_detection_mode = Some(mode_from_name(name));
529                }
530            }
531        });
532    ui.small(
533        "Off = manual only. HardCut1–6 trigger preset changes on bass/treb \
534         spikes — same modes as the F8 cycle.",
535    );
536
537    ui.add_space(8.0);
538    ui.separator();
539    ui.heading("Preset directories");
540    ui.small(
541        "Set via `--preset-dir` on the command line. Default search path: \
542         ~/.config/onedrop/presets, then test-presets/ in the workspace, \
543         then /usr/share/onedrop/presets.",
544    );
545}
546
547fn draw_audio_tab(
548    ui: &mut egui::Ui,
549    settings: &mut Settings,
550    actions: &mut OptionsActions,
551    devices: &[String],
552    refresh: &mut bool,
553) {
554    let a = &mut settings.audio;
555
556    ui.heading("Input device");
557
558    let label = a.input_device.clone().unwrap_or_else(|| "Default".into());
559    egui::ComboBox::from_label("Source")
560        .selected_text(&label)
561        .show_ui(ui, |ui| {
562            // The Default entry always sits at the top — clicking it
563            // clears any saved device name and falls back to whatever
564            // the OS picks at the time of the call.
565            let default_selected = a.input_device.is_none();
566            if ui
567                .selectable_label(default_selected, "Default (OS-picked)")
568                .clicked()
569                && !default_selected
570            {
571                a.input_device = None;
572                actions.audio_device = Some(None);
573            }
574            ui.separator();
575            for name in devices {
576                let selected = a.input_device.as_deref() == Some(name.as_str());
577                if ui.selectable_label(selected, name).clicked() && !selected {
578                    a.input_device = Some(name.clone());
579                    actions.audio_device = Some(Some(name.clone()));
580                }
581            }
582        });
583
584    ui.horizontal(|ui| {
585        ui.small(format!("{} device(s) discovered.", devices.len()));
586        if ui.small_button("Refresh").clicked() {
587            *refresh = true;
588        }
589    });
590    ui.small(
591        "Changing the device restarts the cpal stream. If the named \
592         device disappears between launches, OneDrop falls back to the \
593         OS default and logs a warning.",
594    );
595}
596
597fn draw_window_tab(ui: &mut egui::Ui, settings: &mut Settings) {
598    let w = &mut settings.window;
599
600    ui.heading("HUD overlay");
601    ui.checkbox(&mut w.hud, "Show FPS + preset HUD");
602    ui.small("Top-right corner overlay. Always live-applied.");
603
604    ui.add_space(8.0);
605    ui.separator();
606    ui.heading("Mouse cursor");
607    ui.add(
608        egui::Slider::new(&mut w.cursor_auto_hide_secs, 0.0..=15.0)
609            .text("Auto-hide after (s)")
610            .suffix(" s"),
611    );
612    ui.small("0 = always visible. Moving the mouse re-shows it.");
613}