YrXtals/src/viewport.rs

755 lines
27 KiB
Rust

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<Event>,
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<String> {
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<Self> {
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<String>,
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<std::path::PathBuf>) {
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<String>) {
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<u32>)>) {
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<u8>) {
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<Vec<crate::analyzer::FrameData>> {
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<String>,
artist: Option<String>,
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<F>(
build_surface: F,
) -> Option<(wgpu::Instance, wgpu::Surface<'static>)>
where
F: FnOnce(&wgpu::Instance) -> Option<wgpu::Surface<'static>>,
{
#[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<ViewportHandle> {
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<Message> = Vec::new();
let drained: Vec<Event> = 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<usize>) -> 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<AbsoluteOffset>,
settings: Option<AbsoluteOffset>,
}