diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 651d15bef..74484dd1a 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -77,7 +77,7 @@ Daemon mode therefore provides a persistent background service that reacts to us - Communicate with `capture::CaptureManager` for screenshot actions. - Exit when `InputState.should_exit` is set (Escape, tray close, etc.). -`WaylandState` coordinates the runtime owners handlers need. `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer, cursor, pointer-lock, and single-contact touch protocol lifecycles; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy. The root retains cross-owner input and toolbar routing. +`WaylandState` coordinates the runtime owners handlers need. `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer, cursor, pointer-lock, and single-contact touch protocol lifecycles; `InputHudRuntime` owns system-reader lifecycle and reconciliation; `SpotlightRuntime` owns render memory, warning latches, and wheel timing; `ClipboardRuntime` owns single-flight clipboard workers and queue policy; `PreferenceStores` groups durable preference stores and workers; `UiAnimationClock` owns animation scheduling; and `FontCatalogPrewarm` owns the one-shot font scan. The root retains cross-owner input and toolbar routing. Freeze capture waits for the overlay-suppression frame, then selects `wlr-screencopy`, `ext-image-copy-capture`, or the screenshot portal in that order. The two direct protocols capture the active output into shared memory; the portal captures the desktop and the client crops the selected output when needed. Direct capture and portal crop both require compositor-reported output pixels; a missing current mode fails instead of guessing from the overlay buffer. diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index aa6406030..00bfa3a7d 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -216,7 +216,7 @@ fn advance_post_dispatch_state( if state.input_state.expire_pending_sequence(Instant::now()) { state.input_state.needs_redraw = true; } - if !capture_active && state.ui_animation_due(Instant::now()) { + if !capture_active && state.ui_animation.is_due(Instant::now()) { state.input_state.needs_redraw = true; } if state.input_state.ocr_scan_due(Instant::now()) { @@ -237,7 +237,7 @@ fn advance_post_dispatch_state( fn persist_post_dispatch_state(state: &mut WaylandState) { if state.input_state.command_palette_recents_dirty() { let recents = state.input_state.command_palette.recent.clone(); - if state.palette_recents.request(&recents) { + if state.preferences.palette_recents_mut().request(&recents) { state.input_state.clear_command_palette_recents_dirty(); } } @@ -265,7 +265,7 @@ fn event_loop_timeout( let animation_timeout = min_timeout( min_timeout( min_timeout( - state.ui_animation_timeout(now), + state.ui_animation.timeout(now), state.top_strip_fade_timeout(now), ), state.inline_toolbar_tooltip_timeout(now), diff --git a/src/backend/wayland/config_edits.rs b/src/backend/wayland/config_edits.rs index ac52ee2dc..db9063aa7 100644 --- a/src/backend/wayland/config_edits.rs +++ b/src/backend/wayland/config_edits.rs @@ -769,7 +769,7 @@ pub(in crate::backend::wayland) fn finish_config_edits( impl WaylandState { /// Apply every write that has finished since the last pass. pub(in crate::backend::wayland) fn drain_config_edit_completions(&mut self) { - while let Some(completion) = self.config_edits.try_recv() { + while let Some(completion) = self.preferences.config_edits_mut().try_recv() { self.finish_config_edit(completion); } } @@ -778,7 +778,7 @@ impl WaylandState { finish_config_edits( &mut self.config, &mut self.input_state, - &mut self.config_edits, + self.preferences.config_edits_mut(), ); } diff --git a/src/backend/wayland/runtime_ui_state/wayland.rs b/src/backend/wayland/runtime_ui_state/wayland.rs index 54aa70bda..073a2948d 100644 --- a/src/backend/wayland/runtime_ui_state/wayland.rs +++ b/src/backend/wayland/runtime_ui_state/wayland.rs @@ -24,10 +24,12 @@ impl WaylandState { } pub(in crate::backend::wayland) fn finish_toolbar_item_drag(&mut self, commit: bool) { - let finish = match self.runtime_ui.as_mut() { + let finish = match self.preferences.runtime_ui_mut().state_mut() { Some(runtime) => runtime.finish_item_drag(commit, &self.input_state), None => self - .runtime_ui_unavailable_previews + .preferences + .runtime_ui_mut() + .unavailable_previews_mut() .finish_item_drag(commit), }; self.input_state.clear_toolbar_item_drag(); @@ -38,19 +40,23 @@ impl WaylandState { &mut self, group: ToolbarItemOrderGroup, ) -> bool { - match self.runtime_ui.as_mut() { + match self.preferences.runtime_ui_mut().state_mut() { Some(runtime) => runtime.begin_item_drag(group, &self.input_state), None => self - .runtime_ui_unavailable_previews + .preferences + .runtime_ui_mut() + .unavailable_previews_mut() .begin_item_drag(group, &self.input_state), } } pub(in crate::backend::wayland) fn toolbar_item_drag_update_allowed(&self) -> bool { - match self.runtime_ui.as_ref() { + match self.preferences.runtime_ui().state() { Some(runtime) => runtime.item_drag_update_allowed(), None => self - .runtime_ui_unavailable_previews + .preferences + .runtime_ui() + .unavailable_previews() .item_drag_update_allowed(), } } @@ -59,10 +65,12 @@ impl WaylandState { &self, kind: MoveDragKind, ) -> bool { - match self.runtime_ui.as_ref() { + match self.preferences.runtime_ui().state() { Some(runtime) => runtime.position_drag_update_allowed(kind), None => self - .runtime_ui_unavailable_previews + .preferences + .runtime_ui() + .unavailable_previews() .position_drag_update_allowed(kind), } } @@ -72,10 +80,12 @@ impl WaylandState { kind: MoveDragKind, ) -> bool { let positions = self.toolbar_position_snapshot(); - match self.runtime_ui.as_mut() { + match self.preferences.runtime_ui_mut().state_mut() { Some(runtime) => runtime.begin_position_drag(kind, positions), None => self - .runtime_ui_unavailable_previews + .preferences + .runtime_ui_mut() + .unavailable_previews_mut() .begin_position_drag(kind, positions), } } @@ -86,10 +96,12 @@ impl WaylandState { /// `ui.toolbar.*_offset*` values stay the seeds the configurator edits. pub(in crate::backend::wayland) fn finish_toolbar_position_preview(&mut self, commit: bool) { let positions = self.toolbar_position_snapshot(); - let finish = match self.runtime_ui.as_mut() { + let finish = match self.preferences.runtime_ui_mut().state_mut() { Some(runtime) => runtime.finish_position_drag(commit, positions), None => self - .runtime_ui_unavailable_previews + .preferences + .runtime_ui_mut() + .unavailable_previews_mut() .finish_position_drag(commit), }; self.apply_toolbar_runtime_finish(finish); @@ -114,7 +126,7 @@ impl WaylandState { // One borrow for both halves of the mutation: `input_state` is a // disjoint field, so nothing here has to hand the runtime back and // reacquire it between beginning and finishing. - let Some(runtime) = self.runtime_ui.as_mut() else { + let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { return; }; let Some(prepared) = runtime.begin_toolbar_mutation_with_rollback(target, rollback) else { @@ -145,7 +157,7 @@ impl WaylandState { return; } }; - let Some(runtime) = self.runtime_ui.as_mut() else { + let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { return; }; let Some(prepared) = runtime.begin_toolbar_mutation_with_rollback(target, rollback) else { @@ -172,7 +184,7 @@ impl WaylandState { return; } }; - let Some(runtime) = self.runtime_ui.as_mut() else { + let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { return; }; let Some(prepared) = runtime.begin_toolbar_mutation_with_rollback(target, rollback) else { @@ -206,7 +218,7 @@ impl WaylandState { return; } }; - let Some(runtime) = self.runtime_ui.as_mut() else { + let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { return; }; let Some(prepared) = runtime.begin_toolbar_mutation_with_rollback(target, rollback) else { @@ -231,7 +243,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn drain_pending_toolbar_persistence(&mut self) { use crate::input::state::PendingToolbarPersistence; - match self.runtime_ui.as_ref() { + match self.preferences.runtime_ui().state() { // Degraded mode is run-only: consume the entries so the queue // cannot wake the loop for writes that have nowhere to land. None => { @@ -283,7 +295,7 @@ impl WaylandState { /// drain's no-op filter judges against the post-barrier screen, then /// drain. The caller's writer shutdown flushes the writes to disk. pub(in crate::backend::wayland) fn drain_toolbar_persistence_for_teardown(&mut self) { - if let Some(runtime) = self.runtime_ui.as_mut() { + if let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() { runtime.settle_barrier_for_teardown(); } self.drain_runtime_ui_completions(); @@ -299,8 +311,9 @@ impl WaylandState { pub(in crate::backend::wayland) fn toolbar_persistence_drain_ready(&self) -> bool { self.input_state.has_pending_toolbar_persistence() && self - .runtime_ui - .as_ref() + .preferences + .runtime_ui() + .state() .is_none_or(|runtime| !runtime.mutation_barrier_active()) } @@ -314,7 +327,7 @@ impl WaylandState { .boards .sync_pin_seeds_from_config(&configured_boards); let mut positions = self.toolbar_position_snapshot(); - let Some(runtime) = self.runtime_ui.as_mut() else { + let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { return; }; let refresh = @@ -362,7 +375,7 @@ impl WaylandState { else { return; }; - let Some(runtime) = self.runtime_ui.as_mut() else { + let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() else { self.input_state .apply_board_pinned_runtime(&board_id, !current); return; @@ -376,14 +389,15 @@ impl WaylandState { .input_state .apply_board_pinned_runtime(&prepared.board_id, prepared.desired); let finish = self - .runtime_ui - .as_mut() + .preferences + .runtime_ui_mut() + .state_mut() .expect("runtime state remained available") .finish_board_pin_toggle(prepared, applied); self.apply_toolbar_runtime_finish(finish); } PendingBoardRuntimeUiAction::IdentityDeleted { board_id } => { - if let Some(runtime) = self.runtime_ui.as_mut() { + if let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() { runtime.remove_board_identity(&self.config, &board_id); } } @@ -392,15 +406,19 @@ impl WaylandState { pin_seed, pinned, } => { - let finish = self.runtime_ui.as_mut().and_then(|runtime| { - runtime.restore_board_identity( - &self.config, - &mut self.input_state, - board_id, - pin_seed, - pinned, - ) - }); + let finish = self + .preferences + .runtime_ui_mut() + .state_mut() + .and_then(|runtime| { + runtime.restore_board_identity( + &self.config, + &mut self.input_state, + board_id, + pin_seed, + pinned, + ) + }); if let Some(finish) = finish { self.apply_toolbar_runtime_finish(finish); } @@ -410,8 +428,9 @@ impl WaylandState { pub(in crate::backend::wayland) fn drain_runtime_ui_completions(&mut self) { let drain = self - .runtime_ui - .as_mut() + .preferences + .runtime_ui_mut() + .state_mut() .map(ToolbarRuntimeState::drain_writer_completions) .unwrap_or_default(); for rollback in drain.rollbacks { @@ -423,7 +442,7 @@ impl WaylandState { self.cancel_toolbar_move_drag(); self.cancel_gtk_toolbar_drag_lifecycle(); let mut positions = self.toolbar_position_snapshot(); - if let Some(runtime) = self.runtime_ui.as_ref() { + if let Some(runtime) = self.preferences.runtime_ui().state() { runtime.apply_live_state(&mut self.input_state, &mut positions); } self.restore_toolbar_offsets(positions.top); @@ -436,8 +455,9 @@ impl WaylandState { self.input_state.needs_redraw = true; } let deferred_finishes = self - .runtime_ui - .as_mut() + .preferences + .runtime_ui_mut() + .state_mut() .map(|runtime| runtime.finish_deferred_board_pin_restores(&mut self.input_state)) .unwrap_or_default(); for finish in deferred_finishes { @@ -446,7 +466,7 @@ impl WaylandState { } pub(in crate::backend::wayland) fn shutdown_runtime_ui(&mut self) { - if let Some(runtime) = self.runtime_ui.as_mut() { + if let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() { runtime.shutdown_blocking(); } } diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index b7d9ce0ad..69c187417 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -94,6 +94,7 @@ mod pdf_export; mod perf; mod pointer_runtime; pub(in crate::backend::wayland) use pointer_runtime::TouchTarget; +mod preference_stores; mod protocol_globals; pub(in crate::backend::wayland) use protocol_globals::{ProtocolGlobals, ProtocolGlobalsSeed}; mod region_capture; @@ -109,6 +110,7 @@ mod tablet_runtime; mod text_clipboard; mod text_input; mod toolbar; +mod ui_animation; #[cfg(feature = "toolbar-gtk")] pub(crate) use toolbar::clamp_floating_axis_offset; pub(in crate::backend::wayland) use toolbar::{queue_preset_action, queue_quick_color_edit}; @@ -180,17 +182,14 @@ pub(super) struct WaylandState { /// Authored `[session]` settings were unavailable and the live options are /// defaults. Destructive session actions must refuse to use those options. pub(super) session_config_failed: bool, - pub(super) runtime_ui: Option, - pub(super) runtime_ui_unavailable: Option, - pub(super) runtime_ui_unavailable_previews: - crate::backend::wayland::runtime_ui_state::UnavailablePersistencePreviews, + /// Durable UI preference stores and their background writers. + pub(super) preferences: preference_stores::PreferenceStores, // Input state pub(super) input_state: InputState, /// One-shot worker that enumerates the system font catalog after the first /// committed frame instead of inside a picker-opening input callback. - pub(super) font_catalog_prewarm: RuntimeOperationController<(), Duration>, - pub(super) font_catalog_prewarm_started: bool, + pub(super) font_catalog: font_catalog::FontCatalogPrewarm, /// System-reader lifecycle and reconciliation latches for the input HUD. pub(super) input_hud: input_hud::InputHudRuntime, pub(super) clipboard: clipboard_runtime::ClipboardRuntime, @@ -218,15 +217,8 @@ pub(super) struct WaylandState { pub(super) ocr: crate::ocr::OcrController, /// GTK toolbar frontend; `None` means the built-in bars are in charge. pub(super) gtk_toolbar: Option, - pub(super) onboarding: crate::onboarding::OnboardingStore, - /// Background persistence worker for command-palette recents. - pub(super) palette_recents: crate::palette_recents::PaletteRecentsWriter, - /// Off-dispatch writer for the three explicit `config.toml` edit gestures. - pub(super) config_edits: crate::backend::wayland::config_edits::ConfigEditWorker, - // Next scheduled tick for UI animations (toasts/highlights/preset feedback). - pub(super) ui_animation_next_tick: Option, - // Animation interval; None means uncapped (render every frame while active). - pub(super) ui_animation_interval: Option, + /// Tick scheduling for toasts, highlights, and preset feedback. + pub(super) ui_animation: ui_animation::UiAnimationClock, // Capture manager pub(super) capture: CaptureState, @@ -283,14 +275,6 @@ impl PendingStylusFrame { } impl WaylandState { - fn ui_animation_interval_from_fps(fps: u32) -> Option { - if fps == 0 { - None - } else { - Some(Duration::from_secs_f64(1.0 / fps as f64)) - } - } - const TOP_MARGIN_RIGHT: f64 = 12.0; const TOP_BASE_MARGIN_TOP: f64 = 12.0; const TOP_MARGIN_BOTTOM: f64 = 0.0; @@ -302,30 +286,3 @@ impl WaylandState { pub(super) const ZOOM_PAN_STEP: f64 = 32.0; pub(super) const ZOOM_PAN_STEP_LARGE: f64 = 96.0; } - -impl WaylandState { - pub(super) fn update_ui_animation_tick(&mut self, now: Instant, active: bool) { - if !active { - self.ui_animation_next_tick = None; - return; - } - if let Some(interval) = self.ui_animation_interval { - self.ui_animation_next_tick = Some(now + interval); - } else { - self.ui_animation_next_tick = None; - } - } - - pub(super) fn ui_animation_timeout(&self, now: Instant) -> Option { - self.ui_animation_interval?; - self.ui_animation_next_tick - .map(|next| next.saturating_duration_since(now)) - } - - pub(super) fn ui_animation_due(&self, now: Instant) -> bool { - if self.ui_animation_interval.is_none() { - return false; - } - self.ui_animation_next_tick.is_some_and(|next| now >= next) - } -} diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index 219237bd4..8ca048f35 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -6,7 +6,7 @@ ## Architecture - This subtree supports live overlay runtime state: buffers, damage, boards, capture routing, clipboard paste, color picker, onboarding, PDF export, render helpers, toolbar plumbing, zoom, and core accessors. -- Protocol and interaction sub-states extracted from `WaylandState` live beside it: `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer, cursor, pointer-lock, and touch lifecycles), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). +- Runtime owners extracted from `WaylandState` live beside it: `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer, cursor, pointer-lock, and touch lifecycles), `input_hud.rs` (system-reader lifecycle and reconciliation), `spotlight_runtime.rs` (render memory, warning latches, and wheel timing), `clipboard_runtime.rs` (single-flight workers and queue policy), `preference_stores.rs` (persistence stores and workers), `ui_animation.rs` (animation scheduling), `font_catalog.rs` (font-catalog prewarm), `text_input.rs` (text-input-v3 lifecycle and commit serials), `tablet_runtime.rs` (tablet-input-v2 objects and stylus contact), `key_repeat.rs` (manual key-repeat timing), and `helper_launch.rs` (About/configurator launches requested by input). - `render/` owns overlay render phases; `toolbar/` owns runtime toolbar state helpers; `clipboard/` owns session paste helpers. ## Invariants diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index 50a39df55..f0317ab7c 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -85,13 +85,16 @@ impl WaylandState { ) }); let zoom_manager = screencopy_manager.clone(); - let ui_animation_interval = - WaylandState::ui_animation_interval_from_fps(config.performance.ui_animation_fps); + let ui_animation = super::super::ui_animation::UiAnimationClock::from_fps( + config.performance.ui_animation_fps, + ); let buffer_count = config.performance.buffer_count as usize; let runtime_operation_ids = RuntimeOperationIdSource::new(); - let font_catalog_prewarm = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); + let font_catalog = super::super::font_catalog::FontCatalogPrewarm::new( + runtime_operation_ids.clone(), + runtime_wake.clone(), + ); let clipboard = super::super::clipboard_runtime::ClipboardRuntime::new( runtime_operation_ids.clone(), runtime_wake.clone(), @@ -103,6 +106,13 @@ impl WaylandState { let region_cut_preview = RuntimeOperationController::new(runtime_operation_ids, runtime_wake.clone()); let ocr = crate::ocr::OcrController::new(runtime_wake.clone()); + let preferences = super::super::preference_stores::PreferenceStores::new( + onboarding, + palette_recents, + runtime_ui, + runtime_ui_unavailable, + runtime_wake.clone(), + ); Self { protocol: globals, @@ -113,25 +123,16 @@ impl WaylandState { canvas_layer_cache: super::super::canvas_layer::CanvasLayerCache::new(), spotlight: super::super::spotlight_runtime::SpotlightRuntime::new(), config, - runtime_ui, - runtime_ui_unavailable, - runtime_ui_unavailable_previews: Default::default(), + preferences, input_state, - font_catalog_prewarm, - font_catalog_prewarm_started: false, - palette_recents, + font_catalog, clipboard, desktop_open, window_query, region_cut_preview, ocr, gtk_toolbar: None, - onboarding, - config_edits: super::super::super::config_edits::ConfigEditWorker::new( - runtime_wake.clone(), - ), - ui_animation_next_tick: None, - ui_animation_interval, + ui_animation, capture: CaptureState::new(capture_manager), frozen: FrozenState::new_with_backends( screencopy_manager, diff --git a/src/backend/wayland/state/font_catalog.rs b/src/backend/wayland/state/font_catalog.rs index 964778853..33e00a864 100644 --- a/src/backend/wayland/state/font_catalog.rs +++ b/src/backend/wayland/state/font_catalog.rs @@ -1,44 +1,81 @@ //! Off-dispatch loading for the process-wide system font catalog. -use std::time::Instant; +use std::time::{Duration, Instant}; -use crate::backend::wayland::RuntimeOperationPoll; +use crate::backend::wayland::{ + RuntimeOperationController, RuntimeOperationIdSource, RuntimeOperationPoll, + RuntimeOperationSubmitFailure, RuntimeWakeHandle, +}; use super::WaylandState; -impl WaylandState { - /// Start the one-time catalog walk after a frame has reached the compositor. - pub(in crate::backend::wayland) fn start_font_catalog_prewarm(&mut self) { - if self.font_catalog_prewarm_started { - return; +/// One-shot system font-catalog prewarm and its retry latch. +pub(in crate::backend::wayland) struct FontCatalogPrewarm { + controller: RuntimeOperationController<(), Duration>, + started: bool, +} + +impl FontCatalogPrewarm { + pub(in crate::backend::wayland) fn new( + ids: RuntimeOperationIdSource, + wake: RuntimeWakeHandle, + ) -> Self { + Self { + controller: RuntimeOperationController::new(ids, wake), + started: false, } - if self.input_state.font_picker_load_failed() { - return; + } + + fn start( + &mut self, + load_failed: bool, + catalog_ready: bool, + ) -> Result<(), RuntimeOperationSubmitFailure<()>> { + if self.started || load_failed { + return Ok(()); } - if crate::draw::system_font_catalog_is_ready() { - self.font_catalog_prewarm_started = true; - return; + if catalog_ready { + self.started = true; + return Ok(()); } - match self - .font_catalog_prewarm + self.controller .try_submit((), "wayscriber-font-catalog", || { let started = Instant::now(); crate::draw::prewarm_system_font_catalog(); started.elapsed() - }) { - Ok(_) => self.font_catalog_prewarm_started = true, - Err(failure) => { - let (error, ()) = failure.into_parts(); - log::warn!("Failed to start system font catalog prewarm: {error}"); - self.input_state.fail_font_picker_catalog_load(); - } + }) + .map(|_| self.started = true) + } + + fn poll(&mut self) -> RuntimeOperationPoll<(), Duration> { + let completion = self.controller.poll(); + if matches!( + completion, + RuntimeOperationPoll::ProducerFailed { .. } | RuntimeOperationPoll::Disconnected { .. } + ) { + self.started = false; + } + completion + } +} + +impl WaylandState { + /// Start the one-time catalog walk after a frame has reached the compositor. + pub(in crate::backend::wayland) fn start_font_catalog_prewarm(&mut self) { + if let Err(failure) = self.font_catalog.start( + self.input_state.font_picker_load_failed(), + crate::draw::system_font_catalog_is_ready(), + ) { + let (error, ()) = failure.into_parts(); + log::warn!("Failed to start system font catalog prewarm: {error}"); + self.input_state.fail_font_picker_catalog_load(); } } /// Apply a completed catalog to a picker that opened while it was loading. pub(in crate::backend::wayland) fn drain_font_catalog_prewarm(&mut self) { - match self.font_catalog_prewarm.poll() { + match self.font_catalog.poll() { RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} RuntimeOperationPoll::Ready { outcome: elapsed, .. @@ -51,14 +88,56 @@ impl WaylandState { } RuntimeOperationPoll::ProducerFailed { reason, .. } => { log::warn!("System font catalog prewarm worker failed: {reason}"); - self.font_catalog_prewarm_started = false; self.input_state.fail_font_picker_catalog_load(); } RuntimeOperationPoll::Disconnected { .. } => { log::warn!("System font catalog prewarm worker disconnected"); - self.font_catalog_prewarm_started = false; self.input_state.fail_font_picker_catalog_load(); } } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::RuntimeWakeSource; + + fn prewarm() -> FontCatalogPrewarm { + let wake = RuntimeWakeSource::new().expect("runtime wake source"); + FontCatalogPrewarm::new(RuntimeOperationIdSource::new(), wake.handle()) + } + + #[test] + fn failed_picker_load_does_not_start_prewarm() { + let mut prewarm = prewarm(); + + assert!(prewarm.start(true, false).is_ok()); + assert!(!prewarm.started); + } + + #[test] + fn ready_catalog_marks_prewarm_started_without_worker() { + let mut prewarm = prewarm(); + + assert!(prewarm.start(false, true).is_ok()); + assert!(prewarm.started); + assert!(matches!(prewarm.poll(), RuntimeOperationPoll::Idle)); + } + + #[test] + fn disconnected_worker_reopens_the_start_latch() { + let mut prewarm = prewarm(); + prewarm + .controller + .try_submit_with_spawner_for_test((), || Duration::ZERO, |_job| Ok(())) + .expect("test transport starts"); + prewarm.started = true; + + assert!(matches!( + prewarm.poll(), + RuntimeOperationPoll::Disconnected { .. } + )); + assert!(!prewarm.started); + } +} diff --git a/src/backend/wayland/state/keybindings/implementation.rs b/src/backend/wayland/state/keybindings/implementation.rs index 99749dc11..5a53f9b05 100644 --- a/src/backend/wayland/state/keybindings/implementation.rs +++ b/src/backend/wayland/state/keybindings/implementation.rs @@ -418,7 +418,7 @@ impl WaylandState { queue_keybinding_edit( &self.config.keybindings, &mut self.input_state, - &mut self.config_edits, + self.preferences.config_edits_mut(), request, ); } diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 2b75938c0..849d216f0 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -150,7 +150,10 @@ impl WaylandState { match command { ToastCommand::Dispatch(action) => self.dispatch_input_action(action), ToastCommand::AcknowledgeTip { tip, then } => { - let outcome = acknowledge_tip_command(self.onboarding.acknowledge_tip(tip), then); + let outcome = acknowledge_tip_command( + self.preferences.onboarding_mut().acknowledge_tip(tip), + then, + ); if let Some(action) = outcome.follow_up { self.dispatch_input_action(action); } @@ -166,7 +169,7 @@ impl WaylandState { self.apply_capability_toast(); if !automatic_onboarding_allowed( self.config.ui.show_onboarding_hints, - self.onboarding.persistence_available(), + self.preferences.onboarding().persistence_available(), ) { return; } @@ -197,7 +200,7 @@ impl WaylandState { fn apply_shortcut_coach(&mut self, slow_path: Option<(Action, u32)>) { // Only coach after first-run onboarding: during onboarding the palette // and toolbar are being taught, so slow-path use there is expected. - if !self.onboarding.state().first_run_completed { + if !self.preferences.onboarding().state().first_run_completed { return; } @@ -225,7 +228,7 @@ impl WaylandState { let now = Instant::now(); let should_fire = { let session = &self.data.shortcut_coach; - let state = self.onboarding.state(); + let state = self.preferences.onboarding().state(); shortcut_coach_should_fire( session.streak, session.hints_this_session, @@ -263,7 +266,7 @@ impl WaylandState { session.hints_this_session = session.hints_this_session.saturating_add(1); session.clear_streak(); - let state = self.onboarding.state_mut(); + let state = self.preferences.onboarding_mut().state_mut(); state.coach_hint_count = state.coach_hint_count.saturating_add(1); if state.coach_hint_count >= DEFERRED_HINT_REPEAT_MAX { state.coach_hint_shown = true; @@ -307,7 +310,7 @@ impl WaylandState { let mut changed = false; let mut hint = None; { - let state = self.onboarding.state_mut(); + let state = self.preferences.onboarding_mut().state_mut(); if !state.first_run_completed { return; } @@ -444,7 +447,7 @@ impl WaylandState { } fn apply_toolbar_visibility_hint(&mut self) { - if self.onboarding.state().toolbar_hint_shown { + if self.preferences.onboarding().state().toolbar_hint_shown { return; } if !self.surface.is_configured() || self.overlay_suppressed() { @@ -453,7 +456,7 @@ impl WaylandState { if self.input_state.presenter_mode_active() || self.input_state.help_overlay.is_visible() { return; } - if self.onboarding.state().first_run_active() { + if self.preferences.onboarding().state().first_run_active() { return; } if self.input_state.toolbar_visible() || !self.input_state.toasts_idle() { @@ -475,7 +478,10 @@ impl WaylandState { ), ); if outcome.accepted() { - self.onboarding.state_mut().toolbar_hint_shown = true; + self.preferences + .onboarding_mut() + .state_mut() + .toolbar_hint_shown = true; self.save_onboarding_state(); } } @@ -486,7 +492,7 @@ impl WaylandState { } fn save_onboarding_state(&mut self) -> bool { - match self.onboarding.save() { + match self.preferences.onboarding_mut().save() { Ok(()) => true, Err(error) => { self.show_onboarding_persistence_warning(&error); diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index 7d8d9617b..1f0747441 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -12,7 +12,7 @@ impl WaylandState { key: Key, ) -> bool { if !background_mode_prompt_active( - self.onboarding.state(), + self.preferences.onboarding().state(), self.first_run_onboarding_card_visible(), ) { return false; @@ -25,7 +25,10 @@ impl WaylandState { if enable_background_mode { match crate::daemon::setup::setup_background_mode() { Ok(summary) => { - mark_background_mode_prompt(self.onboarding.state_mut(), true); + mark_background_mode_prompt( + self.preferences.onboarding_mut().state_mut(), + true, + ); self.save_onboarding_state(); self.input_state.push_toast( ToastPriority::Info, @@ -37,7 +40,10 @@ impl WaylandState { ); } Err(err) => { - mark_background_mode_prompt(self.onboarding.state_mut(), false); + mark_background_mode_prompt( + self.preferences.onboarding_mut().state_mut(), + false, + ); self.save_onboarding_state(); self.input_state.push_toast(ToastPriority::Critical, "onboarding.first_run", Toast::error(format!( "Background mode setup failed: {err}. You can set this up later in Background Mode settings." @@ -45,7 +51,7 @@ impl WaylandState { } } } else { - mark_background_mode_prompt(self.onboarding.state_mut(), false); + mark_background_mode_prompt(self.preferences.onboarding_mut().state_mut(), false); self.save_onboarding_state(); self.input_state.push_toast( ToastPriority::Info, @@ -63,12 +69,12 @@ impl WaylandState { pub(in crate::backend::wayland) fn try_skip_first_run_onboarding(&mut self) -> bool { if !first_run_skip_allowed( - self.onboarding.state().first_run_active(), + self.preferences.onboarding().state().first_run_active(), self.first_run_onboarding_card_visible(), ) { return false; } - let state = self.onboarding.state_mut(); + let state = self.preferences.onboarding_mut().state_mut(); state.first_run_skipped = true; state.first_run_completed = true; state.active_step = None; @@ -87,7 +93,7 @@ impl WaylandState { return None; } - let state = self.onboarding.state(); + let state = self.preferences.onboarding().state(); if !state.first_run_active() { return None; } @@ -204,7 +210,7 @@ impl WaylandState { fn first_run_onboarding_card_visible(&self) -> bool { if !super::automatic_onboarding_allowed( self.config.ui.show_onboarding_hints, - self.onboarding.persistence_available(), + self.preferences.onboarding().persistence_available(), ) || !self.surface.is_configured() || self.overlay_suppressed() { @@ -234,7 +240,7 @@ impl WaylandState { let mut completed_now = false; { - let state = self.onboarding.state_mut(); + let state = self.preferences.onboarding_mut().state_mut(); let first_run_active = state.first_run_active(); if apply_persisted_usage_signals(state, &usage) { diff --git a/src/backend/wayland/state/preference_stores.rs b/src/backend/wayland/state/preference_stores.rs new file mode 100644 index 000000000..bb1ab7999 --- /dev/null +++ b/src/backend/wayland/state/preference_stores.rs @@ -0,0 +1,103 @@ +use crate::{ + backend::wayland::{ + RuntimeWakeHandle, + config_edits::ConfigEditWorker, + runtime_ui_state::{ToolbarRuntimeState, UnavailablePersistencePreviews}, + }, + onboarding::OnboardingStore, + palette_recents::PaletteRecentsWriter, + ui::toolbar::RuntimeUiPersistenceSnapshot, +}; + +/// Runtime state for persisted UI preferences and degraded-mode previews. +pub(in crate::backend::wayland) struct RuntimeUiSlot { + state: Option, + unavailable: Option, + unavailable_previews: UnavailablePersistencePreviews, +} + +impl RuntimeUiSlot { + fn new( + state: Option, + unavailable: Option, + ) -> Self { + Self { + state, + unavailable, + unavailable_previews: UnavailablePersistencePreviews::default(), + } + } + + pub(in crate::backend::wayland) fn state(&self) -> Option<&ToolbarRuntimeState> { + self.state.as_ref() + } + + pub(in crate::backend::wayland) fn state_mut(&mut self) -> Option<&mut ToolbarRuntimeState> { + self.state.as_mut() + } + + pub(in crate::backend::wayland) fn unavailable(&self) -> Option<&RuntimeUiPersistenceSnapshot> { + self.unavailable.as_ref() + } + + pub(in crate::backend::wayland) fn unavailable_previews( + &self, + ) -> &UnavailablePersistencePreviews { + &self.unavailable_previews + } + + pub(in crate::backend::wayland) fn unavailable_previews_mut( + &mut self, + ) -> &mut UnavailablePersistencePreviews { + &mut self.unavailable_previews + } +} + +/// Persistence workers and stores whose lifetimes match the Wayland runtime. +pub(in crate::backend::wayland) struct PreferenceStores { + onboarding: OnboardingStore, + palette_recents: PaletteRecentsWriter, + config_edits: ConfigEditWorker, + runtime_ui: RuntimeUiSlot, +} + +impl PreferenceStores { + pub(in crate::backend::wayland) fn new( + onboarding: OnboardingStore, + palette_recents: PaletteRecentsWriter, + runtime_ui: Option, + runtime_ui_unavailable: Option, + wake: RuntimeWakeHandle, + ) -> Self { + Self { + onboarding, + palette_recents, + config_edits: ConfigEditWorker::new(wake), + runtime_ui: RuntimeUiSlot::new(runtime_ui, runtime_ui_unavailable), + } + } + + pub(in crate::backend::wayland) fn onboarding(&self) -> &OnboardingStore { + &self.onboarding + } + + pub(in crate::backend::wayland) fn onboarding_mut(&mut self) -> &mut OnboardingStore { + &mut self.onboarding + } + + pub(in crate::backend::wayland) fn palette_recents_mut(&mut self) -> &mut PaletteRecentsWriter { + &mut self.palette_recents + } + + pub(in crate::backend::wayland) fn config_edits_mut(&mut self) -> &mut ConfigEditWorker { + &mut self.config_edits + } + + pub(in crate::backend::wayland) fn runtime_ui(&self) -> &RuntimeUiSlot { + &self.runtime_ui + } + + pub(in crate::backend::wayland) fn runtime_ui_mut(&mut self) -> &mut RuntimeUiSlot { + &mut self.runtime_ui + } +} diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index f614f0303..e8672c83f 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -138,8 +138,8 @@ impl WaylandState { let animation_state = record_stage!(advance_animations, { self.advance_render_animations(now) }); let ui_animation_active = animation_state.any_active(); - self.update_ui_animation_tick(now, ui_animation_active); - let keep_rendering = ui_animation_active && self.ui_animation_interval.is_none(); + self.ui_animation.schedule(now, ui_animation_active); + let keep_rendering = ui_animation_active && self.ui_animation.is_uncapped(); // Add new dirty regions from input state to the per-buffer damage // tracker. This runs after the buffer is acquired but before its damage diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 6b392cf0b..b63ecc198 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -94,10 +94,11 @@ impl WaylandState { snapshot.spotlight_magnifier_source = Some(self.current_spotlight_magnifier_source()); populate_session_snapshot(&mut snapshot, self.session.options()); snapshot.runtime_ui_persistence = self - .runtime_ui - .as_ref() + .preferences + .runtime_ui() + .state() .map(|runtime| runtime.persistence_snapshot()) - .or_else(|| self.runtime_ui_unavailable.clone()); + .or_else(|| self.preferences.runtime_ui().unavailable().cloned()); snapshot.top_viewport_max = self.top_strip_viewport_max(&snapshot); snapshot.top_available_height = self.top_popover_available_height(&snapshot); snapshot.top_fade = self.data.top_strip_fade.value(); @@ -199,8 +200,9 @@ impl WaylandState { return; } let persistence_lifecycle_handled = self - .runtime_ui - .as_mut() + .preferences + .runtime_ui_mut() + .state_mut() .is_some_and(|runtime| runtime.handle_persistence_lifecycle_event(&event)); if persistence_lifecycle_handled { // Read-only recovery cancellation can terminalize synchronously @@ -246,7 +248,7 @@ impl WaylandState { let prepared_runtime = if starts_item_drag { None } else if let Some(target) = runtime_target { - match self.runtime_ui.as_ref() { + match self.preferences.runtime_ui().state() { Some(runtime) => match runtime.begin_toolbar_mutation(target, &self.input_state) { Some(prepared) => Some(prepared), None => return, @@ -282,7 +284,7 @@ impl WaylandState { } let mut pin_confirmation_allowed = applied && prepared_runtime.is_none(); if let Some(prepared) = prepared_runtime - && let Some(runtime) = self.runtime_ui.as_mut() + && let Some(runtime) = self.preferences.runtime_ui_mut().state_mut() { let finish = runtime.finish_toolbar_mutation(prepared, applied, &self.input_state); pin_confirmation_allowed = diff --git a/src/backend/wayland/state/toolbar/events/presets.rs b/src/backend/wayland/state/toolbar/events/presets.rs index c8d439efd..366591a7d 100644 --- a/src/backend/wayland/state/toolbar/events/presets.rs +++ b/src/backend/wayland/state/toolbar/events/presets.rs @@ -84,7 +84,11 @@ pub(in crate::backend::wayland) fn queue_preset_action( impl WaylandState { pub(in crate::backend::wayland) fn handle_preset_action(&mut self, action: PresetAction) { - queue_preset_action(&mut self.config, &mut self.config_edits, action); + queue_preset_action( + &mut self.config, + self.preferences.config_edits_mut(), + action, + ); } pub(in crate::backend::wayland) fn finish_preset_action( diff --git a/src/backend/wayland/state/toolbar/events/quick_colors.rs b/src/backend/wayland/state/toolbar/events/quick_colors.rs index e0dc448f2..017ba5a54 100644 --- a/src/backend/wayland/state/toolbar/events/quick_colors.rs +++ b/src/backend/wayland/state/toolbar/events/quick_colors.rs @@ -70,7 +70,7 @@ pub(in crate::backend::wayland) fn queue_quick_color_edit( impl WaylandState { pub(in crate::backend::wayland) fn handle_quick_color_edit(&mut self, edit: QuickColorEdit) { - queue_quick_color_edit(&mut self.config, &mut self.config_edits, edit); + queue_quick_color_edit(&mut self.config, self.preferences.config_edits_mut(), edit); } pub(in crate::backend::wayland) fn finish_quick_color_edit( diff --git a/src/backend/wayland/state/ui_animation.rs b/src/backend/wayland/state/ui_animation.rs new file mode 100644 index 000000000..1b2b5de6f --- /dev/null +++ b/src/backend/wayland/state/ui_animation.rs @@ -0,0 +1,83 @@ +use std::time::{Duration, Instant}; + +/// Scheduling state for runtime UI animations. +pub(in crate::backend::wayland) struct UiAnimationClock { + interval: Option, + next_tick: Option, +} + +impl UiAnimationClock { + pub(in crate::backend::wayland) fn from_fps(fps: u32) -> Self { + Self { + interval: (fps != 0).then(|| Duration::from_secs_f64(1.0 / fps as f64)), + next_tick: None, + } + } + + pub(in crate::backend::wayland) fn schedule(&mut self, now: Instant, active: bool) { + self.next_tick = if active { + self.interval.map(|interval| now + interval) + } else { + None + }; + } + + pub(in crate::backend::wayland) fn timeout(&self, now: Instant) -> Option { + self.interval?; + self.next_tick + .map(|next| next.saturating_duration_since(now)) + } + + pub(in crate::backend::wayland) fn is_due(&self, now: Instant) -> bool { + self.interval.is_some() && self.next_tick.is_some_and(|next| now >= next) + } + + pub(in crate::backend::wayland) fn is_uncapped(&self) -> bool { + self.interval.is_none() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn capped_clock_schedules_and_reports_its_deadline() { + let now = Instant::now(); + let mut clock = UiAnimationClock::from_fps(20); + + clock.schedule(now, true); + + assert_eq!(clock.timeout(now), Some(Duration::from_millis(50))); + assert!(!clock.is_due(now + Duration::from_millis(49))); + assert!(clock.is_due(now + Duration::from_millis(50))); + assert_eq!( + clock.timeout(now + Duration::from_millis(51)), + Some(Duration::ZERO) + ); + } + + #[test] + fn inactive_animation_clears_a_scheduled_tick() { + let now = Instant::now(); + let mut clock = UiAnimationClock::from_fps(60); + clock.schedule(now, true); + + clock.schedule(now, false); + + assert_eq!(clock.timeout(now), None); + assert!(!clock.is_due(now + Duration::from_secs(1))); + } + + #[test] + fn zero_fps_is_uncapped_and_never_schedules_a_timeout() { + let now = Instant::now(); + let mut clock = UiAnimationClock::from_fps(0); + + clock.schedule(now, true); + + assert!(clock.is_uncapped()); + assert_eq!(clock.timeout(now), None); + assert!(!clock.is_due(now + Duration::from_secs(1))); + } +}