755 lines
27 KiB
Rust
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>,
|
|
}
|