diff --git a/src/ui/app.rs b/src/ui/app.rs index eadb94c..07b15c2 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2,6 +2,7 @@ use std::path::PathBuf; use std::sync::Arc; use iced_wgpu::core::{Element, Theme}; +use iced_widget::scrollable::AbsoluteOffset; use crate::analyzer::FrameData; use crate::analyzer_worker::AnalyzerWorker; @@ -39,6 +40,21 @@ pub struct App { /// modal status message shown over the UI while waiting on iOS file-coordinator caching. pub coordinating_message: Option, + + /// sidebar scroll offset mirrored from the on_scroll callback. + pub sidebar_scroll: AbsoluteOffset, + + /// settings panel scroll offset mirrored from the on_scroll callback. + pub settings_scroll: AbsoluteOffset, + + /// pending sidebar scroll restore against the live scrollable widget. + pub restore_sidebar_scroll: bool, + + /// pending settings scroll restore against the live scrollable widget. + pub restore_settings_scroll: bool, + + /// show_settings copy bridging the middle-tap collapse cycle. + pub saved_show_settings: bool, } /// every visualizer toggle, slider value, and DSP parameter the settings panel exposes. @@ -106,7 +122,10 @@ pub enum Message { Seek(f32), ToggleImmersive, + ToggleChrome, ToggleSettings, + SidebarScrolled(AbsoluteOffset), + SettingsScrolled(AbsoluteOffset), SetGlass(bool), SetEntropy(bool), SetAlbumColors(bool), @@ -158,6 +177,11 @@ impl App { track_loading: false, library_progress: None, coordinating_message: None, + sidebar_scroll: AbsoluteOffset::default(), + settings_scroll: AbsoluteOffset::default(), + restore_sidebar_scroll: false, + restore_settings_scroll: false, + saved_show_settings: false, } } @@ -552,7 +576,28 @@ impl App { } } Message::ToggleImmersive => self.immersive = !self.immersive, - Message::ToggleSettings => self.show_settings = !self.show_settings, + Message::ToggleChrome => { + if self.immersive { + self.immersive = false; + self.restore_sidebar_scroll = true; + if self.saved_show_settings { + self.show_settings = true; + self.restore_settings_scroll = true; + } + } else { + self.saved_show_settings = self.show_settings; + self.show_settings = false; + self.immersive = true; + } + } + Message::ToggleSettings => { + self.show_settings = !self.show_settings; + if self.show_settings { + self.restore_settings_scroll = true; + } + } + Message::SidebarScrolled(off) => self.sidebar_scroll = off, + Message::SettingsScrolled(off) => self.settings_scroll = off, Message::SetGlass(on) => self.settings.glass = on, Message::SetEntropy(on) => self.settings.entropy_on = on, Message::SetAlbumColors(on) => self.settings.album_colors = on, diff --git a/src/ui/player.rs b/src/ui/player.rs index 64603f9..62ae232 100644 --- a/src/ui/player.rs +++ b/src/ui/player.rs @@ -1,6 +1,7 @@ use std::ops::RangeInclusive; use std::path::PathBuf; +use iced_wgpu::core::widget::Id as WidgetId; use iced_wgpu::core::{Background, Border, Color, Element, Length, Padding, Theme}; use iced_widget::{ button::{self, Status as ButtonStatus}, @@ -30,6 +31,16 @@ pub const TRANSPORT_H: f32 = 72.0; const ROW_H: f32 = 56.0; const THUMB: f32 = 40.0; +/// stable id of the sidebar scrollable. +pub fn sidebar_scroll_id() -> WidgetId { + WidgetId::new("yrx-sidebar-scroll") +} + +/// stable id of the settings-panel scrollable. +pub fn settings_scroll_id() -> WidgetId { + WidgetId::new("yrx-settings-scroll") +} + /// assembles the top bar, sidebar, transport, and visualizer into the active layout. pub fn view(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let body: Element<'_, Message, Theme, iced_wgpu::Renderer> = if app.immersive { @@ -173,7 +184,10 @@ fn sidebar(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { for (i, t) in tracks.iter().enumerate() { col = col.push(track_row_owned(i, t.clone(), selected == Some(i))); } - scrollable(col).height(Length::Fill) + scrollable(col) + .id(sidebar_scroll_id()) + .on_scroll(|vp| Message::SidebarScrolled(vp.absolute_offset())) + .height(Length::Fill) }); container(inner) @@ -303,7 +317,7 @@ fn visualiser(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { ..Default::default() }); - mouse_area(bordered).on_press(Message::ToggleImmersive).into() + mouse_area(bordered).on_press(Message::ToggleChrome).into() } /// animated cog and label overlay shown during track decode. @@ -392,15 +406,9 @@ pub const SETTINGS_W: f32 = 340.0; fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Renderer> { let s = &app.settings; - let header = row![ - text("Settings").size(15).color(palette::text()), - Space::new().width(Length::Fill), - chip_button("Close", Message::ToggleSettings), - ] - .align_y(iced_wgpu::core::Alignment::Center); - let body = column![ - header, + Space::new().height(Length::Fixed(TOP_BAR_H + 4.0)), + text("Settings").size(15).color(palette::text()), Space::new().height(Length::Fixed(10.0)), section_label("style"), toggle_row("glass", s.glass, Message::SetGlass), @@ -504,9 +512,19 @@ fn settings_overlay(app: &App) -> Element<'_, Message, Theme, iced_wgpu::Rendere .padding(Padding::from(16)) .width(Length::Fixed(SETTINGS_W)); - let scroll = scrollable(body).height(Length::Fill); + let scroll = scrollable(body) + .id(settings_scroll_id()) + .on_scroll(|vp| Message::SettingsScrolled(vp.absolute_offset())) + .height(Length::Fill); - let panel = container(scroll) + let close = container(icon_chip_button(SETTINGS_SVG, Message::ToggleSettings)) + .width(Length::Fill) + .height(Length::Fixed(TOP_BAR_H)) + .padding(Padding::from([0, 16])) + .align_x(iced_wgpu::core::alignment::Horizontal::Right) + .align_y(iced_wgpu::core::alignment::Vertical::Center); + + let panel = container(stack![scroll, close]) .width(Length::Fixed(SETTINGS_W)) .height(Length::Fill) .style(settings_panel_style); diff --git a/src/viewport.rs b/src/viewport.rs index d787888..c1eab4a 100644 --- a/src/viewport.rs +++ b/src/viewport.rs @@ -4,14 +4,16 @@ 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::{theme, App, Message}; +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 { @@ -534,6 +536,8 @@ fn render(handle: &mut ViewportHandle) { .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(), @@ -559,6 +563,7 @@ fn render(handle: &mut ViewportHandle) { }; 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 { @@ -566,12 +571,18 @@ fn render(handle: &mut ViewportHandle) { 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(); } @@ -583,3 +594,42 @@ fn render(handle: &mut ViewportHandle) { 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 r = ScrollRestore { + sidebar: state.restore_sidebar_scroll.then_some(state.sidebar_scroll), + settings: state.restore_settings_scroll.then_some(state.settings_scroll), + }; + state.restore_sidebar_scroll = false; + state.restore_settings_scroll = false; + r +} + +/// 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, +}