1use 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#[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#[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 pub beat_detection_mode: Option<BeatDetectionMode>,
47 pub audio_device: Option<Option<String>>,
51 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71enum Tab {
72 Visuals,
73 Engine,
74 Playback,
75 Audio,
76 Window,
77}
78
79pub struct OptionsUi {
83 ctx: egui::Context,
84 state: EguiState,
85 renderer: EguiRenderer,
86 pub visible: bool,
88 tab: Tab,
89 boot: crate::settings::Settings,
93 audio_devices: Vec<String>,
97 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 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 pub fn on_event(&mut self, window: &Window, event: &WindowEvent) -> bool {
146 let response = self.state.on_window_event(window, event);
147 self.visible && response.consumed
151 }
152
153 pub fn toggle(&mut self) {
154 self.visible = !self.visible;
155 }
156
157 #[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 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 #[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
315fn 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
350const 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 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 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 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 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 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 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
493const 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(¤t)
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 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}