1mod 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#[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 #[arg(long = "preset-dir", short = 'd', value_name = "DIR")]
41 preset_dirs: Vec<PathBuf>,
42
43 #[arg(long = "preset", short = 'p', value_name = "FILE")]
45 preset: Option<PathBuf>,
46
47 #[arg(long, short = 'f')]
49 fullscreen: bool,
50
51 #[arg(long, default_value_t = 1280)]
53 width: u32,
54
55 #[arg(long, default_value_t = 720)]
57 height: u32,
58
59 #[arg(long)]
62 no_vsync: bool,
63}
64
65fn 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 dirs.push(PathBuf::from("/usr/share/onedrop/presets"));
86 dirs.push(PathBuf::from("/usr/local/share/onedrop/presets"));
87 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
108struct 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 initial_width: u32,
240 initial_height: u32,
241 fullscreen: bool,
242 no_vsync: bool,
243 last_left_click: Option<Instant>,
245 fps_window_start: Instant,
247 fps_window_frames: u32,
249 last_fps: f32,
252 last_cursor_move: Instant,
255 cursor_visible: bool,
258 settings: Settings,
261 options_ui: Option<OptionsUi>,
263 modifiers: winit::event::Modifiers,
268 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 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 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 let instance = wgpu::Instance::new(wgpu::InstanceDescriptor {
369 backends: wgpu::Backends::all(),
370 ..wgpu::InstanceDescriptor::new_without_display_handle()
371 });
372
373 let surface = instance.create_surface(window.clone())?;
375
376 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 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 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 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 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 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 let mut engine =
475 MilkEngine::from_device(Arc::clone(&device), Arc::clone(&queue), engine_config)?;
476
477 let beat_mode = beat_mode_from_name(&self.settings.playback.beat_detection_mode);
481 engine.set_beat_detection_mode(beat_mode);
482
483 let blit = Blit::new(&device, surface_format);
486
487 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 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 let now = Instant::now();
540 let delta_time = (now - self.last_frame).as_secs_f32();
541 self.last_frame = now;
542
543 let audio_samples: Vec<f32> = if let Some(ref audio_input) = self.audio_input {
545 audio_input.get_fixed_samples(1024)
547 } else {
548 (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 let preset_change = engine.update(&audio_samples, delta_time)?;
559
560 if let Some(change) = preset_change {
562 match change {
563 PresetChange::Random => {
564 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 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 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 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 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 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 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 fn swap_audio_input(&mut self, name: Option<&str>) {
765 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 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 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 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 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 if self.options_open() {
845 return;
846 }
847
848 self.dispatch_action(event_loop, action);
849 }
850
851 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 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 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 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 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 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
1158fn 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
1185fn 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
1202fn 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}