onedrop_gui/
main.rs

1//! OneDrop GUI - Graphical user interface for MilkDrop visualizations
2
3mod keymap;
4mod options_ui;
5mod settings;
6
7use anyhow::Result;
8use clap::Parser;
9use keymap::{Action, Binding, Keymap, Modifiers};
10use onedrop_engine::{
11    AudioInput, BeatDetectionMode, EngineConfig, MilkEngine, PresetChange, PresetManager,
12    RenderConfig, SelectionMode,
13};
14use options_ui::{HudData, OptionsActions, OptionsUi};
15use settings::{PresetSelectionMode, Settings, data_path};
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::time::Instant;
19use winit::{
20    application::ApplicationHandler,
21    event::*,
22    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
23    keyboard::{KeyCode, PhysicalKey},
24    window::{Fullscreen, Window, WindowId},
25};
26
27/// Command-line arguments for `onedrop-gui`.
28#[derive(Parser, Debug)]
29#[command(
30    name = "onedrop-gui",
31    about = "OneDrop — a pure-Rust MilkDrop visualizer",
32    long_about = None,
33    version,
34)]
35struct Args {
36    /// Directory to scan for `.milk` presets. May be repeated. If omitted,
37    /// OneDrop searches `$XDG_CONFIG_HOME/onedrop/presets/`, then a `presets/`
38    /// folder next to the binary, then a `test-presets/` folder in the
39    /// repository (dev convenience).
40    #[arg(long = "preset-dir", short = 'd', value_name = "DIR")]
41    preset_dirs: Vec<PathBuf>,
42
43    /// Load a single `.milk` file directly. Overrides directory search.
44    #[arg(long = "preset", short = 'p', value_name = "FILE")]
45    preset: Option<PathBuf>,
46
47    /// Start the window in fullscreen mode.
48    #[arg(long, short = 'f')]
49    fullscreen: bool,
50
51    /// Window width (ignored if `--fullscreen`).
52    #[arg(long, default_value_t = 1280)]
53    width: u32,
54
55    /// Window height (ignored if `--fullscreen`).
56    #[arg(long, default_value_t = 720)]
57    height: u32,
58
59    /// Disable vsync (uncapped frame rate). Useful for benchmarking — the
60    /// default is vsync ON so the GUI runs at monitor refresh.
61    #[arg(long)]
62    no_vsync: bool,
63}
64
65/// Discover preset files. Honours `--preset` first, then `--preset-dir`
66/// (one or more), then the default search path. Returns the list of
67/// `.milk` files found, in lexicographic order so the boot sequence is
68/// reproducible.
69fn discover_presets(args: &Args) -> Vec<PathBuf> {
70    if let Some(p) = &args.preset {
71        return vec![p.clone()];
72    }
73
74    let mut dirs: Vec<PathBuf> = args.preset_dirs.clone();
75    if dirs.is_empty() {
76        if let Some(cfg) = dirs::config_dir() {
77            dirs.push(cfg.join("onedrop").join("presets"));
78        }
79        if let Ok(exe) = std::env::current_exe()
80            && let Some(parent) = exe.parent()
81        {
82            dirs.push(parent.join("presets"));
83        }
84        // Distro packages install presets here.
85        dirs.push(PathBuf::from("/usr/share/onedrop/presets"));
86        dirs.push(PathBuf::from("/usr/local/share/onedrop/presets"));
87        // Dev convenience: workspace-root and cwd-relative locations.
88        dirs.push(PathBuf::from("test-presets"));
89        dirs.push(PathBuf::from("../test-presets"));
90    }
91
92    let mut out: Vec<PathBuf> = Vec::new();
93    for d in &dirs {
94        if let Ok(entries) = std::fs::read_dir(d) {
95            log::info!("Scanning preset directory: {}", d.display());
96            for entry in entries.flatten() {
97                let path = entry.path();
98                if path.extension().and_then(|s| s.to_str()) == Some("milk") {
99                    out.push(path);
100                }
101            }
102        }
103    }
104    out.sort();
105    out
106}
107
108/// Fullscreen-triangle blit that samples the engine's final display texture
109/// and writes to the swapchain. Sampling (rather than `copy_texture_to_texture`)
110/// sidesteps two failure modes: it doesn't need `COPY_DST` on the swapchain
111/// (not guaranteed on all platforms), and sRGB conversion happens automatically
112/// when source and destination formats disagree.
113struct Blit {
114    pipeline: wgpu::RenderPipeline,
115    bind_group_layout: wgpu::BindGroupLayout,
116    sampler: wgpu::Sampler,
117}
118
119impl Blit {
120    fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
121        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
122            label: Some("Blit Shader"),
123            source: wgpu::ShaderSource::Wgsl(
124                r#"
125@vertex
126fn vs_main(@builtin(vertex_index) vid: u32) -> @builtin(position) vec4<f32> {
127    // Fullscreen triangle covering the viewport.
128    var p = array<vec2<f32>, 3>(
129        vec2<f32>(-1.0, -3.0),
130        vec2<f32>(-1.0,  1.0),
131        vec2<f32>( 3.0,  1.0),
132    );
133    return vec4<f32>(p[vid], 0.0, 1.0);
134}
135
136@group(0) @binding(0) var t: texture_2d<f32>;
137@group(0) @binding(1) var s: sampler;
138
139@fragment
140fn fs_main(@builtin(position) frag: vec4<f32>) -> @location(0) vec4<f32> {
141    let dims = vec2<f32>(textureDimensions(t, 0));
142    let uv = vec2<f32>(frag.x / dims.x, frag.y / dims.y);
143    return textureSample(t, s, uv);
144}
145"#
146                .into(),
147            ),
148        });
149
150        let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
151            label: Some("Blit BGL"),
152            entries: &[
153                wgpu::BindGroupLayoutEntry {
154                    binding: 0,
155                    visibility: wgpu::ShaderStages::FRAGMENT,
156                    ty: wgpu::BindingType::Texture {
157                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
158                        view_dimension: wgpu::TextureViewDimension::D2,
159                        multisampled: false,
160                    },
161                    count: None,
162                },
163                wgpu::BindGroupLayoutEntry {
164                    binding: 1,
165                    visibility: wgpu::ShaderStages::FRAGMENT,
166                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
167                    count: None,
168                },
169            ],
170        });
171
172        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
173            label: Some("Blit Pipeline Layout"),
174            bind_group_layouts: &[Some(&bind_group_layout)],
175            immediate_size: 0,
176        });
177
178        let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
179            label: Some("Blit Pipeline"),
180            layout: Some(&pipeline_layout),
181            vertex: wgpu::VertexState {
182                module: &shader,
183                entry_point: Some("vs_main"),
184                buffers: &[],
185                compilation_options: Default::default(),
186            },
187            fragment: Some(wgpu::FragmentState {
188                module: &shader,
189                entry_point: Some("fs_main"),
190                targets: &[Some(wgpu::ColorTargetState {
191                    format: target_format,
192                    blend: None,
193                    write_mask: wgpu::ColorWrites::ALL,
194                })],
195                compilation_options: Default::default(),
196            }),
197            primitive: wgpu::PrimitiveState {
198                topology: wgpu::PrimitiveTopology::TriangleList,
199                ..Default::default()
200            },
201            depth_stencil: None,
202            multisample: wgpu::MultisampleState::default(),
203            multiview_mask: None,
204            cache: None,
205        });
206
207        let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
208            label: Some("Blit Sampler"),
209            address_mode_u: wgpu::AddressMode::ClampToEdge,
210            address_mode_v: wgpu::AddressMode::ClampToEdge,
211            address_mode_w: wgpu::AddressMode::ClampToEdge,
212            mag_filter: wgpu::FilterMode::Linear,
213            min_filter: wgpu::FilterMode::Linear,
214            mipmap_filter: wgpu::MipmapFilterMode::Nearest,
215            ..Default::default()
216        });
217
218        Self {
219            pipeline,
220            bind_group_layout,
221            sampler,
222        }
223    }
224}
225
226struct App {
227    window: Option<Arc<Window>>,
228    surface: Option<wgpu::Surface<'static>>,
229    surface_config: Option<wgpu::SurfaceConfiguration>,
230    device: Option<Arc<wgpu::Device>>,
231    queue: Option<Arc<wgpu::Queue>>,
232    engine: Option<MilkEngine>,
233    audio_input: Option<AudioInput>,
234    blit: Option<Blit>,
235    preset_manager: PresetManager,
236    last_frame: Instant,
237    frame_count: u32,
238    /// Window-creation preferences read once from CLI args.
239    initial_width: u32,
240    initial_height: u32,
241    fullscreen: bool,
242    no_vsync: bool,
243    /// Timestamp of the last left-click; used to detect double-click.
244    last_left_click: Option<Instant>,
245    /// Start of the current FPS-measurement window.
246    fps_window_start: Instant,
247    /// Frames rendered since `fps_window_start`.
248    fps_window_frames: u32,
249    /// Last computed FPS (refreshed every ~2s alongside the log). Fed
250    /// into the HUD overlay.
251    last_fps: f32,
252    /// Timestamp of the last cursor movement. Drives the auto-hide
253    /// behaviour (`Settings::window::cursor_auto_hide_secs`).
254    last_cursor_move: Instant,
255    /// Current visibility of the cursor — mirrors what we last told
256    /// winit so we don't spam `set_cursor_visible` on every frame.
257    cursor_visible: bool,
258    /// Persistent settings loaded at startup; the Options overlay
259    /// edits this in place and re-saves on every change.
260    settings: Settings,
261    /// egui-based Options overlay. `None` until `init_graphics` runs.
262    options_ui: Option<OptionsUi>,
263    /// Current modifier state, kept in sync with
264    /// `WindowEvent::ModifiersChanged`. The sprite + message
265    /// keybindings (§5, §6) read `Shift` (random sprite) and `Ctrl`
266    /// (clear all) from here.
267    modifiers: winit::event::Modifiers,
268    /// Keybind table. Defaults to MD2-style mapping; overrides come
269    /// from `~/.config/onedrop/settings.toml` `[keymap.bindings]`.
270    keymap: Keymap,
271}
272
273impl App {
274    fn new(args: &Args, settings: Settings) -> Self {
275        let mut preset_manager =
276            PresetManager::with_history_size(settings.presets.history_size.max(1));
277        preset_manager.set_locked(settings.presets.locked);
278        preset_manager.set_mode(match settings.presets.mode {
279            PresetSelectionMode::Random => SelectionMode::Random,
280            PresetSelectionMode::Sequential => SelectionMode::Sequential,
281        });
282        // Persisted ratings live alongside history/state in the XDG
283        // data dir. Missing file → no-op; the manager just runs with
284        // an empty rating map and persists on the next nudge.
285        if let Some(p) = data_path("ratings.toml") {
286            preset_manager.attach_ratings_file(p);
287        }
288
289        let keymap = Keymap::from_settings(&settings.keymap);
290
291        let presets = discover_presets(args);
292        if presets.is_empty() {
293            log::warn!(
294                "No `.milk` presets found. Pass `--preset-dir <DIR>` or drop \
295                 presets into `~/.config/onedrop/presets/`. Starting with the \
296                 built-in default preset only."
297            );
298        } else {
299            log::info!("Loaded {} preset(s)", presets.len());
300            for p in presets {
301                preset_manager.add_preset(p);
302            }
303        }
304
305        // Prefer the device the user last picked, when one is saved.
306        // Falls back to OS default (and finally demo audio) the same
307        // way the no-config path always has.
308        let preferred_device = settings.audio.input_device.as_deref();
309        let audio_input = match AudioInput::with_device(preferred_device) {
310            Ok(input) => {
311                log::info!(
312                    "Real audio input initialized (requested device: {:?})",
313                    preferred_device.unwrap_or("Default")
314                );
315                Some(input)
316            }
317            Err(e) => {
318                log::warn!(
319                    "Failed to initialize audio input: {}. Falling back to demo mode.",
320                    e
321                );
322                None
323            }
324        };
325        Self {
326            window: None,
327            surface: None,
328            surface_config: None,
329            device: None,
330            queue: None,
331            engine: None,
332            audio_input,
333            blit: None,
334            preset_manager,
335            last_frame: Instant::now(),
336            frame_count: 0,
337            initial_width: args.width,
338            initial_height: args.height,
339            fullscreen: args.fullscreen,
340            no_vsync: args.no_vsync,
341            last_left_click: None,
342            fps_window_start: Instant::now(),
343            fps_window_frames: 0,
344            last_fps: 0.0,
345            last_cursor_move: Instant::now(),
346            cursor_visible: true,
347            settings,
348            options_ui: None,
349            modifiers: winit::event::Modifiers::default(),
350            keymap,
351        }
352    }
353
354    fn toggle_fullscreen(&self) {
355        if let Some(window) = &self.window {
356            if window.fullscreen().is_some() {
357                window.set_fullscreen(None);
358            } else {
359                window.set_fullscreen(Some(Fullscreen::Borderless(None)));
360            }
361        }
362    }
363
364    fn init_graphics(&mut self, window: Arc<Window>) -> Result<()> {
365        let size = window.inner_size();
366
367        // Create wgpu instance (wgpu 29: InstanceDescriptor lost Default).
368        let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
369            backends: wgpu::Backends::all(),
370            ..wgpu::InstanceDescriptor::new_without_display_handle()
371        });
372
373        // Create surface
374        let surface = instance.create_surface(window.clone())?;
375
376        // Request adapter — wgpu 29 returns Result<Adapter, RequestAdapterError>.
377        let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions {
378            power_preference: wgpu::PowerPreference::HighPerformance,
379            compatible_surface: Some(&surface),
380            force_fallback_adapter: false,
381        }))
382        .map_err(|e| anyhow::anyhow!("Failed to find adapter: {e}"))?;
383
384        // Request device and queue — single-arg in wgpu 29; new fields on DeviceDescriptor.
385        // Match `GpuContext::new`: the comp pass binds up to 18 sampled textures
386        // (main + 3 blur + 3 noise 2D + 2 noise 3D + 8 user-slot + 1 prev) so the
387        // wgpu default of 16 panics on bind-group creation. 24 leaves headroom.
388        let (device, queue) =
389            pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor {
390                label: Some("Device"),
391                required_features: wgpu::Features::empty(),
392                required_limits: wgpu::Limits {
393                    max_sampled_textures_per_shader_stage: 24,
394                    ..wgpu::Limits::default()
395                },
396                memory_hints: Default::default(),
397                experimental_features: wgpu::ExperimentalFeatures::default(),
398                trace: wgpu::Trace::Off,
399            }))?;
400
401        let device = Arc::new(device);
402        let queue = Arc::new(queue);
403
404        // Configure surface
405        let surface_caps = surface.get_capabilities(&adapter);
406        let surface_format = surface_caps
407            .formats
408            .iter()
409            .find(|f| f.is_srgb())
410            .copied()
411            .unwrap_or(surface_caps.formats[0]);
412
413        // Vsync default: Fifo (capped at refresh rate). `--no-vsync` switches
414        // to Immediate (uncapped, with tearing — for benchmarking) and falls
415        // back to Mailbox if the platform doesn't expose Immediate.
416        let present_mode = if self.no_vsync {
417            let caps = surface_caps.present_modes.as_slice();
418            if caps.contains(&wgpu::PresentMode::Immediate) {
419                wgpu::PresentMode::Immediate
420            } else if caps.contains(&wgpu::PresentMode::Mailbox) {
421                wgpu::PresentMode::Mailbox
422            } else {
423                wgpu::PresentMode::Fifo
424            }
425        } else {
426            wgpu::PresentMode::Fifo
427        };
428
429        // The blit pass writes through the default sRGB view (preserves
430        // current colour-correct compositing of the MilkDrop output),
431        // but egui prefers a linear target — feeding its already-gamma-
432        // encoded output into an sRGB view double-encodes and darkens
433        // every widget. Declare the matching non-sRGB format as an
434        // alternate view so the egui pass can target it on the same
435        // swapchain texture without a separate framebuffer.
436        let linear_view_format = surface_format.remove_srgb_suffix();
437        let view_formats = if linear_view_format != surface_format {
438            vec![linear_view_format]
439        } else {
440            vec![]
441        };
442        let config = wgpu::SurfaceConfiguration {
443            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
444            format: surface_format,
445            width: size.width,
446            height: size.height,
447            present_mode,
448            alpha_mode: surface_caps.alpha_modes[0],
449            view_formats,
450            desired_maximum_frame_latency: 2,
451        };
452
453        surface.configure(&device, &config);
454
455        // Create engine with shared device, seeded by persisted settings.
456        let engine_config = EngineConfig {
457            render_config: RenderConfig {
458                width: size.width,
459                height: size.height,
460                mesh_cols: self.settings.visuals.mesh_cols,
461                mesh_rows: self.settings.visuals.mesh_rows,
462                vsync: self.settings.visuals.vsync,
463                target_fps: self.settings.visuals.target_fps,
464                msaa_samples: self.settings.visuals.msaa_samples,
465                ..Default::default()
466            },
467            enable_per_frame: self.settings.engine.enable_per_frame,
468            transition_duration_s: self.settings.engine.transition_duration_s,
469            profile: self.settings.engine.profile,
470            ..Default::default()
471        };
472
473        // Share device and queue with engine
474        let mut engine =
475            MilkEngine::from_device(Arc::clone(&device), Arc::clone(&queue), engine_config)?;
476
477        // Apply persisted beat-detection mode. The engine boots with
478        // `Off`; `set_beat_detection_mode` is the same path the F8
479        // cycle and the Options panel use.
480        let beat_mode = beat_mode_from_name(&self.settings.playback.beat_detection_mode);
481        engine.set_beat_detection_mode(beat_mode);
482
483        // Build the blit pass that samples the engine's final texture into the
484        // swapchain — see `Blit` for why this exists.
485        let blit = Blit::new(&device, surface_format);
486
487        // egui Options overlay — renders on top of the blit each frame.
488        // Use the non-sRGB view format so egui's gamma encoding lands
489        // on a linear target (see `linear_view_format` above). The
490        // boot-settings snapshot lets the UI detect when a restart is
491        // needed (MSAA, texture format).
492        let options_ui =
493            OptionsUi::new(&window, &device, linear_view_format, self.settings.clone());
494
495        window.set_title("OneDrop");
496
497        self.window = Some(window);
498        self.surface = Some(surface);
499        self.surface_config = Some(config);
500        self.device = Some(device);
501        self.queue = Some(queue);
502        self.engine = Some(engine);
503        self.blit = Some(blit);
504        self.options_ui = Some(options_ui);
505
506        // Load first preset if available
507        if let Some(preset_path) = self.preset_manager.current_preset()
508            && let Some(engine) = &mut self.engine
509        {
510            if let Err(e) = engine.load_preset(preset_path) {
511                log::error!("Failed to load preset: {}", e);
512            } else {
513                log::info!("Loaded preset: {}", preset_path.display());
514            }
515        }
516
517        Ok(())
518    }
519
520    fn render(&mut self) -> Result<()> {
521        let surface = self
522            .surface
523            .as_ref()
524            .ok_or_else(|| anyhow::anyhow!("Graphics not initialized: surface"))?;
525        let device = self
526            .device
527            .as_ref()
528            .ok_or_else(|| anyhow::anyhow!("Graphics not initialized: device"))?;
529        let queue = self
530            .queue
531            .as_ref()
532            .ok_or_else(|| anyhow::anyhow!("Graphics not initialized: queue"))?;
533        let engine = self
534            .engine
535            .as_mut()
536            .ok_or_else(|| anyhow::anyhow!("Graphics not initialized: engine"))?;
537
538        // Calculate delta time
539        let now = Instant::now();
540        let delta_time = (now - self.last_frame).as_secs_f32();
541        self.last_frame = now;
542
543        // Get audio samples - use real audio input or fall back to demo mode
544        let audio_samples: Vec<f32> = if let Some(ref audio_input) = self.audio_input {
545            // Use real audio capture
546            audio_input.get_fixed_samples(1024)
547        } else {
548            // Fallback: generate demo audio (sine wave)
549            (0..1024)
550                .map(|i| {
551                    let t = (self.frame_count * 1024 + i) as f32 * 0.001;
552                    (t * 2.0 * std::f32::consts::PI * 60.0).sin() * 0.5
553                })
554                .collect()
555        };
556
557        // Update engine
558        let preset_change = engine.update(&audio_samples, delta_time)?;
559
560        // Handle automatic preset change from beat detection
561        if let Some(change) = preset_change {
562            match change {
563                PresetChange::Random => {
564                    // Load random preset
565                    if let Some(preset_path) = self.preset_manager.random_preset() {
566                        if let Err(e) = engine.load_preset(preset_path) {
567                            log::error!("Failed to load random preset: {}", e);
568                        } else {
569                            log::info!(
570                                "Beat detection: Loaded random preset: {}",
571                                preset_path.display()
572                            );
573                        }
574                    }
575                }
576                PresetChange::Specific(path) => {
577                    // Load specific preset
578                    if let Err(e) = engine.load_preset(&path) {
579                        log::error!("Failed to load specific preset {}: {}", path, e);
580                    } else {
581                        log::info!("Beat detection: Loaded specific preset: {}", path);
582                    }
583                }
584            }
585        }
586
587        // Get surface texture — wgpu 29 returns CurrentSurfaceTexture (enum),
588        // not Result. Treat non-Success states as "skip this frame".
589        let output = match surface.get_current_texture() {
590            wgpu::CurrentSurfaceTexture::Success(t) => t,
591            wgpu::CurrentSurfaceTexture::Suboptimal(t) => {
592                log::debug!("Surface texture suboptimal — reconfigure recommended");
593                t
594            }
595            other => {
596                log::debug!("Surface texture unavailable ({other:?}) — skipping frame");
597                return Ok(());
598            }
599        };
600        let view = output
601            .texture
602            .create_view(&wgpu::TextureViewDescriptor::default());
603
604        let blit = self
605            .blit
606            .as_ref()
607            .ok_or_else(|| anyhow::anyhow!("Graphics not initialized: blit"))?;
608
609        // Sample the engine's final texture into the swapchain via a
610        // fullscreen-triangle blit (handles format mismatch automatically).
611        let src_view = engine
612            .renderer()
613            .render_texture()
614            .create_view(&wgpu::TextureViewDescriptor::default());
615        let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
616            label: Some("Blit Bind Group"),
617            layout: &blit.bind_group_layout,
618            entries: &[
619                wgpu::BindGroupEntry {
620                    binding: 0,
621                    resource: wgpu::BindingResource::TextureView(&src_view),
622                },
623                wgpu::BindGroupEntry {
624                    binding: 1,
625                    resource: wgpu::BindingResource::Sampler(&blit.sampler),
626                },
627            ],
628        });
629
630        let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
631            label: Some("Render Encoder"),
632        });
633        {
634            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
635                label: Some("Blit Pass"),
636                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
637                    view: &view,
638                    resolve_target: None,
639                    ops: wgpu::Operations {
640                        load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
641                        store: wgpu::StoreOp::Store,
642                    },
643                    depth_slice: None,
644                })],
645                depth_stencil_attachment: None,
646                timestamp_writes: None,
647                occlusion_query_set: None,
648                multiview_mask: None,
649            });
650            pass.set_pipeline(&blit.pipeline);
651            pass.set_bind_group(0, &bind_group, &[]);
652            pass.draw(0..3, 0..1);
653        }
654
655        // egui Options overlay — composites on top of the blit when
656        // `options_ui.visible == true`, no-op otherwise. Apply any
657        // changes the user made this frame to the live engine, and
658        // re-save settings.
659        //
660        // egui targets a non-sRGB view of the same swapchain texture
661        // so its already-gamma-encoded output isn't double-encoded by
662        // the hardware sRGB write. The blit pass continues to use the
663        // default sRGB view.
664        let actions = if let (Some(ui), Some(win), Some(cfg)) =
665            (&mut self.options_ui, &self.window, &self.surface_config)
666        {
667            let linear_format = cfg.format.remove_srgb_suffix();
668            let egui_view = output.texture.create_view(&wgpu::TextureViewDescriptor {
669                label: Some("egui Linear View"),
670                format: Some(linear_format),
671                ..Default::default()
672            });
673            let preset_name = self
674                .preset_manager
675                .current_preset()
676                .and_then(|p| p.file_stem())
677                .and_then(|s| s.to_str())
678                .unwrap_or("")
679                .to_string();
680            let (cols, rows) = engine.renderer().mesh_size();
681            let hud = HudData {
682                fps: self.last_fps,
683                preset_name,
684                mesh_cols: cols,
685                mesh_rows: rows,
686            };
687            ui.render(
688                device,
689                queue,
690                win,
691                &mut encoder,
692                &egui_view,
693                (cfg.width, cfg.height),
694                &mut self.settings,
695                &hud,
696            )
697        } else {
698            OptionsActions::default()
699        };
700        if actions.any() {
701            apply_options_actions(engine, &actions);
702            self.settings.save();
703        }
704
705        queue.submit(std::iter::once(encoder.finish()));
706        self.window
707            .as_ref()
708            .ok_or_else(|| anyhow::anyhow!("Graphics not initialized: window"))?
709            .pre_present_notify();
710        output.present();
711
712        // Deferred Options-panel side-effects: anything that needs
713        // `&mut self` runs after the queue borrow is released and the
714        // current frame has shipped.
715        if let Some(vsync) = actions.vsync {
716            self.reconfigure_present_mode(vsync);
717        }
718        if let Some(ref device_choice) = actions.audio_device {
719            self.swap_audio_input(device_choice.as_deref());
720        }
721        if actions.request_restart {
722            relaunch_self();
723        }
724
725        self.frame_count += 1;
726        self.fps_window_frames += 1;
727        let elapsed = self.fps_window_start.elapsed();
728        if elapsed.as_secs_f32() >= 2.0 {
729            let fps = self.fps_window_frames as f32 / elapsed.as_secs_f32();
730            self.last_fps = fps;
731            let (w, h) = self
732                .surface_config
733                .as_ref()
734                .map(|c| (c.width, c.height))
735                .unwrap_or((0, 0));
736            log::info!("{:.1} fps @ {w}x{h}", fps);
737            self.fps_window_start = Instant::now();
738            self.fps_window_frames = 0;
739        }
740
741        // Cursor auto-hide. The HUD doesn't care about input, so we
742        // can safely hide the cursor while the panel is closed too.
743        // Setting visibility on every frame would be a no-op syscall
744        // but we track our last desired state to avoid the redundancy.
745        let auto_hide = self.settings.window.cursor_auto_hide_secs;
746        if auto_hide > 0.0
747            && let Some(window) = &self.window
748        {
749            let want_visible =
750                self.options_open() || self.last_cursor_move.elapsed().as_secs_f32() < auto_hide;
751            if want_visible != self.cursor_visible {
752                window.set_cursor_visible(want_visible);
753                self.cursor_visible = want_visible;
754            }
755        }
756
757        Ok(())
758    }
759
760    /// Drop the active audio capture stream and rebuild it against a
761    /// different cpal device. `name = None` means "the OS default";
762    /// a name that doesn't match any current device falls back to the
763    /// default with a warning logged by the engine helper.
764    fn swap_audio_input(&mut self, name: Option<&str>) {
765        // Drop the previous stream first so we don't briefly hold two
766        // simultaneous captures on the same device.
767        self.audio_input = None;
768        match AudioInput::with_device(name) {
769            Ok(input) => {
770                log::info!("Audio input switched to {:?}", name.unwrap_or("Default"));
771                self.audio_input = Some(input);
772            }
773            Err(e) => {
774                log::warn!("Failed to open audio input {:?}: {e}", name);
775            }
776        }
777    }
778
779    /// Reconfigure the swapchain for a new VSync preference. Mirrors
780    /// the boot logic in `init_graphics` for picking a present mode:
781    /// `Fifo` for VSync ON, `Immediate` (with `Mailbox` fallback) for
782    /// OFF. Called by the Options panel.
783    fn reconfigure_present_mode(&mut self, vsync: bool) {
784        let (Some(surface), Some(device), Some(cfg)) = (
785            self.surface.as_ref(),
786            self.device.as_ref(),
787            self.surface_config.as_mut(),
788        ) else {
789            return;
790        };
791        let new_mode = if vsync {
792            wgpu::PresentMode::Fifo
793        } else {
794            // We can't re-query the adapter's present-mode capabilities
795            // here without plumbing them; assume Immediate is supported
796            // on the systems where the user would actually flip this
797            // switch. Falling back to Mailbox is a marginal upgrade
798            // anyway.
799            wgpu::PresentMode::Immediate
800        };
801        if cfg.present_mode == new_mode {
802            return;
803        }
804        cfg.present_mode = new_mode;
805        surface.configure(device, cfg);
806        log::info!("Present mode → {:?}", new_mode);
807        self.no_vsync = !vsync;
808    }
809
810    /// Return true when the Options overlay is currently drawn on
811    /// screen. Used by `handle_keyboard` to scope the Esc key to
812    /// "close overlay" rather than "quit" while the panel is open.
813    fn options_open(&self) -> bool {
814        self.options_ui.as_ref().is_some_and(|ui| ui.visible)
815    }
816
817    fn handle_keyboard(&mut self, event_loop: &ActiveEventLoop, key_code: KeyCode) {
818        let mods = Modifiers::from_winit(self.modifiers.state());
819        let binding = Binding::new(key_code, mods);
820        let Some(action) = self.keymap.lookup(binding) else {
821            return;
822        };
823
824        // Tab / Esc-while-options-open are global — they take priority
825        // over the options-overlay suppression below.
826        match action {
827            Action::ToggleOptions => {
828                if let Some(ui) = &mut self.options_ui {
829                    ui.toggle();
830                }
831                return;
832            }
833            Action::Quit if self.options_open() => {
834                if let Some(ui) = &mut self.options_ui {
835                    ui.visible = false;
836                }
837                return;
838            }
839            _ => {}
840        }
841
842        // Suppress preset/transport shortcuts when the overlay is open
843        // so typing in widgets doesn't also flip presets.
844        if self.options_open() {
845            return;
846        }
847
848        self.dispatch_action(event_loop, action);
849    }
850
851    /// Run the side-effects for a resolved [`Action`]. Split out from
852    /// `handle_keyboard` so the lookup → dispatch step is testable in
853    /// isolation and so action handlers can be added without touching
854    /// the winit event-decoding glue.
855    fn dispatch_action(&mut self, event_loop: &ActiveEventLoop, action: Action) {
856        match action {
857            Action::NextPreset | Action::NextPresetInstant => {
858                if let Some(path) = self.preset_manager.advance() {
859                    if let Some(engine) = &mut self.engine {
860                        if let Err(e) = engine.load_preset(&path) {
861                            log::error!("Failed to load preset: {}", e);
862                        } else {
863                            log::info!("Loaded preset: {}", path.display());
864                        }
865                    }
866                } else if self.preset_manager.is_locked() {
867                    log::info!("Preset locked — change blocked. Press ScrollLock to unlock.");
868                }
869            }
870            Action::PreviousPreset => {
871                if let Some(path) = self.preset_manager.retreat() {
872                    if let Some(engine) = &mut self.engine {
873                        if let Err(e) = engine.load_preset(&path) {
874                            log::error!("Failed to load preset: {}", e);
875                        } else {
876                            log::info!("Loaded preset (back): {}", path.display());
877                        }
878                    }
879                } else if self.preset_manager.is_locked() {
880                    log::info!("Preset locked — change blocked. Press ScrollLock to unlock.");
881                }
882            }
883            Action::ToggleRandomMode => {
884                let mode = self.preset_manager.cycle_mode();
885                self.settings.presets.mode = match mode {
886                    SelectionMode::Random => PresetSelectionMode::Random,
887                    SelectionMode::Sequential => PresetSelectionMode::Sequential,
888                };
889                self.settings.save();
890                log::info!("Selection mode: {:?}", mode);
891            }
892            Action::TogglePresetLock => {
893                let locked = self.preset_manager.toggle_lock();
894                self.settings.presets.locked = locked;
895                self.settings.save();
896                log::info!("Preset lock: {}", if locked { "ON" } else { "OFF" });
897            }
898            Action::EngineReset => {
899                if let Some(engine) = &mut self.engine {
900                    engine.reset();
901                    log::info!("Engine reset");
902                }
903            }
904            Action::RateUp | Action::RateDown => {
905                let delta = if action == Action::RateUp { 1 } else { -1 };
906                match self.preset_manager.nudge_current_rating(delta) {
907                    Some(r) => log::info!(
908                        "Rating: {} ({})",
909                        r,
910                        self.preset_manager
911                            .current_preset()
912                            .map(|p| p.display().to_string())
913                            .unwrap_or_else(|| "<no preset>".into())
914                    ),
915                    None => log::info!("No current preset to rate"),
916                }
917            }
918            Action::CycleBeatDetection => {
919                if let Some(engine) = &mut self.engine {
920                    engine.next_beat_detection_mode();
921                    let mode = engine.beat_detector().mode();
922                    log::info!("Beat detection mode: {}", mode.name());
923                    let description = match mode {
924                        BeatDetectionMode::Off => "Off - No automatic preset changes",
925                        BeatDetectionMode::HardCut1 => {
926                            "HardCut1 - Change on bass > 1.5 (delay 0.2s)"
927                        }
928                        BeatDetectionMode::HardCut2 => {
929                            "HardCut2 - Change on treb > 2.9 (delay 0.5s)"
930                        }
931                        BeatDetectionMode::HardCut3 => "HardCut3 - Change on treb > 2.9 (delay 1s)",
932                        BeatDetectionMode::HardCut4 => {
933                            "HardCut4 - Change on treb > 2.9 (delay 3s) or treb > 8"
934                        }
935                        BeatDetectionMode::HardCut5 => "HardCut5 - Change on treb > 2.9 (delay 5s)",
936                        BeatDetectionMode::HardCut6 { .. } => {
937                            "HardCut6 - Change on bass > 1.5, special on bass > 4.90"
938                        }
939                    };
940                    log::info!("  {}", description);
941                    self.settings.playback.beat_detection_mode = mode.name().to_string();
942                    self.settings.save();
943                }
944            }
945            Action::SpriteCycle | Action::SpriteRandom => {
946                if let Some(engine) = &mut self.engine {
947                    let result = if action == Action::SpriteRandom {
948                        let seed =
949                            (engine.state().frame as u64) ^ (engine.state().time.to_bits() as u64);
950                        engine.spawn_random_sprite(seed)
951                    } else {
952                        engine.cycle_next_sprite()
953                    };
954                    match result {
955                        Some(slot) => log::info!(
956                            "Sprite slot {} spawned (active={})",
957                            slot,
958                            engine.sprite_active_count()
959                        ),
960                        None => log::info!(
961                            "No sprite defs loaded (drop a MILK_IMG.INI next to a preset)"
962                        ),
963                    }
964                }
965            }
966            Action::SpritePopRecent => {
967                if let Some(engine) = &mut self.engine {
968                    let popped = engine.pop_most_recent_sprite();
969                    if popped {
970                        log::info!(
971                            "Popped most recent sprite (active={})",
972                            engine.sprite_active_count()
973                        );
974                    }
975                }
976            }
977            Action::MessageCycle => {
978                if let Some(engine) = &mut self.engine {
979                    match engine.cycle_next_message() {
980                        Some(slot) => log::info!("Message slot {} shown", slot),
981                        None => log::info!(
982                            "No message defs loaded (drop a MILK_MSG.INI next to a preset)"
983                        ),
984                    }
985                }
986            }
987            Action::ClearAllOverlays => {
988                if let Some(engine) = &mut self.engine {
989                    engine.clear_sprites();
990                    engine.clear_messages();
991                    log::info!("Cleared all sprite + message overlays");
992                }
993            }
994            Action::ClearMessages => {
995                if let Some(engine) = &mut self.engine {
996                    engine.clear_messages();
997                    log::info!("Cleared all active messages");
998                }
999            }
1000            Action::ToggleFullscreen => {
1001                self.toggle_fullscreen();
1002            }
1003            Action::Quit => {
1004                log::info!("Quit requested");
1005                event_loop.exit();
1006            }
1007            // Placeholders: bound to keys for parity with MD2's table
1008            // but no engine API exists for them in v1.0. Logging keeps
1009            // them discoverable from the GUI without crashing.
1010            Action::LoadMenu
1011            | Action::EditMenu
1012            | Action::SavePreset
1013            | Action::ToggleMonitor
1014            | Action::MashUp
1015            | Action::CycleShaderLock
1016            | Action::ShowHelp
1017            | Action::ShowSongInfo
1018            | Action::ShowFps
1019            | Action::ShowPresetName
1020            | Action::ShowRating
1021            | Action::RereadIni
1022            | Action::ToggleAnaglyph3D
1023            | Action::WarpAmountDown
1024            | Action::WarpAmountUp
1025            | Action::RotationCcw
1026            | Action::RotationCw
1027            | Action::ZoomOut
1028            | Action::ZoomIn
1029            | Action::WarpSpeedDown
1030            | Action::WarpSpeedUp
1031            | Action::CycleWaveform
1032            | Action::WaveScaleDown
1033            | Action::WaveScaleUp
1034            | Action::WaveOpacityDown
1035            | Action::WaveOpacityUp => {
1036                log::info!("Action {:?} is not implemented yet (v1.0 stub)", action);
1037            }
1038            // Handled before the dispatch — should never reach here.
1039            Action::ToggleOptions => {}
1040        }
1041    }
1042}
1043
1044impl ApplicationHandler for App {
1045    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
1046        if self.window.is_none() {
1047            let mut window_attributes = Window::default_attributes()
1048                .with_title("OneDrop")
1049                .with_inner_size(winit::dpi::LogicalSize::new(
1050                    self.initial_width,
1051                    self.initial_height,
1052                ));
1053            if self.fullscreen {
1054                window_attributes =
1055                    window_attributes.with_fullscreen(Some(Fullscreen::Borderless(None)));
1056            }
1057
1058            match event_loop.create_window(window_attributes) {
1059                Ok(window) => {
1060                    let window = Arc::new(window);
1061                    if let Err(e) = self.init_graphics(window) {
1062                        log::error!("Failed to initialize graphics: {}", e);
1063                        event_loop.exit();
1064                    }
1065                }
1066                Err(e) => {
1067                    log::error!("Failed to create window: {}", e);
1068                    event_loop.exit();
1069                }
1070            }
1071        }
1072    }
1073
1074    fn window_event(
1075        &mut self,
1076        event_loop: &ActiveEventLoop,
1077        _window_id: WindowId,
1078        event: WindowEvent,
1079    ) {
1080        // Hand the event to the egui overlay first. When the overlay is
1081        // visible and egui claims the event (e.g., click in a widget),
1082        // we suppress our own handling so typing in egui doesn't also
1083        // flip presets or quit the app.
1084        if let (Some(ui), Some(win)) = (&mut self.options_ui, &self.window)
1085            && ui.on_event(win, &event)
1086        {
1087            return;
1088        }
1089        match event {
1090            WindowEvent::CloseRequested => {
1091                log::info!("Close requested");
1092                event_loop.exit();
1093            }
1094            WindowEvent::KeyboardInput {
1095                event:
1096                    KeyEvent {
1097                        physical_key: PhysicalKey::Code(key_code),
1098                        state: ElementState::Pressed,
1099                        ..
1100                    },
1101                ..
1102            } => {
1103                self.handle_keyboard(event_loop, key_code);
1104            }
1105            WindowEvent::ModifiersChanged(new) => {
1106                self.modifiers = new;
1107            }
1108            WindowEvent::CursorMoved { .. } => {
1109                self.last_cursor_move = Instant::now();
1110            }
1111            WindowEvent::MouseInput {
1112                button: MouseButton::Left,
1113                state: ElementState::Pressed,
1114                ..
1115            } => {
1116                // 500 ms is the de-facto double-click threshold on Linux DEs
1117                // (GTK's `gtk-double-click-time` defaults to 400, Qt's is 500).
1118                let now = Instant::now();
1119                let is_double = self
1120                    .last_left_click
1121                    .is_some_and(|prev| now.duration_since(prev).as_millis() < 500);
1122                self.last_left_click = Some(now);
1123                if is_double {
1124                    self.toggle_fullscreen();
1125                    // Reset so a third click doesn't read as another double.
1126                    self.last_left_click = None;
1127                }
1128            }
1129            WindowEvent::Resized(physical_size) => {
1130                if physical_size.width > 0
1131                    && physical_size.height > 0
1132                    && let (Some(surface), Some(device), Some(config)) =
1133                        (&self.surface, &self.device, &mut self.surface_config)
1134                {
1135                    config.width = physical_size.width;
1136                    config.height = physical_size.height;
1137                    surface.configure(device, config);
1138
1139                    if let Some(engine) = &mut self.engine {
1140                        engine.resize(physical_size.width, physical_size.height);
1141                    }
1142                }
1143            }
1144            WindowEvent::RedrawRequested => {
1145                if let Err(e) = self.render() {
1146                    log::error!("Render error: {}", e);
1147                }
1148
1149                if let Some(window) = &self.window {
1150                    window.request_redraw();
1151                }
1152            }
1153            _ => {}
1154        }
1155    }
1156}
1157
1158/// Apply one frame's worth of Options-panel changes to the live
1159/// engine. Each field of [`OptionsActions`] is `Some` only when the
1160/// user just moved that widget, so the function is a series of cheap
1161/// no-ops on quiet frames.
1162///
1163/// Note: [`OptionsActions::vsync`] is handled by the caller because it
1164/// needs the surface, not the engine.
1165fn apply_options_actions(engine: &mut MilkEngine, actions: &OptionsActions) {
1166    if let Some((cols, rows)) = actions.mesh_size {
1167        engine.renderer_mut().set_mesh_size(cols, rows);
1168        log::info!("Mesh resized to {}×{}", cols, rows);
1169    }
1170    if let Some(d) = actions.transition_duration {
1171        engine.config_mut().transition_duration_s = d;
1172    }
1173    if let Some(b) = actions.enable_per_frame {
1174        engine.config_mut().enable_per_frame = b;
1175    }
1176    if let Some(b) = actions.profile {
1177        engine.config_mut().profile = b;
1178    }
1179    if let Some(mode) = actions.beat_detection_mode.clone() {
1180        log::info!("Beat detection mode → {}", mode.name());
1181        engine.set_beat_detection_mode(mode);
1182    }
1183}
1184
1185/// Parse a beat-detection mode name (matches the Options-panel
1186/// dropdown and the engine's `BeatDetectionMode::name()`). Unknown
1187/// values map to `Off`, which is also the engine's boot default.
1188fn beat_mode_from_name(name: &str) -> BeatDetectionMode {
1189    match name {
1190        "HardCut1" => BeatDetectionMode::HardCut1,
1191        "HardCut2" => BeatDetectionMode::HardCut2,
1192        "HardCut3" => BeatDetectionMode::HardCut3,
1193        "HardCut4" => BeatDetectionMode::HardCut4,
1194        "HardCut5" => BeatDetectionMode::HardCut5,
1195        "HardCut6" => BeatDetectionMode::HardCut6 {
1196            special_preset: String::new(),
1197        },
1198        _ => BeatDetectionMode::Off,
1199    }
1200}
1201
1202/// Spawn a fresh copy of the current binary with the same CLI args
1203/// and exit. Used by the Options panel's "Apply & restart" button so
1204/// settings that can't be applied live (MSAA, texture format) take
1205/// effect on the next boot. We rely on the caller having just called
1206/// `Settings::save()` so the new process reads the user's edits.
1207fn relaunch_self() {
1208    log::info!("Restarting to apply settings…");
1209    let exe = match std::env::current_exe() {
1210        Ok(p) => p,
1211        Err(e) => {
1212            log::error!("Failed to locate current executable: {e}");
1213            return;
1214        }
1215    };
1216    let args: Vec<String> = std::env::args().skip(1).collect();
1217    match std::process::Command::new(&exe).args(&args).spawn() {
1218        Ok(_) => std::process::exit(0),
1219        Err(e) => log::error!("Failed to relaunch {}: {e}", exe.display()),
1220    }
1221}
1222
1223fn main() -> Result<()> {
1224    let args = Args::parse();
1225
1226    env_logger::Builder::from_default_env()
1227        .filter_level(log::LevelFilter::Info)
1228        .init();
1229
1230    log::info!("Starting OneDrop GUI...");
1231    log::info!(
1232        "Keys: N/→ next preset · P/← prev preset · R reset · F8 cycle beat-detect · Tab options · F11 fullscreen · Esc/Q quit"
1233    );
1234
1235    let event_loop = EventLoop::new()?;
1236    event_loop.set_control_flow(ControlFlow::Poll);
1237
1238    let settings = Settings::load();
1239    let mut app = App::new(&args, settings);
1240
1241    event_loop.run_app(&mut app)?;
1242
1243    Ok(())
1244}