use iced_graphics::{Shell as GShell, Viewport}; use iced_runtime::user_interface::{self, UserInterface}; use iced_wgpu::core::renderer::Style; use iced_wgpu::core::time::Instant; use iced_wgpu::core::widget::operation::scrollable as scrollable_op; use iced_wgpu::core::{ clipboard, keyboard, mouse, window, Color, Event, Font, Pixels, Point, Size, SmolStr, Theme, }; use iced_wgpu::Engine; use iced_widget::scrollable::AbsoluteOffset; use raw_window_handle::{RawDisplayHandle, RawWindowHandle}; use crate::ui::{player, theme, App, Message}; /// per-window bundle of the wgpu surface, iced renderer, App state, and pending input event queue. pub struct ViewportHandle { surface: wgpu::Surface<'static>, device: wgpu::Device, #[allow(dead_code)] queue: wgpu::Queue, format: wgpu::TextureFormat, width: u32, height: u32, #[allow(dead_code)] scale: f32, renderer: iced_wgpu::Renderer, viewport: Viewport, cache: user_interface::Cache, events: Vec, cursor: mouse::Cursor, needs_redraw: bool, /// most recent touch coordinate, source of scroll deltas across move events. last_touch: Option<(f32, f32)>, /// touch-down anchor compared against the release point in the tap-vs-drag check. touch_start: Option<(f32, f32)>, /// accumulated euclidean travel from touch-down, gating the tap-vs-scroll decision. touch_drift: f32, /// marks an active touch originating over the sidebar, routing vertical drags to wheel scrolls. touch_in_sidebar: bool, /// classification of an active touch over the settings panel: pending until SLOP, then tap/drag/scroll. settings_touch: SettingsTouch, pub state: App, } /// classifies an in-flight settings-panel touch once its dominant axis is known. #[derive(Copy, Clone, PartialEq, Eq)] enum SettingsTouch { None, Pending, Drag, Scroll, } /// stub clipboard handed to iced, returning empty on reads and dropping writes. struct NullClipboard; impl clipboard::Clipboard for NullClipboard { fn read(&self, _kind: clipboard::Kind) -> Option { None } fn write(&mut self, _kind: clipboard::Kind, _contents: String) {} } impl ViewportHandle { /// builds a viewport surface from raw display+window handles supplied by the host shell. pub fn new_from_raw( raw_window: RawWindowHandle, raw_display: RawDisplayHandle, width: f32, height: f32, scale: f32, ) -> Option { let (instance, surface) = create_instance_and_surface(|instance| { let target = wgpu::SurfaceTargetUnsafe::RawHandle { raw_display_handle: raw_display, raw_window_handle: raw_window, }; unsafe { instance.create_surface_unsafe(target).ok() } })?; finalise(instance, surface, width, height, scale) } /// drives one frame of state ticking, iced layout, and wgpu submission. pub fn render_frame(&mut self) { render(self); } /// reconfigures the wgpu surface and iced viewport against the supplied size and scale factor. pub fn resize_px(&mut self, width: f32, height: f32, scale: f32) { let phys_w = (width * scale) as u32; let phys_h = (height * scale) as u32; if phys_w == 0 || phys_h == 0 { return; } self.width = phys_w; self.height = phys_h; self.scale = scale; self.viewport = Viewport::with_physical_size(Size::new(phys_w, phys_h), scale); self.surface.configure( &self.device, &wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format: self.format, width: phys_w, height: phys_h, present_mode: wgpu::PresentMode::AutoVsync, alpha_mode: wgpu::CompositeAlphaMode::Auto, view_formats: vec![], desired_maximum_frame_latency: 2, }, ); self.needs_redraw = true; } /// queues a cursor-moved event in logical coords and flags the viewport for redraw. pub fn push_mouse_move(&mut self, x: f32, y: f32) { let p = Point::new(x, y); self.cursor = mouse::Cursor::Available(p); self.events .push(Event::Mouse(mouse::Event::CursorMoved { position: p })); self.needs_redraw = true; } /// queues a cursor-left event after the pointer exits the window. pub fn push_mouse_left(&mut self) { self.cursor = mouse::Cursor::Unavailable; self.events.push(Event::Mouse(mouse::Event::CursorLeft)); self.needs_redraw = true; } /// queues a cursor move plus mouse button press or release at the supplied logical point. pub fn push_mouse_button(&mut self, x: f32, y: f32, button: u32, pressed: bool) { let p = Point::new(x, y); self.cursor = mouse::Cursor::Available(p); self.events .push(Event::Mouse(mouse::Event::CursorMoved { position: p })); let b = match button { 0 => mouse::Button::Left, 1 => mouse::Button::Right, 2 => mouse::Button::Middle, _ => return, }; let ev = if pressed { mouse::Event::ButtonPressed(b) } else { mouse::Event::ButtonReleased(b) }; self.events.push(Event::Mouse(ev)); self.needs_redraw = true; } /// queues a wheel-scrolled event with pixel-delta semantics. pub fn push_mouse_scroll(&mut self, x: f32, y: f32, dx: f32, dy: f32) { let p = Point::new(x, y); self.cursor = mouse::Cursor::Available(p); self.events.push(Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Pixels { x: dx, y: dy }, })); self.needs_redraw = true; } /// folds an iOS touch into mouse-style events, treating sidebar/settings vertical drags as wheel scrolls. pub fn push_touch(&mut self, x: f32, y: f32, pressed: bool, moved: bool) { const TOUCH_TAP_SLOP: f32 = 10.0; if !pressed && !moved { if self.touch_in_sidebar { if let Some((sx, sy)) = self.touch_start.take() { if self.touch_drift <= TOUCH_TAP_SLOP { self.push_mouse_button(sx, sy, 0, true); self.push_mouse_button(sx, sy, 0, false); } } } else { match self.settings_touch { SettingsTouch::None => { self.push_mouse_button(x, y, 0, false); } SettingsTouch::Pending => { if let Some((sx, sy)) = self.touch_start { self.push_mouse_button(sx, sy, 0, true); self.push_mouse_button(sx, sy, 0, false); } } SettingsTouch::Drag => { self.push_mouse_button(x, y, 0, false); } SettingsTouch::Scroll => {} } } self.last_touch = None; self.touch_start = None; self.touch_drift = 0.0; self.touch_in_sidebar = false; self.settings_touch = SettingsTouch::None; return; } if pressed && !moved { let logical = self.viewport.logical_size(); let h = logical.height; let w = logical.width; let in_settings = self.state.point_in_settings(x, y, w); self.touch_in_sidebar = !in_settings && self.state.point_in_sidebar(x, y, h); self.touch_start = Some((x, y)); self.touch_drift = 0.0; self.last_touch = Some((x, y)); if in_settings { self.settings_touch = SettingsTouch::Pending; self.push_mouse_move(x, y); } else if self.touch_in_sidebar { self.push_mouse_move(x, y); self.settings_touch = SettingsTouch::None; } else { self.settings_touch = SettingsTouch::None; self.push_mouse_button(x, y, 0, true); } return; } if let Some((px, py)) = self.last_touch { let dx = x - px; let dy = y - py; self.touch_drift += (dx * dx + dy * dy).sqrt(); if self.settings_touch == SettingsTouch::Pending && self.touch_drift > TOUCH_TAP_SLOP { if let Some((sx, sy)) = self.touch_start { let total_dx = (x - sx).abs(); let total_dy = (y - sy).abs(); if total_dy > total_dx { self.settings_touch = SettingsTouch::Scroll; } else { self.settings_touch = SettingsTouch::Drag; self.push_mouse_button(sx, sy, 0, true); } } } match self.settings_touch { SettingsTouch::Scroll => { if dy.abs() > 0.0 { self.events.push(Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, })); } } SettingsTouch::Drag => { self.push_mouse_move(x, y); } SettingsTouch::Pending => {} SettingsTouch::None => { if self.touch_in_sidebar { if dy.abs() > 0.0 { self.events.push(Event::Mouse(mouse::Event::WheelScrolled { delta: mouse::ScrollDelta::Pixels { x: 0.0, y: dy }, })); } } else { self.push_mouse_move(x, y); } } } } self.last_touch = Some((x, y)); } /// translates a host-encoded key triple into iced keyboard events with modifiers. pub fn push_key_event( &mut self, named: u32, utf8: Option, mods: u32, pressed: bool, ) { let text = utf8.map(SmolStr::from); let key = match named { 1 => keyboard::Key::Named(keyboard::key::Named::Enter), 2 => keyboard::Key::Named(keyboard::key::Named::Escape), 3 => keyboard::Key::Named(keyboard::key::Named::Backspace), 4 => keyboard::Key::Named(keyboard::key::Named::Tab), 5 => keyboard::Key::Named(keyboard::key::Named::ArrowLeft), 6 => keyboard::Key::Named(keyboard::key::Named::ArrowRight), 7 => keyboard::Key::Named(keyboard::key::Named::ArrowUp), 8 => keyboard::Key::Named(keyboard::key::Named::ArrowDown), 9 => keyboard::Key::Named(keyboard::key::Named::Delete), 10 => keyboard::Key::Named(keyboard::key::Named::Home), 11 => keyboard::Key::Named(keyboard::key::Named::End), _ => match &text { Some(s) => keyboard::Key::Character(s.clone()), None => keyboard::Key::Unidentified, }, }; let mut m = keyboard::Modifiers::empty(); if mods & 1 != 0 { m |= keyboard::Modifiers::SHIFT; } if mods & 2 != 0 { m |= keyboard::Modifiers::CTRL; } if mods & 4 != 0 { m |= keyboard::Modifiers::ALT; } if mods & 8 != 0 { m |= keyboard::Modifiers::LOGO; } let physical = keyboard::key::Physical::Unidentified(keyboard::key::NativeCode::Unidentified); let event = if pressed { keyboard::Event::KeyPressed { key: key.clone(), modified_key: key, physical_key: physical, location: keyboard::Location::Standard, modifiers: m, text, repeat: false, } } else { keyboard::Event::KeyReleased { key: key.clone(), modified_key: key, physical_key: physical, location: keyboard::Location::Standard, modifiers: m, } }; self.events.push(Event::Keyboard(event)); self.needs_redraw = true; } /// hands a folder picked by the host into the App's library loading flow. pub fn apply_picked_folder(&mut self, path: std::path::PathBuf) { self.state.update(Message::PickedFolder(path)); self.needs_redraw = true; } /// hands a single file picked by the host into the App's track loading flow. pub fn apply_picked_file(&mut self, path: std::path::PathBuf) { self.state.update(Message::PickedFile(path)); self.needs_redraw = true; } /// hands a multi-file pick from the host into the ad-hoc library loading flow. pub fn apply_picked_files(&mut self, paths: Vec) { self.state.update(Message::PickedFiles(paths)); self.needs_redraw = true; } /// updates the library import progress strip, hiding on a total of zero. pub fn set_library_progress(&mut self, current: u32, total: u32) { self.state.library_progress = if total == 0 { None } else { Some((current, total)) }; self.needs_redraw = true; } /// shows or clears the modal coordinator-wait overlay. pub fn set_coordinating_message(&mut self, msg: Option) { self.state.coordinating_message = msg; self.needs_redraw = true; } /// pushes placeholder track rows from the iOS host ahead of export completion. pub fn set_pending_titles(&mut self, entries: Vec<(String, Option)>) { self.state.set_pending_titles(entries); self.needs_redraw = true; } /// resolves a placeholder row to a real path once the host finishes the export. pub fn set_track_path(&mut self, idx: usize, path: std::path::PathBuf) { self.state.set_track_path(idx, path); self.needs_redraw = true; } /// forwards raw artwork bytes from the host to the art decode worker. pub fn set_track_art_bytes(&mut self, idx: usize, bytes: Vec) { self.state.set_track_art_bytes(idx, bytes); self.needs_redraw = true; } /// drains the App's picker request flag. pub fn take_pending_pick(&mut self) -> u8 { self.state.take_pending_pick() } /// drains the App's pending playback-capture action flag. pub fn take_pending_capture_action(&mut self) -> u8 { self.state.take_pending_capture_action() } /// drains the App's pending Picture-in-Picture request flag. pub fn take_pending_pip_request(&mut self) -> bool { self.state.take_pending_pip_request() } /// returns a cloned handle to the most recent analyzer frame pair for read-only host inspection (PiP mirror, debug). pub fn frame_data_snapshot(&self) -> std::sync::Arc> { self.state.frame_data.clone() } /// re-applies Playback capture mode onto a freshly created viewport without firing the start action. pub fn restore_capture_session(&mut self) { self.state.restore_capture_session(); self.needs_redraw = true; } /// switches the top-level playback mode from a u32 wire value (0=Local, 1=Capture). pub fn set_playback_mode(&mut self, mode: u32) { self.state.set_playback_mode(crate::ui::app::PlaybackMode::from_u32(mode)); self.needs_redraw = true; } /// forwards captured PCM from the host shell into the App. pub fn push_capture_pcm(&mut self, samples: &[f32], sample_rate: u32, channels: u32) { self.state.push_capture_pcm(samples, sample_rate, channels); } /// updates the displayed playback-capture metadata from the host shell. pub fn set_capture_metadata( &mut self, title: Option, artist: Option, position_ms: u64, duration_ms: u64, ) { self.state.set_capture_metadata(title, artist, position_ms, duration_ms); self.needs_redraw = true; } /// stores the foreign-session playback flag and live position from the host MediaController callback. pub fn set_capture_playback_state(&mut self, playing: bool, position_ms: u64) { self.state.set_capture_playback_state(playing, position_ms); self.needs_redraw = true; } /// resets every Playback capture metadata field. pub fn clear_capture_session(&mut self) { self.state.clear_capture_session(); self.needs_redraw = true; } /// stores the notification-listener access flag pushed by the host shell. pub fn set_notification_access(&mut self, granted: bool) { self.state.set_notification_access(granted); self.needs_redraw = true; } /// drains the pending request to open the system Notification Listener settings. pub fn take_pending_open_listener_settings(&mut self) -> bool { self.state.take_pending_open_listener_settings() } /// drains the pending capture-mode transport command flag. pub fn take_pending_capture_transport(&mut self) -> u8 { self.state.take_pending_capture_transport() } /// returns a cloneable handle that pushes live PCM directly into the analyzer worker. pub fn pcm_sender(&self) -> crate::analyzer_worker::PcmSender { self.state.worker.pcm_sender() } /// serializes both settings slots as JSON for the host shell to persist. pub fn settings_json(&self) -> String { self.state.settings_json() } /// applies a settings JSON blob loaded by the host shell at startup. pub fn apply_settings_json(&mut self, json: &str) { self.state.apply_settings_json(json); self.needs_redraw = true; } /// drains the pending-persist flag set after a settings change. pub fn take_pending_persist_settings(&mut self) -> bool { self.state.take_pending_persist_settings() } /// drains the pending capture-mode seek fraction. pub fn take_pending_capture_seek(&mut self) -> f32 { self.state.take_pending_capture_seek() } /// drains the pending capture-rebuild flag set after an input-device change. pub fn take_pending_rebuild_capture(&mut self) -> bool { self.state.take_pending_rebuild_capture() } /// returns the currently-selected input device name, or None for the system default. pub fn selected_input_device(&self) -> Option<&str> { self.state.input_device.as_deref() } } /// builds a wgpu instance restricted to the platform's preferred backend and obtains a surface from the caller. fn create_instance_and_surface( build_surface: F, ) -> Option<(wgpu::Instance, wgpu::Surface<'static>)> where F: FnOnce(&wgpu::Instance) -> Option>, { #[cfg(any(target_os = "macos", target_os = "ios"))] let backends = wgpu::Backends::METAL; #[cfg(target_os = "windows")] let backends = wgpu::Backends::DX12; #[cfg(not(any(target_os = "macos", target_os = "ios", target_os = "windows")))] let backends = wgpu::Backends::all(); let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { backends, ..Default::default() }); let surface = build_surface(&instance)?; Some((instance, surface)) } /// completes wgpu adapter selection, configures the surface, loads the bundled font, and assembles the App. fn finalise( instance: wgpu::Instance, surface: wgpu::Surface<'static>, width: f32, height: f32, scale: f32, ) -> Option { let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, compatible_surface: Some(&surface), force_fallback_adapter: false, })) .ok()?; let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())).ok()?; let phys_w = (width * scale) as u32; let phys_h = (height * scale) as u32; let caps = surface.get_capabilities(&adapter); let format = caps.formats.first().copied()?; let alpha_mode = caps .alpha_modes .first() .copied() .unwrap_or(wgpu::CompositeAlphaMode::Auto); surface.configure( &device, &wgpu::SurfaceConfiguration { usage: wgpu::TextureUsages::RENDER_ATTACHMENT, format, width: phys_w.max(1), height: phys_h.max(1), present_mode: wgpu::PresentMode::AutoVsync, alpha_mode, view_formats: vec![], desired_maximum_frame_latency: 2, }, ); let engine = Engine::new( &adapter, device.clone(), queue.clone(), format, None, GShell::headless(), ); static FONT_LOADED: std::sync::OnceLock<()> = std::sync::OnceLock::new(); FONT_LOADED.get_or_init(|| { let bytes: &'static [u8] = include_bytes!("../fonts/Inter-Regular.ttf"); if let Ok(mut fs) = iced_graphics::text::font_system().write() { fs.load_font(std::borrow::Cow::Borrowed(bytes)); } }); let renderer = iced_wgpu::Renderer::new(engine, Font::with_name("Inter 18pt"), Pixels(14.0)); let viewport = Viewport::with_physical_size(Size::new(phys_w.max(1), phys_h.max(1)), scale); let state = App::new(device.clone(), queue.clone()); Some(ViewportHandle { surface, device, queue, format, width: phys_w, height: phys_h, scale, renderer, viewport, cache: user_interface::Cache::new(), events: Vec::new(), cursor: mouse::Cursor::Available(Point::new(width / 2.0, height / 2.0)), needs_redraw: true, last_touch: None, touch_start: None, touch_drift: 0.0, touch_in_sidebar: false, settings_touch: SettingsTouch::None, state, }) } /// runs one tick, rebuilds the UI, dispatches messages, and presents the next surface frame. fn render(handle: &mut ViewportHandle) { handle.state.tick(); let pending = !handle.events.is_empty(); let playing = handle .state .engine .as_ref() .map(|e| e.is_playing()) .unwrap_or(false); let animating = playing || handle.state.track_loading || handle.state.library_progress.is_some() || handle.state.playback_mode == crate::ui::app::PlaybackMode::Capture; if !animating && !handle.needs_redraw && !pending { return; } let frame = match handle.surface.get_current_texture() { Ok(f) => f, Err(_e) => { #[cfg(debug_assertions)] { use std::sync::atomic::{AtomicBool, Ordering}; static LOGGED: AtomicBool = AtomicBool::new(false); if !LOGGED.swap(true, Ordering::Relaxed) { eprintln!("yr_crystals: surface acquire failed: {_e:?}"); } } return; } }; let view = frame.texture.create_view(&Default::default()); let logical_size = handle.viewport.logical_size(); handle .events .push(Event::Window(window::Event::RedrawRequested(Instant::now()))); let pre_restore = take_pending_scroll_restores(&mut handle.state); let cache = std::mem::take(&mut handle.cache); let mut ui = UserInterface::build( handle.state.view(), Size::new(logical_size.width, logical_size.height), cache, &mut handle.renderer, ); let mut clipboard = NullClipboard; let mut messages: Vec = Vec::new(); let drained: Vec = handle.events.drain(..).collect(); let _ = ui.update( &drained, handle.cursor, &mut handle.renderer, &mut clipboard, &mut messages, ); let theme = Theme::Dark; let style = Style { text_color: Color::WHITE, }; if messages.is_empty() { apply_scroll_restores(&mut ui, &handle.renderer, pre_restore); ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); handle.cache = ui.into_cache(); } else { let cache = ui.into_cache(); for msg in messages.drain(..) { handle.state.update(msg); } let post_restore = take_pending_scroll_restores(&mut handle.state); let combined = ScrollRestore { sidebar: post_restore.sidebar.or(pre_restore.sidebar), settings: post_restore.settings.or(pre_restore.settings), }; let mut ui = UserInterface::build( handle.state.view(), Size::new(logical_size.width, logical_size.height), cache, &mut handle.renderer, ); apply_scroll_restores(&mut ui, &handle.renderer, combined); ui.draw(&mut handle.renderer, &theme, &style, handle.cursor); handle.cache = ui.into_cache(); } let background = theme::compositor_clear(); handle .renderer .present(Some(background), handle.format, &view, &handle.viewport); frame.present(); handle.needs_redraw = false; } /// drains scroll-restore flags from App state into a side-channel struct. fn take_pending_scroll_restores(state: &mut App) -> ScrollRestore { let sidebar = if state.restore_sidebar_scroll { Some(sidebar_snap_offset(state.selected_track)) } else { None }; let settings = state.restore_settings_scroll.then_some(state.settings_scroll); state.restore_sidebar_scroll = false; state.restore_settings_scroll = false; ScrollRestore { sidebar, settings } } /// vertical offset placing the playing row at the top of the sidebar viewport. fn sidebar_snap_offset(selected: Option) -> AbsoluteOffset { let idx = selected.unwrap_or(0) as f32; AbsoluteOffset { x: 0.0, y: player::SIDEBAR_PADDING_TOP + idx * (player::ROW_H + player::ROW_SPACING), } } /// pushes the stored sidebar and settings scroll offsets onto the UserInterface. fn apply_scroll_restores( ui: &mut UserInterface<'_, Message, Theme, iced_wgpu::Renderer>, renderer: &iced_wgpu::Renderer, restore: ScrollRestore, ) { if let Some(off) = restore.sidebar { let mut op = scrollable_op::scroll_to::<()>( player::sidebar_scroll_id(), AbsoluteOffset { x: Some(off.x), y: Some(off.y) }, ); ui.operate(renderer, &mut op); } if let Some(off) = restore.settings { let mut op = scrollable_op::scroll_to::<()>( player::settings_scroll_id(), AbsoluteOffset { x: Some(off.x), y: Some(off.y) }, ); ui.operate(renderer, &mut op); } } /// scroll offsets staged for the UserInterface::operate pass. struct ScrollRestore { sidebar: Option, settings: Option, }