diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 74484dd1a..0e7aebaa2 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; `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. +`WaylandState` coordinates the runtime owners handlers need. `FocusState` owns activation, focus, and startup acquisition; `ProtocolGlobals` owns bound globals and toolkit handler state; `PointerRuntime` owns pointer position, board-pan and chrome gestures, cursor, pointer-lock, and single-contact touch protocol lifecycles; `ToolbarChrome` owns toolbar placement, inline interaction, and fade state; `ToolbarDrag` owns built-in and GTK drag lifecycles; `RegionCaptureRuntime` owns region selection generations, active/review/window-snap state, and the window-query and cut-preview workers; `AcquisitionRuntime` owns the capacity-one screen-acquisition and zoom-waiter registries plus eyedropper source correlation; `FrozenState` owns its availability and one-shot startup gate; `SurfaceState` owns output/fullscreen/layer placement and frozen-fullscreen transitions; `OverlaySuppressionState` owns suppression reason, keyboard policy, capture barrier, and clickthrough state; `RenderRuntime` owns the canvas layer cache, render-profile baseline, and per-effect damage history; `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 coordination. `handlers::route::SurfaceRouter` is the single classifier for pointer, touch, and stylus surfaces and supplies overlay screen coordinates before modality-specific dispatch. 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. @@ -93,8 +93,9 @@ Freeze capture waits for the overlay-suppression frame, then selects `wlr-screen - Key presses can emit typed backend work; the event loop drains the ordered `InputEffectOutbox` runtime batch through `InputState::drain_input_effects`. -2. **Mouse events (`handlers/pointer.rs`)** - - Update `current_mouse_x/y`. +2. **Mouse events (`handlers/pointer/`)** + - Classify canvas, layer-shell toolbar, and foreign surfaces once through `SurfaceRouter`; toolbar-local positions are converted to overlay screen coordinates. + - Update the pointer position. - Call `InputState::on_mouse_press`, `on_mouse_motion`, `on_mouse_release`. - Adjust pen thickness or font size via scroll wheel + modifiers; scrolling over a Spotlight loupe adjusts its magnification instead. - Keep touchpad-finger Spotlight adjustments in one undo gesture until Wayland reports `axis_stop`; wheel-like sources use a quiet-period fallback when no stop arrives. @@ -178,8 +179,8 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` → selection; `capture_region_interactive` enters review so Copy, Save, Both, or Board can choose the terminal request. Interactive review can apply sequential band cuts through `capture::band_cut` after flattening a - snapshot. Live cut previews run on `WaylandState.region_cut_preview`, a - capacity-one `RuntimeOperationController` independent of `CaptureManager`, + snapshot. Live cut previews run on the capacity-one controller owned by + `RegionCaptureRuntime`, independent of `CaptureManager`, so a replaceable preview cannot occupy the capture reservation slot. Flatten, cut, and PNG encode stay off the event loop; terminal Copy, Save, Both, or Board still submit through `CaptureManager`. diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index c73b3257e..cd817ede1 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -50,7 +50,7 @@ pub(super) fn capture_timeout(state: &WaylandState, now: Instant) -> Option {} @@ -288,12 +296,14 @@ fn handle_frozen_toggle(state: &mut WaylandState, user_requested: bool) { ); } FrozenUserToggleAction::RequestUserFreeze => { - let _ = state.request_screen_acquisition(ScreenAcquisitionOwner::UserFreeze); + let _ = state + .acquisition + .request(ScreenAcquisitionOwner::UserFreeze); } } let record = if decision.user_action == FrozenUserToggleAction::RequestUserFreeze { - state.queued_screen_acquisition() + state.acquisition.queued() } else { decision.queued_to_start }; @@ -311,7 +321,7 @@ fn handle_frozen_toggle(state: &mut WaylandState, user_requested: bool) { } match state.frozen.start_capture_for(record.id, record.owner) { Ok(()) => { - state.mark_screen_acquisition_started(record.id, record.owner); + state.acquisition.mark_started(record.id, record.owner); } Err(err) => { warn!("Frozen capture failed to start: {err}"); @@ -615,7 +625,7 @@ fn handle_capture_results(state: &mut WaylandState) { // Exit-after-capture is intentional teardown. Mark it explicit so XDG // stay-mode cannot clear should_exit while the overlay is unfocused // (for example after a portal dialog stole focus during capture). - state.mark_xdg_explicit_close_requested(); + state.focus.mark_xdg_explicit_close_requested(); state.input_state.should_exit = true; } } diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 00bfa3a7d..fdf8c4814 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -102,7 +102,7 @@ pub(super) fn run_event_loop( let capture_active = state.capture.is_in_progress() || state.frozen.is_in_progress() || state.zoom.is_in_progress() - || state.overlay_blocks_event_loop(); + || state.suppression.blocks_event_loop(); let timeout = event_loop_timeout(state, capture_active, last_render_time); if let Err(e) = dispatch::dispatch_events(event_queue, state, runtime_wake, signal_state, timeout) @@ -273,7 +273,7 @@ fn event_loop_timeout( state.input_state.ocr_scan_wake_after(now), ); let autosave_timeout = session_save::autosave_timeout(state, now); - let focus_exit_timeout = state.focus_exit_timeout(now); + let focus_exit_timeout = state.focus.exit_timeout(now); let base_timeout = if should_block { min_timeout(autosave_timeout, focus_exit_timeout) } else if !vsync_enabled && state.input_state.needs_redraw { @@ -318,15 +318,15 @@ fn event_loop_timeout( fn handle_xdg_focus_loss(state: &mut WaylandState, qh: &wayland_client::QueueHandle) { if !state.surface.is_xdg_window() || state.input_state.should_exit - || state.has_keyboard_focus() + || state.focus.keyboard_focused() || state.desktop_open_in_progress() - || !state.focus_exit_suppression_expired(Instant::now()) + || !state.focus.exit_suppression_expired(Instant::now()) { return; } if state.xdg_focus_loss_exits_overlay() { warn!("Keyboard focus not restored after clipboard action; exiting overlay"); - state.clear_focus_exit_suppression(); + state.focus.clear_exit_suppression(); notification::send_notification_async( &state.tokio_handle, "Wayscriber lost focus".to_string(), @@ -338,8 +338,10 @@ fn handle_xdg_focus_loss(state: &mut WaylandState, qh: &wayland_client::QueueHan warn!( "Keyboard focus not restored after clipboard action; keeping overlay open (ui.xdg_focus_loss_behavior=stay)" ); - state.clear_focus_exit_suppression(); - state.set_xdg_close_guard_for(Duration::from_millis(2500)); + state.focus.clear_exit_suppression(); + state + .focus + .guard_xdg_close_for(Instant::now(), Duration::from_millis(2500)); state.request_xdg_activation(qh); } } @@ -395,12 +397,12 @@ fn break_on_requested_exit(state: &mut WaylandState) -> bool { if !state.input_state.should_exit { return false; } - let explicit_xdg_close_requested = state.take_xdg_explicit_close_requested() + let explicit_xdg_close_requested = state.focus.take_xdg_explicit_close_requested() || state.input_state.take_explicit_exit_requested(); if should_defer_xdg_unfocused_exit( state.surface.is_xdg_window(), !state.xdg_focus_loss_exits_overlay(), - state.has_keyboard_focus(), + state.focus.keyboard_focused(), explicit_xdg_close_requested, ) { warn!("Exit requested while unfocused in xdg stay mode; keeping overlay open"); diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index e6d5a5011..eea01b039 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -123,14 +123,14 @@ pub(super) fn maybe_render( state.input_state.status_hud.hover(), state.input_state.zoom_chip.hover(), ); - if chrome_hover_before != chrome_hover_after && state.has_pointer_focus() { + if chrome_hover_before != chrome_hover_after && state.focus.pointer_focused() { // Layout can move under a stationary pointer (for example, // Fit removes the zoom-chip Lock button). The render pass // reclassifies hover; publish the matching Wayland cursor // now instead of waiting for another motion event. Pointer // focus is required so a leave-triggered redraw cannot // publish or cache a cursor for stale coordinates. - state.update_pointer_cursor(state.pointer_over_toolbar(), conn); + state.update_pointer_cursor(state.toolbar_chrome.pointer_over_toolbar(), conn); } state.record_perf_render_complete( render_start, diff --git a/src/backend/wayland/backend/event_loop/session_save.rs b/src/backend/wayland/backend/event_loop/session_save.rs index 3aabced3d..cdff9caf1 100644 --- a/src/backend/wayland/backend/event_loop/session_save.rs +++ b/src/backend/wayland/backend/event_loop/session_save.rs @@ -536,9 +536,9 @@ fn record_persistence_transport_failure( pub(in crate::backend::wayland) fn should_defer_for_interaction(state: &WaylandState) -> bool { persistence_interaction_active( input_persistence_interaction_active(&state.input_state), - state.toolbar_dragging(), - state.is_move_dragging(), - state.board_panning_active(), + state.toolbar_drag.item_dragging(), + state.toolbar_drag.is_moving(), + state.pointer.board_pan_active(), state.zoom_panning_active(), stylus_tip_down(state), ) diff --git a/src/backend/wayland/backend/helpers.rs b/src/backend/wayland/backend/helpers.rs index 5d7bd5b08..4f8c210ad 100644 --- a/src/backend/wayland/backend/helpers.rs +++ b/src/backend/wayland/backend/helpers.rs @@ -249,7 +249,7 @@ where } fn take_toolbar_drag_flush_requested(&mut self) -> bool { - self.state.take_toolbar_drag_flush_requested() + self.state.toolbar_drag.take_flush_requested() } fn flush(&mut self) -> Result<()> { diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index a97a4ffb3..d4725e8e8 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -9,7 +9,9 @@ use super::WaylandBackend; use super::runtime_wake::RuntimeWakeSource; use super::setup::WaylandSetup; use crate::backend::wayland::portal_capture::screenshot_portal_available; -use crate::env_vars::{DESKTOP_SESSION_ENV, XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV}; +use crate::env_vars::{ + DESKTOP_SESSION_ENV, XDG_ACTIVATION_TOKEN_ENV, XDG_CURRENT_DESKTOP_ENV, XDG_SESSION_DESKTOP_ENV, +}; use crate::{ capture::CaptureManager, config::Config, @@ -103,6 +105,13 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let direct_capture_supported = screencopy_supported || image_copy_capture_supported; let frozen_supported = direct_capture_supported || portal_freeze_supported; let tokio_handle = backend.tokio_runtime.handle().clone(); + let startup_activation_token = env::var(XDG_ACTIVATION_TOKEN_ENV) + .ok() + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()); + if startup_activation_token.is_some() { + info!("Received startup activation token from launcher environment"); + } // Set compositor capabilities based on detected Wayland protocols input_state.compositor_capabilities = CompositorCapabilities { @@ -174,6 +183,7 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul globals: setup.state_globals, config, input_state, + startup_activation_token, onboarding, palette_recents, capture_manager, diff --git a/src/backend/wayland/backend/surface.rs b/src/backend/wayland/backend/surface.rs index 13b96d43c..1788f3c86 100644 --- a/src/backend/wayland/backend/surface.rs +++ b/src/backend/wayland/backend/surface.rs @@ -15,10 +15,10 @@ pub(super) fn create_overlay_surface( // Create surface using layer-shell when available, otherwise fall back to xdg-shell let wl_surface = state.protocol.compositor().create_surface(qh); if state.protocol.layer_shell().is_some() { - state.begin_main_layer_focus_acquisition(); + state.focus.begin_main_layer_acquisition(); } if let Some(layer_shell) = state.protocol.layer_shell() { - let layer = state.main_surface_layer(); + let layer = state.surface.placement().layer(); info!("Creating layer shell surface in {:?} layer", layer); let layer_surface = layer_shell.create_layer_surface( qh, @@ -39,7 +39,9 @@ pub(super) fn create_overlay_surface( layer_surface.commit(); state.surface.set_layer_surface(layer_surface); - state.set_current_keyboard_interactivity(Some(desired_keyboard_mode)); + state + .focus + .set_keyboard_interactivity(Some(desired_keyboard_mode)); info!("Layer shell surface created"); } else if let Some(xdg_shell) = state.protocol.xdg_shell() { info!("Layer shell missing; creating xdg-shell window"); @@ -47,7 +49,7 @@ pub(super) fn create_overlay_surface( window.set_title("wayscriber overlay"); let app_id = runtime_app_id(); window.set_app_id(&app_id); - if state.xdg_fullscreen() { + if state.surface.placement().xdg_fullscreen() { if let Some(output) = state.preferred_fullscreen_output() { info!("Requesting fullscreen on preferred output"); window.set_fullscreen(Some(&output)); diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index ea29c226a..6f983ea5b 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -112,6 +112,8 @@ impl DirectCaptureContext { /// End-to-end controller for frozen mode capture and image storage. #[allow(clippy::type_complexity)] pub struct FrozenState { + enabled: bool, + pending_on_start: bool, pub(super) manager: Option, pub(super) ext_managers: Option, pub(super) portal_available: bool, @@ -143,7 +145,7 @@ pub struct FrozenState { impl FrozenState { #[cfg(test)] pub fn new(manager: Option) -> Self { - Self::new_inner(manager, None, false, None) + Self::new_inner(manager, None, false, None, true, false) } #[cfg(test)] @@ -151,7 +153,7 @@ impl FrozenState { manager: Option, runtime_wake: RuntimeWakeHandle, ) -> Self { - Self::new_inner(manager, None, true, Some(runtime_wake)) + Self::new_inner(manager, None, true, Some(runtime_wake), true, false) } pub(in crate::backend::wayland) fn new_with_backends( @@ -159,8 +161,17 @@ impl FrozenState { ext_managers: Option, portal_available: bool, runtime_wake: RuntimeWakeHandle, + enabled: bool, + pending_on_start: bool, ) -> Self { - Self::new_inner(manager, ext_managers, portal_available, Some(runtime_wake)) + Self::new_inner( + manager, + ext_managers, + portal_available, + Some(runtime_wake), + enabled, + pending_on_start, + ) } fn new_inner( @@ -168,8 +179,12 @@ impl FrozenState { ext_managers: Option, portal_available: bool, runtime_wake: Option, + enabled: bool, + pending_on_start: bool, ) -> Self { Self { + enabled, + pending_on_start, manager, ext_managers, portal_available, @@ -197,6 +212,14 @@ impl FrozenState { } } + pub(in crate::backend::wayland) const fn enabled(&self) -> bool { + self.enabled + } + + pub(in crate::backend::wayland) fn take_pending_on_start(&mut self) -> bool { + std::mem::take(&mut self.pending_on_start) + } + pub(in crate::backend::wayland) fn preferred_backend(&self) -> Option { select_capture_backend( self.manager.is_some(), @@ -836,6 +859,23 @@ mod tests { .expect("verified test output geometry") } + #[test] + fn capability_gate_matches_construction() { + let enabled = FrozenState::new_inner(None, None, false, None, true, false); + let disabled = FrozenState::new_inner(None, None, false, None, false, false); + + assert!(enabled.enabled()); + assert!(!disabled.enabled()); + } + + #[test] + fn pending_on_start_is_consumed_once() { + let mut state = FrozenState::new_inner(None, None, false, None, true, true); + + assert!(state.take_pending_on_start()); + assert!(!state.take_pending_on_start()); + } + #[test] fn capture_backend_priority_is_wlr_then_ext_then_portal() { assert_eq!( @@ -1490,7 +1530,7 @@ mod tests { #[test] fn acquisition_terminal_consumes_attempt_once_and_retains_old_image_on_failure() { - let mut state = FrozenState::new_inner(None, None, true, None); + let mut state = FrozenState::new_inner(None, None, true, None, true, false); let mut input_state = make_test_input_state(); let mut registry = ScreenAcquisitionRegistry::default(); let id = registry.request(ScreenAcquisitionOwner::Ocr).expect("id"); @@ -1668,7 +1708,7 @@ mod tests { #[test] fn every_pending_image_rejection_finishes_its_acquisition_exactly_once() { for fixture in PendingImageRejectionFixture::ALL { - let mut state = FrozenState::new_inner(None, None, true, None); + let mut state = FrozenState::new_inner(None, None, true, None, true, false); let mut input_state = make_test_input_state(); let mut registry = ScreenAcquisitionRegistry::default(); let owner = ScreenAcquisitionOwner::UserFreeze; @@ -1753,7 +1793,7 @@ mod tests { #[test] fn undrained_terminal_is_taken_only_by_its_correlated_owner() { - let mut state = FrozenState::new_inner(None, None, true, None); + let mut state = FrozenState::new_inner(None, None, true, None, true, false); let mut input_state = make_test_input_state(); let mut registry = ScreenAcquisitionRegistry::default(); let id = registry.request(ScreenAcquisitionOwner::Ocr).expect("id"); @@ -1778,7 +1818,7 @@ mod tests { #[test] fn undrained_ready_terminal_transfers_its_exact_generation_once() { - let mut state = FrozenState::new_inner(None, None, true, None); + let mut state = FrozenState::new_inner(None, None, true, None, true, false); let mut input_state = make_test_input_state(); let mut registry = ScreenAcquisitionRegistry::default(); let id = registry @@ -1820,7 +1860,7 @@ mod tests { #[test] fn preflight_layout_failure_is_classified_as_stale_layout() { - let mut state = FrozenState::new_inner(None, None, true, None); + let mut state = FrozenState::new_inner(None, None, true, None, true, false); let mut input_state = make_test_input_state(); let mut registry = ScreenAcquisitionRegistry::default(); let id = registry diff --git a/src/backend/wayland/handlers/AGENTS.md b/src/backend/wayland/handlers/AGENTS.md index 18fb562ce..0faa553fc 100644 --- a/src/backend/wayland/handlers/AGENTS.md +++ b/src/backend/wayland/handlers/AGENTS.md @@ -6,11 +6,13 @@ ## Architecture - Handler files translate protocol callbacks into `WaylandState`, `InputState`, capture, surface, and render operations. - Pointer, keyboard, tablet, touch, output, layer, registry, buffer, screencopy, seat, SHM, and XDG behavior is split by protocol area. +- `route.rs` is the single surface classifier for pointer, touch, and stylus input. It converts toolbar-local positions to overlay screen coordinates; `TouchTarget` separately binds a touch sequence to its initial target. - Translation helpers such as keyboard keysym mapping should stay testable and isolated. ## Invariants - Keep handlers thin; do not bury durable business logic in protocol callbacks. - Preserve coordinate transforms, modifier synchronization, seat/device lifetimes, frame/callback ordering, and tablet feature gating. +- Route protocol surfaces through `SurfaceRouter`; do not add modality-specific canvas/toolbar classifiers. - Avoid blocking protocol callback paths. ## Coupled Changes diff --git a/src/backend/wayland/handlers/compositor.rs b/src/backend/wayland/handlers/compositor.rs index 97335983b..15e96c48c 100644 --- a/src/backend/wayland/handlers/compositor.rs +++ b/src/backend/wayland/handlers/compositor.rs @@ -119,10 +119,10 @@ impl CompositorHandler for WaylandState { let previous_output = self.surface.current_output(); let output_changed = previous_output.as_ref() != Some(output); self.surface.set_current_output(output.clone()); - self.set_has_seen_surface_enter(true); + self.focus.note_surface_enter(); if output_changed { // Keep layer-shell toolbars pinned to the monitor that owns the drawing surface. - self.set_toolbar_needs_recreate(true); + self.toolbar_chrome.set_needs_recreate(true); } self.refresh_active_output_label(); @@ -148,9 +148,8 @@ impl CompositorHandler for WaylandState { self.cancel_screen_modals_if_source_changed(); // If freeze-on-start was requested, trigger it once the surface is configured and active. - if self.pending_freeze_on_start() { + if self.frozen.take_pending_on_start() { info!("Applying freeze-on-start after initial configure"); - self.set_pending_freeze_on_start(false); self.input_state.request_frozen_toggle(); } @@ -173,7 +172,7 @@ impl CompositorHandler for WaylandState { debug!("Surface left output"); self.surface.clear_output(output); if self.surface.current_output().is_none() { - self.set_has_seen_surface_enter(false); + self.focus.clear_surface_enter(); } self.refresh_active_output_label(); self.frozen.set_active_output(None, None); diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index be93e7190..d24e1396d 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -54,24 +54,23 @@ impl KeyboardHandler for WaylandState { _keysyms: &[smithay_client_toolkit::seat::keyboard::Keysym], ) { debug!("Keyboard focus entered"); - self.set_keyboard_focus(true); - self.clear_focus_exit_suppression(); - self.clear_xdg_close_guard(); - self.set_last_activation_serial(Some(serial)); + self.focus.keyboard_entered(); + self.focus.clear_exit_suppression(); + self.focus.clear_xdg_close_guard(); + self.focus.note_activation_serial(serial); self.maybe_retry_activation(qh); if self.toolbar.is_focusable_surface(surface) { - self.set_toolbar_focus_active(true); + self.toolbar_chrome.set_focus_active(true); } else { self.clear_toolbar_focus(); } - // Mark overlay as ready once we have focus and surface is configured - if self.surface.is_configured() { - self.set_overlay_ready(true); + // Mark overlay as ready once we have focus and surface is configured. + if self.surface.is_configured() && self.focus.mark_ready_if_focused() { debug!("Overlay ready for keybinds"); } let is_current_main_layer_surface = !self.surface.is_xdg_window() && self.surface.is_surface(surface); - let acquisition_was_pending = self.main_layer_focus_acquiring(); + let acquisition_was_pending = self.focus.main_layer_acquiring(); if self.try_complete_main_layer_focus_acquisition(is_current_main_layer_surface) { debug!("Initial main-layer keyboard focus acquired"); self.refresh_keyboard_interactivity(); @@ -94,7 +93,7 @@ impl KeyboardHandler for WaylandState { match xdg_focus_leave_action( self.surface.is_xdg_window(), self.desktop_open_in_progress(), - self.focus_exit_suppressed(), + self.focus.exit_suppressed(Instant::now()), self.xdg_focus_loss_exits_overlay(), ) { XdgFocusLeaveAction::Ignore => {} @@ -109,14 +108,16 @@ impl KeyboardHandler for WaylandState { warn!( "Keyboard focus lost in xdg fallback; suppressing exit after clipboard action" ); - self.set_xdg_close_guard_for(Duration::from_millis(2500)); + self.focus + .guard_xdg_close_for(Instant::now(), Duration::from_millis(2500)); self.request_xdg_activation(qh); } XdgFocusLeaveAction::StayOpen => { warn!( "Keyboard focus lost in xdg fallback; keeping overlay open without auto-reactivation (ui.xdg_focus_loss_behavior=stay)" ); - self.set_xdg_close_guard_for(Duration::from_millis(2500)); + self.focus + .guard_xdg_close_for(Instant::now(), Duration::from_millis(2500)); } XdgFocusLeaveAction::Exit => { warn!("Keyboard focus lost in xdg fallback; exiting overlay"); @@ -141,7 +142,7 @@ impl KeyboardHandler for WaylandState { event: KeyEvent, ) { // Block keybinds until overlay is fully ready (prevents Ctrl+W leaking to apps) - if !self.is_overlay_ready() { + if !self.focus.is_ready() { debug!("Ignoring key press before overlay ready"); return; } @@ -174,7 +175,7 @@ impl KeyboardHandler for WaylandState { return; } if matches!(key, Key::Space) && self.should_capture_space_for_board_pan() { - self.set_board_pan_key_held(true); + self.pointer.set_board_pan_key_held(true); self.input_state.needs_redraw = true; return; } @@ -232,7 +233,7 @@ impl KeyboardHandler for WaylandState { // dispatch. Some dedicated entry modals manage or intentionally block // repeat themselves; other routed overlays (for example Help search) // still use this timer even though they disable the canvas IME. - if !modal_blocks_repeat && is_repeatable_key(key) && self.has_keyboard_focus() { + if !modal_blocks_repeat && is_repeatable_key(key) && self.focus.keyboard_focused() { self.key_repeat .arm(key, Instant::now(), Self::KEY_REPEAT_INITIAL_DELAY); } @@ -256,8 +257,8 @@ impl KeyboardHandler for WaylandState { ) { return; } - if matches!(key, Key::Space) && self.board_pan_key_held() { - self.set_board_pan_key_held(false); + if matches!(key, Key::Space) && self.pointer.board_pan_key_held() { + self.pointer.set_board_pan_key_held(false); self.input_state.needs_redraw = true; return; } @@ -456,7 +457,7 @@ impl WaylandState { /// loop otherwise sleeps until a real event and would never wake to /// repeat a held key. pub(in crate::backend::wayland) fn key_repeat_timeout(&self, now: Instant) -> Option { - self.key_repeat.timeout(now, self.has_keyboard_focus()) + self.key_repeat.timeout(now, self.focus.keyboard_focused()) } /// Fire a repeat if one is due, then reschedule from `now` (so a long @@ -468,7 +469,7 @@ impl WaylandState { qh: &QueueHandle, ) { let can_repeat = - self.has_keyboard_focus() && !self.input_state.modal_blocks_canvas_key_repeat(); + self.focus.keyboard_focused() && !self.input_state.modal_blocks_canvas_key_repeat(); let Some(key) = self .key_repeat .take_due(now, can_repeat, KEY_REPEAT_INTERVAL) @@ -483,7 +484,7 @@ impl WaylandState { /// `apply_input_key`). Shared by the manual repeat tick and sctk's /// `repeat_key`. fn dispatch_key_repeat(&mut self, key: Key, conn: &Connection, qh: &QueueHandle) { - if !self.is_overlay_ready() { + if !self.focus.is_ready() { return; } // A held key ticks the HUD chip's repeat counter at the repeat rate, @@ -493,7 +494,7 @@ impl WaylandState { if self.input_state.region_is_engaged() || self.input_state.eyedropper_is_engaged() { return; } - if matches!(key, Key::Space) && self.board_pan_key_held() { + if matches!(key, Key::Space) && self.pointer.board_pan_key_held() { return; } if self.zoom.active { diff --git a/src/backend/wayland/handlers/layer.rs b/src/backend/wayland/handlers/layer.rs index 954ae237d..f4c75567a 100644 --- a/src/backend/wayland/handlers/layer.rs +++ b/src/backend/wayland/handlers/layer.rs @@ -79,9 +79,8 @@ impl LayerShellHandler for WaylandState { self.refresh_active_output_label(); self.input_state.needs_redraw = true; - // Mark overlay ready if we already have keyboard focus (configure came after enter) - if self.has_keyboard_focus() && !self.is_overlay_ready() { - self.set_overlay_ready(true); + // Mark overlay ready if we already have keyboard focus (configure came after enter). + if self.focus.mark_ready_if_focused() { debug!("Overlay ready for keybinds (from configure)"); } diff --git a/src/backend/wayland/handlers/mod.rs b/src/backend/wayland/handlers/mod.rs index fafdf7ac9..cb32def0b 100644 --- a/src/backend/wayland/handlers/mod.rs +++ b/src/backend/wayland/handlers/mod.rs @@ -33,6 +33,7 @@ mod pointer; mod pointer_constraints; mod registry; mod relative_pointer; +mod route; mod screencopy; mod seat; mod shm; diff --git a/src/backend/wayland/handlers/output.rs b/src/backend/wayland/handlers/output.rs index b4f5a6279..3af5871dc 100644 --- a/src/backend/wayland/handlers/output.rs +++ b/src/backend/wayland/handlers/output.rs @@ -48,7 +48,7 @@ impl OutputHandler for WaylandState { debug!("Output destroyed"); self.surface.clear_output(&output); if self.surface.current_output().is_none() { - self.set_has_seen_surface_enter(false); + self.focus.clear_surface_enter(); } self.refresh_active_output_label(); // SCTK 0.20 calls this before removing the output from OutputState, so diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index e4846547c..ba51ef93c 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -117,12 +117,12 @@ impl WaylandState { pub(super) fn handle_pointer_axis( &mut self, event: &PointerEvent, - on_toolbar: bool, + routed: RoutedInput, vertical: AxisScroll, source: Option, ) { let stopped = vertical.stop; - self.handle_pointer_axis_inner(event, on_toolbar, vertical, source); + self.handle_pointer_axis_inner(event, routed, vertical, source); finalize_spotlight_wheel_if_axis_stopped( &mut self.input_state, self.spotlight.wheel_idle_deadline_mut(), @@ -133,7 +133,7 @@ impl WaylandState { fn handle_pointer_axis_inner( &mut self, event: &PointerEvent, - on_toolbar: bool, + routed: RoutedInput, vertical: AxisScroll, source: Option, ) { @@ -145,6 +145,9 @@ impl WaylandState { self.input_state .note_input_hud_scroll(scroll_direction < 0, self.input_state.modifiers); } + if routed.surface == InputSurface::Foreign { + return; + } // Handle radial menu scroll-to-thickness if self.input_state.is_radial_menu_open() { if scroll_direction != 0 { @@ -183,7 +186,8 @@ impl WaylandState { ) { return; } - let over_toolbar = on_toolbar || self.pointer_over_toolbar(); + let over_toolbar = + routed.surface == InputSurface::Toolbar || self.toolbar_chrome.pointer_over_toolbar(); let over_top_toolbar = over_toolbar && self.wheel_over_top_toolbar(&event.surface, event.position); match axis_surface_route( diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 6a61d07de..3d5fde80e 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -83,13 +83,96 @@ const fn resize_cursor(handle: SelectionHandle) -> CursorIcon { } } +trait CursorHint { + fn icon(self) -> CursorIcon; +} + +impl CursorHint for ColorPickerCursorHint { + fn icon(self) -> CursorIcon { + match self { + Self::Text => CursorIcon::Text, + Self::Crosshair => CursorIcon::Crosshair, + Self::Pointer => CursorIcon::Pointer, + Self::Default => CursorIcon::Default, + } + } +} + +impl CursorHint for BoardPickerCursorHint { + fn icon(self) -> CursorIcon { + match self { + Self::Text => CursorIcon::Text, + Self::Pointer => CursorIcon::Pointer, + Self::Grab => CursorIcon::Grab, + Self::Grabbing => CursorIcon::Grabbing, + Self::Default => CursorIcon::Default, + } + } +} + +impl CursorHint for ContextMenuCursorHint { + fn icon(self) -> CursorIcon { + match self { + Self::Pointer => CursorIcon::Pointer, + Self::Default => CursorIcon::Default, + } + } +} + +impl CursorHint for CommandPaletteCursorHint { + fn icon(self) -> CursorIcon { + match self { + Self::Text => CursorIcon::Text, + Self::Pointer => CursorIcon::Pointer, + Self::Default => CursorIcon::Default, + } + } +} + +impl CursorHint for HelpOverlayCursorHint { + fn icon(self) -> CursorIcon { + match self { + Self::Text => CursorIcon::Text, + Self::Pointer => CursorIcon::Pointer, + Self::Default => CursorIcon::Default, + } + } +} + +impl CursorHint for ToolbarCursorHint { + fn icon(self) -> CursorIcon { + match self { + Self::Pointer => CursorIcon::Pointer, + Self::Grab => CursorIcon::Grab, + Self::Default => CursorIcon::Default, + } + } +} + +fn drawing_state_cursor(state: &DrawingState) -> Option { + match state { + DrawingState::TextInput { .. } => Some(CursorIcon::Text), + DrawingState::MovingSelection { .. } | DrawingState::BendingArrow { .. } => { + Some(CursorIcon::Grabbing) + } + DrawingState::ResizingText { .. } => Some(CursorIcon::SeResize), + DrawingState::Drawing { .. } + | DrawingState::BuildingPolygon { .. } + | DrawingState::Selecting { .. } => Some(CursorIcon::Crosshair), + DrawingState::PendingTextClick { .. } => Some(CursorIcon::Default), + DrawingState::ResizingSelection { handle, .. } => Some(resize_cursor(*handle)), + DrawingState::AdjustingSpotlightMagnification { .. } => Some(CursorIcon::EwResize), + DrawingState::Idle => None, + } +} + impl WaylandState { pub(in crate::backend::wayland) fn update_pointer_cursor( &mut self, toolbar_hover: bool, conn: &Connection, ) { - if self.toolbar_dragging() && self.pointer_lock_active() { + if self.toolbar_drag.item_dragging() && self.pointer_lock_active() { self.hide_pointer_cursor(); return; } @@ -108,11 +191,15 @@ impl WaylandState { pub(super) fn refresh_screen_modal_cursor( &mut self, modal_before: bool, - on_toolbar: bool, + routed: RoutedInput, conn: &Connection, ) { if modal_before || self.input_state.screen_modal_is_active() { - self.update_pointer_cursor(on_toolbar || self.pointer_over_toolbar(), conn); + self.update_pointer_cursor( + routed.surface == InputSurface::Toolbar + || self.toolbar_chrome.pointer_over_toolbar(), + conn, + ); } } @@ -128,7 +215,7 @@ impl WaylandState { ..ScreenModalCursorContext::default() }; } - let (mouse_x, mouse_y) = self.current_mouse(); + let (mouse_x, mouse_y) = self.pointer.position(); let point = (f64::from(mouse_x), f64::from(mouse_y)); let dragging = region_state.selection_owner().is_some(); // While a grip is held its identity comes from the drag, not from @@ -156,163 +243,84 @@ impl WaylandState { if self.input_state.screen_modal_is_active() && !toolbar_hover { return screen_modal_cursor(self.screen_modal_cursor_context()); } - - // Check color picker popup first (takes priority) - if self.input_state.is_color_picker_popup_open() { - let (mx, my) = self.current_mouse(); - if let Some(layout) = self.input_state.color_picker_popup_layout() { - // When dragging on gradient, always show crosshair - if self.input_state.color_picker_popup_is_dragging() { - return CursorIcon::Crosshair; - } - let recent_count = self.input_state.recent_colors().len(); - return match layout.cursor_hint_at(mx as f64, my as f64, recent_count) { - ColorPickerCursorHint::Text => CursorIcon::Text, - ColorPickerCursorHint::Crosshair => CursorIcon::Crosshair, - ColorPickerCursorHint::Pointer => CursorIcon::Pointer, - ColorPickerCursorHint::Default => CursorIcon::Default, - }; - } + if let Some(icon) = self.popup_cursor() { + return icon; } - - // Check board picker popup - if self.input_state.is_board_picker_open() { - let (mx, my) = self.current_mouse(); - if let Some(hint) = self.input_state.board_picker_cursor_hint_at(mx, my) { - return match hint { - BoardPickerCursorHint::Text => CursorIcon::Text, - BoardPickerCursorHint::Pointer => CursorIcon::Pointer, - BoardPickerCursorHint::Grab => CursorIcon::Grab, - BoardPickerCursorHint::Grabbing => CursorIcon::Grabbing, - BoardPickerCursorHint::Default => CursorIcon::Default, - }; - } + if let Some(icon) = self.toolbar_cursor(toolbar_hover) { + return icon; } - - // Check context menu - if self.input_state.is_context_menu_open() { - let (mx, my) = self.current_mouse(); - if let Some(hint) = self.input_state.context_menu_cursor_hint_at(mx, my) { - return match hint { - ContextMenuCursorHint::Pointer => CursorIcon::Pointer, - ContextMenuCursorHint::Default => CursorIcon::Default, - }; - } + if let Some(icon) = drawing_state_cursor(&self.input_state.state) { + return icon; } + self.idle_canvas_cursor() + } - // Check command palette - if self.input_state.command_palette.open { - let (mx, my) = self.current_mouse(); - let screen_width = self.surface.width(); - let screen_height = self.surface.height(); - if let Some(hint) = - self.input_state - .command_palette_cursor_hint_at(mx, my, screen_width, screen_height) - { - return match hint { - CommandPaletteCursorHint::Text => CursorIcon::Text, - CommandPaletteCursorHint::Pointer => CursorIcon::Pointer, - CommandPaletteCursorHint::Default => CursorIcon::Default, - }; + fn popup_cursor(&self) -> Option { + let (mx, my) = self.pointer.position(); + if self.input_state.is_color_picker_popup_open() + && let Some(layout) = self.input_state.color_picker_popup_layout() + { + if self.input_state.color_picker_popup_is_dragging() { + return Some(CursorIcon::Crosshair); } + return Some( + layout + .cursor_hint_at(mx as f64, my as f64, self.input_state.recent_colors().len()) + .icon(), + ); } - - // Check help overlay - if self.input_state.help_overlay.is_visible() { - let (mx, my) = self.current_mouse(); - if let Some(hint) = self.input_state.help_overlay_cursor_hint_at(mx, my) { - return match hint { - HelpOverlayCursorHint::Text => CursorIcon::Text, - HelpOverlayCursorHint::Pointer => CursorIcon::Pointer, - HelpOverlayCursorHint::Default => CursorIcon::Default, - }; - } + if self.input_state.is_board_picker_open() + && let Some(hint) = self.input_state.board_picker_cursor_hint_at(mx, my) + { + return Some(hint.icon()); } - - if self.toolbar_dragging() { - return CursorIcon::Grabbing; + if self.input_state.is_context_menu_open() + && let Some(hint) = self.input_state.context_menu_cursor_hint_at(mx, my) + { + return Some(hint.icon()); } - if self.board_panning_active() { - return CursorIcon::Grabbing; + if self.input_state.command_palette.open + && let Some(hint) = self.input_state.command_palette_cursor_hint_at( + mx, + my, + self.surface.width(), + self.surface.height(), + ) + { + return Some(hint.icon()); } - if self.board_pan_key_held() && self.can_start_board_pan() { - return CursorIcon::Grab; + if self.input_state.help_overlay.is_visible() + && let Some(hint) = self.input_state.help_overlay_cursor_hint_at(mx, my) + { + return Some(hint.icon()); } + None + } - // Inline toolbar cursor hints (when using inline mode) - if self.inline_toolbars_active() - && self.pointer_over_toolbar() + fn toolbar_cursor(&self, toolbar_hover: bool) -> Option { + if self.toolbar_drag.item_dragging() || self.pointer.board_pan_active() { + return Some(CursorIcon::Grabbing); + } + if self.pointer.board_pan_key_held() && self.can_start_board_pan() { + return Some(CursorIcon::Grab); + } + if self.toolbar_chrome.inline_toolbars() + && self.toolbar_chrome.pointer_over_toolbar() && let Some(hint) = self.inline_toolbar_cursor_hint() { - return match hint { - ToolbarCursorHint::Pointer => CursorIcon::Pointer, - ToolbarCursorHint::Grab => CursorIcon::Grab, - ToolbarCursorHint::Default => CursorIcon::Default, - }; + return Some(hint.icon()); } - - // Layer-shell toolbar cursor hints (sliders get grab, buttons get pointer, etc.) if toolbar_hover { - if let Some(hint) = self.toolbar.cursor_hint() { - return match hint { - ToolbarCursorHint::Pointer => CursorIcon::Pointer, - ToolbarCursorHint::Grab => CursorIcon::Grab, - ToolbarCursorHint::Default => CursorIcon::Default, - }; - } - return CursorIcon::Default; - } - - // Check drawing state for context - match &self.input_state.state { - // Text input mode - show text cursor - DrawingState::TextInput { .. } => { - return CursorIcon::Text; - } - // Dragging selection - show grabbing cursor - DrawingState::MovingSelection { .. } => { - return CursorIcon::Grabbing; - } - // Resizing text - show resize cursor - DrawingState::ResizingText { .. } => { - return CursorIcon::SeResize; - } - // Drawing - use crosshair - DrawingState::Drawing { .. } => { - return CursorIcon::Crosshair; - } - DrawingState::BuildingPolygon { .. } => { - return CursorIcon::Crosshair; - } - // Selecting (marquee) - use crosshair - DrawingState::Selecting { .. } => { - return CursorIcon::Crosshair; - } - // Pending text click - use default - DrawingState::PendingTextClick { .. } => { - return CursorIcon::Default; - } - // Resizing selection - show appropriate resize cursor - DrawingState::ResizingSelection { handle, .. } => { - return resize_cursor(*handle); - } - // Dragging the loupe's magnification knob - horizontal travel only - DrawingState::AdjustingSpotlightMagnification { .. } => { - return CursorIcon::EwResize; - } - // Dragging a curved arrow's bend handle - free travel, the - // perpendicular component of which is what the arc follows - DrawingState::BendingArrow { .. } => { - return CursorIcon::Grabbing; - } - // Idle - check for hover contexts - DrawingState::Idle => {} + return Some( + self.toolbar + .cursor_hint() + .map_or(CursorIcon::Default, CursorHint::icon), + ); } + None + } - // Interactive chrome under an idle pointer: hand cursor over - // actionable chips/buttons, neutral arrow over the rest of the - // pill. Both surfaces render above the canvas, so they outrank - // selection-handle hover in the pixels they occupy. + fn idle_canvas_cursor(&mut self) -> CursorIcon { if self.input_state.status_hud.hover().is_some() || self.input_state.zoom_chip.hover().is_some() { @@ -325,12 +333,6 @@ impl WaylandState { return CursorIcon::Default; } - // Hovering an on-canvas handle. Resolved through the same routing a - // press uses, so the cursor cannot promise one operation where a click - // would start another — these handles overlap, and a bend grip on a - // shallow arc lands within a few pixels of the selection box's edge - // handle. Checked on hover as well as during the drag, or a grip would - // look inert until it was already grabbed. let (canvas_x, canvas_y) = self.input_state.canvas_pointer_position(); match self.input_state.hit_idle_handle(canvas_x, canvas_y) { Some(IdleHandle::SpotlightMagnification(_)) => return CursorIcon::EwResize, @@ -339,8 +341,6 @@ impl WaylandState { Some(IdleHandle::SelectionResize(handle)) => return resize_cursor(handle), None => {} } - - // Check if hovering over a selected shape (for move) if let Some(hit_id) = self.input_state.hit_test_at(canvas_x, canvas_y) && self .input_state @@ -349,8 +349,6 @@ impl WaylandState { { return CursorIcon::Grab; } - - // Default: crosshair for drawing CursorIcon::Crosshair } @@ -361,9 +359,18 @@ impl WaylandState { #[cfg(test)] mod tests { - use super::{ScreenModalCursorContext, screen_modal_cursor}; - use crate::input::SelectionHandle; + use super::{CursorHint, ScreenModalCursorContext, drawing_state_cursor, screen_modal_cursor}; + use crate::{ + backend::wayland::toolbar::ToolbarCursorHint, + draw::{Shape, color::BLACK, frame::ShapeSnapshot}, + input::{ + BoardPickerCursorHint, ColorPickerCursorHint, CommandPaletteCursorHint, + ContextMenuCursorHint, DrawingState, HelpOverlayCursorHint, SelectionHandle, Tool, + }, + util::Rect, + }; use smithay_client_toolkit::seat::pointer::CursorIcon; + use std::sync::Arc; fn review(context: ScreenModalCursorContext) -> ScreenModalCursorContext { ScreenModalCursorContext { @@ -372,6 +379,167 @@ mod tests { } } + fn snapshot() -> ShapeSnapshot { + ShapeSnapshot { + shape: Shape::Line { + x1: 0, + y1: 0, + x2: 1, + y2: 1, + color: BLACK, + thick: 1.0, + }, + locked: false, + } + } + + #[test] + fn every_popup_and_toolbar_hint_has_one_cursor_mapping() { + for (hint, expected) in [ + (ColorPickerCursorHint::Default, CursorIcon::Default), + (ColorPickerCursorHint::Text, CursorIcon::Text), + (ColorPickerCursorHint::Crosshair, CursorIcon::Crosshair), + (ColorPickerCursorHint::Pointer, CursorIcon::Pointer), + ] { + assert_eq!(hint.icon(), expected); + } + for (hint, expected) in [ + (BoardPickerCursorHint::Default, CursorIcon::Default), + (BoardPickerCursorHint::Text, CursorIcon::Text), + (BoardPickerCursorHint::Pointer, CursorIcon::Pointer), + (BoardPickerCursorHint::Grab, CursorIcon::Grab), + (BoardPickerCursorHint::Grabbing, CursorIcon::Grabbing), + ] { + assert_eq!(hint.icon(), expected); + } + for (hint, expected) in [ + (ContextMenuCursorHint::Default, CursorIcon::Default), + (ContextMenuCursorHint::Pointer, CursorIcon::Pointer), + ] { + assert_eq!(hint.icon(), expected); + } + for (hint, expected) in [ + (CommandPaletteCursorHint::Default, CursorIcon::Default), + (CommandPaletteCursorHint::Text, CursorIcon::Text), + (CommandPaletteCursorHint::Pointer, CursorIcon::Pointer), + ] { + assert_eq!(hint.icon(), expected); + } + for (hint, expected) in [ + (HelpOverlayCursorHint::Default, CursorIcon::Default), + (HelpOverlayCursorHint::Text, CursorIcon::Text), + (HelpOverlayCursorHint::Pointer, CursorIcon::Pointer), + ] { + assert_eq!(hint.icon(), expected); + } + for (hint, expected) in [ + (ToolbarCursorHint::Default, CursorIcon::Default), + (ToolbarCursorHint::Pointer, CursorIcon::Pointer), + (ToolbarCursorHint::Grab, CursorIcon::Grab), + ] { + assert_eq!(hint.icon(), expected); + } + } + + #[test] + fn every_drawing_state_arm_selects_its_cursor() { + let cases = [ + ( + DrawingState::TextInput { + x: 0, + y: 0, + buffer: String::new(), + caret: 0, + selection_anchor: None, + }, + Some(CursorIcon::Text), + ), + ( + DrawingState::MovingSelection { + last_x: 0, + last_y: 0, + snapshots: Vec::new(), + moved: false, + }, + Some(CursorIcon::Grabbing), + ), + ( + DrawingState::ResizingText { + shape_id: 1, + snapshot: snapshot(), + base_x: 0, + size: 12.0, + }, + Some(CursorIcon::SeResize), + ), + ( + DrawingState::Drawing { + tool: Tool::Pen, + start_x: 0, + start_y: 0, + points: Vec::new(), + point_thicknesses: Vec::new(), + }, + Some(CursorIcon::Crosshair), + ), + ( + DrawingState::BuildingPolygon { + points: Vec::new(), + preview: None, + fill: false, + color: BLACK, + thick: 1.0, + }, + Some(CursorIcon::Crosshair), + ), + ( + DrawingState::Selecting { + start_x: 0, + start_y: 0, + additive: false, + }, + Some(CursorIcon::Crosshair), + ), + ( + DrawingState::PendingTextClick { + x: 0, + y: 0, + tool: Tool::Select, + shape_id: 1, + }, + Some(CursorIcon::Default), + ), + ( + DrawingState::ResizingSelection { + handle: SelectionHandle::Right, + original_bounds: Rect::new(0, 0, 1, 1).unwrap(), + start_x: 0, + start_y: 0, + snapshots: Arc::new(Vec::new()), + }, + Some(CursorIcon::EwResize), + ), + ( + DrawingState::AdjustingSpotlightMagnification { + shape_id: 1, + snapshot: snapshot(), + }, + Some(CursorIcon::EwResize), + ), + ( + DrawingState::BendingArrow { + shape_id: 1, + snapshot: snapshot(), + }, + Some(CursorIcon::Grabbing), + ), + (DrawingState::Idle, None), + ]; + for (state, expected) in cases { + assert_eq!(drawing_state_cursor(&state), expected, "{state:?}"); + } + } + #[test] fn targeting_keeps_the_crosshair_and_window_mode_takes_the_hand() { assert_eq!( diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs index 0e8ea1d2b..bc7c78557 100644 --- a/src/backend/wayland/handlers/pointer/enter_leave.rs +++ b/src/backend/wayland/handlers/pointer/enter_leave.rs @@ -11,35 +11,35 @@ impl WaylandState { &mut self, conn: &Connection, event: &PointerEvent, - on_toolbar: bool, - inline_active: bool, + routed: RoutedInput, ) { + let on_toolbar = routed.surface == InputSurface::Toolbar; let preview_was_eligible = self.mouse_tool_preview_eligible(); debug!( "Pointer entered at ({}, {}), on_toolbar={}, is_move_dragging={}", event.position.0, event.position.1, on_toolbar, - self.is_move_dragging() + self.toolbar_drag.is_moving() ); - self.set_pointer_focus(true); - self.set_pointer_over_toolbar(on_toolbar); + self.focus.set_pointer_focused(true); + self.toolbar_chrome.set_pointer_over_toolbar(on_toolbar); if on_toolbar { - if let Some((sx, sy)) = - self.toolbar_surface_screen_coords(&event.surface, event.position) - { - self.set_current_mouse(sx as i32, sy as i32); + if let Some((sx, sy)) = routed.screen { + self.pointer.set_position((sx as i32, sy as i32)); let (wx, wy) = self.zoomed_world_coords(sx, sy); self.input_state .update_pointer_positions(sx as i32, sy as i32, wx, wy); } else { - self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); + self.pointer + .set_position((event.position.0 as i32, event.position.1 as i32)); } // Ensure pointer-driven visuals (e.g. eraser hover) update once on enter. self.input_state.needs_redraw = true; } - if !on_toolbar { - self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); + if routed.surface == InputSurface::Canvas { + self.pointer + .set_position((event.position.0 as i32, event.position.1 as i32)); let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); self.input_state.update_pointer_positions( event.position.0.round() as i32, @@ -59,7 +59,7 @@ impl WaylandState { self.input_state.clear_chrome_hover(); } self.update_pointer_cursor(on_toolbar, conn); - if inline_active { + if routed.inline_toolbars && routed.surface == InputSurface::Canvas { self.inline_toolbar_motion(event.position); } if preview_was_eligible != self.mouse_tool_preview_eligible() { @@ -67,35 +67,31 @@ impl WaylandState { } } - pub(super) fn handle_pointer_leave( - &mut self, - event: &PointerEvent, - on_toolbar: bool, - inline_active: bool, - ) { + pub(super) fn handle_pointer_leave(&mut self, event: &PointerEvent, routed: RoutedInput) { + let on_toolbar = routed.surface == InputSurface::Toolbar; let preview_was_eligible = self.mouse_tool_preview_eligible(); debug!( "Pointer left surface: on_toolbar={}, is_move_dragging={}", on_toolbar, - self.is_move_dragging() + self.toolbar_drag.is_moving() ); - self.set_pointer_focus(false); + self.focus.set_pointer_focused(false); // The pointer is gone, so no further wheel tick can extend the burst. self.input_state.flush_spotlight_magnification_gesture(); self.spotlight.clear_wheel_idle_deadline(); - if !on_toolbar { + if routed.surface == InputSurface::Canvas { self.cancel_region_selection_from(RegionInputSource::Pointer); } if on_toolbar { - self.set_pointer_over_toolbar(false); + self.toolbar_chrome.set_pointer_over_toolbar(false); self.toolbar.pointer_leave(&event.surface); // Don't clear drag state if we're in a move drag - the user may be // dragging the toolbar and their pointer left the toolbar surface. // The drag will continue on the main surface. - if !self.is_move_dragging() { + if !self.toolbar_drag.is_moving() { debug!("Clearing toolbar drag state on leave"); self.finish_toolbar_item_drag(false); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.cancel_toolbar_move_drag(); } else { debug!("Preserving move drag state on toolbar leave"); @@ -104,16 +100,16 @@ impl WaylandState { // Ensure pointer-driven visuals (e.g. eraser hover) update once on leave. self.input_state.needs_redraw = true; } - if !on_toolbar + if routed.surface == InputSurface::Canvas && self.input_state.style.eraser_mode == EraserMode::Stroke && self.input_state.active_tool() == Tool::Eraser { self.input_state.needs_redraw = true; } - if inline_active { + if routed.inline_toolbars { self.inline_toolbar_leave(); } - if (on_toolbar || inline_active) && !self.is_move_dragging() { + if (on_toolbar || routed.inline_toolbars) && !self.toolbar_drag.is_moving() { self.end_toolbar_move_drag(); } if preview_was_eligible != self.mouse_tool_preview_eligible() { diff --git a/src/backend/wayland/handlers/pointer/mod.rs b/src/backend/wayland/handlers/pointer/mod.rs index 50041ee9d..dbe5a9b65 100644 --- a/src/backend/wayland/handlers/pointer/mod.rs +++ b/src/backend/wayland/handlers/pointer/mod.rs @@ -7,6 +7,7 @@ use crate::backend::wayland::state::{debug_toolbar_drag_logging_enabled, surface use crate::input::state::RegionInputSource; use super::super::state::WaylandState; +use super::route::{InputSurface, RoutedInput}; mod axis; mod cursor; @@ -24,48 +25,47 @@ impl PointerHandler for WaylandState { events: &[PointerEvent], ) { for event in events { - let on_toolbar = self.toolbar.is_toolbar_surface(&event.surface); - let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); + let routed = self.route_input(&event.surface, event.position); if debug_toolbar_drag_logging_enabled() { debug!( "pointer {:?}: seat={:?}, surface={}, on_toolbar={}, inline_active={}, pos=({:.1}, {:.1}), drag_active={}, toolbar_dragging={}, pointer_over_toolbar={}", event.kind, - self.current_seat_id(), + self.focus.current_seat_id(), surface_id(&event.surface), - on_toolbar, - inline_active, + routed.surface == InputSurface::Toolbar, + routed.inline_toolbars, event.position.0, event.position.1, - self.is_move_dragging(), - self.toolbar_dragging(), - self.pointer_over_toolbar() + self.toolbar_drag.is_moving(), + self.toolbar_drag.item_dragging(), + self.toolbar_chrome.pointer_over_toolbar() ); } match event.kind { PointerEventKind::Enter { .. } => { - self.handle_pointer_enter(conn, event, on_toolbar, inline_active); + self.handle_pointer_enter(conn, event, routed); } PointerEventKind::Leave { .. } => { - self.handle_pointer_leave(event, on_toolbar, inline_active); + self.handle_pointer_leave(event, routed); } PointerEventKind::Motion { .. } => { - self.handle_pointer_motion(conn, event, on_toolbar, inline_active); + self.handle_pointer_motion(conn, event, routed); } PointerEventKind::Press { button, serial, .. } => { - self.set_last_activation_serial(Some(serial)); + self.focus.note_activation_serial(serial); let modal_before = self.input_state.screen_modal_is_active(); - self.handle_pointer_press(conn, qh, event, on_toolbar, inline_active, button); - self.refresh_screen_modal_cursor(modal_before, on_toolbar, conn); + self.handle_pointer_press(conn, qh, event, routed, button); + self.refresh_screen_modal_cursor(modal_before, routed, conn); } PointerEventKind::Release { button, .. } => { let modal_before = self.input_state.screen_modal_is_active(); - self.handle_pointer_release(event, on_toolbar, inline_active, button); - self.refresh_screen_modal_cursor(modal_before, on_toolbar, conn); + self.handle_pointer_release(event, routed, button); + self.refresh_screen_modal_cursor(modal_before, routed, conn); } PointerEventKind::Axis { vertical, source, .. } => { - self.handle_pointer_axis(event, on_toolbar, vertical, source); + self.handle_pointer_axis(event, routed, vertical, source); } } } diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index 64ff79d71..60adb0a9b 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -2,8 +2,7 @@ use log::debug; use smithay_client_toolkit::seat::pointer::PointerEvent; use wayland_client::Connection; -use crate::backend::wayland::state::PerfInputSource; -use crate::backend::wayland::state::drag_log; +use crate::backend::wayland::state::{PerfInputSource, drag_log}; use crate::backend::wayland::toolbar_intent::intent_to_event; use super::*; @@ -13,234 +12,234 @@ impl WaylandState { &mut self, conn: &Connection, event: &PointerEvent, - on_toolbar: bool, - inline_active: bool, + routed: RoutedInput, ) { - if self.try_handle_region_pointer_motion(conn, event, on_toolbar) { + if self.motion_owned_by_screen_modal(conn, routed) + || self.motion_owned_by_move_drag(conn, event, routed) + || self.motion_owned_by_radial_menu(conn, routed) + || self.motion_over_toolbar(conn, event, routed) + || self.motion_owned_by_pan(conn, routed) + { return; } + if routed.surface == InputSurface::Canvas { + self.motion_on_canvas(conn, event, routed.screen.unwrap_or(event.position)); + } + } - if self.try_handle_eyedropper_pointer_motion(conn, event, on_toolbar) { - return; + fn motion_owned_by_screen_modal(&mut self, conn: &Connection, routed: RoutedInput) -> bool { + if self.input_state.region_is_active() { + if let Some((x, y)) = routed.screen { + self.pointer + .set_position((x.round() as i32, y.round() as i32)); + self.update_region_selection(RegionInputSource::Pointer, x, y); + } + self.update_pointer_cursor( + routed.surface == InputSurface::Toolbar + || self.toolbar_chrome.pointer_over_toolbar(), + conn, + ); + return true; + } + if !self.input_state.eyedropper_is_active() { + return false; } + let inline_hover = routed.surface == InputSurface::Canvas + && routed.inline_toolbars + && routed + .screen + .is_some_and(|position| self.inline_toolbar_motion(position)); + if let Some((x, y)) = routed.screen { + self.pointer + .set_position((x.round() as i32, y.round() as i32)); + self.update_eyedropper_hover(x, y); + } + self.update_pointer_cursor( + routed.surface == InputSurface::Toolbar + || inline_hover + || self.toolbar_chrome.pointer_over_toolbar(), + conn, + ); + true + } - if self.is_move_dragging() - && let Some(kind) = self.active_move_drag_kind() - { + fn motion_owned_by_move_drag( + &mut self, + conn: &Connection, + event: &PointerEvent, + routed: RoutedInput, + ) -> bool { + if let Some(kind) = self.toolbar_drag.kind() { drag_log(|| { format!( - "pointer motion: drag_active kind={:?}, pos=({:.3}, {:.3}), on_toolbar={}, inline_active={}", - kind, event.position.0, event.position.1, on_toolbar, inline_active + "pointer motion: drag_active kind={:?}, pos=({:.3}, {:.3}), surface={:?}, inline_active={}", + kind, + event.position.0, + event.position.1, + routed.surface, + routed.inline_toolbars ) }); debug!( - "Move drag motion: kind={:?}, pos=({}, {}), on_toolbar={}", - kind, event.position.0, event.position.1, on_toolbar + "Move drag motion: kind={:?}, pos=({}, {}), surface={:?}", + kind, event.position.0, event.position.1, routed.surface ); - // On toolbar surface: coords are toolbar-local, need conversion - // On main surface: coords are already screen-relative (fullscreen overlay) - if on_toolbar { - self.handle_toolbar_move(kind, event.position); - } else { - self.handle_toolbar_move_screen(kind, event.position); + match routed.surface { + InputSurface::Toolbar => self.handle_toolbar_move(kind, event.position), + InputSurface::Canvas => self.handle_toolbar_move_screen(kind, event.position), + InputSurface::Foreign => return true, } self.toolbar.mark_dirty(); - if inline_active { + if routed.inline_toolbars { self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; } - return; + return true; } - // An open radial menu owns pointer motion everywhere on screen: - // flick sampling and wedge hover must keep working when the pointer - // crosses a toolbar region, so bypass the toolbar gates below until - // the menu closes. - if self.input_state.is_radial_menu_open() && !self.is_move_dragging() { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((sx, sy)) = screen_position { - self.set_current_mouse(sx.round() as i32, sy.round() as i32); - let (wx, wy) = self.zoomed_world_coords(sx, sy); - self.input_state.update_pointer_positions( - sx.round() as i32, - sy.round() as i32, - wx, - wy, - ); - self.input_state.on_mouse_motion_with_canvas( - sx.round() as i32, - sy.round() as i32, - wx, - wy, - ); - self.update_pointer_cursor(false, conn); - return; - } + if !self.toolbar_drag.is_moving() || routed.surface != InputSurface::Canvas { + return false; } - if inline_active && self.inline_toolbar_motion(event.position) { - self.update_pointer_cursor(true, conn); - return; + if let Some(intent) = self.move_drag_intent(event.position.0, event.position.1) { + let event = intent_to_event(intent, self.toolbar.last_snapshot()); + self.handle_toolbar_event(event, None, None); + self.toolbar.mark_dirty(); + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; } - if on_toolbar { - self.set_pointer_over_toolbar(true); - if let Some((sx, sy)) = - self.toolbar_surface_screen_coords(&event.surface, event.position) - { - self.set_current_mouse(sx as i32, sy as i32); - let (wx, wy) = self.zoomed_world_coords(sx, sy); - self.input_state - .update_pointer_positions(sx as i32, sy as i32, wx, wy); - } - let evt = self.toolbar.pointer_motion(&event.surface, event.position); - if self.toolbar_dragging() { - // Use move_drag_intent if pointer_motion didn't return an intent - // This allows dragging to continue when mouse moves outside hit region - let intent = - evt.or_else(|| self.move_drag_intent(event.position.0, event.position.1)); - if let Some(intent) = intent { - let evt = intent_to_event(intent, self.toolbar.last_snapshot()); - self.handle_toolbar_event(evt, None, None); - } - } else { - self.toolbar.mark_dirty(); - } - if inline_active { - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - } - self.refresh_keyboard_interactivity(); - self.update_pointer_cursor(true, conn); - return; + self.update_pointer_cursor(false, conn); + true + } + + fn motion_owned_by_radial_menu(&mut self, conn: &Connection, routed: RoutedInput) -> bool { + if !self.input_state.is_radial_menu_open() || self.toolbar_drag.is_moving() { + return false; } - if self.pointer_over_toolbar() { - self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); - let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_positions( - event.position.0.round() as i32, - event.position.1.round() as i32, - wx, - wy, - ); - let evt = self.toolbar.pointer_motion(&event.surface, event.position); - if self.toolbar_dragging() { - // Use move_drag_intent if pointer_motion didn't return an intent - // This allows dragging to continue when mouse moves outside hit region - let intent = - evt.or_else(|| self.move_drag_intent(event.position.0, event.position.1)); - if let Some(intent) = intent { - let evt = intent_to_event(intent, self.toolbar.last_snapshot()); - self.handle_toolbar_event(evt, None, None); - } - } else { - self.toolbar.mark_dirty(); - } - if inline_active { - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; - } - self.refresh_keyboard_interactivity(); + let Some((sx, sy)) = routed.screen else { + return false; + }; + self.pointer + .set_position((sx.round() as i32, sy.round() as i32)); + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state + .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); + self.input_state + .on_mouse_motion_with_canvas(sx.round() as i32, sy.round() as i32, wx, wy); + self.update_pointer_cursor(false, conn); + true + } + + fn motion_over_toolbar( + &mut self, + conn: &Connection, + event: &PointerEvent, + routed: RoutedInput, + ) -> bool { + if routed.surface == InputSurface::Canvas + && routed.inline_toolbars + && self.inline_toolbar_motion(event.position) + { self.update_pointer_cursor(true, conn); - return; + return true; } - // Handle move drag that continues on the main surface after leaving toolbar - if self.is_move_dragging() { - if let Some(intent) = self.move_drag_intent(event.position.0, event.position.1) { - let evt = intent_to_event(intent, self.toolbar.last_snapshot()); - self.handle_toolbar_event(evt, None, None); - self.toolbar.mark_dirty(); - self.input_state.dirty_tracker.mark_full(); - self.input_state.needs_redraw = true; + let toolbar_surface = routed.surface == InputSurface::Toolbar; + if !toolbar_surface && !self.toolbar_chrome.pointer_over_toolbar() { + return false; + } + self.toolbar_chrome.set_pointer_over_toolbar(true); + if let Some((sx, sy)) = routed.screen { + self.pointer.set_position((sx as i32, sy as i32)); + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state + .update_pointer_positions(sx as i32, sy as i32, wx, wy); + } + self.update_toolbar_pointer_motion(event); + if routed.inline_toolbars { + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } + self.refresh_keyboard_interactivity(); + self.update_pointer_cursor(true, conn); + true + } + + fn update_toolbar_pointer_motion(&mut self, event: &PointerEvent) { + let toolbar_event = self.toolbar.pointer_motion(&event.surface, event.position); + if self.toolbar_drag.item_dragging() { + let intent = + toolbar_event.or_else(|| self.move_drag_intent(event.position.0, event.position.1)); + if let Some(intent) = intent { + let event = intent_to_event(intent, self.toolbar.last_snapshot()); + self.handle_toolbar_event(event, None, None); } - self.update_pointer_cursor(false, conn); - return; + } else { + self.toolbar.mark_dirty(); } + } + + fn motion_owned_by_pan(&mut self, conn: &Connection, routed: RoutedInput) -> bool { + let Some((sx, sy)) = routed + .screen + .filter(|_| routed.surface == InputSurface::Canvas) + else { + return false; + }; if self.zoom.panning { - self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); - let (dx, dy) = self - .zoom - .update_pan_position(event.position.0, event.position.1); + self.pointer.set_position((sx as i32, sy as i32)); + let (dx, dy) = self.zoom.update_pan_position(sx, sy); self.zoom .pan_by_screen_delta(dx, dy, self.surface.width(), self.surface.height()); self.sync_input_zoom_state(); - let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_positions( - event.position.0.round() as i32, - event.position.1.round() as i32, - wx, - wy, - ); + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state + .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; self.update_pointer_cursor(false, conn); - return; + return true; } - if self.board_panning_active() { - self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); - let (dx, dy) = self.update_board_pan_position(event.position.0, event.position.1); - let _ = self.pan_board_by_screen_delta(dx, dy); - let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_positions( - event.position.0.round() as i32, - event.position.1.round() as i32, - wx, - wy, - ); - self.update_pointer_cursor(false, conn); - return; + if !self.pointer.board_pan_active() { + return false; } - // Capture the pre-motion pointer so the idle tool-preview bubble can - // damage its old position; the new position is the incoming event. - let prev_mouse = self.current_mouse(); - let next_mouse = (event.position.0 as i32, event.position.1 as i32); - self.set_current_mouse(next_mouse.0, next_mouse.1); - // The command palette owns hover rendering (including shortcut-action - // tooltips), so keep its screen-space pointer cache and redraw current - // even though normal canvas motion remains blocked by the modal. + self.pointer.set_position((sx as i32, sy as i32)); + let (dx, dy) = self.pointer.advance_board_pan((sx, sy)); + let _ = self.pan_board_by_screen_delta(dx, dy); + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state + .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); + self.update_pointer_cursor(false, conn); + true + } + + fn motion_on_canvas( + &mut self, + conn: &Connection, + event: &PointerEvent, + screen_position: (f64, f64), + ) { + let (sx, sy) = screen_position; + let previous = self.pointer.position(); + let next = (sx as i32, sy as i32); + self.pointer.set_position(next); if self.input_state.command_palette.open { - let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_positions( - event.position.0.round() as i32, - event.position.1.round() as i32, - wx, - wy, - ); + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state + .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; self.update_pointer_cursor(false, conn); return; } - // Block normal pointer motion while the tour modal is active. if self.input_state.tour.is_active() { self.update_pointer_cursor(false, conn); return; } - let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_positions( - event.position.0.round() as i32, - event.position.1.round() as i32, - wx, - wy, - ); - self.input_state.on_mouse_motion_with_canvas( - event.position.0.round() as i32, - event.position.1.round() as i32, - wx, - wy, - ); - // Hover classification above consumes the incoming coordinates. Pick - // the icon only now so entering or leaving a HUD/zoom-chip button is - // reflected by this same motion event, even when the pointer stops. + let (wx, wy) = self.zoomed_world_coords(sx, sy); + self.input_state + .update_pointer_positions(sx.round() as i32, sy.round() as i32, wx, wy); + self.input_state + .on_mouse_motion_with_canvas(sx.round() as i32, sy.round() as i32, wx, wy); self.update_pointer_cursor(false, conn); - // Idle pointer motion otherwise only refreshes cached coordinates, so - // the trailing tool-preview bubble would freeze at its previous spot - // (stroke/eraser hover redraws are handled above). Damage the old and - // new bubble footprints so it follows the cursor. Evaluated after the - // motion so a drag that started a stroke (state -> Drawing) is not - // treated as an eligible preview. - self.mark_mouse_tool_preview_dirty(prev_mouse, next_mouse); + self.mark_mouse_tool_preview_dirty(previous, next); self.record_perf_input_sample( PerfInputSource::Pointer, event.position.0.round() as i32, @@ -250,55 +249,4 @@ impl WaylandState { false, ); } - - fn try_handle_region_pointer_motion( - &mut self, - conn: &Connection, - event: &PointerEvent, - on_toolbar: bool, - ) -> bool { - if !self.input_state.region_is_active() { - return false; - } - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((x, y)) = screen_position { - self.set_current_mouse(x.round() as i32, y.round() as i32); - self.update_region_selection(RegionInputSource::Pointer, x, y); - } - self.update_pointer_cursor(on_toolbar || self.pointer_over_toolbar(), conn); - true - } - - fn try_handle_eyedropper_pointer_motion( - &mut self, - conn: &Connection, - event: &PointerEvent, - on_toolbar: bool, - ) -> bool { - if !self.input_state.eyedropper_is_active() { - return false; - } - let inline_hover = !on_toolbar - && self.inline_toolbars_active() - && self.toolbar.is_visible() - && self.inline_toolbar_motion(event.position); - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((x, y)) = screen_position { - self.set_current_mouse(x.round() as i32, y.round() as i32); - self.update_eyedropper_hover(x, y); - } - self.update_pointer_cursor( - on_toolbar || inline_hover || self.pointer_over_toolbar(), - conn, - ); - true - } } diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 384f411aa..4d118b3d3 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -6,7 +6,6 @@ use crate::backend::wayland::state::{RegionReviewPress, drag_log}; use crate::backend::wayland::toolbar_intent::intent_to_event; use crate::input::MouseButton; use crate::input::state::HelpOverlayPressSource; -use crate::ui::ZoomChipPress; use crate::ui::toolbar::ToolbarEvent; use super::*; @@ -22,10 +21,11 @@ impl WaylandState { conn: &wayland_client::Connection, qh: &QueueHandle, event: &PointerEvent, - on_toolbar: bool, - inline_active: bool, + routed: RoutedInput, button: u32, ) { + let on_toolbar = routed.surface == InputSurface::Toolbar; + let inline_active = routed.inline_toolbars; // Report the physical button to the input HUD before any modal or // toolbar routing consumes it. GTK toolbar surfaces are separate // windows and never reach this handler, so their clicks only show in @@ -47,6 +47,10 @@ impl WaylandState { .clear_help_overlay_press_for(help_press_source); } + if routed.surface == InputSurface::Foreign { + return; + } + if self.handle_region_pointer_press(event, on_toolbar, button) { return; } @@ -55,7 +59,7 @@ impl WaylandState { return; } - if self.handle_modal_pointer_press(event, on_toolbar, button, help_press_source) { + if self.handle_modal_pointer_press(event, routed, button, help_press_source) { return; } @@ -71,7 +75,7 @@ impl WaylandState { button, on_toolbar, inline_active, - self.is_move_dragging() + self.toolbar_drag.is_moving() ); } if inline_active && self.handle_inline_pointer_press(conn, qh, event, button) { @@ -80,9 +84,9 @@ impl WaylandState { if on_toolbar { self.handle_toolbar_pointer_press(conn, qh, event, button); return; - } else if self.pointer_over_toolbar() { + } else if self.toolbar_chrome.pointer_over_toolbar() { self.finish_toolbar_item_drag(false); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); return; } @@ -90,7 +94,12 @@ impl WaylandState { return; } - if button == BTN_LEFT && self.handle_overlay_pointer_press(event.position) { + if button == BTN_LEFT + && self.press_overlay_chrome( + event.position.0.round() as i32, + event.position.1.round() as i32, + ) + { return; } @@ -104,8 +113,9 @@ impl WaylandState { self.input_state.needs_redraw = true; return; } - if button == BTN_LEFT && self.board_pan_key_held() && self.can_start_board_pan() { - self.start_board_pan(event.position.0, event.position.1); + if button == BTN_LEFT && self.pointer.board_pan_key_held() && self.can_start_board_pan() { + self.pointer + .start_board_pan((event.position.0, event.position.1)); self.input_state.needs_redraw = true; return; } @@ -134,7 +144,7 @@ impl WaylandState { if !self.input_state.region_is_active() { return false; } - if on_toolbar || self.pointer_over_toolbar() { + if on_toolbar || self.toolbar_chrome.pointer_over_toolbar() { // A toolbar interaction ends the region first, then runs normally; // the click never lands on the selector. self.cancel_region_for_toolbar_interaction(); @@ -152,14 +162,14 @@ impl WaylandState { } RegionReviewPress::Consumed { suppress_release } => { if suppress_release { - self.suppress_next_release_from(RegionInputSource::Pointer); + self.pointer.suppress_release(RegionInputSource::Pointer); } } } } BTN_RIGHT => { self.cancel_active_region_selector(); - self.suppress_next_release_from(RegionInputSource::Pointer); + self.pointer.suppress_release(RegionInputSource::Pointer); } _ => {} } @@ -175,18 +185,18 @@ impl WaylandState { if !self.input_state.eyedropper_is_active() { return false; } - if on_toolbar || self.pointer_over_toolbar() { + if on_toolbar || self.toolbar_chrome.pointer_over_toolbar() { self.cancel_eyedropper(); return false; } match button { BTN_LEFT => { self.sample_eyedropper(event.position.0, event.position.1); - self.suppress_next_release_from(RegionInputSource::Pointer); + self.pointer.suppress_release(RegionInputSource::Pointer); } BTN_RIGHT => { self.cancel_eyedropper(); - self.suppress_next_release_from(RegionInputSource::Pointer); + self.pointer.suppress_release(RegionInputSource::Pointer); } _ => {} } @@ -196,7 +206,7 @@ impl WaylandState { fn handle_modal_pointer_press( &mut self, event: &PointerEvent, - on_toolbar: bool, + routed: RoutedInput, button: u32, help_press_source: HelpOverlayPressSource, ) -> bool { @@ -205,12 +215,7 @@ impl WaylandState { } // Help is modal: remember the target so release can require the same row. if self.input_state.help_overlay.is_visible() { - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - match screen_position { + match routed.screen { Some((sx, sy)) => self.input_state.note_help_overlay_press( help_press_source, sx.round() as i32, @@ -234,7 +239,7 @@ impl WaylandState { self.surface.height(), ); if handled { - self.suppress_next_release_from(RegionInputSource::Pointer); + self.pointer.suppress_release(RegionInputSource::Pointer); } } true @@ -257,18 +262,18 @@ impl WaylandState { drag_log(|| { format!( "pointer press: inline handled, drag_active={}, pos=({:.3}, {:.3}), surface={}", - self.toolbar_dragging(), + self.toolbar_drag.item_dragging(), event.position.0, event.position.1, surface_id(&event.surface) ) }); - if self.is_move_dragging() { + if self.toolbar_drag.is_moving() { self.lock_pointer_for_drag(qh, &event.surface); } return true; } - if !self.pointer_over_toolbar() { + if !self.toolbar_chrome.pointer_over_toolbar() { return false; } if button == BTN_LEFT { @@ -323,10 +328,10 @@ impl WaylandState { "toolbar press: drag_start={}, surface={}, seat={:?}, inline_active={}", drag, surface_id(&event.surface), - self.current_seat_id(), - self.inline_toolbars_active() + self.focus.current_seat_id(), + self.toolbar_chrome.inline_toolbars() ); - self.set_toolbar_dragging(drag); + self.toolbar_drag.set_item_dragging(drag); self.handle_toolbar_event(toolbar_event, Some(conn), Some(qh)); self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; @@ -334,26 +339,23 @@ impl WaylandState { true } - fn handle_overlay_pointer_press(&mut self, position: (f64, f64)) -> bool { - let screen_x = position.0.round() as i32; - let screen_y = position.1.round() as i32; - self.set_pending_toast_press(None); + pub(in crate::backend::wayland) fn press_overlay_chrome( + &mut self, + screen_x: i32, + screen_y: i32, + ) -> bool { + self.pointer.clear_chrome_press(); if let Some(pressed) = self.input_state.toast_press_at(screen_x, screen_y) { - self.set_pending_toast_press(Some(pressed)); - return true; + return self.pointer.arm_toast_press(pressed); } - self.set_pending_status_hud_press(false); if self.input_state.status_hud_contains(screen_x, screen_y) { - self.set_pending_status_hud_press(true); - return true; + return self.pointer.arm_status_hud_press(); } - self.set_pending_zoom_chip_press(ZoomChipPress::None); if !self.input_state.zoom_chip_contains(screen_x, screen_y) { return false; } - let pressed = self.input_state.zoom_chip_press_at(screen_x, screen_y); - self.set_pending_zoom_chip_press(pressed); - true + self.pointer + .arm_zoom_chip_press(self.input_state.zoom_chip_press_at(screen_x, screen_y)) } fn try_dispatch_pointer_shortcut(&mut self, button: u32) -> bool { @@ -422,7 +424,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn dismiss_top_toolbar_menus(&mut self) -> bool { let changed = self.input_state.close_top_toolbar_menus(); if changed { - if self.inline_toolbars_active() { + if self.toolbar_chrome.inline_toolbars() { self.mark_inline_toolbar_full_damage(); } else { self.toolbar.mark_dirty(); diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index 51c62a994..fce313a0f 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -12,10 +12,11 @@ impl WaylandState { pub(super) fn handle_pointer_release( &mut self, event: &PointerEvent, - on_toolbar: bool, - inline_active: bool, + routed: RoutedInput, button: u32, ) { + let on_toolbar = routed.surface == InputSurface::Toolbar; + let inline_active = routed.inline_toolbars; if self .input_state .take_consumed_pointer_shortcut_button(button) @@ -23,7 +24,7 @@ impl WaylandState { return; } - if self.handle_region_pointer_release(event, on_toolbar, button) { + if self.handle_region_pointer_release(routed, button) { return; } @@ -32,8 +33,11 @@ impl WaylandState { } // Swallow releases after modal clicks (e.g., palette dismiss) - if self.take_suppressed_release_from(crate::input::state::RegionInputSource::Pointer) { - self.clear_pending_overlay_presses(); + if self + .pointer + .take_suppressed_release(crate::input::state::RegionInputSource::Pointer) + { + self.pointer.clear_chrome_press(); return; } @@ -41,19 +45,22 @@ impl WaylandState { // swallowed by help must not leak its release into a newly opened // popup. Conversely, a press that preceded help has no owner and falls // through to finish its original gesture. - if self.handle_help_pointer_release(event, on_toolbar, button) { - self.clear_pending_overlay_presses(); + if self.handle_help_pointer_release(routed, button) { + self.pointer.clear_chrome_press(); return; } // Block pointer input when modal overlays are active if self.input_state.command_palette.open || self.input_state.tour.is_active() { // For command palette, press handles the click - release is a no-op - self.clear_pending_overlay_presses(); + self.pointer.clear_chrome_press(); return; } - if self.handle_pending_overlay_release(event, on_toolbar, button) { + if self.handle_pending_overlay_release(routed, button) { + return; + } + if routed.surface == InputSurface::Foreign { return; } @@ -63,9 +70,9 @@ impl WaylandState { button, on_toolbar, inline_active, - self.is_move_dragging(), - self.toolbar_dragging(), - self.pointer_over_toolbar() + self.toolbar_drag.is_moving(), + self.toolbar_drag.item_dragging(), + self.toolbar_chrome.pointer_over_toolbar() ); } // An open radial menu owns pointer releases everywhere on screen: a @@ -73,18 +80,18 @@ impl WaylandState { // still commit (or cancel) instead of being swallowed by the toolbar // gates below. The radial release router consumes every button while // the menu is open, so nothing leaks through to canvas handling. - if self.handle_radial_pointer_release(event, on_toolbar, button) { + if self.handle_radial_pointer_release(routed, button) { return; } if inline_active && self.handle_inline_pointer_release(event, button) { return; } - if on_toolbar || self.pointer_over_toolbar() { + if on_toolbar || self.toolbar_chrome.pointer_over_toolbar() { self.handle_toolbar_pointer_release(event, button); return; } // End move drag if released on the main surface - if button == BTN_LEFT && self.is_move_dragging() { + if button == BTN_LEFT && self.toolbar_drag.is_moving() { self.finish_main_surface_drag(event); return; } @@ -97,8 +104,8 @@ impl WaylandState { } return; } - if button == BTN_LEFT && self.board_panning_active() { - self.stop_board_pan(); + if button == BTN_LEFT && self.pointer.board_pan_active() { + self.pointer.stop_board_pan(); self.input_state.needs_redraw = true; return; } @@ -118,55 +125,32 @@ impl WaylandState { self.input_state.needs_redraw = true; } - fn handle_region_pointer_release( - &mut self, - event: &PointerEvent, - on_toolbar: bool, - button: u32, - ) -> bool { + fn handle_region_pointer_release(&mut self, routed: RoutedInput, button: u32) -> bool { if !self.input_state.region_is_active() { return false; } if button != BTN_LEFT { return true; } - if self.take_suppressed_release_from(RegionInputSource::Pointer) { + if self + .pointer + .take_suppressed_release(RegionInputSource::Pointer) + { return true; } - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - if let Some((x, y)) = screen_position { + if let Some((x, y)) = routed.screen { self.finish_region_selection(RegionInputSource::Pointer, x, y); } true } - fn clear_pending_overlay_presses(&mut self) { - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); - } - - fn handle_help_pointer_release( - &mut self, - event: &PointerEvent, - on_toolbar: bool, - button: u32, - ) -> bool { + fn handle_help_pointer_release(&mut self, routed: RoutedInput, button: u32) -> bool { let source = HelpOverlayPressSource::Pointer(button); if button != BTN_LEFT { // Non-left help presses are modal-owned but never resolve rows. return self.input_state.clear_help_overlay_press_for(source); } - let screen_position = if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) - }; - match screen_position { + match routed.screen { Some((sx, sy)) => { self.handle_help_overlay_release(source, sx.round() as i32, sy.round() as i32) } @@ -174,107 +158,54 @@ impl WaylandState { } } - fn handle_pending_overlay_release( - &mut self, - event: &PointerEvent, - on_toolbar: bool, - button: u32, - ) -> bool { + fn handle_pending_overlay_release(&mut self, routed: RoutedInput, button: u32) -> bool { if button != BTN_LEFT { return false; } - if let Some(pressed) = self.take_pending_toast_press() { - self.resolve_toast_pointer_release(event, on_toolbar, pressed); - return true; - } - if self.take_pending_status_hud_press() { - self.resolve_status_hud_pointer_release(event, on_toolbar); - return true; - } - let pressed = self.take_pending_zoom_chip_press(); - if !pressed.is_pending() { + let Some((screen_x, screen_y)) = routed.screen else { + self.pointer.clear_chrome_press(); return false; - } - if let ZoomChipPress::Button(kind) = pressed { - self.resolve_zoom_chip_pointer_release(event, on_toolbar, kind); - } - true - } - - fn resolve_toast_pointer_release( - &mut self, - event: &PointerEvent, - on_toolbar: bool, - pressed: crate::input::state::ToastPress, - ) { - let Some((screen_x, screen_y)) = self.pointer_release_screen_position(event, on_toolbar) - else { - return; }; - let (hit, action) = self.input_state.resolve_toast_release( - pressed, - screen_x.round() as i32, - screen_y.round() as i32, - ); - if hit && let Some(command) = action { - self.handle_toast_command(command); - } - } - - fn resolve_status_hud_pointer_release(&mut self, event: &PointerEvent, on_toolbar: bool) { - let Some((screen_x, screen_y)) = self.pointer_release_screen_position(event, on_toolbar) - else { - return; - }; - let (hit, action) = self - .input_state - .check_status_hud_click(screen_x.round() as i32, screen_y.round() as i32); - if hit && let Some(action) = action { - self.dispatch_input_action(action); - } + self.release_overlay_chrome(screen_x.round() as i32, screen_y.round() as i32) } - fn resolve_zoom_chip_pointer_release( + pub(in crate::backend::wayland) fn release_overlay_chrome( &mut self, - event: &PointerEvent, - on_toolbar: bool, - kind: crate::ui::ZoomChipButtonKind, - ) { - let Some((screen_x, screen_y)) = self.pointer_release_screen_position(event, on_toolbar) - else { - return; - }; - let (_, action) = self.input_state.check_zoom_chip_click( - kind, - screen_x.round() as i32, - screen_y.round() as i32, - ); - if let Some(action) = action { - self.dispatch_input_action(action); + screen_x: i32, + screen_y: i32, + ) -> bool { + if let Some(pressed) = self.pointer.take_toast_press() { + let (hit, action) = self + .input_state + .resolve_toast_release(pressed, screen_x, screen_y); + if hit && let Some(command) = action { + self.handle_toast_command(command); + } + return true; } - } - - fn pointer_release_screen_position( - &self, - event: &PointerEvent, - on_toolbar: bool, - ) -> Option<(f64, f64)> { - if on_toolbar { - self.toolbar_surface_screen_coords(&event.surface, event.position) - } else { - Some(event.position) + if self.pointer.take_status_hud_press() { + let (hit, action) = self.input_state.check_status_hud_click(screen_x, screen_y); + if hit && let Some(action) = action { + self.dispatch_input_action(action); + } + return true; } + let pressed = self.pointer.take_zoom_chip_press(); + if let ZoomChipPress::Button(kind) = pressed { + let (_, action) = self + .input_state + .check_zoom_chip_click(kind, screen_x, screen_y); + if let Some(action) = action { + self.dispatch_input_action(action); + } + } + pressed.is_pending() } - fn handle_radial_pointer_release( - &mut self, - event: &PointerEvent, - on_toolbar: bool, - button: u32, - ) -> bool { + fn handle_radial_pointer_release(&mut self, routed: RoutedInput, button: u32) -> bool { if !self.input_state.is_radial_menu_open() - || self.is_move_dragging() - || self.toolbar_dragging() + || self.toolbar_drag.is_moving() + || self.toolbar_drag.item_dragging() { return false; } @@ -284,8 +215,7 @@ impl WaylandState { BTN_RIGHT => Some(MouseButton::Right), _ => None, }; - let screen_position = self.pointer_release_screen_position(event, on_toolbar); - let (Some(mb), Some((sx, sy))) = (mb, screen_position) else { + let (Some(mb), Some((sx, sy))) = (mb, routed.screen) else { return false; }; let (wx, wy) = self.zoomed_world_coords(sx, sy); @@ -307,14 +237,14 @@ impl WaylandState { "pointer release: inline handled, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", event.position.0, event.position.1, - self.is_move_dragging(), - self.pointer_over_toolbar() + self.toolbar_drag.is_moving(), + self.toolbar_chrome.pointer_over_toolbar() ) }); self.unlock_pointer(); return true; } - if !self.pointer_over_toolbar() && !self.toolbar_dragging() { + if !self.toolbar_chrome.pointer_over_toolbar() && !self.toolbar_drag.item_dragging() { return false; } drag_log(|| { @@ -322,8 +252,8 @@ impl WaylandState { "pointer release: inline end drag, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", event.position.0, event.position.1, - self.is_move_dragging(), - self.pointer_over_toolbar() + self.toolbar_drag.is_moving(), + self.toolbar_chrome.pointer_over_toolbar() ) }); self.end_toolbar_move_drag(); @@ -334,15 +264,15 @@ impl WaylandState { fn handle_toolbar_pointer_release(&mut self, event: &PointerEvent, button: u32) { if button == BTN_LEFT { self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); } drag_log(|| { format!( "pointer release: toolbar end drag, pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", event.position.0, event.position.1, - self.is_move_dragging(), - self.pointer_over_toolbar() + self.toolbar_drag.is_moving(), + self.toolbar_chrome.pointer_over_toolbar() ) }); self.end_toolbar_move_drag(); @@ -351,7 +281,7 @@ impl WaylandState { fn finish_main_surface_drag(&mut self, event: &PointerEvent) { self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); drag_log(|| { format!( "pointer release: main surface end drag, pos=({:.3}, {:.3})", diff --git a/src/backend/wayland/handlers/relative_pointer.rs b/src/backend/wayland/handlers/relative_pointer.rs index d960c6350..57cbbf46e 100644 --- a/src/backend/wayland/handlers/relative_pointer.rs +++ b/src/backend/wayland/handlers/relative_pointer.rs @@ -14,18 +14,18 @@ impl RelativePointerHandler for WaylandState { _pointer: &wl_pointer::WlPointer, event: RelativeMotionEvent, ) { - if !self.pointer_lock_active() || !self.is_move_dragging() { + if !self.pointer_lock_active() || !self.toolbar_drag.is_moving() { drag_log(|| { format!( "relative motion ignored: lock_active={}, drag_active={}", self.pointer_lock_active(), - self.is_move_dragging() + self.toolbar_drag.is_moving() ) }); return; } - let Some(kind) = self.active_move_drag_kind() else { + let Some(kind) = self.toolbar_drag.kind() else { return; }; @@ -36,8 +36,8 @@ impl RelativePointerHandler for WaylandState { event.delta.0, event.delta.1, event.utime, - self.toolbar_top_offset(), - self.toolbar_top_offset_y() + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); diff --git a/src/backend/wayland/handlers/route.rs b/src/backend/wayland/handlers/route.rs new file mode 100644 index 000000000..317dc0e5c --- /dev/null +++ b/src/backend/wayland/handlers/route.rs @@ -0,0 +1,121 @@ +use wayland_client::protocol::wl_surface; + +use super::super::state::{MoveDragKind, WaylandState, surface_id}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::backend::wayland) enum InputSurface { + Canvas, + Toolbar, + Foreign, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub(in crate::backend::wayland) struct RoutedInput { + pub(in crate::backend::wayland) surface: InputSurface, + /// Built-in inline strips are drawn on the canvas and hit-tested there. + pub(in crate::backend::wayland) inline_toolbars: bool, + /// Overlay screen coordinates. Toolbar-local positions are converted; + /// foreign surfaces have no overlay coordinates. + pub(in crate::backend::wayland) screen: Option<(f64, f64)>, +} + +/// Snapshot of the protocol surfaces that can own an input callback. +pub(in crate::backend::wayland) struct SurfaceRouter { + canvas: Option, + toolbar: Option, + inline_toolbars: bool, +} + +impl SurfaceRouter { + fn new(canvas: Option, toolbar: Option, inline_toolbars: bool) -> Self { + Self { + canvas, + toolbar, + inline_toolbars, + } + } + + fn classify(&self, surface: u32) -> InputSurface { + if self.canvas == Some(surface) { + InputSurface::Canvas + } else if self.toolbar == Some(surface) { + InputSurface::Toolbar + } else { + InputSurface::Foreign + } + } + + fn route( + &self, + surface: u32, + position: (f64, f64), + toolbar_screen_position: (f64, f64), + ) -> RoutedInput { + let surface = self.classify(surface); + let screen = match surface { + InputSurface::Canvas => Some(position), + InputSurface::Toolbar => Some(toolbar_screen_position), + InputSurface::Foreign => None, + }; + RoutedInput { + surface, + inline_toolbars: self.inline_toolbars, + screen, + } + } +} + +impl WaylandState { + pub(in crate::backend::wayland) fn route_input( + &self, + surface: &wl_surface::WlSurface, + position: (f64, f64), + ) -> RoutedInput { + SurfaceRouter::new( + self.surface.wl_surface().map(surface_id), + self.toolbar.wl_surface().map(surface_id), + self.toolbar_chrome.inline_toolbars() && self.toolbar.is_visible(), + ) + .route( + surface_id(surface), + position, + self.local_to_screen_coords(MoveDragKind::Top, position), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{InputSurface, SurfaceRouter}; + + #[test] + fn classifies_canvas_toolbar_and_foreign_surface_ids() { + for (surface, canvas, toolbar, expected) in [ + (10, Some(10), Some(20), InputSurface::Canvas), + (20, Some(10), Some(20), InputSurface::Toolbar), + (30, Some(10), Some(20), InputSurface::Foreign), + (20, Some(10), None, InputSurface::Foreign), + (10, None, Some(20), InputSurface::Foreign), + ] { + let router = SurfaceRouter::new(canvas, toolbar, false); + assert_eq!(router.classify(surface), expected); + } + } + + #[test] + fn routes_only_owned_surfaces_into_overlay_coordinates() { + let router = SurfaceRouter::new(Some(10), Some(20), true); + let canvas = router.route(10, (4.0, 5.0), (104.0, 205.0)); + assert_eq!(canvas.surface, InputSurface::Canvas); + assert_eq!(canvas.screen, Some((4.0, 5.0))); + assert!(canvas.inline_toolbars); + + let toolbar = router.route(20, (4.0, 5.0), (104.0, 205.0)); + assert_eq!(toolbar.surface, InputSurface::Toolbar); + assert_eq!(toolbar.screen, Some((104.0, 205.0))); + + let foreign = router.route(30, (4.0, 5.0), (104.0, 205.0)); + assert_eq!(foreign.surface, InputSurface::Foreign); + assert_eq!(foreign.screen, None); + } +} diff --git a/src/backend/wayland/handlers/seat.rs b/src/backend/wayland/handlers/seat.rs index c32c5ce96..70b0afb24 100644 --- a/src/backend/wayland/handlers/seat.rs +++ b/src/backend/wayland/handlers/seat.rs @@ -25,7 +25,7 @@ impl SeatHandler for WaylandState { match capability { Capability::Keyboard => { info!("Keyboard capability available"); - self.set_current_seat(Some(seat.clone())); + self.focus.set_current_seat(Some(seat.clone())); if self .protocol .seat_mut() diff --git a/src/backend/wayland/handlers/tablet/frame.rs b/src/backend/wayland/handlers/tablet/frame.rs index 23105bfd8..32e7a5e29 100644 --- a/src/backend/wayland/handlers/tablet/frame.rs +++ b/src/backend/wayland/handlers/tablet/frame.rs @@ -104,7 +104,7 @@ impl WaylandState { .motion .or(self.tablet.last_pos) .unwrap_or_else(|| { - let (x, y) = self.current_mouse(); + let (x, y) = self.pointer.position(); (x as f64, y as f64) }) } @@ -135,7 +135,7 @@ impl WaylandState { fn commit_stylus_motion_sample(&mut self, x: f64, y: f64, pressure_sample: bool) { let previous_hover_cursor_pos = self.stylus_hover_cursor_position(); - self.set_current_mouse(x as i32, y as i32); + self.pointer.set_position((x as i32, y as i32)); self.tablet.last_pos = Some((x, y)); let (wx, wy) = self.zoomed_world_coords(x, y); self.input_state @@ -192,7 +192,8 @@ impl WaylandState { // Record the press target but do not begin a canvas interaction. if self.input_state.help_overlay.is_visible() { let (x, y) = self.current_stylus_position(); - self.set_current_mouse(x.round() as i32, y.round() as i32); + self.pointer + .set_position((x.round() as i32, y.round() as i32)); self.input_state.note_help_overlay_press( HelpOverlayPressSource::Stylus, x.round() as i32, @@ -217,16 +218,16 @@ impl WaylandState { let hover_cursor_pos = self.stylus_hover_cursor_position(); let (x, y) = self.current_stylus_position(); - self.set_current_mouse(x as i32, y as i32); + self.pointer.set_position((x as i32, y as i32)); self.tablet.tip_down = true; self.mark_stylus_hover_cursor_dirty(hover_cursor_pos, None); info!( "Stylus DOWN at ({}, {})", - self.current_mouse().0, - self.current_mouse().1 + self.pointer.position().0, + self.pointer.position().1 ); - let screen_x = self.current_mouse().0; - let screen_y = self.current_mouse().1; + let screen_x = self.pointer.position().0; + let screen_y = self.pointer.position().1; let (wx, wy) = self.zoomed_world_coords(x, y); self.input_state .on_mouse_press_with_canvas(MouseButton::Left, screen_x, screen_y, wx, wy); @@ -256,13 +257,13 @@ impl WaylandState { self.tablet.peak_thickness = None; info!( "Stylus UP at ({}, {})", - self.current_mouse().0, - self.current_mouse().1 + self.pointer.position().0, + self.pointer.position().1 ); let (x, y) = self.current_stylus_position(); - self.set_current_mouse(x as i32, y as i32); - let screen_x = self.current_mouse().0; - let screen_y = self.current_mouse().1; + self.pointer.set_position((x as i32, y as i32)); + let screen_x = self.pointer.position().0; + let screen_y = self.pointer.position().1; if self.handle_help_overlay_release(HelpOverlayPressSource::Stylus, screen_x, screen_y) { let hover_cursor_pos = self.stylus_hover_cursor_position(); self.mark_stylus_hover_cursor_dirty(None, hover_cursor_pos); diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index e50f72993..654640ee4 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -8,7 +8,10 @@ use crate::{ util::Rect, }; -use crate::backend::wayland::state::{RegionReviewPress, WaylandState}; +use crate::backend::wayland::{ + handlers::route::InputSurface, + state::{RegionReviewPress, WaylandState}, +}; use crate::input::state::RegionInputSource; const STYLUS_CURSOR_DAMAGE_RADIUS: i32 = 64; @@ -66,7 +69,7 @@ impl WaylandState { || self.input_state.is_board_picker_open() || self.input_state.is_properties_panel_open() || self.input_state.is_context_menu_open() - || (self.inline_toolbars_active() && self.toolbar.is_visible()) + || (self.toolbar_chrome.inline_toolbars() && self.toolbar.is_visible()) } pub(in crate::backend::wayland) fn mark_stylus_hover_cursor_dirty( @@ -131,16 +134,14 @@ impl WaylandState { "Tablet proximity in: tool {:?}, type: {:?}", tool_id, tool_type ); - let on_overlay = self - .surface - .wl_surface() - .is_some_and(|candidate| candidate.id() == surface.id()); - let on_toolbar = self.toolbar.is_toolbar_surface(&surface); + let routed = self.route_input(&surface, (0.0, 0.0)); + let on_overlay = routed.surface == InputSurface::Canvas; + let on_toolbar = routed.surface == InputSurface::Toolbar; self.tablet.surface = Some(surface); self.tablet.on_overlay = on_overlay; self.tablet.on_toolbar = on_toolbar; self.finish_toolbar_item_drag(false); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.cancel_toolbar_move_drag(); self.tablet.tip_down = false; self.tablet.base_thickness = Some(self.input_state.style.current_thickness); @@ -192,10 +193,10 @@ impl WaylandState { self.tablet.on_overlay = false; self.tablet.on_toolbar = false; self.finish_toolbar_item_drag(false); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.cancel_toolbar_move_drag(); if let Some(surface) = self.tablet.surface.take() - && self.toolbar.is_toolbar_surface(&surface) + && self.route_input(&surface, (0.0, 0.0)).surface == InputSurface::Toolbar { self.toolbar.pointer_leave(&surface); self.toolbar.mark_dirty(); @@ -229,7 +230,7 @@ impl WaylandState { if self.handle_stylus_region_down() || self.handle_stylus_eyedropper_down() { return; } - if self.inline_toolbars_active() + if self.toolbar_chrome.inline_toolbars() && self.toolbar.is_visible() && self.handle_inline_stylus_down(conn, qh) { @@ -286,7 +287,8 @@ impl WaylandState { return false; } self.tablet.on_toolbar = true; - self.set_toolbar_dragging(self.toolbar_dragging()); + self.toolbar_drag + .set_item_dragging(self.toolbar_drag.item_dragging()); true } @@ -295,11 +297,11 @@ impl WaylandState { return false; } let (x, y) = self.current_or_pending_stylus_position(); - self.set_current_mouse(x as i32, y as i32); + self.pointer.set_position((x as i32, y as i32)); if let Some(surface) = self.tablet.surface.as_ref() && let Some((intent, drag)) = self.toolbar.pointer_press(surface, (x, y)) { - self.set_toolbar_dragging(drag); + self.toolbar_drag.set_item_dragging(drag); let event = intent_to_event(intent, self.toolbar.last_snapshot()); self.handle_toolbar_event(event, Some(conn), Some(qh)); self.toolbar.mark_dirty(); @@ -320,18 +322,18 @@ impl WaylandState { } return; } - let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); + let inline_active = self.toolbar_chrome.inline_toolbars() && self.toolbar.is_visible(); if inline_active && self.tablet.on_toolbar { - let (x, y) = self.current_mouse(); + let (x, y) = self.pointer.position(); self.inline_toolbar_release((x as f64, y as f64)); self.tablet.on_toolbar = false; - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.end_toolbar_move_drag(); return; } if self.tablet.on_toolbar { self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.end_toolbar_move_drag(); return; } @@ -348,7 +350,7 @@ impl WaylandState { if self.handle_toolbar_stylus_motion(conn, qh, x, y) { return; } - if self.inline_toolbars_active() && self.toolbar.is_visible() { + if self.toolbar_chrome.inline_toolbars() && self.toolbar.is_visible() { self.tablet.last_pos = Some((x, y)); if self.inline_toolbar_motion((x, y)) { self.commit_pending_stylus_frame(); @@ -367,13 +369,15 @@ impl WaylandState { fn handle_modal_stylus_motion(&mut self, x: f64, y: f64) -> bool { if self.input_state.region_is_active() && self.tablet.on_overlay { self.tablet.last_pos = Some((x, y)); - self.set_current_mouse(x.round() as i32, y.round() as i32); + self.pointer + .set_position((x.round() as i32, y.round() as i32)); self.update_region_selection(RegionInputSource::Stylus, x, y); return true; } if self.input_state.eyedropper_is_active() && self.tablet.on_overlay { self.tablet.last_pos = Some((x, y)); - self.set_current_mouse(x.round() as i32, y.round() as i32); + self.pointer + .set_position((x.round() as i32, y.round() as i32)); self.update_eyedropper_hover(x, y); return true; } @@ -381,10 +385,10 @@ impl WaylandState { } fn handle_stylus_move_drag(&mut self, x: f64, y: f64) -> bool { - if !self.is_move_dragging() { + if !self.toolbar_drag.is_moving() { return false; } - let Some(kind) = self.active_move_drag_kind() else { + let Some(kind) = self.toolbar_drag.kind() else { return false; }; if self.tablet.on_toolbar { @@ -394,7 +398,7 @@ impl WaylandState { } self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; - self.set_current_mouse(x as i32, y as i32); + self.pointer.set_position((x as i32, y as i32)); true } @@ -411,7 +415,7 @@ impl WaylandState { self.tablet.last_pos = Some((x, y)); if let Some(surface) = self.tablet.surface.as_ref() { let event = self.toolbar.pointer_motion(surface, (x, y)); - if self.toolbar_dragging() { + if self.toolbar_drag.item_dragging() { let intent = event.or_else(|| self.move_drag_intent(x, y)); if let Some(intent) = intent { let event = intent_to_event(intent, self.toolbar.last_snapshot()); @@ -423,7 +427,7 @@ impl WaylandState { self.input_state.needs_redraw = true; self.refresh_keyboard_interactivity(); } - self.set_current_mouse(x as i32, y as i32); + self.pointer.set_position((x as i32, y as i32)); true } } diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 8941fd42d..18c12e93b 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -12,7 +12,8 @@ use crate::backend::wayland::state::{ use crate::backend::wayland::toolbar_intent::intent_to_event; use crate::input::MouseButton; use crate::input::state::{HelpOverlayPressSource, RegionInputSource}; -use crate::ui::ZoomChipPress; + +use super::route::{InputSurface, RoutedInput}; impl TouchHandler for WaylandState { fn down( @@ -26,6 +27,7 @@ impl TouchHandler for WaylandState { id: i32, position: (f64, f64), ) { + let routed = self.route_input(&surface, position); if !self .pointer .begin_touch(id, position, surface.clone(), TouchTarget::None) @@ -34,8 +36,8 @@ impl TouchHandler for WaylandState { return; } - self.set_last_activation_serial(Some(serial)); - let target = self.handle_touch_down(conn, qh, &surface, position); + self.focus.note_activation_serial(serial); + let target = self.handle_touch_down(conn, qh, &surface, position, routed); self.pointer.set_touch_target(target); } @@ -53,7 +55,11 @@ impl TouchHandler for WaylandState { return; }; - self.handle_touch_up(&end.surface, end.position, end.target); + let mut routed = self.route_input(&end.surface, end.position); + if matches!(end.target, TouchTarget::None | TouchTarget::Foreign) { + routed.screen = None; + } + self.handle_touch_up(&end.surface, end.position, end.target, routed); } fn motion( @@ -68,7 +74,11 @@ impl TouchHandler for WaylandState { let Some((surface, target)) = self.pointer.touch_position(id, position) else { return; }; - self.handle_touch_motion(conn, &surface, position, target); + let mut routed = self.route_input(&surface, position); + if matches!(target, TouchTarget::None | TouchTarget::Foreign) { + routed.screen = None; + } + self.handle_touch_motion(conn, &surface, position, target, routed); } fn shape( @@ -111,16 +121,13 @@ impl WaylandState { // owns, would outlive the touch that opened it. A region another device // is dragging is untouched. self.cancel_region_selection_from(RegionInputSource::Touch); - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); - self.clear_suppressed_release_from(RegionInputSource::Touch); + self.pointer.clear_chrome_press(); self.input_state .clear_help_overlay_press_for(HelpOverlayPressSource::Touch); if !matches!( target, - TouchTarget::Overlay | TouchTarget::Toolbar | TouchTarget::InlineToolbar + TouchTarget::Canvas | TouchTarget::Toolbar | TouchTarget::InlineToolbar ) { return; } @@ -128,81 +135,71 @@ impl WaylandState { if target == TouchTarget::Toolbar { self.toolbar.pointer_leave(&end.surface); self.toolbar.mark_dirty(); - self.set_pointer_over_toolbar(false); + self.toolbar_chrome.set_pointer_over_toolbar(false); } if target == TouchTarget::InlineToolbar { self.inline_toolbar_leave(); } self.finish_toolbar_item_drag(false); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.cancel_toolbar_move_drag(); - if self.board_panning_active() { - self.stop_board_pan(); + if self.pointer.board_pan_active() { + self.pointer.stop_board_pan(); } self.input_state.cancel_active_interaction(); self.input_state.needs_redraw = true; } - fn classify_touch_surface(&self, surface: &wl_surface::WlSurface) -> TouchTarget { - if self.toolbar.is_toolbar_surface(surface) { - TouchTarget::Toolbar - } else if self - .surface - .wl_surface() - .is_some_and(|overlay| overlay == surface) - { - TouchTarget::Overlay - } else { - TouchTarget::Other - } - } - - fn touch_screen_position( - &self, - surface: &wl_surface::WlSurface, - position: (f64, f64), - target: TouchTarget, - ) -> Option<(f64, f64)> { - match target { - TouchTarget::Overlay | TouchTarget::InlineToolbar => Some(position), - TouchTarget::Toolbar => self.toolbar_surface_screen_coords(surface, position), - TouchTarget::None | TouchTarget::Other => None, - } - } - fn handle_touch_down( &mut self, conn: &Connection, qh: &QueueHandle, surface: &wl_surface::WlSurface, position: (f64, f64), + routed: RoutedInput, ) -> TouchTarget { - // A finished scan card is transient chrome: the next interaction of any - // kind takes it away rather than making the user wait it out. self.input_state.dismiss_ocr_scan_result(); - let target = self.classify_touch_surface(surface); - let Some(screen_position) = self.touch_screen_position(surface, position, target) else { - return TouchTarget::Other; + let target = match routed.surface { + InputSurface::Canvas => TouchTarget::Canvas, + InputSurface::Toolbar => TouchTarget::Toolbar, + InputSurface::Foreign => TouchTarget::Foreign, }; - let screen_x = screen_position.0.round() as i32; - let screen_y = screen_position.1.round() as i32; - self.set_current_mouse(screen_x, screen_y); - + let Some((screen_x, screen_y)) = routed.screen else { + return TouchTarget::Foreign; + }; + let screen_position = (screen_x, screen_y); + let screen_x = screen_x.round() as i32; + let screen_y = screen_y.round() as i32; + self.pointer.set_position((screen_x, screen_y)); if !self.input_state.help_overlay.is_visible() { - // A new touch supersedes any consume-only help ownership left by - // a sequence whose release/cancel was not delivered. self.input_state .clear_help_overlay_press_for(HelpOverlayPressSource::Touch); } + if let Some(target) = self.touch_down_screen_modal(target, routed, screen_position) { + return target; + } + if let Some(target) = self.touch_down_modal_chrome(target, screen_x, screen_y) { + return target; + } + if let Some(target) = self.touch_down_toolbar(conn, qh, surface, position, routed) { + return target; + } + self.touch_down_canvas(target, screen_position, screen_x, screen_y) + } + fn touch_down_screen_modal( + &mut self, + target: TouchTarget, + routed: RoutedInput, + screen_position: (f64, f64), + ) -> Option { if self.input_state.region_is_active() { - let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); - let inline_hit = target == TouchTarget::Overlay - && inline_active + let inline_hit = target == TouchTarget::Canvas + && routed.inline_toolbars && self.inline_toolbar_motion(screen_position); if target == TouchTarget::Toolbar || inline_hit { self.cancel_region_for_toolbar_interaction(); - } else if target == TouchTarget::Overlay { + } else if target == TouchTarget::Canvas { match self.consume_region_review_press(RegionInputSource::Touch, screen_position) { RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => { self.begin_region_selection( @@ -211,127 +208,114 @@ impl WaylandState { screen_position.1, ); } - RegionReviewPress::Consumed { suppress_release } => { - if suppress_release { - self.suppress_next_release_from(RegionInputSource::Touch); - } + RegionReviewPress::Consumed { suppress_release } if suppress_release => { + self.pointer.suppress_release(RegionInputSource::Touch); } + RegionReviewPress::Consumed { .. } => {} } - // Unlike the one-shot eyedropper sample, an OCR region is a - // drag: report the real target so motion and release still - // resolve to screen coordinates and reach the selector. - return TouchTarget::Overlay; + return Some(TouchTarget::Canvas); } } - - if self.input_state.eyedropper_is_active() { - let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); - let inline_hit = target == TouchTarget::Overlay - && inline_active - && self.inline_toolbar_motion(screen_position); - if target == TouchTarget::Toolbar || inline_hit { - self.cancel_eyedropper(); - } else if target == TouchTarget::Overlay { - self.sample_eyedropper(screen_position.0, screen_position.1); - return TouchTarget::Other; - } + if !self.input_state.eyedropper_is_active() { + return None; + } + let inline_hit = target == TouchTarget::Canvas + && routed.inline_toolbars + && self.inline_toolbar_motion(screen_position); + if target == TouchTarget::Toolbar || inline_hit { + self.cancel_eyedropper(); + None + } else if target == TouchTarget::Canvas { + self.sample_eyedropper(screen_position.0, screen_position.1); + Some(TouchTarget::Foreign) + } else { + None } + } + fn touch_down_modal_chrome( + &mut self, + target: TouchTarget, + screen_x: i32, + screen_y: i32, + ) -> Option { if self.input_state.tour.is_active() { - return TouchTarget::Other; + return Some(TouchTarget::Foreign); } - - // Help is modal for every pointing modality. Record the same - // screen-space target as the mouse path and swallow the touch so it - // cannot operate the toolbar or canvas underneath. if self.input_state.help_overlay.is_visible() { self.input_state.note_help_overlay_press( HelpOverlayPressSource::Touch, screen_x, screen_y, ); - return target; + return Some(target); } - - if self.input_state.command_palette.open { - let screen_width = self.surface.width(); - let screen_height = self.surface.height(); - if self.input_state.handle_command_palette_click( - screen_x, - screen_y, - screen_width, - screen_height, - ) { - self.suppress_next_release_from(RegionInputSource::Touch); - } - return TouchTarget::Other; + if !self.input_state.command_palette.open { + return None; } - - let inline_active = self.inline_toolbars_active() && self.toolbar.is_visible(); - if target == TouchTarget::Overlay - && inline_active - && self.inline_toolbar_press(screen_position, Some(conn), Some(qh)) - { - return TouchTarget::InlineToolbar; + if self.input_state.handle_command_palette_click( + screen_x, + screen_y, + self.surface.width(), + self.surface.height(), + ) { + self.pointer.suppress_release(RegionInputSource::Touch); } + Some(TouchTarget::Foreign) + } - if target == TouchTarget::Toolbar { - self.set_pointer_over_toolbar(true); - if let Some((intent, drag)) = self.toolbar.pointer_press(surface, position) { - let toolbar_event = intent_to_event(intent, self.toolbar.last_snapshot()); - self.set_toolbar_dragging(drag); - self.handle_toolbar_event(toolbar_event, Some(conn), Some(qh)); - self.toolbar.mark_dirty(); - self.input_state.needs_redraw = true; - self.refresh_keyboard_interactivity(); - } - return TouchTarget::Toolbar; + fn touch_down_toolbar( + &mut self, + conn: &Connection, + qh: &QueueHandle, + surface: &wl_surface::WlSurface, + position: (f64, f64), + routed: RoutedInput, + ) -> Option { + if routed.surface == InputSurface::Canvas + && routed.inline_toolbars + && self.inline_toolbar_press(routed.screen?, Some(conn), Some(qh)) + { + return Some(TouchTarget::InlineToolbar); } - - self.set_pointer_over_toolbar(false); - if target != TouchTarget::Overlay { - return target; + if routed.surface != InputSurface::Toolbar { + return None; } - - // Canvas click-away: a tap on the canvas with a top popover open - // (Canvas/Session/Settings) dismisses it and swallows the tap, exactly - // like the mouse and tablet pen-down paths — otherwise the tap would - // start a stray stroke instead of closing the popover. - if self.dismiss_top_toolbar_menus() { + self.toolbar_chrome.set_pointer_over_toolbar(true); + if let Some((intent, drag)) = self.toolbar.pointer_press(surface, position) { + let toolbar_event = intent_to_event(intent, self.toolbar.last_snapshot()); + self.toolbar_drag.set_item_dragging(drag); + self.handle_toolbar_event(toolbar_event, Some(conn), Some(qh)); + self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; - return target; + self.refresh_keyboard_interactivity(); } + Some(TouchTarget::Toolbar) + } - self.set_pending_toast_press(None); - if let Some(pressed) = self.input_state.toast_press_at(screen_x, screen_y) { - self.set_pending_toast_press(Some(pressed)); + fn touch_down_canvas( + &mut self, + target: TouchTarget, + screen_position: (f64, f64), + screen_x: i32, + screen_y: i32, + ) -> TouchTarget { + self.toolbar_chrome.set_pointer_over_toolbar(false); + if target != TouchTarget::Canvas { return target; } - - // Interactive status HUD: press reports the hit; release activates. - self.set_pending_status_hud_press(false); - if self.input_state.status_hud_contains(screen_x, screen_y) { - self.set_pending_status_hud_press(true); + if self.dismiss_top_toolbar_menus() { + self.input_state.needs_redraw = true; return target; } - - // Interactive zoom chip: any press inside the pill is swallowed and - // recorded as `Passive` (the `NN%` readout / inter-piece gap) or - // `Button(kind)`, so its release stays consumed either way; a `Button` - // release activates only when it lands on the SAME button. - self.set_pending_zoom_chip_press(ZoomChipPress::None); - if self.input_state.zoom_chip_contains(screen_x, screen_y) { - let pressed = self.input_state.zoom_chip_press_at(screen_x, screen_y); - self.set_pending_zoom_chip_press(pressed); + if self.press_overlay_chrome(screen_x, screen_y) { return target; } - - if self.board_pan_key_held() && self.can_start_board_pan() { - self.start_board_pan(screen_position.0, screen_position.1); + if self.pointer.board_pan_key_held() && self.can_start_board_pan() { + self.pointer.start_board_pan(screen_position); self.input_state.needs_redraw = true; return target; } - let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); self.input_state .on_mouse_press_with_canvas(MouseButton::Left, screen_x, screen_y, wx, wy); @@ -345,16 +329,17 @@ impl WaylandState { surface: &wl_surface::WlSurface, position: (f64, f64), target: TouchTarget, + routed: RoutedInput, ) { - let Some(screen_position) = self.touch_screen_position(surface, position, target) else { + let Some(screen_position) = routed.screen else { return; }; let screen_x = screen_position.0.round() as i32; let screen_y = screen_position.1.round() as i32; - self.set_current_mouse(screen_x, screen_y); + self.pointer.set_position((screen_x, screen_y)); if self.input_state.region_is_active() { - if target == TouchTarget::Overlay { + if target == TouchTarget::Canvas { self.update_region_selection( RegionInputSource::Touch, screen_position.0, @@ -365,14 +350,14 @@ impl WaylandState { } if self.input_state.eyedropper_is_active() { - if target == TouchTarget::Overlay { + if target == TouchTarget::Canvas { self.update_eyedropper_hover(screen_position.0, screen_position.1); } return; } - if self.is_move_dragging() - && let Some(kind) = self.active_move_drag_kind() + if self.toolbar_drag.is_moving() + && let Some(kind) = self.toolbar_drag.kind() { if target == TouchTarget::Toolbar { self.handle_toolbar_move(kind, position); @@ -390,12 +375,12 @@ impl WaylandState { } if target == TouchTarget::Toolbar { - self.set_pointer_over_toolbar(true); + self.toolbar_chrome.set_pointer_over_toolbar(true); let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); self.input_state .update_pointer_positions(screen_x, screen_y, wx, wy); let evt = self.toolbar.pointer_motion(surface, position); - if self.toolbar_dragging() { + if self.toolbar_drag.item_dragging() { let intent = evt.or_else(|| self.move_drag_intent(position.0, position.1)); if let Some(intent) = intent { let evt = intent_to_event(intent, self.toolbar.last_snapshot()); @@ -409,12 +394,14 @@ impl WaylandState { return; } - if target != TouchTarget::Overlay { + if target != TouchTarget::Canvas { return; } - if self.board_panning_active() { - let (dx, dy) = self.update_board_pan_position(screen_position.0, screen_position.1); + if self.pointer.board_pan_active() { + let (dx, dy) = self + .pointer + .advance_board_pan((screen_position.0, screen_position.1)); let _ = self.pan_board_by_screen_delta(dx, dy); let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); self.input_state @@ -440,144 +427,124 @@ impl WaylandState { fn handle_touch_up( &mut self, surface: &wl_surface::WlSurface, - position: (f64, f64), + _position: (f64, f64), target: TouchTarget, + routed: RoutedInput, ) { - if self.input_state.region_is_active() { - if self.take_suppressed_release_from(RegionInputSource::Touch) { - return; - } - if let Some((x, y)) = self.touch_screen_position(surface, position, target) { - self.finish_region_selection(RegionInputSource::Touch, x, y); - } else { - self.cancel_region_selection_from(RegionInputSource::Touch); - } + if self.touch_up_screen_modal(routed) || self.touch_up_consumed(routed) { return; } - - if self.take_suppressed_release_from(RegionInputSource::Touch) { - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); + if self.touch_up_toolbar(surface, target) { return; } + self.touch_up_canvas(target, routed); + } - // Resolve help ownership even after help closes, before routing into a - // popup that may have opened in the meantime. - let help_owned_release = match self.touch_screen_position(surface, position, target) { - Some((screen_x, screen_y)) => self.handle_help_overlay_release( + fn touch_up_screen_modal(&mut self, routed: RoutedInput) -> bool { + if !self.input_state.region_is_active() { + return false; + } + if self + .pointer + .take_suppressed_release(RegionInputSource::Touch) + { + return true; + } + if let Some((x, y)) = routed.screen { + self.finish_region_selection(RegionInputSource::Touch, x, y); + } else { + self.cancel_region_selection_from(RegionInputSource::Touch); + } + true + } + + fn touch_up_consumed(&mut self, routed: RoutedInput) -> bool { + if self + .pointer + .take_suppressed_release(RegionInputSource::Touch) + { + self.pointer.clear_chrome_press(); + return true; + } + let help_owned = match routed.screen { + Some((x, y)) => self.handle_help_overlay_release( HelpOverlayPressSource::Touch, - screen_x.round() as i32, - screen_y.round() as i32, + x.round() as i32, + y.round() as i32, ), None => self .input_state .clear_help_overlay_press_for(HelpOverlayPressSource::Touch), }; - if help_owned_release { - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); - return; + if help_owned { + self.pointer.clear_chrome_press(); + return true; } - - if self.input_state.command_palette.open || self.input_state.tour.is_active() { - self.set_pending_toast_press(None); - self.set_pending_status_hud_press(false); - self.set_pending_zoom_chip_press(ZoomChipPress::None); - self.cancel_active_touch_sequence(); - return; + if !self.input_state.command_palette.open && !self.input_state.tour.is_active() { + return false; } + self.pointer.clear_chrome_press(); + self.cancel_active_touch_sequence(); + true + } - if target == TouchTarget::Toolbar { - if debug_toolbar_drag_logging_enabled() { - debug!( - "touch release: target={:?}, drag_active={}, toolbar_dragging={}", - target, - self.is_move_dragging(), - self.toolbar_dragging() - ); - } - self.toolbar.pointer_leave(surface); - self.set_pointer_over_toolbar(false); - self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); - self.end_toolbar_move_drag(); - self.toolbar.mark_dirty(); - self.input_state.needs_redraw = true; - return; + fn touch_up_toolbar(&mut self, surface: &wl_surface::WlSurface, target: TouchTarget) -> bool { + if target != TouchTarget::Toolbar { + return false; + } + if debug_toolbar_drag_logging_enabled() { + debug!( + "touch release: target={:?}, drag_active={}, toolbar_dragging={}", + target, + self.toolbar_drag.is_moving(), + self.toolbar_drag.item_dragging() + ); } + self.toolbar.pointer_leave(surface); + self.toolbar_chrome.set_pointer_over_toolbar(false); + self.finish_toolbar_item_drag(true); + self.toolbar_drag.set_item_dragging(false); + self.end_toolbar_move_drag(); + self.toolbar.mark_dirty(); + self.input_state.needs_redraw = true; + true + } - let Some(screen_position) = self.touch_screen_position(surface, position, target) else { + fn touch_up_canvas(&mut self, target: TouchTarget, routed: RoutedInput) { + let Some(screen_position) = routed.screen else { return; }; let screen_x = screen_position.0.round() as i32; let screen_y = screen_position.1.round() as i32; - - if let Some(pressed) = self.take_pending_toast_press() { - let (hit, action) = self - .input_state - .resolve_toast_release(pressed, screen_x, screen_y); - if hit && let Some(command) = action { - self.handle_toast_command(command); - } - return; - } - - if self.take_pending_status_hud_press() { - let (hit, action) = self.input_state.check_status_hud_click(screen_x, screen_y); - if hit && let Some(action) = action { - self.dispatch_input_action(action); - } - return; - } - - let pressed = self.take_pending_zoom_chip_press(); - if pressed.is_pending() { - // Any pending chip press (`Passive` or `Button`) consumes its - // release; only a `Button` release on the SAME button dispatches. - if let ZoomChipPress::Button(kind) = pressed { - let (_, action) = self - .input_state - .check_zoom_chip_click(kind, screen_x, screen_y); - if let Some(action) = action { - self.dispatch_input_action(action); - } - } + if self.release_overlay_chrome(screen_x, screen_y) { return; } - if debug_toolbar_drag_logging_enabled() { debug!( "touch release: target={:?}, drag_active={}, toolbar_dragging={}", target, - self.is_move_dragging(), - self.toolbar_dragging() + self.toolbar_drag.is_moving(), + self.toolbar_drag.item_dragging() ); } - if target == TouchTarget::InlineToolbar { let _ = self.inline_toolbar_release(screen_position); return; } - - if self.is_move_dragging() { + if self.toolbar_drag.is_moving() { self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.end_toolbar_move_drag(); return; } - - if target != TouchTarget::Overlay { + if target != TouchTarget::Canvas { return; } - - if self.board_panning_active() { - self.stop_board_pan(); + if self.pointer.board_pan_active() { + self.pointer.stop_board_pan(); self.input_state.needs_redraw = true; return; } - let (wx, wy) = self.zoomed_world_coords(screen_position.0, screen_position.1); self.input_state.on_mouse_release_with_canvas( MouseButton::Left, diff --git a/src/backend/wayland/handlers/xdg.rs b/src/backend/wayland/handlers/xdg.rs index d32790f91..21d4ac03e 100644 --- a/src/backend/wayland/handlers/xdg.rs +++ b/src/backend/wayland/handlers/xdg.rs @@ -9,11 +9,10 @@ use super::super::state::{FullDamageReason, WaylandState}; impl WindowHandler for WaylandState { fn request_close(&mut self, _conn: &Connection, _qh: &QueueHandle, _window: &Window) { - if should_ignore_xdg_close_request( - !self.xdg_focus_loss_exits_overlay(), - self.has_keyboard_focus(), - self.xdg_close_guard_active(Instant::now()), - ) { + if self + .focus + .ignores_xdg_close(!self.xdg_focus_loss_exits_overlay(), Instant::now()) + { warn!( "xdg window close requested while unfocused in stay mode; keeping overlay open without auto-reactivation" ); @@ -21,7 +20,7 @@ impl WindowHandler for WaylandState { } info!("xdg window close requested by compositor"); - self.mark_xdg_explicit_close_requested(); + self.focus.mark_xdg_explicit_close_requested(); self.input_state.should_exit = true; } @@ -68,13 +67,13 @@ impl WindowHandler for WaylandState { .map(|h| h.get()) .unwrap_or(fallback_dimensions.1); - if self.xdg_frozen_fullscreen_requested() { + if self.surface.placement().xdg_frozen().requested() { if let Some(output) = self.preferred_fullscreen_output() { window.set_fullscreen(Some(&output)); } else if !configure.is_fullscreen() { window.set_fullscreen(None); } - } else if self.xdg_fullscreen() { + } else if self.surface.placement().xdg_fullscreen() { if let Some(output) = self.preferred_fullscreen_output() { // Reassert fullscreen on the preferred output every configure in case // the compositor picked a different monitor initially. @@ -91,7 +90,7 @@ impl WindowHandler for WaylandState { && let Some(output) = self.protocol.output().outputs().next() { self.surface.set_current_output(output); - self.set_has_seen_surface_enter(false); + self.focus.clear_surface_enter(); } self.refresh_active_output_label(); @@ -108,9 +107,8 @@ impl WindowHandler for WaylandState { self.surface.set_configured(true); - // Mark overlay ready if we already have keyboard focus (configure came after enter) - if self.has_keyboard_focus() && !self.is_overlay_ready() { - self.set_overlay_ready(true); + // Mark overlay ready if we already have keyboard focus (configure came after enter). + if self.focus.mark_ready_if_focused() { log::debug!("Overlay ready for keybinds (from xdg configure)"); } @@ -124,13 +122,15 @@ impl WindowHandler for WaylandState { self.refresh_freeze_zoom_geometry(); self.cancel_screen_modals_if_source_changed(); - if self.xdg_frozen_fullscreen_requested() && self.frozen.has_pending_image() { - if self.xdg_frozen_fullscreen_pending_configure() && !configure.is_fullscreen() { + if self.surface.placement().xdg_frozen().requested() && self.frozen.has_pending_image() { + if self.surface.placement().xdg_frozen().pending_configure() + && !configure.is_fullscreen() + { warn!("xdg frozen fullscreen was not granted; activating freeze on current size"); } self.activate_pending_frozen_image_for_current_surface(); } - if self.xdg_frozen_fullscreen_requested() + if self.surface.placement().xdg_frozen().requested() && !self.input_state.frozen_active() && !self.frozen.has_pending_image() { @@ -146,24 +146,3 @@ impl WindowHandler for WaylandState { self.begin_configure_fallback_session_transition("xdg configure fallback"); } } - -fn should_ignore_xdg_close_request( - stay_mode: bool, - has_keyboard_focus: bool, - close_guard_active: bool, -) -> bool { - stay_mode && !has_keyboard_focus && close_guard_active -} - -#[cfg(test)] -mod tests { - use super::should_ignore_xdg_close_request; - - #[test] - fn ignores_close_only_for_unfocused_stay_with_active_guard() { - assert!(should_ignore_xdg_close_request(true, false, true)); - assert!(!should_ignore_xdg_close_request(true, true, true)); - assert!(!should_ignore_xdg_close_request(false, false, true)); - assert!(!should_ignore_xdg_close_request(true, false, false)); - } -} diff --git a/src/backend/wayland/runtime_ui_state/wayland.rs b/src/backend/wayland/runtime_ui_state/wayland.rs index 073a2948d..ccbbbef7b 100644 --- a/src/backend/wayland/runtime_ui_state/wayland.rs +++ b/src/backend/wayland/runtime_ui_state/wayland.rs @@ -4,7 +4,10 @@ use super::*; impl WaylandState { pub(in crate::backend::wayland) fn toolbar_position_snapshot(&self) -> ToolbarPositionSnapshot { ToolbarPositionSnapshot { - top: (self.toolbar_top_offset(), self.toolbar_top_offset_y()), + top: ( + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1, + ), } } @@ -17,7 +20,7 @@ impl WaylandState { }; let mut positions = self.toolbar_position_snapshot(); apply_toolbar_runtime_rollback(&mut self.input_state, &mut positions, &rollback); - self.restore_toolbar_offsets(positions.top); + self.toolbar_chrome.set_top_offset(positions.top); self.toolbar.mark_dirty(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; @@ -337,13 +340,13 @@ impl WaylandState { } if refresh.item_drag_aborted { self.input_state.clear_toolbar_item_drag(); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); } if refresh.position_drag_aborted { self.cancel_toolbar_move_drag(); self.cancel_gtk_toolbar_drag_lifecycle(); } - self.restore_toolbar_offsets(positions.top); + self.toolbar_chrome.set_top_offset(positions.top); self.toolbar.mark_dirty(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; @@ -438,14 +441,14 @@ impl WaylandState { } if drain.rebuild_live { self.input_state.clear_toolbar_item_drag(); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.cancel_toolbar_move_drag(); self.cancel_gtk_toolbar_drag_lifecycle(); let mut positions = self.toolbar_position_snapshot(); 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); + self.toolbar_chrome.set_top_offset(positions.top); self.toolbar.mark_dirty(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 69c187417..4f7f2f3cc 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -8,10 +8,7 @@ use smithay_client_toolkit::{ shell::wlr_layer::KeyboardInteractivity, }; use std::time::{Duration, Instant}; -use wayland_client::{ - Proxy, QueueHandle, - protocol::{wl_output, wl_seat}, -}; +use wayland_client::{QueueHandle, protocol::wl_output}; #[cfg(feature = "tablet-input")] use wayland_protocols::wp::tablet::zv2::client::zwp_tablet_manager_v2::ZwpTabletManagerV2; use wayland_protocols::wp::{ @@ -41,24 +38,19 @@ use crate::{ ui::toolbar::{ToolbarBindingHints, ToolbarEvent, ToolbarSnapshot}, }; -use self::data::{MoveDrag, StateData}; -pub use self::data::{ - MoveDragKind, OverlaySuppression, OverlaySuppressionKeyboardPolicy, XdgFrozenFullscreenState, +pub(in crate::backend::wayland) use self::core::overlay::{ + OverlaySuppression, OverlaySuppressionKeyboardPolicy, }; pub(in crate::backend::wayland) use self::region_capture::WindowSnapDirection; +pub(in crate::backend::wayland) use self::toolbar::MoveDragKind; use super::{ RuntimeOperationController, RuntimeOperationIdSource, capture::{CapturePreflightRequest, CaptureState, PendingPdfExport}, frozen::{ExtImageCopyManagers, FrozenState}, overlay_passthrough::set_surface_clickthrough, session::SessionState, - surface::SurfaceState, - toolbar::{ - ToolbarSurfaceManager, - hit::{drag_intent_for_hit, intent_for_hit, quick_color_slot_for_hit}, - layout::top_size, - render::render_top_strip, - }, + surface::{SurfacePlacement, SurfaceState}, + toolbar::{ToolbarSurfaceManager, layout::top_size, render::render_top_strip}, toolbar_intent::intent_to_event, zoom::ZoomState, }; @@ -76,9 +68,9 @@ pub(in crate::backend::wayland) use clipboard_runtime::{ }; mod color_picker; mod core; -mod data; mod desktop_open; mod eyedropper; +mod focus; mod font_catalog; mod gtk_toolbar; mod helper_launch; @@ -137,6 +129,7 @@ pub(in crate::backend::wayland) struct WaylandStateInit { pub globals: ProtocolGlobals, pub config: Config, pub input_state: InputState, + pub startup_activation_token: Option, pub onboarding: crate::onboarding::OnboardingStore, pub palette_recents: crate::palette_recents::PaletteRecentsWriter, pub capture_manager: CaptureManager, @@ -169,11 +162,16 @@ pub(super) struct WaylandState { // Surface and buffer management pub(super) surface: SurfaceState, pub(super) toolbar: ToolbarSurfaceManager, - data: StateData, + pub(super) toolbar_chrome: toolbar::ToolbarChrome, + pub(super) toolbar_drag: toolbar::ToolbarDrag, + pub(super) render: render::RenderRuntime, + pub(super) suppression: core::overlay::OverlaySuppressionState, + shortcut_coach: onboarding::ShortcutCoachSession, + /// Keyboard, pointer, activation, and focus-loss lifecycle. + pub(super) focus: focus::FocusState, /// Per-buffer damage tracking for correct incremental rendering. pub(super) buffer_damage: buffer_damage::BufferDamageTracker, /// Baked committed-shapes layer for panned canvas rendering. - pub(super) canvas_layer_cache: canvas_layer::CanvasLayerCache, /// Render memory, warning latches, and wheel timing for Spotlight effects. pub(super) spotlight: spotlight_runtime::SpotlightRuntime, @@ -196,22 +194,9 @@ pub(super) struct WaylandState { /// Desktop-open work completes off-dispatch; successful completion is what /// requests overlay exit, so runtime-owned broker teardown cannot race it. pub(super) desktop_open: RuntimeOperationController>, - /// Capacity-one compositor window query for the current native region picker. - /// Its context owns the picker/source correlation, so stale workers cannot - /// mutate a later picker generation. - pub(super) window_query: RuntimeOperationController< - region_capture::WindowSnapQuery, - Result< - crate::capture::window_geometry::WindowQueryResult, - crate::capture::window_geometry::WindowGeometryError, - >, - >, - /// Capacity-one Review cut preview. Independent of capture delivery so a - /// replaceable preview cannot occupy the capture reservation slot. - pub(super) region_cut_preview: RuntimeOperationController< - region_capture::CutPreviewKey, - region_capture::CutPreviewOutcome, - >, + /// Region picker state, window-query work, and replaceable cut previews. + pub(super) region_capture: region_capture::RegionCaptureRuntime, + pub(super) acquisition: acquisition::AcquisitionRuntime, /// Capacity-one screen text recognition. A busy controller reports /// busy rather than queuing a region the user has moved on from. pub(super) ocr: crate::ocr::OcrController, @@ -280,7 +265,6 @@ impl WaylandState { const TOP_MARGIN_BOTTOM: f64 = 0.0; const INLINE_TOP_Y: f64 = Self::TOP_BASE_MARGIN_TOP; const INLINE_TOP_X: f64 = 24.0; - const TOOLBAR_CONFIGURE_FAIL_THRESHOLD: u32 = 180; const ZOOM_STEP_KEY: f64 = 1.2; const ZOOM_STEP_SCROLL: f64 = 1.1; pub(super) const ZOOM_PAN_STEP: f64 = 32.0; diff --git a/src/backend/wayland/state/AGENTS.md b/src/backend/wayland/state/AGENTS.md index 8ca048f35..cde37b4b6 100644 --- a/src/backend/wayland/state/AGENTS.md +++ b/src/backend/wayland/state/AGENTS.md @@ -6,8 +6,9 @@ ## 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. -- 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. +- Runtime owners extracted from `WaylandState` live beside it: `focus.rs` (activation, focus, and startup acquisition), `protocol_globals.rs` (bound globals and toolkit handler state), `pointer_runtime.rs` (pointer position, board-pan and chrome gestures, cursor, pointer-lock, and touch lifecycles), `region_capture/runtime.rs` (region generations, active/review/window-snap state, and query/preview workers), `acquisition.rs` (screen acquisition, zoom waiters, and eyedropper source correlation), `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). +- `core/overlay.rs` owns suppression policy and capture-barrier state; `../surface.rs` owns output/fullscreen/layer placement and frozen-fullscreen transitions. +- `render/` owns overlay render phases plus `RenderRuntime` cache, baseline, and per-effect damage history; `toolbar/` owns `ToolbarChrome` (placement, inline interaction, and fade state), `ToolbarDrag` (built-in and GTK drag lifecycles), and runtime toolbar effects; `clipboard/` owns session paste helpers. ## Invariants - Preserve snapshot boundaries for export and session actions. diff --git a/src/backend/wayland/state/acquisition.rs b/src/backend/wayland/state/acquisition.rs index 0d477db4d..2e62136e9 100644 --- a/src/backend/wayland/state/acquisition.rs +++ b/src/backend/wayland/state/acquisition.rs @@ -3,9 +3,10 @@ mod transaction; use crate::backend::wayland::acquisition::{ AcquisitionRecord, AcquisitionStage, ScreenAcquisitionBusy, ScreenAcquisitionCompletion, ScreenAcquisitionId, ScreenAcquisitionOutcome, ScreenAcquisitionOwner, + ScreenAcquisitionRegistry, }; use crate::backend::wayland::zoom::{ - ZoomSourceOutcome, ZoomSourceTerminal, ZoomWaiter, ZoomWaiterOwner, + ZoomSourceOutcome, ZoomSourceTerminal, ZoomWaiter, ZoomWaiterOwner, ZoomWaiterRegistry, }; use crate::input::state::{EyedropperCaptureSource, ScreenCaptureSource}; @@ -17,16 +18,99 @@ pub(super) use transaction::{ report_screen_source_activation_rejected_to, report_zoom_terminal_to, }; -use super::screen_image::displayed_screen_image; +use super::screen_image::{ScreenSourceToken, displayed_screen_image}; use super::{OverlaySuppression, WaylandState}; +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct AcquisitionRuntime { + registry: ScreenAcquisitionRegistry, + zoom_waiter: ZoomWaiterRegistry, + eyedropper_source: Option, +} + +impl AcquisitionRuntime { + pub(in crate::backend::wayland) fn slot(&self) -> Option { + self.registry.slot().copied() + } + + fn take(&mut self) -> Option { + self.registry.take() + } + + fn take_matching( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> Option { + self.registry.take_matching(id, owner) + } + + pub(in crate::backend::wayland) fn request( + &mut self, + owner: ScreenAcquisitionOwner, + ) -> Result { + self.registry.request(owner) + } + + pub(in crate::backend::wayland) fn queued(&self) -> Option { + self.slot() + .filter(|record| record.stage == AcquisitionStage::Queued) + } + + pub(in crate::backend::wayland) fn mark_started( + &mut self, + id: ScreenAcquisitionId, + owner: ScreenAcquisitionOwner, + ) -> bool { + // This transition is a required side effect, not a debug-only check. + let transitioned = self.registry.mark_started(id, owner); + debug_assert!(transitioned, "the queued acquisition was just started"); + transitioned + } + + fn register_zoom_waiter(&mut self, waiter: ZoomWaiter) -> bool { + self.zoom_waiter.register(waiter) + } + + pub(in crate::backend::wayland::state) fn clear_zoom_waiter( + &mut self, + owner: ZoomWaiterOwner, + ) -> bool { + self.zoom_waiter.clear_owner(owner) + } + + fn take_zoom_waiter_for_terminal( + &mut self, + terminal: &ZoomSourceTerminal, + ) -> Option<(ZoomWaiter, bool)> { + self.zoom_waiter.take_for_terminal(terminal) + } + + pub(in crate::backend::wayland::state) fn set_eyedropper_source( + &mut self, + source: ScreenSourceToken, + ) { + self.eyedropper_source = Some(source); + } + + pub(in crate::backend::wayland::state) fn eyedropper_source( + &self, + ) -> Option { + self.eyedropper_source + } + + fn clear_eyedropper_source(&mut self) { + self.eyedropper_source = None; + } +} + impl AcquisitionTransactionRuntime for WaylandState { fn acquisition_slot(&self) -> Option { - self.data.screen_acquisition.slot().copied() + self.acquisition.slot() } fn take_acquisition_record(&mut self) -> Option { - self.data.screen_acquisition.take() + self.acquisition.take() } fn take_matching_acquisition_record( @@ -34,7 +118,7 @@ impl AcquisitionTransactionRuntime for WaylandState { id: ScreenAcquisitionId, owner: ScreenAcquisitionOwner, ) -> Option { - self.data.screen_acquisition.take_matching(id, owner) + self.acquisition.take_matching(id, owner) } fn owner_waiter_matches( @@ -73,7 +157,7 @@ impl AcquisitionTransactionRuntime for WaylandState { } fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner) { - self.clear_zoom_waiter_for(owner); + self.acquisition.clear_zoom_waiter(owner); } fn frozen_generation(&self) -> u64 { @@ -113,7 +197,7 @@ impl AcquisitionTransactionRuntime for WaylandState { } fn frozen_suppressed(&self) -> bool { - self.data.overlay_suppression == OverlaySuppression::Frozen + self.suppression.reason() == OverlaySuppression::Frozen } fn end_frozen_suppression(&mut self) { @@ -129,21 +213,16 @@ impl WaylandState { let Some(id) = self.zoom.current_capture_id() else { return false; }; - self.data.zoom_waiter.register(ZoomWaiter { id, owner }) - } - - pub(in crate::backend::wayland) fn clear_zoom_waiter_for( - &mut self, - owner: ZoomWaiterOwner, - ) -> bool { - self.data.zoom_waiter.clear_owner(owner) + self.acquisition + .register_zoom_waiter(ZoomWaiter { id, owner }) } pub(in crate::backend::wayland) fn resolve_zoom_waiter( &mut self, terminal: ZoomSourceTerminal, ) { - let Some((waiter, matches)) = self.data.zoom_waiter.take_for_terminal(&terminal) else { + let Some((waiter, matches)) = self.acquisition.take_zoom_waiter_for_terminal(&terminal) + else { report_zoom_terminal_to(&mut self.input_state, None, &terminal); return; }; @@ -202,26 +281,24 @@ impl WaylandState { self.input_state.eyedropper_state().pending_source() == Some(EyedropperCaptureSource::Zoom) } - ZoomWaiterOwner::Ocr => self.data.active_screen_region.is_some_and(|region| { + ZoomWaiterOwner::Ocr => self.region_capture.active().is_some_and(|region| { region.purpose() == crate::input::state::RegionPurposeTag::Ocr && matches!( region, super::region_capture::ActiveScreenRegion::PendingZoom { .. } ) }), - ZoomWaiterOwner::RegionCapture => { - self.data.active_screen_region.is_some_and(|region| { - region.purpose().is_capture() - && matches!( - region, - super::region_capture::ActiveScreenRegion::PendingZoom { .. } - ) - && matches!( - self.capture.region_phase(), - crate::backend::wayland::capture::RegionCapturePhase::Reserved(_) - ) - }) - } + ZoomWaiterOwner::RegionCapture => self.region_capture.active().is_some_and(|region| { + region.purpose().is_capture() + && matches!( + region, + super::region_capture::ActiveScreenRegion::PendingZoom { .. } + ) + && matches!( + self.capture.region_phase(), + crate::backend::wayland::capture::RegionCapturePhase::Reserved(_) + ) + }), }; if !waiting { return false; @@ -254,39 +331,6 @@ impl WaylandState { } } - pub(in crate::backend::wayland) fn request_screen_acquisition( - &mut self, - owner: ScreenAcquisitionOwner, - ) -> Result { - self.data.screen_acquisition.request(owner) - } - - pub(in crate::backend::wayland) fn queued_screen_acquisition( - &self, - ) -> Option { - self.data - .screen_acquisition - .slot() - .copied() - .filter(|record| record.stage == AcquisitionStage::Queued) - } - - pub(in crate::backend::wayland) fn screen_acquisition_slot(&self) -> Option { - self.data.screen_acquisition.slot().copied() - } - - pub(in crate::backend::wayland) fn mark_screen_acquisition_started( - &mut self, - id: ScreenAcquisitionId, - owner: ScreenAcquisitionOwner, - ) { - // This transition is a required side effect, not a debug-only check. - // Keeping the mutation outside `debug_assert!` prevents release builds - // from leaving the record queued and starting it again next pass. - let transitioned = self.data.screen_acquisition.mark_started(id, owner); - debug_assert!(transitioned, "the queued acquisition was just started"); - } - pub(in crate::backend::wayland) fn complete_queued_acquisition( &mut self, id: ScreenAcquisitionId, @@ -316,12 +360,12 @@ impl WaylandState { self.input_state.eyedropper_state().pending_source() == Some(EyedropperCaptureSource::Frozen) } - ScreenAcquisitionOwner::Ocr => self.data.active_screen_region.is_some_and(|region| { + ScreenAcquisitionOwner::Ocr => self.region_capture.active().is_some_and(|region| { region.purpose() == crate::input::state::RegionPurposeTag::Ocr && region.waits_for_acquisition(completion.id) }), ScreenAcquisitionOwner::RegionCapture => { - self.data.active_screen_region.is_some_and(|region| { + self.region_capture.active().is_some_and(|region| { region.purpose().is_capture() && region.waits_for_acquisition(completion.id) && matches!( @@ -381,7 +425,7 @@ impl WaylandState { } pub(in crate::backend::wayland) fn cancel_eyedropper_ui_only(&mut self) { - self.data.active_eyedropper_source = None; + self.acquisition.clear_eyedropper_source(); let _ = self.input_state.cancel_eyedropper(); } @@ -411,3 +455,27 @@ impl WaylandState { release_owned_generation(self, generation) } } + +#[cfg(test)] +mod owner_tests { + use super::*; + + #[test] + fn acquisition_slot_is_capacity_one_and_stage_checked() { + let mut runtime = AcquisitionRuntime::default(); + let id = runtime + .request(ScreenAcquisitionOwner::Ocr) + .expect("first request"); + + assert!(runtime.request(ScreenAcquisitionOwner::Eyedropper).is_err()); + assert_eq!( + runtime.slot().map(|record| record.stage), + Some(AcquisitionStage::Queued) + ); + assert!(runtime.mark_started(id, ScreenAcquisitionOwner::Ocr)); + assert_eq!( + runtime.slot().map(|record| record.stage), + Some(AcquisitionStage::Started) + ); + } +} diff --git a/src/backend/wayland/state/activation.rs b/src/backend/wayland/state/activation.rs index d41b91a33..f0f53f9e9 100644 --- a/src/backend/wayland/state/activation.rs +++ b/src/backend/wayland/state/activation.rs @@ -12,7 +12,7 @@ impl WaylandState { return false; } - let Some(token) = self.take_startup_activation_token() else { + let Some(token) = self.focus.take_startup_activation_token() else { return false; }; let Some(activation) = self.protocol.activation() else { @@ -41,10 +41,9 @@ impl WaylandState { }; if let Some(seat_serial) = self + .focus .current_seat() - .as_ref() - .cloned() - .zip(self.last_activation_serial()) + .zip(self.focus.last_activation_serial()) { let app_id = runtime_app_id(); activation.request_token::( @@ -57,7 +56,7 @@ impl WaylandState { ); } else { // Defer until we have a keyboard enter serial. - self.set_pending_activation_token(Some(String::new())); // marker + self.focus.defer_activation_until_serial(); } } @@ -66,7 +65,7 @@ impl WaylandState { return; } - let Some(token) = self.pending_activation_token() else { + let Some(token) = self.focus.activation_token_to_apply() else { return; }; @@ -79,13 +78,13 @@ impl WaylandState { }; activation.activate::(&wl_surface, token); - self.set_pending_activation_token(None); + self.focus.clear_pending_activation_token(); } pub(in crate::backend::wayland) fn maybe_retry_activation(&mut self, qh: &QueueHandle) { - if self.pending_activation_token().is_some() && self.last_activation_serial().is_some() { + if self.focus.retry_activation_wanted() { // Drop the placeholder and re-request with the new serial. - self.set_pending_activation_token(None); + self.focus.clear_pending_activation_token(); self.request_xdg_activation(qh); } } @@ -95,7 +94,7 @@ impl ActivationHandler for WaylandState { type RequestData = RequestData; fn new_token(&mut self, token: String, _data: &Self::RequestData) { - self.set_pending_activation_token(Some(token)); + self.focus.note_activation_token(token); self.activate_xdg_window_if_possible(); } } diff --git a/src/backend/wayland/state/boards.rs b/src/backend/wayland/state/boards.rs index c61aa546f..ac5d7374d 100644 --- a/src/backend/wayland/state/boards.rs +++ b/src/backend/wayland/state/boards.rs @@ -65,27 +65,6 @@ impl WaylandState { && matches!(self.input_state.state, DrawingState::Idle) } - pub(in crate::backend::wayland) fn start_board_pan(&mut self, screen_x: f64, screen_y: f64) { - self.data.board_panning = true; - self.data.board_pan_last_pos = (screen_x, screen_y); - } - - pub(in crate::backend::wayland) fn stop_board_pan(&mut self) { - self.data.board_panning = false; - } - - pub(in crate::backend::wayland) fn board_panning_active(&self) -> bool { - self.data.board_panning - } - - pub(in crate::backend::wayland) fn board_pan_key_held(&self) -> bool { - self.data.board_pan_key_held - } - - pub(in crate::backend::wayland) fn set_board_pan_key_held(&mut self, held: bool) { - self.data.board_pan_key_held = held; - } - pub(in crate::backend::wayland) fn pan_board_by_screen_delta( &mut self, dx: f64, @@ -112,16 +91,6 @@ impl WaylandState { changed } - pub(in crate::backend::wayland) fn update_board_pan_position( - &mut self, - screen_x: f64, - screen_y: f64, - ) -> (f64, f64) { - let (last_x, last_y) = self.data.board_pan_last_pos; - self.data.board_pan_last_pos = (screen_x, screen_y); - (screen_x - last_x, screen_y - last_y) - } - pub(in crate::backend::wayland) fn should_capture_space_for_board_pan(&self) -> bool { self.input_state.boards.pan_enabled() && !self.input_state.board_is_transparent() @@ -134,8 +103,8 @@ impl WaylandState { && !self.input_state.is_context_menu_open() && !self.input_state.is_properties_panel_open() && !self.input_state.is_radial_menu_open() - && !self.pointer_over_toolbar() - && !self.toolbar_focus_active() + && !self.toolbar_chrome.pointer_over_toolbar() + && !self.toolbar_chrome.focus_active() && matches!(self.input_state.state, DrawingState::Idle) } } diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index 831076105..ce3aef0f8 100644 --- a/src/backend/wayland/state/canvas_layer.rs +++ b/src/backend/wayland/state/canvas_layer.rs @@ -178,7 +178,7 @@ impl WaylandState { let shapes_len = frame.shapes.len(); let last_shape_id = frame.shapes.last().map(|shape| shape.id); - let cache = &self.canvas_layer_cache; + let cache = self.render.canvas_layer_cache(); let params_match = cache.valid && cache.surface.is_some() && cache.scale == scale @@ -204,36 +204,37 @@ impl WaylandState { let phys_w = bake_w.saturating_mul(scale); let phys_h = bake_h.saturating_mul(scale); if phys_w <= 0 || phys_h <= 0 || phys_w > CAIRO_MAX_DIM || phys_h > CAIRO_MAX_DIM { - self.canvas_layer_cache.clear(); + self.render.canvas_layer_cache_mut().clear(); return false; } if phys_w as usize * phys_h as usize * 4 > MAX_CACHE_BYTES { - self.canvas_layer_cache.clear(); + self.render.canvas_layer_cache_mut().clear(); return false; } let reuse_surface = self - .canvas_layer_cache + .render + .canvas_layer_cache_mut() .surface .as_ref() .is_some_and(|surface| surface.width() == phys_w && surface.height() == phys_h); if !reuse_surface { match cairo::ImageSurface::create(cairo::Format::ARgb32, phys_w, phys_h) { - Ok(surface) => self.canvas_layer_cache.surface = Some(surface), + Ok(surface) => self.render.canvas_layer_cache_mut().surface = Some(surface), Err(err) => { debug!("canvas layer cache: surface allocation failed: {err}"); - self.canvas_layer_cache.clear(); + self.render.canvas_layer_cache_mut().clear(); return false; } } } { - let Some(surface) = self.canvas_layer_cache.surface.as_ref() else { + let Some(surface) = self.render.canvas_layer_cache_mut().surface.as_ref() else { return false; }; let Ok(bake_ctx) = cairo::Context::new(surface) else { - self.canvas_layer_cache.clear(); + self.render.canvas_layer_cache_mut().clear(); return false; }; @@ -275,11 +276,11 @@ impl WaylandState { } } } - if let Some(surface) = self.canvas_layer_cache.surface.as_ref() { + if let Some(surface) = self.render.canvas_layer_cache_mut().surface.as_ref() { surface.flush(); } - let cache = &mut self.canvas_layer_cache; + let cache = self.render.canvas_layer_cache_mut(); cache.world_x = world_x; cache.world_y = world_y; cache.width = bake_w; diff --git a/src/backend/wayland/state/capture/barrier.rs b/src/backend/wayland/state/capture/barrier.rs index 685385ba5..6eed72cf1 100644 --- a/src/backend/wayland/state/capture/barrier.rs +++ b/src/backend/wayland/state/capture/barrier.rs @@ -225,14 +225,14 @@ impl WaylandState { &self, now: Instant, ) -> Option { - self.data.overlay_capture_barrier.frame_timeout(now) + self.suppression.barrier.frame_timeout(now) } pub(in crate::backend::wayland) fn poll_overlay_capture_barrier_timeout( &mut self, now: Instant, ) { - let Some(timeout) = self.data.overlay_capture_barrier.take_frame_timeout(now) else { + let Some(timeout) = self.suppression.barrier.take_frame_timeout(now) else { return; }; log::warn!( @@ -258,8 +258,8 @@ impl WaylandState { generation: u64, qh: &QueueHandle, ) { - self.data - .overlay_capture_barrier + self.suppression + .barrier .mark_main_surface_frame_ready(generation); self.begin_ready_overlay_capture(qh); } @@ -271,11 +271,7 @@ impl WaylandState { &mut self, generation: u64, ) { - if self - .data - .overlay_capture_barrier - .acknowledge_gtk_paint(generation) - { + if self.suppression.barrier.acknowledge_gtk_paint(generation) { self.buffer_damage .mark_all_full(FullDamageReason::OverlaySuppression); self.input_state.needs_redraw = true; @@ -291,8 +287,8 @@ impl WaylandState { error: &str, ) { let Some(reason) = self - .data - .overlay_capture_barrier + .suppression + .barrier .reason_waiting_for_gtk_generation(generation) else { log::info!( @@ -313,7 +309,7 @@ impl WaylandState { /// A failed GTK connection cannot prove that its mapped surfaces painted /// transparent. Cancel only captures still waiting for that proof. pub(in crate::backend::wayland) fn cancel_overlay_capture_waiting_for_gtk(&mut self) { - let Some(reason) = self.data.overlay_capture_barrier.reason_waiting_for_gtk() else { + let Some(reason) = self.suppression.barrier.reason_waiting_for_gtk() else { return; }; log::warn!( @@ -327,13 +323,13 @@ impl WaylandState { } fn begin_ready_overlay_capture(&mut self, qh: &QueueHandle) { - let Some(reason) = self.data.overlay_capture_barrier.take_ready() else { + let Some(reason) = self.suppression.barrier.take_ready() else { return; }; - if self.data.overlay_suppression != reason { + if self.suppression.reason() != reason { log::warn!( "Capture barrier completed for {reason:?} while suppression is {:?}; cancelling", - self.data.overlay_suppression + self.suppression.reason() ); self.cancel_overlay_capture_preflight(reason, None); return; @@ -389,7 +385,7 @@ impl WaylandState { self.cancel_overlay_capture_preflight(reason, None); return; }; - if !self.capture_suppressed() { + if !self.suppression.capture_suppressed() { log::warn!( "Capture preflight completed without capture suppression; cancelling" ); @@ -443,7 +439,7 @@ impl WaylandState { terminal_will_report } OverlaySuppression::None | OverlaySuppression::ExternalDialog => { - self.data.overlay_capture_barrier.cancel(reason); + self.suppression.barrier.cancel(reason); false } } diff --git a/src/backend/wayland/state/clipboard.rs b/src/backend/wayland/state/clipboard.rs index f0ddeb780..175febfe6 100644 --- a/src/backend/wayland/state/clipboard.rs +++ b/src/backend/wayland/state/clipboard.rs @@ -124,7 +124,8 @@ impl WaylandState { } fn start_selection_clipboard_publish(&mut self, generation: u64, payload_json: String) { - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(Instant::now(), Duration::from_millis(1500)); if let Err(failure) = self.clipboard.submit_publish(generation, payload_json) { let (error, generation) = failure.into_parts(); log::warn!("Could not submit clipboard publish operation: {error}"); @@ -156,7 +157,8 @@ impl WaylandState { request.target_page_index, request.target_page_generation ); - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(Instant::now(), Duration::from_millis(1500)); let local_selection = self.input_state.selection_clipboard_snapshot(); let pending_shapes = diff --git a/src/backend/wayland/state/color_picker.rs b/src/backend/wayland/state/color_picker.rs index 1faf321c8..17f735852 100644 --- a/src/backend/wayland/state/color_picker.rs +++ b/src/backend/wayland/state/color_picker.rs @@ -13,7 +13,8 @@ impl WaylandState { pub(in crate::backend::wayland) fn handle_copy_hex_color(&mut self, color: Color) { let hex = color_to_hex(color); log::info!("Hex copy requested: {}", hex); - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(std::time::Instant::now(), Duration::from_millis(1500)); if let Err(err) = self.clipboard.queue_hex_copy(hex) { log::warn!("Failed to start hex clipboard copy: {err}"); @@ -93,7 +94,8 @@ impl WaylandState { return; } log::info!("Hex paste requested"); - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(std::time::Instant::now(), Duration::from_millis(1500)); let clipboard = match std::panic::catch_unwind(read_clipboard_text_via_command) { Ok(Ok(text)) => text, Ok(Err(ClipboardTextError::Empty)) => { diff --git a/src/backend/wayland/state/core/accessors.rs b/src/backend/wayland/state/core/accessors.rs index 02315cd8b..8cfd13292 100644 --- a/src/backend/wayland/state/core/accessors.rs +++ b/src/backend/wayland/state/core/accessors.rs @@ -1,65 +1,14 @@ -use smithay_client_toolkit::shell::{WaylandSurface, wlr_layer::Layer}; -use wayland_client::protocol::wl_pointer; - use super::super::*; -use std::time::{Duration, Instant}; - -const XDG_FROZEN_FULLSCREEN_TIMEOUT: Duration = Duration::from_millis(1500); - -fn xdg_frozen_fullscreen_timeout( - pending_configure: bool, - requested_at: Option, - now: Instant, -) -> Option { - if !pending_configure { - return None; - } - Some( - requested_at - .and_then(|requested_at| requested_at.checked_add(XDG_FROZEN_FULLSCREEN_TIMEOUT)) - .map(|deadline| deadline.saturating_duration_since(now)) - .unwrap_or(Duration::ZERO), - ) -} - -fn finish_xdg_frozen_fullscreen_request( - state: &mut XdgFrozenFullscreenState, - requested_at: &mut Option, -) { - *state = XdgFrozenFullscreenState::Inactive; - *requested_at = None; -} +use smithay_client_toolkit::shell::WaylandSurface; +use std::time::Instant; impl WaylandState { - pub(in crate::backend::wayland) fn current_mouse(&self) -> (i32, i32) { - (self.data.current_mouse_x, self.data.current_mouse_y) - } - - pub(in crate::backend::wayland) fn set_current_mouse(&mut self, x: i32, y: i32) { - self.data.current_mouse_x = x; - self.data.current_mouse_y = y; - } - - #[allow(dead_code)] - pub(in crate::backend::wayland) fn has_keyboard_focus(&self) -> bool { - self.data.has_keyboard_focus - } - - pub(in crate::backend::wayland) fn set_keyboard_focus(&mut self, value: bool) { - self.data.has_keyboard_focus = value; - } - - #[allow(dead_code)] - pub(in crate::backend::wayland) fn has_pointer_focus(&self) -> bool { - self.data.has_pointer_focus - } - pub(in crate::backend::wayland) fn has_cursor_focus(&self) -> bool { - self.has_pointer_focus() || self.stylus_hover_cursor_visible() + self.focus.pointer_focused() || self.stylus_hover_cursor_visible() } pub(in crate::backend::wayland) fn cursor_blocked_by_toolbar(&self) -> bool { - self.stylus_hover_cursor_position().is_none() && self.pointer_over_toolbar() + self.stylus_hover_cursor_position().is_none() && self.toolbar_chrome.pointer_over_toolbar() } #[cfg(feature = "tablet-input")] @@ -117,198 +66,14 @@ impl WaylandState { #[cfg(not(feature = "tablet-input"))] pub(in crate::backend::wayland) fn retire_stylus_contact(&mut self) {} - pub(in crate::backend::wayland) fn set_pointer_focus(&mut self, value: bool) { - self.data.has_pointer_focus = value; - } - - pub(in crate::backend::wayland) fn current_seat(&self) -> Option { - self.data.current_seat.clone() - } - - #[allow(dead_code)] // Kept for potential future pointer lock support - pub(in crate::backend::wayland) fn current_pointer(&self) -> Option { - self.pointer.current_pointer() - } - - pub(in crate::backend::wayland) fn current_seat_id(&self) -> Option { - self.data - .current_seat - .as_ref() - .map(|seat| seat.id().protocol_id()) - } - - pub(in crate::backend::wayland) fn set_current_seat(&mut self, seat: Option) { - self.data.current_seat = seat; - } - - pub(in crate::backend::wayland) fn last_activation_serial(&self) -> Option { - self.data.last_activation_serial - } - - pub(in crate::backend::wayland) fn set_last_activation_serial(&mut self, serial: Option) { - self.data.last_activation_serial = serial; - } - - pub(in crate::backend::wayland) fn current_keyboard_interactivity( - &self, - ) -> Option { - self.data.current_keyboard_interactivity - } - - pub(in crate::backend::wayland) fn set_current_keyboard_interactivity( - &mut self, - interactivity: Option, - ) { - self.data.current_keyboard_interactivity = interactivity; - } - - pub(in crate::backend::wayland) fn suppress_focus_exit_for(&mut self, duration: Duration) { - self.data.suppress_focus_exit_until = Some(Instant::now() + duration); - } - - pub(in crate::backend::wayland) fn focus_exit_suppressed(&self) -> bool { - self.data - .suppress_focus_exit_until - .is_some_and(|until| Instant::now() <= until) - } - - pub(in crate::backend::wayland) fn focus_exit_timeout(&self, now: Instant) -> Option { - self.data - .suppress_focus_exit_until - .and_then(|until| (until > now).then(|| until.saturating_duration_since(now))) - } - - pub(in crate::backend::wayland) fn focus_exit_suppression_expired(&self, now: Instant) -> bool { - self.data - .suppress_focus_exit_until - .is_some_and(|until| now >= until) - } - - pub(in crate::backend::wayland) fn clear_focus_exit_suppression(&mut self) { - self.data.suppress_focus_exit_until = None; - } - - pub(in crate::backend::wayland) fn set_xdg_close_guard_for(&mut self, duration: Duration) { - self.data.xdg_close_guard_until = Some(Instant::now() + duration); - } - - pub(in crate::backend::wayland) fn clear_xdg_close_guard(&mut self) { - self.data.xdg_close_guard_until = None; - } - - pub(in crate::backend::wayland) fn xdg_close_guard_active(&self, now: Instant) -> bool { - self.data - .xdg_close_guard_until - .is_some_and(|until| now <= until) - } - - pub(in crate::backend::wayland) fn mark_xdg_explicit_close_requested(&mut self) { - self.data.xdg_explicit_close_requested = true; - } - - pub(in crate::backend::wayland) fn take_xdg_explicit_close_requested(&mut self) -> bool { - let was_requested = self.data.xdg_explicit_close_requested; - self.data.xdg_explicit_close_requested = false; - was_requested - } - - pub(in crate::backend::wayland) fn frozen_enabled(&self) -> bool { - self.data.frozen_enabled - } - - #[allow(dead_code)] - pub(in crate::backend::wayland) fn set_frozen_enabled(&mut self, value: bool) { - self.data.frozen_enabled = value; - } - - pub(in crate::backend::wayland) fn pending_freeze_on_start(&self) -> bool { - self.data.pending_freeze_on_start - } - - pub(in crate::backend::wayland) fn set_pending_freeze_on_start(&mut self, value: bool) { - self.data.pending_freeze_on_start = value; - } - - #[allow(dead_code)] - pub(in crate::backend::wayland) fn has_seen_surface_enter(&self) -> bool { - self.data.has_seen_surface_enter - } - - pub(in crate::backend::wayland) fn set_has_seen_surface_enter(&mut self, value: bool) { - self.data.has_seen_surface_enter = value; - } - - pub(in crate::backend::wayland) fn pending_activation_token(&self) -> Option { - self.data.pending_activation_token.clone() - } - - pub(in crate::backend::wayland) fn set_pending_activation_token( - &mut self, - token: Option, - ) { - self.data.pending_activation_token = token; - } - - pub(in crate::backend::wayland) fn take_startup_activation_token(&mut self) -> Option { - self.data.startup_activation_token.take() - } - - pub(in crate::backend::wayland) fn preferred_output_identity(&self) -> Option<&str> { - self.data.preferred_output_identity.as_deref() - } - - #[allow(dead_code)] - pub(in crate::backend::wayland) fn set_preferred_output_identity( - &mut self, - value: Option, - ) { - self.data.preferred_output_identity = value; - } - - pub(in crate::backend::wayland) fn xdg_fullscreen(&self) -> bool { - self.data.xdg_fullscreen - } - - pub(in crate::backend::wayland) fn xdg_frozen_fullscreen_requested(&self) -> bool { - !matches!( - self.data.xdg_frozen_fullscreen_state, - crate::backend::wayland::state::XdgFrozenFullscreenState::Inactive - ) - } - - pub(in crate::backend::wayland) fn xdg_frozen_fullscreen_pending_configure(&self) -> bool { - matches!( - self.data.xdg_frozen_fullscreen_state, - crate::backend::wayland::state::XdgFrozenFullscreenState::PendingConfigure - ) - } - - pub(in crate::backend::wayland) fn xdg_frozen_fullscreen_timeout( - &self, - now: Instant, - ) -> Option { - xdg_frozen_fullscreen_timeout( - self.xdg_frozen_fullscreen_pending_configure(), - self.data.xdg_frozen_fullscreen_requested_at, - now, - ) - } - - pub(in crate::backend::wayland) fn xdg_frozen_fullscreen_timed_out( - &self, - now: Instant, - ) -> bool { - self.xdg_frozen_fullscreen_timeout(now) - .is_some_and(|timeout| timeout.is_zero()) - } - pub(in crate::backend::wayland) fn begin_xdg_frozen_fullscreen(&mut self) -> bool { let Some(window) = self.surface.xdg_window().cloned() else { return false; }; - self.data.xdg_frozen_fullscreen_state = - crate::backend::wayland::state::XdgFrozenFullscreenState::PendingConfigure; - self.data.xdg_frozen_fullscreen_requested_at = Some(Instant::now()); + self.surface + .placement_mut() + .xdg_frozen_mut() + .request(Instant::now()); if let Some(output) = self.preferred_fullscreen_output() { window.set_fullscreen(Some(&output)); } else { @@ -319,11 +84,11 @@ impl WaylandState { } pub(in crate::backend::wayland) fn restore_xdg_after_frozen(&mut self) { - if !self.xdg_frozen_fullscreen_requested() { + if !self.surface.placement().xdg_frozen().requested() { return; } if let Some(window) = self.surface.xdg_window().cloned() { - if self.xdg_fullscreen() { + if self.surface.placement().xdg_fullscreen() { if let Some(output) = self.preferred_fullscreen_output() { window.set_fullscreen(Some(&output)); } else { @@ -335,16 +100,13 @@ impl WaylandState { } window.commit(); } - finish_xdg_frozen_fullscreen_request( - &mut self.data.xdg_frozen_fullscreen_state, - &mut self.data.xdg_frozen_fullscreen_requested_at, - ); + self.surface.placement_mut().xdg_frozen_mut().finish(); } pub(in crate::backend::wayland) fn activate_pending_frozen_image_for_current_surface( &mut self, ) { - let was_xdg_frozen_fullscreen = self.xdg_frozen_fullscreen_requested(); + let was_xdg_frozen_fullscreen = self.surface.placement().xdg_frozen().requested(); let (phys_width, phys_height) = self.surface.physical_dimensions(); let live_output_count = self.live_output_count(); match self.frozen.activate_pending_image_with_live_outputs( @@ -355,9 +117,7 @@ impl WaylandState { ) { Ok(true) => { if was_xdg_frozen_fullscreen { - self.data.xdg_frozen_fullscreen_state = - crate::backend::wayland::state::XdgFrozenFullscreenState::Active; - self.data.xdg_frozen_fullscreen_requested_at = None; + self.surface.placement_mut().xdg_frozen_mut().activate(); } } Ok(false) => {} @@ -368,14 +128,6 @@ impl WaylandState { } } - pub(in crate::backend::wayland) fn main_surface_layer(&self) -> Layer { - if self.data.main_surface_uses_overlay_layer { - Layer::Overlay - } else { - Layer::Top - } - } - pub(in crate::backend::wayland) fn xdg_focus_loss_exits_overlay(&self) -> bool { matches!( self.config.ui.xdg_focus_loss_behavior, @@ -383,75 +135,7 @@ impl WaylandState { ) } - #[allow(dead_code)] - pub(in crate::backend::wayland) fn set_xdg_fullscreen(&mut self, value: bool) { - self.data.xdg_fullscreen = value; - } - pub(in crate::backend::wayland) fn session_options(&self) -> Option<&SessionOptions> { self.session.options() } - - #[allow(dead_code)] - pub(in crate::backend::wayland) fn session_options_mut( - &mut self, - ) -> Option<&mut SessionOptions> { - self.session.options_mut() - } - - /// Returns true if the overlay is ready to process keybinds (surface configured + focus). - pub(in crate::backend::wayland) fn is_overlay_ready(&self) -> bool { - self.data.overlay_ready - } - - /// Sets the overlay ready state. Should be true only when surface is configured and has focus. - pub(in crate::backend::wayland) fn set_overlay_ready(&mut self, value: bool) { - self.data.overlay_ready = value; - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn xdg_frozen_fullscreen_deadline_uses_injected_time() { - let start = Instant::now(); - assert_eq!( - xdg_frozen_fullscreen_timeout(true, Some(start), start), - Some(XDG_FROZEN_FULLSCREEN_TIMEOUT) - ); - assert_eq!( - xdg_frozen_fullscreen_timeout(true, Some(start), start + XDG_FROZEN_FULLSCREEN_TIMEOUT,), - Some(Duration::ZERO) - ); - assert_eq!( - xdg_frozen_fullscreen_timeout(false, Some(start), start), - None - ); - assert_eq!( - xdg_frozen_fullscreen_timeout(true, None, start), - Some(Duration::ZERO) - ); - } - - #[test] - fn finishing_xdg_frozen_fullscreen_request_eliminates_an_expired_timeout() { - let start = Instant::now(); - let mut state = XdgFrozenFullscreenState::PendingConfigure; - let mut requested_at = Some(start); - - finish_xdg_frozen_fullscreen_request(&mut state, &mut requested_at); - - assert_eq!(state, XdgFrozenFullscreenState::Inactive); - assert_eq!(requested_at, None); - assert_eq!( - xdg_frozen_fullscreen_timeout( - state == XdgFrozenFullscreenState::PendingConfigure, - requested_at, - start + XDG_FROZEN_FULLSCREEN_TIMEOUT, - ), - None - ); - } } diff --git a/src/backend/wayland/state/core/focus.rs b/src/backend/wayland/state/core/focus.rs deleted file mode 100644 index 241794991..000000000 --- a/src/backend/wayland/state/core/focus.rs +++ /dev/null @@ -1,159 +0,0 @@ -use smithay_client_toolkit::shell::wlr_layer::KeyboardInteractivity; - -use super::super::{WaylandState, data::MainLayerFocusPhase}; - -#[derive(Debug, Clone, Copy)] -pub(in crate::backend::wayland::state) struct MainLayerEnterFacts { - pub(in crate::backend::wayland::state) is_current_main_layer_surface: bool, - pub(in crate::backend::wayland::state) phase: MainLayerFocusPhase, - pub(in crate::backend::wayland::state) committed_keyboard_interactivity: - Option, - pub(in crate::backend::wayland::state) keyboard_release_requested: bool, -} - -pub(in crate::backend::wayland::state) fn can_complete_main_layer_focus_acquisition( - facts: MainLayerEnterFacts, -) -> bool { - facts.is_current_main_layer_surface - && facts.phase.is_acquiring() - && facts.committed_keyboard_interactivity == Some(KeyboardInteractivity::Exclusive) - && !facts.keyboard_release_requested -} - -impl WaylandState { - pub(in crate::backend::wayland) fn begin_main_layer_focus_acquisition(&mut self) { - self.data.main_layer_focus_phase.begin(); - } - - pub(in crate::backend::wayland) fn main_layer_focus_acquiring(&self) -> bool { - self.data.main_layer_focus_phase.is_acquiring() - } - - pub(in crate::backend::wayland) fn try_complete_main_layer_focus_acquisition( - &mut self, - is_current_main_layer_surface: bool, - ) -> bool { - let facts = MainLayerEnterFacts { - is_current_main_layer_surface, - phase: self.data.main_layer_focus_phase, - committed_keyboard_interactivity: self.current_keyboard_interactivity(), - keyboard_release_requested: self.overlay_keyboard_passthrough_requested(), - }; - if !can_complete_main_layer_focus_acquisition(facts) { - return false; - } - self.data.main_layer_focus_phase.complete() - } - - /// Retire every keyboard-owned transient when focus is lost. - /// - /// Both the compositor's keyboard-leave callback and layer-output surface - /// recreation enter through this state lifecycle boundary. - pub(in crate::backend::wayland) fn teardown_keyboard_focus(&mut self) { - self.data.main_layer_focus_phase = - self.data.main_layer_focus_phase.after_keyboard_teardown(); - self.set_keyboard_focus(false); - self.set_overlay_ready(false); - self.clear_toolbar_focus(); - self.input_state.clear_focus_owned_key_state(); - self.sync_region_square_modifier(false); - self.clear_key_repeat(); - self.set_board_pan_key_held(false); - self.stop_board_pan(); - } -} - -#[cfg(test)] -mod tests { - use smithay_client_toolkit::shell::wlr_layer::KeyboardInteractivity; - - use super::{ - MainLayerEnterFacts, MainLayerFocusPhase, can_complete_main_layer_focus_acquisition, - }; - - #[test] - fn stale_main_enter_under_none_keeps_acquisition_pending() { - let mut phase = MainLayerFocusPhase::default(); - - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase, - committed_keyboard_interactivity: Some(KeyboardInteractivity::None), - keyboard_release_requested: true, - } - )); - assert!(phase.is_acquiring()); - - assert!(can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase, - committed_keyboard_interactivity: Some(KeyboardInteractivity::Exclusive), - keyboard_release_requested: false, - } - )); - assert!(phase.complete()); - assert!(!phase.is_acquiring()); - } - - #[test] - fn main_layer_enter_requires_current_surface_acquiring_exclusive_and_no_release() { - let valid = MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase: MainLayerFocusPhase::Acquiring, - committed_keyboard_interactivity: Some(KeyboardInteractivity::Exclusive), - keyboard_release_requested: false, - }; - - assert!(can_complete_main_layer_focus_acquisition(valid)); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: false, - ..valid - } - )); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - phase: MainLayerFocusPhase::Acquired, - ..valid - } - )); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - committed_keyboard_interactivity: Some(KeyboardInteractivity::OnDemand), - ..valid - } - )); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - committed_keyboard_interactivity: None, - ..valid - } - )); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - keyboard_release_requested: true, - ..valid - } - )); - } - - #[test] - fn ordinary_keyboard_teardown_keeps_an_acquired_surface_out_of_acquisition() { - let mut phase = MainLayerFocusPhase::default(); - assert!(phase.complete()); - - phase = phase.after_keyboard_teardown(); - - assert!(!phase.is_acquiring()); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase, - committed_keyboard_interactivity: Some(KeyboardInteractivity::Exclusive), - keyboard_release_requested: false, - } - )); - } -} diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index f0317ab7c..c4e2c966f 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -1,6 +1,6 @@ use super::super::buffer_damage::BufferDamageTracker; use super::super::*; -use crate::env_vars::{FORCE_INLINE_TOOLBARS_ENV, XDG_ACTIVATION_TOKEN_ENV}; +use crate::env_vars::FORCE_INLINE_TOOLBARS_ENV; impl WaylandState { pub(in crate::backend::wayland) fn new(init: WaylandStateInit) -> Self { @@ -8,6 +8,7 @@ impl WaylandState { globals, config, input_state, + startup_activation_token, onboarding, palette_recents, capture_manager, @@ -41,19 +42,13 @@ impl WaylandState { } }; - let mut data = StateData::new(); - data.frozen_enabled = frozen_enabled; - data.pending_freeze_on_start = pending_freeze_on_start; - let startup_activation_token = startup_activation_token_from_env(); - if startup_activation_token.is_some() { - info!("Received startup activation token from launcher environment"); - } - data.startup_activation_token = startup_activation_token; - data.preferred_output_identity = preferred_output_identity; - data.xdg_fullscreen = xdg_fullscreen; - data.main_surface_uses_overlay_layer = main_surface_uses_overlay_layer; + let placement = SurfacePlacement::new( + preferred_output_identity, + xdg_fullscreen, + main_surface_uses_overlay_layer, + ); let force_inline_toolbars = force_inline_toolbars_requested(&config); - data.inline_toolbars = globals.layer_shell().is_none() + let inline_toolbars = globals.layer_shell().is_none() || force_inline_toolbars || main_surface_uses_overlay_layer; if force_inline_toolbars { @@ -76,12 +71,12 @@ impl WaylandState { if let Some(runtime_ui) = runtime_ui.as_ref() { runtime_ui.apply_startup_positions(&mut positions); } - data.toolbar_top_offset = positions.top.0; - data.toolbar_top_offset_y = positions.top.1; + let toolbar_chrome = + super::super::toolbar::ToolbarChrome::new(inline_toolbars, positions.top); drag_log(|| { format!( "load offsets from config seeds and runtime overrides: top_offset=({}, {})", - data.toolbar_top_offset, data.toolbar_top_offset_y + positions.top.0, positions.top.1 ) }); let zoom_manager = screencopy_manager.clone(); @@ -101,10 +96,10 @@ impl WaylandState { ); let desktop_open = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let window_query = - RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); - let region_cut_preview = - RuntimeOperationController::new(runtime_operation_ids, runtime_wake.clone()); + let region_capture = super::super::region_capture::RegionCaptureRuntime::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, @@ -116,11 +111,15 @@ impl WaylandState { Self { protocol: globals, - surface: SurfaceState::new(), + surface: SurfaceState::new(placement), toolbar: ToolbarSurfaceManager::new(), - data, + toolbar_chrome, + toolbar_drag: super::super::toolbar::ToolbarDrag::new(), + render: super::super::render::RenderRuntime::new(), + suppression: Default::default(), + shortcut_coach: Default::default(), + focus: super::super::focus::FocusState::new(startup_activation_token), buffer_damage: BufferDamageTracker::new(buffer_count), - canvas_layer_cache: super::super::canvas_layer::CanvasLayerCache::new(), spotlight: super::super::spotlight_runtime::SpotlightRuntime::new(), config, preferences, @@ -128,8 +127,8 @@ impl WaylandState { font_catalog, clipboard, desktop_open, - window_query, - region_cut_preview, + region_capture, + acquisition: Default::default(), ocr, gtk_toolbar: None, ui_animation, @@ -139,6 +138,8 @@ impl WaylandState { ext_image_copy_managers, portal_freeze_supported, runtime_wake.clone(), + frozen_enabled, + pending_freeze_on_start, ), zoom: ZoomState::new_with_runtime_wake(zoom_manager, runtime_wake.clone()), perf: perf::PerfMetrics::from_env(), @@ -159,10 +160,3 @@ impl WaylandState { } } } - -fn startup_activation_token_from_env() -> Option { - std::env::var(XDG_ACTIVATION_TOKEN_ENV) - .ok() - .map(|value| value.trim().to_string()) - .filter(|value| !value.is_empty()) -} diff --git a/src/backend/wayland/state/core/mod.rs b/src/backend/wayland/state/core/mod.rs index 04eeae997..14e1f1c07 100644 --- a/src/backend/wayland/state/core/mod.rs +++ b/src/backend/wayland/state/core/mod.rs @@ -1,6 +1,5 @@ mod accessors; -pub(in crate::backend::wayland::state) mod focus; mod init; mod output; -mod overlay; +pub(in crate::backend::wayland::state) mod overlay; mod session; diff --git a/src/backend/wayland/state/core/output/focus.rs b/src/backend/wayland/state/core/output/focus.rs index e403ebb64..209974de2 100644 --- a/src/backend/wayland/state/core/output/focus.rs +++ b/src/backend/wayland/state/core/output/focus.rs @@ -66,7 +66,7 @@ impl WaylandState { let target_identity = self.output_identity_for(&target_output); if self.surface.is_xdg_window() { - if !self.xdg_fullscreen() { + if !self.surface.placement().xdg_fullscreen() { self.input_state.push_toast( ToastPriority::Info, "output", @@ -83,7 +83,7 @@ impl WaylandState { window.set_fullscreen(Some(&target_output)); window.commit(); self.surface.set_current_output(target_output); - self.set_has_seen_surface_enter(false); + self.focus.clear_surface_enter(); self.refresh_active_output_label(); self.begin_session_output_transition(target_identity, "output switch"); self.request_xdg_activation(qh); @@ -101,7 +101,7 @@ impl WaylandState { self.teardown_keyboard_focus(); self.recreate_layer_surface_for_output(qh, &target_output); self.surface.set_current_output(target_output); - self.set_has_seen_surface_enter(false); + self.focus.clear_surface_enter(); self.refresh_active_output_label(); self.begin_session_output_transition(target_identity, "output switch"); self.input_state.needs_redraw = true; @@ -113,14 +113,14 @@ impl WaylandState { qh: &QueueHandle, output: &wl_output::WlOutput, ) { - self.begin_main_layer_focus_acquisition(); + self.focus.begin_main_layer_acquisition(); let Some(layer_shell) = self.protocol.layer_shell() else { return; }; let wl_surface = self.protocol.compositor().create_surface(qh); wl_surface.set_buffer_scale(self.surface.scale().max(1)); - let layer = self.main_surface_layer(); + let layer = self.surface.placement().layer(); let layer_surface = layer_shell.create_layer_surface( qh, wl_surface, @@ -137,10 +137,11 @@ impl WaylandState { layer_surface.commit(); self.surface.set_layer_surface(layer_surface); - self.set_current_keyboard_interactivity(Some(desired_keyboard_mode)); + self.focus + .set_keyboard_interactivity(Some(desired_keyboard_mode)); self.force_sync_overlay_interactivity(); self.buffer_damage .mark_all_full(FullDamageReason::LayerSurfaceRecreated); - self.set_toolbar_needs_recreate(true); + self.toolbar_chrome.set_needs_recreate(true); } } diff --git a/src/backend/wayland/state/core/output/identity.rs b/src/backend/wayland/state/core/output/identity.rs index d648133c2..bf10cebb0 100644 --- a/src/backend/wayland/state/core/output/identity.rs +++ b/src/backend/wayland/state/core/output/identity.rs @@ -4,7 +4,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn preferred_fullscreen_output( &self, ) -> Option { - if let Some(preferred) = self.preferred_output_identity() + if let Some(preferred) = self.surface.placement().preferred_output_identity() && let Some(output) = self.protocol.output().outputs().find(|output| { self.output_identity_for(output) .map(|id| id.eq_ignore_ascii_case(preferred)) diff --git a/src/backend/wayland/state/core/overlay.rs b/src/backend/wayland/state/core/overlay.rs index 76ccafb89..c9e9c7e38 100644 --- a/src/backend/wayland/state/core/overlay.rs +++ b/src/backend/wayland/state/core/overlay.rs @@ -1,55 +1,168 @@ +use super::super::capture::OverlayCaptureBarrier; use super::super::*; -impl WaylandState { - /// Derived chrome suppression for the native capture picker. - /// - /// This never changes the user's toolbar/chrome preferences and never - /// enters the capture-preflight suppression state. Ending the picker - /// therefore reveals whatever Focus/Light/overlay suppression still - /// permits instead of restoring a stale snapshot over it. - pub(in crate::backend::wayland) fn capture_picker_chrome_suppressed(&self) -> bool { - capture_picker_chrome_suppressed_for(self.input_state.region_state()) +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(in crate::backend::wayland) enum OverlaySuppression { + #[default] + None, + Capture, + DesktopBackdrop, + ExternalDialog, + Frozen, + Zoom, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(in crate::backend::wayland) enum OverlaySuppressionKeyboardPolicy { + #[default] + Release, + Retain, +} + +impl OverlaySuppression { + pub(in crate::backend::wayland) fn requires_capture_barrier(self) -> bool { + matches!( + self, + Self::Capture | Self::DesktopBackdrop | Self::Frozen | Self::Zoom + ) } - pub(in crate::backend::wayland) fn overlay_suppressed(&self) -> bool { - self.data.overlay_suppression != OverlaySuppression::None + pub(in crate::backend::wayland) fn effective_for_board( + self, + board_is_transparent: bool, + ) -> Self { + if self == Self::Zoom && !board_is_transparent { + Self::None + } else { + self + } } - pub(in crate::backend::wayland) fn overlay_blocks_event_loop(&self) -> bool { - matches!( - self.data.overlay_suppression, - OverlaySuppression::Capture - | OverlaySuppression::DesktopBackdrop - | OverlaySuppression::ExternalDialog - | OverlaySuppression::Frozen - | OverlaySuppression::Zoom + pub(in crate::backend::wayland) fn renders_canvas(self) -> bool { + !matches!( + self, + Self::DesktopBackdrop | Self::ExternalDialog | Self::Frozen | Self::Zoom ) } + pub(in crate::backend::wayland) fn renders_canvas_transients(self) -> bool { + self == Self::None + } + + pub(in crate::backend::wayland) fn renders_ui(self) -> bool { + self == Self::None + } +} + +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct OverlaySuppressionState { + reason: OverlaySuppression, + keyboard_policy: OverlaySuppressionKeyboardPolicy, + pub(in crate::backend::wayland::state) barrier: OverlayCaptureBarrier, + clickthrough: bool, +} + +impl OverlaySuppressionState { + pub(in crate::backend::wayland) fn reason(&self) -> OverlaySuppression { + self.reason + } + + pub(in crate::backend::wayland) fn suppressed(&self) -> bool { + self.reason != OverlaySuppression::None + } + + pub(in crate::backend::wayland) fn blocks_event_loop(&self) -> bool { + self.suppressed() + } + pub(in crate::backend::wayland) fn capture_suppressed(&self) -> bool { matches!( - self.data.overlay_suppression, + self.reason, OverlaySuppression::Capture | OverlaySuppression::DesktopBackdrop ) } + pub(in crate::backend::wayland) fn requires_capture_barrier(&self) -> bool { + self.reason.requires_capture_barrier() + } + + pub(in crate::backend::wayland) fn enter( + &mut self, + reason: OverlaySuppression, + keyboard_policy: OverlaySuppressionKeyboardPolicy, + wait_for_gtk: bool, + ) -> Result<(), OverlaySuppression> { + if self.reason != OverlaySuppression::None { + return Err(self.reason); + } + self.reason = reason; + self.keyboard_policy = keyboard_policy; + if reason.requires_capture_barrier() { + self.barrier.begin(reason, wait_for_gtk); + } + Ok(()) + } + + pub(in crate::backend::wayland) fn exit(&mut self, reason: OverlaySuppression) -> bool { + if self.reason != reason { + return false; + } + self.barrier.cancel(reason); + self.reason = OverlaySuppression::None; + self.keyboard_policy = OverlaySuppressionKeyboardPolicy::Release; + true + } + + pub(in crate::backend::wayland) fn passthrough_requested( + &self, + light_mode_passthrough: bool, + ) -> bool { + self.reason != OverlaySuppression::None || light_mode_passthrough + } + + pub(in crate::backend::wayland) fn keyboard_passthrough_requested( + &self, + light_mode_passthrough: bool, + ) -> bool { + light_mode_passthrough + || (self.reason != OverlaySuppression::None + && self.keyboard_policy == OverlaySuppressionKeyboardPolicy::Release) + } + + pub(in crate::backend::wayland) fn set_clickthrough(&mut self, value: bool) -> bool { + if self.clickthrough == value { + return false; + } + self.clickthrough = value; + true + } +} + +impl WaylandState { + /// Derived chrome suppression for the native capture picker. + /// + /// This never changes the user's toolbar/chrome preferences and never + /// enters the capture-preflight suppression state. Ending the picker + /// therefore reveals whatever Focus/Light/overlay suppression still + /// permits instead of restoring a stale snapshot over it. + pub(in crate::backend::wayland) fn capture_picker_chrome_suppressed(&self) -> bool { + capture_picker_chrome_suppressed_for(self.input_state.region_state()) + } + pub(in crate::backend::wayland) fn overlay_passthrough_requested(&self) -> bool { - self.overlay_suppressed() || self.input_state.light_mode_passthrough() + self.suppression + .passthrough_requested(self.input_state.light_mode_passthrough()) } pub(in crate::backend::wayland) fn overlay_keyboard_passthrough_requested(&self) -> bool { - overlay_keyboard_passthrough_requested_for( - self.data.overlay_suppression, - self.data.overlay_suppression_keyboard_policy, - self.input_state.light_mode_passthrough(), - ) + self.suppression + .keyboard_passthrough_requested(self.input_state.light_mode_passthrough()) } fn set_overlay_clickthrough(&mut self, clickthrough: bool) { - if self.data.overlay_clickthrough == clickthrough { + if !self.suppression.set_clickthrough(clickthrough) { return; } - self.data.overlay_clickthrough = clickthrough; if let Some(wl_surface) = self.surface.wl_surface().cloned() { set_surface_clickthrough(self.protocol.compositor(), &wl_surface, clickthrough); } @@ -63,7 +176,8 @@ impl WaylandState { } pub(in crate::backend::wayland) fn force_sync_overlay_interactivity(&mut self) { - self.data.overlay_clickthrough = !self.overlay_passthrough_requested(); + let desired = self.overlay_passthrough_requested(); + self.suppression.set_clickthrough(!desired); self.sync_overlay_interactivity(); } @@ -82,20 +196,15 @@ impl WaylandState { reason: OverlaySuppression, keyboard_policy: OverlaySuppressionKeyboardPolicy, ) -> bool { - if self.data.overlay_suppression != OverlaySuppression::None { + if let Err(active) = + self.suppression + .enter(reason, keyboard_policy, self.gtk_toolbar.is_some()) + { log::warn!( - "capture.preflight component=overlay reason={reason:?} phase=enter-rejected active={:?}", - self.data.overlay_suppression + "capture.preflight component=overlay reason={reason:?} phase=enter-rejected active={active:?}" ); return false; } - self.data.overlay_suppression = reason; - self.data.overlay_suppression_keyboard_policy = keyboard_policy; - if reason.requires_capture_barrier() { - self.data - .overlay_capture_barrier - .begin(reason, self.gtk_toolbar.is_some()); - } self.sync_overlay_interactivity(); self.buffer_damage .mark_all_full(FullDamageReason::OverlaySuppression); @@ -108,16 +217,13 @@ impl WaylandState { &mut self, reason: OverlaySuppression, ) { - if self.data.overlay_suppression != reason { + if !self.suppression.exit(reason) { log::info!( "capture.preflight component=overlay reason={reason:?} phase=exit-ignored active={:?}", - self.data.overlay_suppression + self.suppression.reason() ); return; } - self.data.overlay_capture_barrier.cancel(reason); - self.data.overlay_suppression = OverlaySuppression::None; - self.data.overlay_suppression_keyboard_policy = OverlaySuppressionKeyboardPolicy::Release; self.sync_overlay_interactivity(); self.buffer_damage .mark_all_full(FullDamageReason::OverlayRestored); @@ -127,16 +233,6 @@ impl WaylandState { } } -fn overlay_keyboard_passthrough_requested_for( - suppression: OverlaySuppression, - keyboard_policy: OverlaySuppressionKeyboardPolicy, - light_mode_passthrough: bool, -) -> bool { - light_mode_passthrough - || (suppression != OverlaySuppression::None - && keyboard_policy == OverlaySuppressionKeyboardPolicy::Release) -} - fn capture_picker_chrome_suppressed_for(region: crate::input::state::RegionSelectUiState) -> bool { region.is_engaged() && region.purpose().is_some_and(|purpose| purpose.is_capture()) } @@ -147,21 +243,79 @@ mod tests { #[test] fn fast_zoom_suppression_can_retain_keyboard_focus() { - assert!(!overlay_keyboard_passthrough_requested_for( - OverlaySuppression::Zoom, - OverlaySuppressionKeyboardPolicy::Retain, - false, - )); - assert!(overlay_keyboard_passthrough_requested_for( - OverlaySuppression::Zoom, - OverlaySuppressionKeyboardPolicy::Release, - false, - )); - assert!(overlay_keyboard_passthrough_requested_for( - OverlaySuppression::None, - OverlaySuppressionKeyboardPolicy::Retain, - true, - )); + let mut state = OverlaySuppressionState::default(); + state + .enter( + OverlaySuppression::Zoom, + OverlaySuppressionKeyboardPolicy::Retain, + false, + ) + .expect("idle state accepts suppression"); + assert!(!state.keyboard_passthrough_requested(false)); + + assert!(state.exit(OverlaySuppression::Zoom)); + state + .enter( + OverlaySuppression::Zoom, + OverlaySuppressionKeyboardPolicy::Release, + false, + ) + .expect("finished state accepts suppression"); + assert!(state.keyboard_passthrough_requested(false)); + assert!(OverlaySuppressionState::default().keyboard_passthrough_requested(true)); + } + + #[test] + fn suppression_rejects_overlap_and_only_matching_exit_clears_it() { + let mut state = OverlaySuppressionState::default(); + state + .enter( + OverlaySuppression::Capture, + OverlaySuppressionKeyboardPolicy::Release, + false, + ) + .expect("first suppression"); + + assert_eq!( + state.enter( + OverlaySuppression::Frozen, + OverlaySuppressionKeyboardPolicy::Release, + false, + ), + Err(OverlaySuppression::Capture) + ); + assert!(!state.exit(OverlaySuppression::Frozen)); + assert_eq!(state.reason(), OverlaySuppression::Capture); + assert!(state.exit(OverlaySuppression::Capture)); + assert_eq!(state.reason(), OverlaySuppression::None); + } + + #[test] + fn clickthrough_reports_only_real_changes() { + let mut state = OverlaySuppressionState::default(); + + assert!(!state.set_clickthrough(false)); + assert!(state.set_clickthrough(true)); + assert!(!state.set_clickthrough(true)); + } + + #[test] + fn suppression_render_policy_keeps_capture_canvas_only() { + let capture = OverlaySuppression::Capture.effective_for_board(true); + assert!(capture.renders_canvas()); + assert!(!capture.renders_ui()); + assert!(!capture.renders_canvas_transients()); + + assert!(!OverlaySuppression::DesktopBackdrop.renders_canvas()); + assert!(!OverlaySuppression::ExternalDialog.renders_ui()); + assert_eq!( + OverlaySuppression::Zoom.effective_for_board(false), + OverlaySuppression::None + ); + assert_eq!( + OverlaySuppression::Zoom.effective_for_board(true), + OverlaySuppression::Zoom + ); } #[test] diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs deleted file mode 100644 index deb99f1e3..000000000 --- a/src/backend/wayland/state/data.rs +++ /dev/null @@ -1,457 +0,0 @@ -use std::time::Instant; - -use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; -use crate::backend::wayland::toolbar::hit::HitRegion; -use crate::backend::wayland::zoom::ZoomWaiterRegistry; - -use super::region_capture::{ActiveScreenRegion, WindowSnapSession}; -use super::screen_image::ScreenSourceToken; - -use super::capture::OverlayCaptureBarrier; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum MoveDragKind { - Top, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum OverlaySuppression { - #[default] - None, - Capture, - DesktopBackdrop, - ExternalDialog, - Frozen, - Zoom, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum OverlaySuppressionKeyboardPolicy { - #[default] - Release, - Retain, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) enum MainLayerFocusPhase { - #[default] - Acquiring, - Acquired, -} - -impl MainLayerFocusPhase { - pub(super) fn begin(&mut self) { - *self = Self::Acquiring; - } - - pub(super) fn complete(&mut self) -> bool { - if *self == Self::Acquired { - return false; - } - *self = Self::Acquired; - true - } - - pub(super) fn is_acquiring(self) -> bool { - self == Self::Acquiring - } - - pub(super) fn after_keyboard_teardown(self) -> Self { - self - } -} - -/// One-shot release latches owned by the pointing device whose press armed -/// them. Pointer and touch can both have an outstanding release; neither may -/// consume or clear the other's sequence. Stylus contacts use tablet-tool -/// retirement instead of this latch. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(super) struct ReleaseSuppression { - pointer: bool, - touch: bool, -} - -impl ReleaseSuppression { - pub(super) fn arm(&mut self, source: crate::input::state::RegionInputSource) { - match source { - crate::input::state::RegionInputSource::Pointer => self.pointer = true, - crate::input::state::RegionInputSource::Touch => self.touch = true, - crate::input::state::RegionInputSource::Stylus => {} - } - } - - pub(super) fn clear(&mut self, source: crate::input::state::RegionInputSource) { - match source { - crate::input::state::RegionInputSource::Pointer => self.pointer = false, - crate::input::state::RegionInputSource::Touch => self.touch = false, - crate::input::state::RegionInputSource::Stylus => {} - } - } - - pub(super) fn take(&mut self, source: crate::input::state::RegionInputSource) -> bool { - let slot = match source { - crate::input::state::RegionInputSource::Pointer => &mut self.pointer, - crate::input::state::RegionInputSource::Touch => &mut self.touch, - crate::input::state::RegionInputSource::Stylus => return false, - }; - std::mem::take(slot) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum XdgFrozenFullscreenState { - #[default] - Inactive, - PendingConfigure, - Active, -} - -impl OverlaySuppression { - pub(in crate::backend::wayland) fn requires_capture_barrier(self) -> bool { - matches!( - self, - Self::Capture | Self::DesktopBackdrop | Self::Frozen | Self::Zoom - ) - } - - pub(in crate::backend::wayland) fn effective_for_board( - self, - board_is_transparent: bool, - ) -> Self { - if self == Self::Zoom && !board_is_transparent { - Self::None - } else { - self - } - } - - pub(in crate::backend::wayland) fn renders_canvas(self) -> bool { - !matches!( - self, - Self::DesktopBackdrop | Self::ExternalDialog | Self::Frozen | Self::Zoom - ) - } - - /// Whether pointer-driven previews and editing affordances belong in the - /// canvas pass. A capture frame retains committed annotations but omits - /// transient state that is not part of the saved drawing. - pub(in crate::backend::wayland) fn renders_canvas_transients(self) -> bool { - self == Self::None - } - - pub(in crate::backend::wayland) fn renders_ui(self) -> bool { - self == Self::None - } -} - -#[derive(Debug, Clone, Copy)] -pub struct MoveDrag { - pub kind: MoveDragKind, - pub last_coord: (f64, f64), - /// Whether last_coord is in screen coordinates (true) or toolbar-local (false) - pub coord_is_screen: bool, -} -use wayland_client::protocol::wl_seat; - -/// Focus/pointer/toolbar interaction data owned by WaylandState and shared with handlers. -#[derive(Debug, Default)] -pub struct StateData { - pub(super) has_keyboard_focus: bool, - pub(super) main_layer_focus_phase: MainLayerFocusPhase, - pub(super) has_pointer_focus: bool, - pub(super) current_mouse_x: i32, - pub(super) current_mouse_y: i32, - pub(super) board_panning: bool, - pub(super) board_pan_last_pos: (f64, f64), - pub(super) board_pan_key_held: bool, - pub(super) current_seat: Option, - pub(super) last_activation_serial: Option, - pub(super) pointer_over_toolbar: bool, - pub(super) toolbar_dragging: bool, - pub(super) toolbar_drag_preview: bool, - pub(super) current_keyboard_interactivity: - Option, - pub(super) toolbar_needs_recreate: bool, - pub(super) toolbar_layer_shell_missing_logged: bool, - pub(super) inline_toolbars: bool, - pub(super) inline_top_hits: Vec, - pub(super) inline_top_rect: Option<(f64, f64, f64, f64)>, - pub(super) inline_top_hover: Option<(f64, f64)>, - pub(super) inline_top_hover_start: Option, - pub(super) inline_top_tooltip_pending: bool, - pub(super) inline_top_focus_index: Option, - pub(super) inline_top_focus_id: Option, - /// True while keyboard focus is routed into the top toolbar. - pub(super) toolbar_focus_active: bool, - pub(super) toolbar_top_offset: f64, - pub(super) toolbar_top_offset_y: f64, - pub(super) toolbar_configure_miss_count: u32, - /// Highest GTK drag sequence numbers drained per bar; echoed in - /// updates so the GTK side can discard stale offset mirrors. - pub(super) gtk_top_offset_seq: u64, - /// GTK surface currently parked at its drag origin while the main overlay - /// renders the moving toolbar preview. - pub(super) gtk_drag_preview: Option, - /// Offset corrections accumulated while a persistence barrier freezes a - /// start-relative GTK drag. They discard fenced motion if the exact same - /// preview later resumes. - pub(super) gtk_top_drag_rebase: Option<(f64, f64)>, - /// A GTK drag that emitted feedback while a modal was engaged stays - /// blocked until its matching drag-end feedback arrives. - pub(super) gtk_top_drag_blocked: bool, - /// Pointer is over the GTK top toolbar window (reported via feedback; - /// GTK runs on its own connection). Restores the top-strip idle fade. - pub(super) gtk_top_hover: bool, - pub(super) last_applied_top_margin: Option, - pub(super) last_applied_top_margin_top: Option, - pub(super) toolbar_move_drag: Option, - pub(super) active_drag_kind: Option, - pub(super) drag_top_base_x: Option, - pub(super) drag_top_base_y: Option, - pub(super) toolbar_drag_handoff_at: Option, - pub(super) toolbar_drag_flush_requested: bool, - pub(super) toolbar_drag_pending_apply: bool, - pub(super) last_toolbar_drag_apply: Option, - pub(super) pending_activation_token: Option, - pub(super) startup_activation_token: Option, - pub(super) pending_freeze_on_start: bool, - pub(super) screen_acquisition: ScreenAcquisitionRegistry, - pub(super) zoom_waiter: ZoomWaiterRegistry, - pub(super) active_eyedropper_source: Option, - pub(super) active_screen_region: Option, - pub(super) window_snap: Option, - pub(super) region_review_edits: Option, - pub(super) next_screen_region_generation: u64, - pub(super) frozen_enabled: bool, - pub(super) has_seen_surface_enter: bool, - pub(super) preferred_output_identity: Option, - pub(super) xdg_fullscreen: bool, - pub(super) xdg_frozen_fullscreen_state: XdgFrozenFullscreenState, - pub(super) xdg_frozen_fullscreen_requested_at: Option, - pub(super) main_surface_uses_overlay_layer: bool, - pub(super) overlay_suppression: OverlaySuppression, - pub(super) overlay_suppression_keyboard_policy: OverlaySuppressionKeyboardPolicy, - pub(super) overlay_capture_barrier: OverlayCaptureBarrier, - pub(super) overlay_clickthrough: bool, - /// True when surface is configured and has keyboard focus; keys are blocked until ready. - pub(super) overlay_ready: bool, - /// Suppress modal-owned pointer/touch releases without crossing devices. - pub(super) release_suppression: ReleaseSuppression, - /// Exact toast activation a left press began inside. A release is accepted - /// only while this same activation remains visible. - pub(super) pending_toast_press: Option, - /// True when a left press began inside the interactive status HUD. - pub(super) pending_status_hud_press: bool, - /// The chip press a left press began (`None` when no chip press is pending; - /// `Passive` for the passive `NN%` readout / inter-piece gap; `Button(kind)` - /// for an actionable button). Any pending press keeps its release consumed; - /// a `Button` release fires only when it lands on the SAME button. - pub(super) pending_zoom_chip_press: crate::ui::ZoomChipPress, - /// Suppress overlay exit on focus loss for a short window (e.g., clipboard helpers). - pub(super) suppress_focus_exit_until: Option, - /// Short guard window after xdg focus loss where compositor close requests are ignored - /// in stay mode to avoid spurious GNOME close events. - pub(super) xdg_close_guard_until: Option, - /// Explicit compositor close request received for xdg fallback window. - pub(super) xdg_explicit_close_requested: bool, - /// Reused pre-UI pixel snapshot for render-profile UI-only remapping. - pub(super) render_profile_ui_baseline: Vec, - /// Previous-frame damage bounds for transient UI effects, so partial - /// redraws cover both the old and new footprint of each effect. - pub(super) prev_ui_toast_damage: Option, - pub(super) prev_preset_toast_damage: Option, - pub(super) blocked_feedback_was_active: bool, - pub(super) prev_text_edit_entry_damage: Option, - pub(super) prev_status_hud_damage: Option, - pub(super) prev_zoom_chip_damage: Option, - pub(super) prev_input_hud_damage: Option, - pub(super) prev_command_palette_damage: Option, - pub(super) prev_color_picker_damage: Option, - pub(super) prev_tool_preview_damage: Option, - pub(super) prev_shape_measure_badge_damage: Option, - /// Union the OCR scan overlay covered last frame, so its sweep is cleared. - pub(super) prev_ocr_scan_damage: Option, - /// Previous-frame strips for Measure Mode's crosshair, frame, and readout. - pub(super) prev_measure_picker_damage: Vec, - /// Idle-fade engine for the top-strip islands; its value is published - /// on every toolbar snapshot as `top_fade`. - pub(super) top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade, - /// Per-session shortcut-coach accumulator (slow-path streak, cooldown, and - /// per-session cap). Session-only; the across-session cap and learned - /// suppression live in the persisted onboarding state. - pub(super) shortcut_coach: super::onboarding::ShortcutCoachSession, -} - -impl StateData { - pub fn new() -> Self { - Self { - has_keyboard_focus: false, - main_layer_focus_phase: MainLayerFocusPhase::default(), - has_pointer_focus: false, - current_mouse_x: 0, - current_mouse_y: 0, - board_panning: false, - board_pan_last_pos: (0.0, 0.0), - board_pan_key_held: false, - current_seat: None, - last_activation_serial: None, - pointer_over_toolbar: false, - toolbar_dragging: false, - toolbar_drag_preview: false, - current_keyboard_interactivity: None, - toolbar_needs_recreate: true, - toolbar_layer_shell_missing_logged: false, - inline_toolbars: false, - inline_top_hits: Vec::new(), - inline_top_rect: None, - inline_top_hover: None, - inline_top_hover_start: None, - inline_top_tooltip_pending: false, - inline_top_focus_index: None, - inline_top_focus_id: None, - toolbar_focus_active: false, - toolbar_top_offset: 0.0, - toolbar_top_offset_y: 0.0, - toolbar_configure_miss_count: 0, - gtk_top_offset_seq: 0, - gtk_drag_preview: None, - gtk_top_drag_rebase: None, - gtk_top_drag_blocked: false, - gtk_top_hover: false, - last_applied_top_margin: None, - last_applied_top_margin_top: None, - toolbar_move_drag: None, - active_drag_kind: None, - drag_top_base_x: None, - drag_top_base_y: None, - toolbar_drag_handoff_at: None, - toolbar_drag_flush_requested: false, - toolbar_drag_pending_apply: false, - last_toolbar_drag_apply: None, - pending_activation_token: None, - startup_activation_token: None, - pending_freeze_on_start: false, - screen_acquisition: ScreenAcquisitionRegistry::default(), - zoom_waiter: ZoomWaiterRegistry::default(), - active_eyedropper_source: None, - active_screen_region: None, - window_snap: None, - region_review_edits: None, - next_screen_region_generation: 1, - frozen_enabled: false, - has_seen_surface_enter: false, - preferred_output_identity: None, - xdg_fullscreen: false, - xdg_frozen_fullscreen_state: XdgFrozenFullscreenState::Inactive, - xdg_frozen_fullscreen_requested_at: None, - main_surface_uses_overlay_layer: false, - overlay_suppression: OverlaySuppression::None, - overlay_suppression_keyboard_policy: OverlaySuppressionKeyboardPolicy::Release, - overlay_capture_barrier: OverlayCaptureBarrier::default(), - overlay_clickthrough: false, - overlay_ready: false, - release_suppression: ReleaseSuppression::default(), - pending_toast_press: None, - pending_status_hud_press: false, - pending_zoom_chip_press: crate::ui::ZoomChipPress::None, - suppress_focus_exit_until: None, - xdg_close_guard_until: None, - xdg_explicit_close_requested: false, - render_profile_ui_baseline: Vec::new(), - prev_ui_toast_damage: None, - prev_preset_toast_damage: None, - blocked_feedback_was_active: false, - prev_text_edit_entry_damage: None, - prev_status_hud_damage: None, - prev_zoom_chip_damage: None, - prev_input_hud_damage: None, - prev_command_palette_damage: None, - prev_color_picker_damage: None, - prev_tool_preview_damage: None, - prev_shape_measure_badge_damage: None, - prev_ocr_scan_damage: None, - prev_measure_picker_damage: Vec::new(), - top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade::new(), - shortcut_coach: super::onboarding::ShortcutCoachSession::default(), - } - } -} - -#[cfg(test)] -mod tests { - use super::{MainLayerFocusPhase, OverlaySuppression}; - use crate::input::state::RegionInputSource; - - #[test] - fn release_suppression_is_owned_by_the_originating_device() { - let mut suppression = super::ReleaseSuppression::default(); - suppression.arm(RegionInputSource::Pointer); - - assert!(!suppression.take(RegionInputSource::Touch)); - assert!(suppression.take(RegionInputSource::Pointer)); - assert!(!suppression.take(RegionInputSource::Pointer)); - } - - #[test] - fn main_layer_focus_phase_completes_once_and_restarts_for_a_new_surface() { - let mut phase = MainLayerFocusPhase::default(); - - assert!(phase.is_acquiring()); - assert!(phase.complete()); - assert!(!phase.is_acquiring()); - assert!(!phase.complete()); - - phase.begin(); - - assert!(phase.is_acquiring()); - } - - #[test] - fn clearing_one_device_keeps_the_other_devices_latch() { - let mut suppression = super::ReleaseSuppression::default(); - suppression.arm(RegionInputSource::Pointer); - suppression.arm(RegionInputSource::Touch); - suppression.clear(RegionInputSource::Touch); - - assert!(!suppression.take(RegionInputSource::Touch)); - assert!(suppression.take(RegionInputSource::Pointer)); - } - - #[test] - fn desktop_backdrop_suppression_hides_canvas_and_ui() { - let suppression = OverlaySuppression::DesktopBackdrop.effective_for_board(true); - - assert!(!suppression.renders_canvas()); - assert!(!suppression.renders_ui()); - } - - #[test] - fn normal_capture_suppression_keeps_canvas_without_ui() { - let suppression = OverlaySuppression::Capture.effective_for_board(true); - - assert!(suppression.renders_canvas()); - assert!(!suppression.renders_ui()); - assert!(!suppression.renders_canvas_transients()); - assert!(OverlaySuppression::None.renders_canvas_transients()); - } - - #[test] - fn external_dialog_suppression_hides_canvas_and_ui() { - let suppression = OverlaySuppression::ExternalDialog.effective_for_board(true); - - assert!(!suppression.renders_canvas()); - assert!(!suppression.renders_ui()); - } - - #[test] - fn zoom_suppression_only_applies_on_transparent_boards() { - assert_eq!( - OverlaySuppression::Zoom.effective_for_board(false), - OverlaySuppression::None - ); - assert_eq!( - OverlaySuppression::Zoom.effective_for_board(true), - OverlaySuppression::Zoom - ); - } -} diff --git a/src/backend/wayland/state/desktop_open.rs b/src/backend/wayland/state/desktop_open.rs index 6614ca6f0..0f75eb8a8 100644 --- a/src/backend/wayland/state/desktop_open.rs +++ b/src/backend/wayland/state/desktop_open.rs @@ -56,7 +56,7 @@ impl WaylandState { match handoff_exit_intent(&completion) { HandoffExitIntent::None => {} HandoffExitIntent::ExitExplicitly => { - self.mark_xdg_explicit_close_requested(); + self.focus.mark_xdg_explicit_close_requested(); self.input_state.should_exit = true; } } @@ -88,7 +88,8 @@ impl WaylandState { ); // If an opener partially launched an application before failing, keep // this failure visible instead of immediately applying focus-loss exit. - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(std::time::Instant::now(), Duration::from_millis(1500)); self.input_state.push_toast( ToastPriority::Critical, "launcher", diff --git a/src/backend/wayland/state/eyedropper.rs b/src/backend/wayland/state/eyedropper.rs index f5adbb225..6e978fc4d 100644 --- a/src/backend/wayland/state/eyedropper.rs +++ b/src/backend/wayland/state/eyedropper.rs @@ -58,8 +58,8 @@ impl WaylandState { self.cancel_ocr(); self.input_state.prepare_for_screen_modal(); self.zoom.stop_pan(); - self.stop_board_pan(); - self.set_board_pan_key_held(false); + self.pointer.stop_board_pan(); + self.pointer.set_board_pan_key_held(false); // Entering a different modal interaction interrupts any unfinished // toolbar move; it is not an accepted drop. self.cancel_toolbar_move_drag(); @@ -74,7 +74,7 @@ impl WaylandState { self.input_state.board_is_transparent(), self.zoom.is_engaged(), self.zoom.active, - self.frozen_enabled(), + self.frozen.enabled(), ); match decision { ScreenSourceEntry::Activate => { @@ -95,7 +95,7 @@ impl WaylandState { } } ScreenSourceEntry::AutoFreeze => { - match self.request_screen_acquisition(ScreenAcquisitionOwner::Eyedropper) { + match self.acquisition.request(ScreenAcquisitionOwner::Eyedropper) { Ok(_) => self .input_state .set_eyedropper_pending_capture(EyedropperCaptureSource::Frozen), @@ -179,7 +179,7 @@ impl WaylandState { return false; }; self.retire_stylus_contact(); - self.data.active_eyedropper_source = Some(token); + self.acquisition.set_eyedropper_source(token); self.input_state .activate_eyedropper(owned_frozen_generation); true @@ -262,7 +262,7 @@ impl WaylandState { let was_active = eyedropper_state.is_engaged(); let pending_acquisition = (eyedropper_state.pending_source() == Some(EyedropperCaptureSource::Frozen)) - .then(|| self.screen_acquisition_slot()) + .then(|| self.acquisition.slot()) .flatten() .filter(|record| record.owner == ScreenAcquisitionOwner::Eyedropper) .map(|record| record.id); diff --git a/src/backend/wayland/state/focus.rs b/src/backend/wayland/state/focus.rs new file mode 100644 index 000000000..80a7f07ba --- /dev/null +++ b/src/backend/wayland/state/focus.rs @@ -0,0 +1,380 @@ +use std::time::{Duration, Instant}; + +use smithay_client_toolkit::shell::wlr_layer::KeyboardInteractivity; +use wayland_client::{Proxy, protocol::wl_seat}; + +use super::WaylandState; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum MainLayerFocusPhase { + #[default] + Acquiring, + Acquired, +} + +/// Keyboard, pointer, activation, and focus-loss state for the overlay. +pub(in crate::backend::wayland) struct FocusState { + has_keyboard_focus: bool, + main_layer_focus_phase: MainLayerFocusPhase, + has_pointer_focus: bool, + current_seat: Option, + last_activation_serial: Option, + has_seen_surface_enter: bool, + overlay_ready: bool, + suppress_focus_exit_until: Option, + xdg_close_guard_until: Option, + xdg_explicit_close_requested: bool, + pending_activation_token: Option, + startup_activation_token: Option, + current_keyboard_interactivity: Option, +} + +impl FocusState { + pub(in crate::backend::wayland) fn new(startup_activation_token: Option) -> Self { + Self { + has_keyboard_focus: false, + main_layer_focus_phase: MainLayerFocusPhase::default(), + has_pointer_focus: false, + current_seat: None, + last_activation_serial: None, + has_seen_surface_enter: false, + overlay_ready: false, + suppress_focus_exit_until: None, + xdg_close_guard_until: None, + xdg_explicit_close_requested: false, + pending_activation_token: None, + startup_activation_token, + current_keyboard_interactivity: None, + } + } + + pub(in crate::backend::wayland) fn keyboard_focused(&self) -> bool { + self.has_keyboard_focus + } + + pub(in crate::backend::wayland) fn keyboard_entered(&mut self) { + self.has_keyboard_focus = true; + } + + pub(in crate::backend::wayland) fn keyboard_left(&mut self) { + self.has_keyboard_focus = false; + self.overlay_ready = false; + self.main_layer_focus_phase = self.main_layer_focus_phase.after_keyboard_teardown(); + } + + pub(in crate::backend::wayland) fn pointer_focused(&self) -> bool { + self.has_pointer_focus + } + + pub(in crate::backend::wayland) fn set_pointer_focused(&mut self, focused: bool) { + self.has_pointer_focus = focused; + } + + pub(in crate::backend::wayland) fn current_seat(&self) -> Option { + self.current_seat.clone() + } + + pub(in crate::backend::wayland) fn current_seat_id(&self) -> Option { + self.current_seat + .as_ref() + .map(|seat| seat.id().protocol_id()) + } + + pub(in crate::backend::wayland) fn set_current_seat(&mut self, seat: Option) { + self.current_seat = seat; + } + + pub(in crate::backend::wayland) fn last_activation_serial(&self) -> Option { + self.last_activation_serial + } + + pub(in crate::backend::wayland) fn note_activation_serial(&mut self, serial: u32) { + self.last_activation_serial = Some(serial); + } + + pub(in crate::backend::wayland) fn current_keyboard_interactivity( + &self, + ) -> Option { + self.current_keyboard_interactivity + } + + pub(in crate::backend::wayland) fn set_keyboard_interactivity( + &mut self, + interactivity: Option, + ) { + self.current_keyboard_interactivity = interactivity; + } + + pub(in crate::backend::wayland) fn begin_main_layer_acquisition(&mut self) { + self.main_layer_focus_phase = MainLayerFocusPhase::Acquiring; + } + + pub(in crate::backend::wayland) fn main_layer_acquiring(&self) -> bool { + self.main_layer_focus_phase == MainLayerFocusPhase::Acquiring + } + + pub(in crate::backend::wayland) fn can_complete_main_layer_acquisition( + &self, + is_current_main_layer_surface: bool, + keyboard_release_requested: bool, + ) -> bool { + is_current_main_layer_surface + && self.main_layer_acquiring() + && self.current_keyboard_interactivity == Some(KeyboardInteractivity::Exclusive) + && !keyboard_release_requested + } + + pub(in crate::backend::wayland) fn complete_main_layer_acquisition(&mut self) -> bool { + if self.main_layer_focus_phase == MainLayerFocusPhase::Acquired { + return false; + } + self.main_layer_focus_phase = MainLayerFocusPhase::Acquired; + true + } + + pub(in crate::backend::wayland) fn mark_ready_if_focused(&mut self) -> bool { + if !self.has_keyboard_focus || self.overlay_ready { + return false; + } + self.overlay_ready = true; + true + } + + pub(in crate::backend::wayland) fn is_ready(&self) -> bool { + self.overlay_ready + } + + pub(in crate::backend::wayland) fn suppress_exit_for( + &mut self, + now: Instant, + duration: Duration, + ) { + self.suppress_focus_exit_until = Some(now + duration); + } + + pub(in crate::backend::wayland) fn exit_suppressed(&self, now: Instant) -> bool { + self.suppress_focus_exit_until + .is_some_and(|until| now <= until) + } + + pub(in crate::backend::wayland) fn exit_timeout(&self, now: Instant) -> Option { + self.suppress_focus_exit_until + .and_then(|until| (until > now).then(|| until.saturating_duration_since(now))) + } + + pub(in crate::backend::wayland) fn exit_suppression_expired(&self, now: Instant) -> bool { + self.suppress_focus_exit_until + .is_some_and(|until| now >= until) + } + + pub(in crate::backend::wayland) fn clear_exit_suppression(&mut self) { + self.suppress_focus_exit_until = None; + } + + pub(in crate::backend::wayland) fn guard_xdg_close_for( + &mut self, + now: Instant, + duration: Duration, + ) { + self.xdg_close_guard_until = Some(now + duration); + } + + pub(in crate::backend::wayland) fn clear_xdg_close_guard(&mut self) { + self.xdg_close_guard_until = None; + } + + pub(in crate::backend::wayland) fn xdg_close_guard_active(&self, now: Instant) -> bool { + self.xdg_close_guard_until.is_some_and(|until| now <= until) + } + + pub(in crate::backend::wayland) fn ignores_xdg_close( + &self, + stay_mode: bool, + now: Instant, + ) -> bool { + stay_mode && !self.has_keyboard_focus && self.xdg_close_guard_active(now) + } + + pub(in crate::backend::wayland) fn mark_xdg_explicit_close_requested(&mut self) { + self.xdg_explicit_close_requested = true; + } + + pub(in crate::backend::wayland) fn take_xdg_explicit_close_requested(&mut self) -> bool { + std::mem::take(&mut self.xdg_explicit_close_requested) + } + + pub(in crate::backend::wayland) fn note_surface_enter(&mut self) { + self.has_seen_surface_enter = true; + } + + pub(in crate::backend::wayland) fn clear_surface_enter(&mut self) { + self.has_seen_surface_enter = false; + } + + pub(in crate::backend::wayland) fn activation_token_to_apply(&self) -> Option { + self.pending_activation_token.clone() + } + + pub(in crate::backend::wayland) fn note_activation_token(&mut self, token: String) { + self.pending_activation_token = Some(token); + } + + pub(in crate::backend::wayland) fn defer_activation_until_serial(&mut self) { + self.pending_activation_token = Some(String::new()); + } + + pub(in crate::backend::wayland) fn clear_pending_activation_token(&mut self) { + self.pending_activation_token = None; + } + + pub(in crate::backend::wayland) fn retry_activation_wanted(&self) -> bool { + self.pending_activation_token.is_some() && self.last_activation_serial.is_some() + } + + pub(in crate::backend::wayland) fn take_startup_activation_token(&mut self) -> Option { + self.startup_activation_token.take() + } +} + +impl MainLayerFocusPhase { + fn after_keyboard_teardown(self) -> Self { + self + } +} + +impl WaylandState { + pub(in crate::backend::wayland) fn try_complete_main_layer_focus_acquisition( + &mut self, + is_current_main_layer_surface: bool, + ) -> bool { + if !self.focus.can_complete_main_layer_acquisition( + is_current_main_layer_surface, + self.overlay_keyboard_passthrough_requested(), + ) { + return false; + } + self.focus.complete_main_layer_acquisition() + } + + /// Retire every keyboard-owned transient when focus is lost. + pub(in crate::backend::wayland) fn teardown_keyboard_focus(&mut self) { + self.focus.keyboard_left(); + self.clear_toolbar_focus(); + self.input_state.clear_focus_owned_key_state(); + self.sync_region_square_modifier(false); + self.clear_key_repeat(); + self.pointer.set_board_pan_key_held(false); + self.pointer.stop_board_pan(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn focus_exit_window_expires_at_its_deadline() { + let now = Instant::now(); + let mut focus = FocusState::new(None); + focus.suppress_exit_for(now, Duration::from_millis(20)); + + assert!(focus.exit_suppressed(now + Duration::from_millis(20))); + assert!(focus.exit_suppression_expired(now + Duration::from_millis(20))); + assert_eq!(focus.exit_timeout(now + Duration::from_millis(20)), None); + } + + #[test] + fn close_guard_is_active_at_deadline_and_inactive_after() { + let now = Instant::now(); + let mut focus = FocusState::new(None); + focus.guard_xdg_close_for(now, Duration::from_millis(20)); + + assert!(focus.xdg_close_guard_active(now + Duration::from_millis(20))); + assert!(!focus.xdg_close_guard_active(now + Duration::from_millis(21))); + } + + #[test] + fn explicit_close_is_one_shot() { + let mut focus = FocusState::new(None); + focus.mark_xdg_explicit_close_requested(); + + assert!(focus.take_xdg_explicit_close_requested()); + assert!(!focus.take_xdg_explicit_close_requested()); + } + + #[test] + fn ignores_close_only_for_unfocused_stay_with_active_guard() { + let now = Instant::now(); + let mut focus = FocusState::new(None); + focus.guard_xdg_close_for(now, Duration::from_millis(20)); + + assert!(focus.ignores_xdg_close(true, now)); + focus.keyboard_entered(); + assert!(!focus.ignores_xdg_close(true, now)); + focus.keyboard_left(); + assert!(!focus.ignores_xdg_close(false, now)); + assert!(!focus.ignores_xdg_close(true, now + Duration::from_millis(21))); + } + + #[test] + fn startup_token_is_taken_once() { + let mut focus = FocusState::new(Some("startup".to_string())); + + assert_eq!( + focus.take_startup_activation_token().as_deref(), + Some("startup") + ); + assert_eq!(focus.take_startup_activation_token(), None); + } + + #[test] + fn readiness_requires_keyboard_focus() { + let mut focus = FocusState::new(None); + + assert!(!focus.mark_ready_if_focused()); + assert!(!focus.is_ready()); + focus.keyboard_entered(); + assert!(focus.mark_ready_if_focused()); + assert!(!focus.mark_ready_if_focused()); + assert!(focus.is_ready()); + } + + #[test] + fn main_layer_phase_completes_once_and_restarts() { + let mut focus = FocusState::new(None); + focus.set_keyboard_interactivity(Some(KeyboardInteractivity::Exclusive)); + + assert!(focus.can_complete_main_layer_acquisition(true, false)); + assert!(focus.complete_main_layer_acquisition()); + assert!(!focus.main_layer_acquiring()); + assert!(!focus.complete_main_layer_acquisition()); + + focus.begin_main_layer_acquisition(); + + assert!(focus.main_layer_acquiring()); + assert!(focus.can_complete_main_layer_acquisition(true, false)); + } + + #[test] + fn main_layer_completion_requires_current_exclusive_surface_without_release() { + let mut focus = FocusState::new(None); + focus.set_keyboard_interactivity(Some(KeyboardInteractivity::Exclusive)); + + assert!(!focus.can_complete_main_layer_acquisition(false, false)); + assert!(!focus.can_complete_main_layer_acquisition(true, true)); + focus.set_keyboard_interactivity(Some(KeyboardInteractivity::OnDemand)); + assert!(!focus.can_complete_main_layer_acquisition(true, false)); + focus.set_keyboard_interactivity(None); + assert!(!focus.can_complete_main_layer_acquisition(true, false)); + } + + #[test] + fn keyboard_teardown_keeps_acquired_main_layer_out_of_acquisition() { + let mut focus = FocusState::new(None); + assert!(focus.complete_main_layer_acquisition()); + + focus.keyboard_left(); + + assert!(!focus.main_layer_acquiring()); + } +} diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs index bd5f1d08c..072f09aa3 100644 --- a/src/backend/wayland/state/gtk_toolbar.rs +++ b/src/backend/wayland/state/gtk_toolbar.rs @@ -27,42 +27,6 @@ fn gtk_toolbar_top_visible( requested && !unmap_suppressed && !capture_picker_suppressed } -fn acknowledge_blocked_gtk_drag_feedback(top_seq: &mut u64, feedback: &GtkToolbarFeedback) { - match feedback { - GtkToolbarFeedback::SetTopOffset { seq, .. } => { - *top_seq = (*top_seq).max(*seq); - } - GtkToolbarFeedback::Event { .. } - | GtkToolbarFeedback::PointerShortcut { .. } - | GtkToolbarFeedback::TopHover { .. } - | GtkToolbarFeedback::CaptureSuppressionReady { .. } - | GtkToolbarFeedback::CaptureSuppressionFailed { .. } => {} - } -} - -fn gtk_toolbar_feedback_is_blocked( - modal_engaged: bool, - top_drag_blocked: &mut bool, - feedback: &GtkToolbarFeedback, -) -> bool { - match feedback { - GtkToolbarFeedback::CaptureSuppressionReady { .. } - | GtkToolbarFeedback::CaptureSuppressionFailed { .. } => false, - // Hover is passive state, not a user action; never gate it. - GtkToolbarFeedback::TopHover { .. } => false, - GtkToolbarFeedback::Event { .. } | GtkToolbarFeedback::PointerShortcut { .. } => { - modal_engaged - } - GtkToolbarFeedback::SetTopOffset { phase, .. } => { - let blocked = modal_engaged || *top_drag_blocked; - if blocked { - *top_drag_blocked = !phase.is_end(); - } - blocked - } - } -} - impl WaylandState { /// True while the GTK frontend owns the toolbars (built-in bars stay /// unmapped). @@ -80,7 +44,8 @@ impl WaylandState { feature_compiled: cfg!(feature = "toolbar-gtk"), layer_shell: self.protocol.layer_shell().is_some(), force_inline: super::force_inline_toolbars_requested(&self.config), - main_surface_uses_overlay_layer: self.data.main_surface_uses_overlay_layer, + main_surface_uses_overlay_layer: self.surface.placement().layer() + == smithay_client_toolkit::shell::wlr_layer::Layer::Overlay, }; match resolve_frontend(request, preconditions) { ToolbarFrontend::Gtk => { @@ -122,12 +87,10 @@ impl WaylandState { // modal first. Acknowledge rejected sequences so the authoritative // backend offsets pushed later in this pass snap GTK back and do // not become stale. - if gtk_toolbar_feedback_is_blocked( - gtk_toolbar_feedback_blocked(&self.input_state), - &mut self.data.gtk_top_drag_blocked, - &feedback, - ) { - acknowledge_blocked_gtk_drag_feedback(&mut self.data.gtk_top_offset_seq, &feedback); + if self + .toolbar_drag + .gtk_note_feedback(gtk_toolbar_feedback_blocked(&self.input_state), &feedback) + { // If a modal opened after an accepted drag start, the blocked // end still has to close the preview lifecycle. Keep the last // accepted position rather than applying motion produced while @@ -138,16 +101,12 @@ impl WaylandState { phase, .. } if phase.is_end() - && self.data.gtk_drag_preview + && self.toolbar_drag.gtk_preview_kind() == Some(crate::toolbar_gtk::GtkToolbarKind::Top) => { - self.data.gtk_top_drag_rebase = None; - self.apply_gtk_top_offset( - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, - surface_size, - phase, - ); + self.toolbar_drag.set_gtk_rebase(None); + let offset = self.toolbar_chrome.top_offset(); + self.apply_gtk_top_offset(offset.0, offset.1, surface_size, phase); } _ => {} } @@ -183,7 +142,7 @@ impl WaylandState { } } GtkToolbarFeedback::TopHover { hovered } => { - self.data.gtk_top_hover = hovered; + self.toolbar_chrome.set_gtk_top_hover(hovered); } GtkToolbarFeedback::SetTopOffset { x, @@ -198,7 +157,7 @@ impl WaylandState { surface_size.width, surface_size.height, ) }); - self.data.gtk_top_offset_seq = seq; + self.toolbar_drag.note_gtk_offset_seq(seq); self.apply_gtk_top_offset(x, y, surface_size, phase); } } @@ -209,7 +168,7 @@ impl WaylandState { if failed { self.cancel_overlay_capture_waiting_for_gtk(); self.cancel_gtk_toolbar_drag_lifecycle(); - self.data.gtk_top_hover = false; + self.toolbar_chrome.set_gtk_top_hover(false); self.gtk_toolbar = None; } } @@ -224,7 +183,7 @@ impl WaylandState { // Capture suppression keeps normally visible layer surfaces mapped // but transparent, avoiding compositor-owned close-animation // snapshots. Other suppression and light passthrough still unmap. - let capture_suppressed = self.data.overlay_suppression.requires_capture_barrier(); + let capture_suppressed = self.suppression.requires_capture_barrier(); let unmap_suppressed = self.overlay_passthrough_requested() && !capture_suppressed; let capture_picker_suppressed = self.capture_picker_chrome_suppressed(); let update = GtkToolbarUpdate { @@ -233,8 +192,8 @@ impl WaylandState { unmap_suppressed, capture_picker_suppressed, ), - top_offset: (self.data.toolbar_top_offset, self.data.toolbar_top_offset_y), - top_offset_seq: self.data.gtk_top_offset_seq, + top_offset: self.toolbar_chrome.top_offset(), + top_offset_seq: self.toolbar_drag.gtk_offset_seq(), top_base_x: self.gtk_top_base_x(), output_name: self .surface @@ -248,18 +207,15 @@ impl WaylandState { self.input_state.modifiers.alt, ), modal_engaged: gtk_toolbar_feedback_blocked(&self.input_state), - drag_preview: self.data.gtk_drag_preview, + drag_preview: self.toolbar_drag.gtk_preview_kind(), capture_suppressed, - capture_suppression_generation: self - .data - .overlay_capture_barrier - .gtk_paint_generation(), + capture_suppression_generation: self.suppression.barrier.gtk_paint_generation(), snapshot, }; if let Some(generation) = update.capture_suppression_generation { log::info!( "capture.preflight id={generation} component=backend phase=gtk-update-queued reason={:?} top_visible={} output={:?}", - self.data.overlay_suppression, + self.suppression.reason(), update.top_visible, update.output_name ); @@ -275,14 +231,6 @@ mod modal_tests { use super::*; use crate::config::Action; use crate::input::state::test_support::make_test_input_state; - use crate::toolbar_gtk::GtkToolbarDragPhase; - - const TEST_SURFACE_SIZE: crate::toolbar_gtk::GtkToolbarSurfaceSize = - crate::toolbar_gtk::GtkToolbarSurfaceSize { - width: 260, - height: 789, - }; - #[test] fn command_palette_and_shortcut_capture_block_all_gtk_feedback() { let mut input_state = make_test_input_state(); @@ -317,106 +265,4 @@ mod modal_tests { assert!(!gtk_toolbar_top_visible(requested, true, false)); assert!(requested, "the persisted/live request remains untouched"); } - - #[test] - fn blocked_drag_feedback_advances_the_sequence_and_never_regresses_it() { - let mut top_seq = 4; - - acknowledge_blocked_gtk_drag_feedback( - &mut top_seq, - &GtkToolbarFeedback::SetTopOffset { - x: 100.0, - y: 50.0, - surface_size: TEST_SURFACE_SIZE, - seq: 9, - phase: GtkToolbarDragPhase::End, - }, - ); - assert_eq!(top_seq, 9); - - acknowledge_blocked_gtk_drag_feedback( - &mut top_seq, - &GtkToolbarFeedback::SetTopOffset { - x: 0.0, - y: 0.0, - surface_size: TEST_SURFACE_SIZE, - seq: 8, - phase: GtkToolbarDragPhase::Move, - }, - ); - assert_eq!(top_seq, 9); - } - - #[test] - fn drag_started_under_modal_stays_blocked_until_done() { - let mut top_blocked = false; - let top_update = |phase| GtkToolbarFeedback::SetTopOffset { - x: 10.0, - y: 20.0, - surface_size: TEST_SURFACE_SIZE, - seq: 1, - phase, - }; - - assert!(gtk_toolbar_feedback_is_blocked( - true, - &mut top_blocked, - &top_update(GtkToolbarDragPhase::Start), - )); - assert!(top_blocked); - - assert!(gtk_toolbar_feedback_is_blocked( - false, - &mut top_blocked, - &top_update(GtkToolbarDragPhase::Move), - )); - assert!(top_blocked); - - assert!(gtk_toolbar_feedback_is_blocked( - false, - &mut top_blocked, - &top_update(GtkToolbarDragPhase::End), - )); - assert!(!top_blocked); - - assert!(!gtk_toolbar_feedback_is_blocked( - false, - &mut top_blocked, - &top_update(GtkToolbarDragPhase::Start), - )); - } - - #[test] - fn capture_suppression_ack_bypasses_modal_feedback_blocking() { - let mut top_blocked = true; - - assert!(!gtk_toolbar_feedback_is_blocked( - true, - &mut top_blocked, - &GtkToolbarFeedback::CaptureSuppressionReady { generation: 7 }, - )); - assert!(top_blocked); - } - - #[test] - fn pointer_shortcuts_are_blocked_like_toolbar_events() { - let mut top_blocked = false; - let aux = GtkToolbarFeedback::PointerShortcut { - button: 8, - ctrl: false, - shift: false, - alt: false, - logo: false, - }; - assert!(gtk_toolbar_feedback_is_blocked( - true, - &mut top_blocked, - &aux, - )); - assert!(!gtk_toolbar_feedback_is_blocked( - false, - &mut top_blocked, - &aux, - )); - } } diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 444aea22e..065e1a109 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -81,8 +81,8 @@ impl WaylandState { self.cancel_eyedropper(); self.input_state.prepare_for_screen_modal(); self.zoom.stop_pan(); - self.stop_board_pan(); - self.set_board_pan_key_held(false); + self.pointer.stop_board_pan(); + self.pointer.set_board_pan_key_held(false); self.cancel_toolbar_move_drag(); self.unlock_pointer(); // The gesture just cancelled above may belong to a pen that is still @@ -91,13 +91,13 @@ impl WaylandState { // wait would commit the cancelled stroke's peak pressure to the tool. self.retire_stylus_contact(); - let generation = self.next_screen_region_generation(); + let generation = self.region_capture.next_generation(); match screen_source_entry( self.ocr_screen_source().is_some(), self.input_state.board_is_transparent(), self.zoom.is_engaged(), self.zoom.active, - self.frozen_enabled(), + self.frozen.enabled(), ) { ScreenSourceEntry::Activate => { if !self.activate_ocr_selector(generation, FreezeOwnership::PreExisting) { @@ -121,7 +121,7 @@ impl WaylandState { } } ScreenSourceEntry::AutoFreeze => { - match self.request_screen_acquisition(ScreenAcquisitionOwner::Ocr) { + match self.acquisition.request(ScreenAcquisitionOwner::Ocr) { Ok(acquisition) => self.set_pending_screen_region( RegionPurposeTag::Ocr, generation, @@ -183,7 +183,7 @@ impl WaylandState { capture_source: ScreenCaptureSource, installed_generation: u64, ) -> bool { - let Some(region) = self.data.active_screen_region else { + let Some(region) = self.region_capture.active() else { return false; }; let generation = region.generation(); @@ -228,8 +228,8 @@ impl WaylandState { /// Leave OCR selection, releasing only a freeze OCR created itself. pub(in crate::backend::wayland) fn cancel_ocr(&mut self) -> bool { - let Some(region) = self.data.active_screen_region else { - self.clear_zoom_waiter_for(ZoomWaiterOwner::Ocr); + let Some(region) = self.region_capture.active() else { + self.acquisition.clear_zoom_waiter(ZoomWaiterOwner::Ocr); self.input_state.cancel_region_ui_only(); return false; }; @@ -312,10 +312,11 @@ impl WaylandState { if self.finish_region_cut_drag(source, (x, y)) { return true; } + let (active, review_edits) = self.region_capture.selection_parts(); let rect = match finalize_region_selection_with_review_edits( - &mut self.data.active_screen_region, + active, &mut self.input_state, - &mut self.data.region_review_edits, + review_edits, source, (x, y), ) { @@ -366,7 +367,7 @@ impl WaylandState { // Mapped before the region is released: the token is what turns the // authoritative image rectangle into the surface pixels the sweep is // painted over. - let scan_region = match self.data.active_screen_region { + let scan_region = match self.region_capture.active() { Some(ActiveScreenRegion::Ready { source, .. }) => Some( crate::backend::wayland::state::screen_image::screen_rect_for_image_rect( &source, rect, @@ -429,7 +430,10 @@ impl WaylandState { Ok(id) => { // wl-copy needs the overlay to stay alive long enough to serve // the selection it publishes. - self.suppress_focus_exit_for(std::time::Duration::from_millis(1500)); + self.focus.suppress_exit_for( + std::time::Instant::now(), + std::time::Duration::from_millis(1500), + ); log::debug!("OCR request {id} started"); Some(id) } diff --git a/src/backend/wayland/state/onboarding.rs b/src/backend/wayland/state/onboarding.rs index 849d216f0..fec912da8 100644 --- a/src/backend/wayland/state/onboarding.rs +++ b/src/backend/wayland/state/onboarding.rs @@ -207,11 +207,11 @@ impl WaylandState { // Fold this tick's slow-path signal into the streak first — even behind // a modal — so a sustained habit still accumulates toward the threshold. if let Some((action, repeats)) = slow_path { - self.data.shortcut_coach.record(action, repeats); + self.shortcut_coach.record(action, repeats); } // Never compete with real feedback or interrupt a modal overlay. - if !self.surface.is_configured() || self.overlay_suppressed() { + if !self.surface.is_configured() || self.suppression.suppressed() { return; } if self.input_state.presenter_mode_active() @@ -227,7 +227,7 @@ impl WaylandState { let now = Instant::now(); let should_fire = { - let session = &self.data.shortcut_coach; + let session = &self.shortcut_coach; let state = self.preferences.onboarding().state(); shortcut_coach_should_fire( session.streak, @@ -242,12 +242,12 @@ impl WaylandState { return; } - let Some(action) = self.data.shortcut_coach.tracked_action else { + let Some(action) = self.shortcut_coach.tracked_action else { return; }; let Some(shortcut) = self.input_state.shortcut_for_action(action) else { // The shortcut was unbound since we started counting; drop the streak. - self.data.shortcut_coach.clear_streak(); + self.shortcut_coach.clear_streak(); return; }; @@ -261,7 +261,7 @@ impl WaylandState { automatic_tip_toast(message, OnboardingTip::ShortcutCoach), ); if outcome.accepted() { - let session = &mut self.data.shortcut_coach; + let session = &mut self.shortcut_coach; session.last_hint_at = Some(now); session.hints_this_session = session.hints_this_session.saturating_add(1); session.clear_streak(); @@ -276,7 +276,7 @@ impl WaylandState { } fn apply_contextual_feature_hints(&mut self) { - if !self.surface.is_configured() || self.overlay_suppressed() { + if !self.surface.is_configured() || self.suppression.suppressed() { return; } if self.input_state.presenter_mode_active() @@ -450,7 +450,7 @@ impl WaylandState { if self.preferences.onboarding().state().toolbar_hint_shown { return; } - if !self.surface.is_configured() || self.overlay_suppressed() { + if !self.surface.is_configured() || self.suppression.suppressed() { return; } if self.input_state.presenter_mode_active() || self.input_state.help_overlay.is_visible() { diff --git a/src/backend/wayland/state/onboarding/first_run.rs b/src/backend/wayland/state/onboarding/first_run.rs index 1f0747441..0a47dfb8c 100644 --- a/src/backend/wayland/state/onboarding/first_run.rs +++ b/src/backend/wayland/state/onboarding/first_run.rs @@ -212,7 +212,7 @@ impl WaylandState { self.config.ui.show_onboarding_hints, self.preferences.onboarding().persistence_available(), ) || !self.surface.is_configured() - || self.overlay_suppressed() + || self.suppression.suppressed() { return false; } diff --git a/src/backend/wayland/state/pointer_runtime.rs b/src/backend/wayland/state/pointer_runtime.rs index 8a12b204e..37ebec75e 100644 --- a/src/backend/wayland/state/pointer_runtime.rs +++ b/src/backend/wayland/state/pointer_runtime.rs @@ -9,14 +9,19 @@ use wayland_protocols::wp::{ relative_pointer::zv1::client::zwp_relative_pointer_v1::ZwpRelativePointerV1, }; +use crate::{ + input::state::{RegionInputSource, ToastPress}, + ui::ZoomChipPress, +}; + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(in crate::backend::wayland) enum TouchTarget { #[default] None, - Overlay, + Canvas, Toolbar, InlineToolbar, - Other, + Foreign, } pub(in crate::backend::wayland) struct TouchEnd { @@ -77,6 +82,124 @@ impl TouchState { } } +#[derive(Debug, Clone, Copy, Default)] +struct BoardPanGesture { + panning: bool, + last_pos: (f64, f64), + key_held: bool, +} + +impl BoardPanGesture { + fn start(&mut self, position: (f64, f64)) { + self.panning = true; + self.last_pos = position; + } + + fn stop(&mut self) { + self.panning = false; + } + + fn advance(&mut self, position: (f64, f64)) -> (f64, f64) { + let previous = self.last_pos; + self.last_pos = position; + (position.0 - previous.0, position.1 - previous.1) + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct ReleaseSuppression { + pointer: bool, + touch: bool, +} + +impl ReleaseSuppression { + fn slot_mut(&mut self, source: RegionInputSource) -> Option<&mut bool> { + match source { + RegionInputSource::Pointer => Some(&mut self.pointer), + RegionInputSource::Touch => Some(&mut self.touch), + RegionInputSource::Stylus => None, + } + } + + fn arm(&mut self, source: RegionInputSource) { + if let Some(slot) = self.slot_mut(source) { + *slot = true; + } + } + + fn clear(&mut self, source: RegionInputSource) { + if let Some(slot) = self.slot_mut(source) { + *slot = false; + } + } + + fn take(&mut self, source: RegionInputSource) -> bool { + self.slot_mut(source).is_some_and(std::mem::take) + } + + fn clear_all(&mut self) { + self.clear(RegionInputSource::Pointer); + self.clear(RegionInputSource::Touch); + } +} + +#[derive(Debug, Clone, Copy, Default)] +struct PendingChromePress { + toast: Option, + status_hud: bool, + zoom_chip: ZoomChipPress, + release_suppression: ReleaseSuppression, +} + +impl PendingChromePress { + fn occupied(&self) -> bool { + self.toast.is_some() || self.status_hud || self.zoom_chip.is_pending() + } + + fn clear(&mut self) { + self.toast = None; + self.status_hud = false; + self.zoom_chip = ZoomChipPress::None; + self.release_suppression.clear_all(); + } + + fn arm_toast(&mut self, press: ToastPress) -> bool { + if self.occupied() { + return false; + } + self.toast = Some(press); + true + } + + fn take_toast(&mut self) -> Option { + self.toast.take() + } + + fn arm_status_hud(&mut self) -> bool { + if self.occupied() { + return false; + } + self.status_hud = true; + true + } + + fn take_status_hud(&mut self) -> bool { + std::mem::take(&mut self.status_hud) + } + + fn arm_zoom_chip(&mut self, press: ZoomChipPress) -> bool { + if self.occupied() || !press.is_pending() { + return false; + } + self.zoom_chip = press; + true + } + + fn take_zoom_chip(&mut self) -> ZoomChipPress { + std::mem::replace(&mut self.zoom_chip, ZoomChipPress::None) + } +} + /// Pointer, cursor, pointer-lock, and single-contact touch protocol runtime. pub(in crate::backend::wayland) struct PointerRuntime { themed_pointer: Option>, @@ -88,6 +211,9 @@ pub(in crate::backend::wayland) struct PointerRuntime { current_pointer_shape: Option, relative_pointer: Option, cursor_hidden: bool, + position: (i32, i32), + board_pan: BoardPanGesture, + chrome_press: PendingChromePress, } impl PointerRuntime { @@ -101,6 +227,9 @@ impl PointerRuntime { current_pointer_shape: None, relative_pointer: None, cursor_hidden: false, + position: (0, 0), + board_pan: BoardPanGesture::default(), + chrome_press: PendingChromePress::default(), } } @@ -272,6 +401,83 @@ impl PointerRuntime { }) } + pub(in crate::backend::wayland) fn position(&self) -> (i32, i32) { + self.position + } + + pub(in crate::backend::wayland) fn set_position(&mut self, position: (i32, i32)) { + self.position = position; + } + + pub(in crate::backend::wayland) fn start_board_pan(&mut self, position: (f64, f64)) { + self.board_pan.start(position); + } + + pub(in crate::backend::wayland) fn stop_board_pan(&mut self) { + self.board_pan.stop(); + } + + pub(in crate::backend::wayland) fn board_pan_active(&self) -> bool { + self.board_pan.panning + } + + pub(in crate::backend::wayland) fn board_pan_key_held(&self) -> bool { + self.board_pan.key_held + } + + pub(in crate::backend::wayland) fn set_board_pan_key_held(&mut self, held: bool) { + self.board_pan.key_held = held; + } + + pub(in crate::backend::wayland) fn advance_board_pan( + &mut self, + position: (f64, f64), + ) -> (f64, f64) { + self.board_pan.advance(position) + } + + pub(in crate::backend::wayland) fn clear_chrome_press(&mut self) { + self.chrome_press.clear(); + } + + pub(in crate::backend::wayland) fn arm_toast_press(&mut self, press: ToastPress) -> bool { + self.chrome_press.arm_toast(press) + } + + pub(in crate::backend::wayland) fn take_toast_press(&mut self) -> Option { + self.chrome_press.take_toast() + } + + pub(in crate::backend::wayland) fn arm_status_hud_press(&mut self) -> bool { + self.chrome_press.arm_status_hud() + } + + pub(in crate::backend::wayland) fn take_status_hud_press(&mut self) -> bool { + self.chrome_press.take_status_hud() + } + + pub(in crate::backend::wayland) fn arm_zoom_chip_press( + &mut self, + press: ZoomChipPress, + ) -> bool { + self.chrome_press.arm_zoom_chip(press) + } + + pub(in crate::backend::wayland) fn take_zoom_chip_press(&mut self) -> ZoomChipPress { + self.chrome_press.take_zoom_chip() + } + + pub(in crate::backend::wayland) fn suppress_release(&mut self, source: RegionInputSource) { + self.chrome_press.release_suppression.arm(source); + } + + pub(in crate::backend::wayland) fn take_suppressed_release( + &mut self, + source: RegionInputSource, + ) -> bool { + self.chrome_press.release_suppression.take(source) + } + fn reset_cursor_cache(&mut self) { self.current_pointer_shape = None; self.cursor_hidden = false; @@ -289,7 +495,11 @@ impl PointerRuntime { #[cfg(test)] mod tests { - use super::{PointerRuntime, TouchState, TouchTarget}; + use super::{PendingChromePress, PointerRuntime, TouchState, TouchTarget}; + use crate::{ + input::state::{RegionInputSource, ToastPress}, + ui::ZoomChipPress, + }; use smithay_client_toolkit::seat::pointer::CursorIcon; #[test] @@ -317,21 +527,90 @@ mod tests { #[test] fn active_touch_rejects_a_second_contact_and_foreign_end() { let mut touch = TouchState::default(); - assert!(touch.begin(7, (10.0, 20.0), TouchTarget::Overlay)); + assert!(touch.begin(7, (10.0, 20.0), TouchTarget::Canvas)); assert!(!touch.begin(8, (30.0, 40.0), TouchTarget::Toolbar)); assert_eq!(touch.end(8), None); - assert_eq!(touch.end(7), Some(((10.0, 20.0), TouchTarget::Overlay))); + assert_eq!(touch.end(7), Some(((10.0, 20.0), TouchTarget::Canvas))); assert_eq!(touch.end(7), None); } #[test] fn active_touch_updates_only_the_owned_contact() { let mut touch = TouchState::default(); - assert!(touch.begin(7, (10.0, 20.0), TouchTarget::Overlay)); + assert!(touch.begin(7, (10.0, 20.0), TouchTarget::Canvas)); assert!(!touch.update_position(8, (30.0, 40.0))); assert!(touch.update_position(7, (50.0, 60.0))); - assert_eq!(touch.end(7), Some(((50.0, 60.0), TouchTarget::Overlay))); + assert_eq!(touch.end(7), Some(((50.0, 60.0), TouchTarget::Canvas))); + } + + #[test] + fn chrome_press_priority_keeps_the_first_target() { + let mut press = PendingChromePress::default(); + let toast = ToastPress::body(7); + + assert!(press.arm_toast(toast)); + assert!(!press.arm_status_hud()); + assert!(!press.arm_zoom_chip(ZoomChipPress::Passive)); + assert_eq!(press.take_toast(), Some(toast)); + assert_eq!(press.take_toast(), None); + } + + #[test] + fn clearing_chrome_press_empties_targets_and_release_latches() { + let mut runtime = PointerRuntime::new(); + assert!(runtime.arm_status_hud_press()); + runtime.suppress_release(RegionInputSource::Pointer); + runtime.suppress_release(RegionInputSource::Touch); + + runtime.clear_chrome_press(); + + assert!(!runtime.take_status_hud_press()); + assert_eq!(runtime.take_zoom_chip_press(), ZoomChipPress::None); + assert!(!runtime.take_suppressed_release(RegionInputSource::Pointer)); + assert!(!runtime.take_suppressed_release(RegionInputSource::Touch)); + } + + #[test] + fn release_suppression_is_owned_by_its_source() { + let mut runtime = PointerRuntime::new(); + runtime.suppress_release(RegionInputSource::Pointer); + runtime.suppress_release(RegionInputSource::Touch); + runtime + .chrome_press + .release_suppression + .clear(RegionInputSource::Touch); + + assert!(!runtime.take_suppressed_release(RegionInputSource::Touch)); + assert!(runtime.take_suppressed_release(RegionInputSource::Pointer)); + assert!(!runtime.take_suppressed_release(RegionInputSource::Pointer)); + assert!(!runtime.take_suppressed_release(RegionInputSource::Stylus)); + } + + #[test] + fn chrome_press_targets_are_taken_once() { + let mut runtime = PointerRuntime::new(); + assert!(runtime.arm_zoom_chip_press(ZoomChipPress::Passive)); + + assert_eq!(runtime.take_zoom_chip_press(), ZoomChipPress::Passive); + assert_eq!(runtime.take_zoom_chip_press(), ZoomChipPress::None); + } + + #[test] + fn board_pan_advance_uses_and_updates_the_previous_sample() { + let mut runtime = PointerRuntime::new(); + runtime.start_board_pan((10.0, 20.0)); + + assert_eq!(runtime.advance_board_pan((13.5, 18.0)), (3.5, -2.0)); + assert_eq!(runtime.advance_board_pan((15.0, 22.0)), (1.5, 4.0)); + } + + #[test] + fn pointer_position_round_trips() { + let mut runtime = PointerRuntime::new(); + runtime.set_position((17, 23)); + + assert_eq!(runtime.position(), (17, 23)); } #[test] diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs index 79a12a0c1..966c75ca0 100644 --- a/src/backend/wayland/state/region_capture.rs +++ b/src/backend/wayland/state/region_capture.rs @@ -18,6 +18,7 @@ mod picker; mod render; mod review_state; mod runtime; +pub(in crate::backend::wayland) use runtime::RegionCaptureRuntime; mod selection_state; mod source_guard; mod window_snap; diff --git a/src/backend/wayland/state/region_capture/cut_preview.rs b/src/backend/wayland/state/region_capture/cut_preview.rs index 13726fe33..4d0d624da 100644 --- a/src/backend/wayland/state/region_capture/cut_preview.rs +++ b/src/backend/wayland/state/region_capture/cut_preview.rs @@ -414,7 +414,7 @@ impl WaylandState { rect: ImagePixelRect, include_drawings: bool, ) -> Result { - let correlation = capture_ready_correlation(self.data.active_screen_region)?; + let correlation = capture_ready_correlation(self.region_capture.active())?; let shared_image = self.retain_current_capture_image(&correlation.source)?; let fingerprint = self.fingerprint_for_live_render(correlation, rect, include_drawings); let source = self.region_pixel_source(&fingerprint, shared_image)?; @@ -427,7 +427,7 @@ impl WaylandState { pub(super) fn current_region_fingerprint(&self) -> Option { let rect = self.region_review_rect()?; let include_drawings = self.region_picker_include_drawings(); - let correlation = capture_ready_correlation(self.data.active_screen_region).ok()?; + let correlation = capture_ready_correlation(self.region_capture.active()).ok()?; if self .retain_current_capture_image(&correlation.source) .is_err() @@ -439,12 +439,12 @@ impl WaylandState { pub(super) fn schedule_region_cut_preview(&mut self) { let Some(desired) = desired_preview_to_schedule( - self.data.region_review_edits.as_ref(), - self.region_cut_preview.is_active(), + self.region_capture.review_edits(), + self.region_capture.cut_preview_active(), ) else { return; }; - let Some(edits) = self.data.region_review_edits.as_ref() else { + let Some(edits) = self.region_capture.review_edits() else { return; }; let cached_base = edits @@ -500,7 +500,7 @@ impl WaylandState { source, base: cached_base, }; - if let Err(failure) = self.region_cut_preview.try_submit( + if let Err(failure) = self.region_capture.cut_preview_mut().try_submit( desired, "wayscriber-region-cut-preview", move || run_cut_preview(job), @@ -517,7 +517,7 @@ impl WaylandState { } pub(in crate::backend::wayland) fn poll_region_cut_preview_completion(&mut self) { - if let Some(outcome) = cut_preview_from_poll(self.region_cut_preview.poll()) { + if let Some(outcome) = cut_preview_from_poll(self.region_capture.cut_preview_mut().poll()) { self.finish_cut_preview_poll(outcome); self.schedule_region_cut_preview(); } @@ -525,7 +525,7 @@ impl WaylandState { fn finish_cut_preview_poll(&mut self, outcome: CutPreviewOutcome) { let effect = visible_effect_for_cut_preview( - &mut self.data.region_review_edits, + self.region_capture.review_edits_slot_mut(), outcome, |edits, cuts| { native_extent_display( diff --git a/src/backend/wayland/state/region_capture/cut_review.rs b/src/backend/wayland/state/region_capture/cut_review.rs index ff4778f6a..c743b232c 100644 --- a/src/backend/wayland/state/region_capture/cut_review.rs +++ b/src/backend/wayland/state/region_capture/cut_review.rs @@ -597,28 +597,22 @@ fn apply_cut_history_change( } impl WaylandState { - pub(super) fn region_review_edits(&self) -> Option<&RegionReviewEdits> { - self.data.region_review_edits.as_ref() - } - - pub(super) fn region_review_edits_mut(&mut self) -> Option<&mut RegionReviewEdits> { - self.data.region_review_edits.as_mut() - } - pub(in crate::backend::wayland) fn region_review_crop_locked(&self) -> bool { - self.region_review_edits() + self.region_capture + .review_edits() .is_some_and(RegionReviewEdits::crop_locked) } pub(in crate::backend::wayland) fn region_review_loupe_suppressed(&self) -> bool { - self.region_review_edits() + self.region_capture + .review_edits() .is_some_and(RegionReviewEdits::loupe_suppressed) } pub(in crate::backend::wayland) fn region_cut_displayed_selection( &self, ) -> Option { - let edits = self.region_review_edits()?; + let edits = self.region_capture.review_edits()?; if let Some(preview) = &edits.ready_preview { return Some(preview.display); } @@ -627,24 +621,26 @@ impl WaylandState { } pub(in crate::backend::wayland) fn region_cut_availability(&self) -> RegionActionAvailability { - self.region_review_edits() + self.region_capture + .review_edits() .map(RegionReviewEdits::availability) .unwrap_or_default() } pub(in crate::backend::wayland) fn region_cut_status(&self) -> Option { - self.region_review_edits() + self.region_capture + .review_edits() .and_then(RegionReviewEdits::status) } pub(in crate::backend::wayland) fn region_cut_mode_armed(&self) -> bool { - self.region_review_edits() + self.region_capture + .review_edits() .is_some_and(|edits| edits.mode == CutMode::Armed) } pub(super) fn create_region_review_edits(&mut self, rect: ImagePixelRect) { - self.data.region_review_edits = - review_edits_for_active_region(self.data.active_screen_region, rect); + self.region_capture.set_review_edits_for(rect); } pub(super) fn mark_region_cut_ui_dirty(&mut self) { @@ -676,7 +672,7 @@ impl WaylandState { } fn toggle_region_cut_mode(&mut self) -> bool { - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return false; }; let owner = edits.toggle_mode(); @@ -690,7 +686,7 @@ impl WaylandState { return false; }; if !apply_cut_history_change( - &mut self.data.region_review_edits, + self.region_capture.review_edits_slot_mut(), &mut self.input_state, |edits| edits.undo(fingerprint), ) { @@ -706,7 +702,7 @@ impl WaylandState { return false; }; if !apply_cut_history_change( - &mut self.data.region_review_edits, + self.region_capture.review_edits_slot_mut(), &mut self.input_state, |edits| edits.redo(fingerprint), ) { @@ -719,7 +715,7 @@ impl WaylandState { fn reset_region_cuts(&mut self) -> bool { if !apply_cut_history_change( - &mut self.data.region_review_edits, + self.region_capture.review_edits_slot_mut(), &mut self.input_state, RegionReviewEdits::reset, ) { @@ -740,14 +736,14 @@ impl WaylandState { if !display_contains(display, point) { return false; } - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return false; }; if !edits.begin_drag(owner, point) { return false; } if !self.input_state.begin_region_review_move(owner) { - if let Some(edits) = self.region_review_edits_mut() { + if let Some(edits) = self.region_capture.review_edits_mut() { edits.drag = None; } return false; @@ -761,7 +757,7 @@ impl WaylandState { owner: RegionInputSource, point: (f64, f64), ) -> bool { - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return false; }; if !edits.update_drag(owner, point) { @@ -777,7 +773,8 @@ impl WaylandState { point: (f64, f64), ) -> bool { if !self - .region_review_edits() + .region_capture + .review_edits() .and_then(|edits| edits.drag) .is_some_and(|drag| drag.owner == owner) { @@ -791,7 +788,7 @@ impl WaylandState { self.abandon_region_cut_drag(owner); return true; }; - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return false; }; let commit = edits.finish_drag(owner, point, display, fingerprint); @@ -820,7 +817,7 @@ impl WaylandState { &mut self, owner: RegionInputSource, ) -> bool { - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return false; }; let Some(drag) = edits.drag else { @@ -837,9 +834,10 @@ impl WaylandState { pub(in crate::backend::wayland) fn handle_region_cut_escape(&mut self) -> bool { let owner = self - .region_review_edits() + .region_capture + .review_edits() .and_then(|edits| edits.drag.map(|drag| drag.owner)); - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return false; }; if !edits.disarm_mode() { @@ -856,7 +854,7 @@ impl WaylandState { let Some(rect) = self.region_review_rect() else { return; }; - let Some(edits) = self.region_review_edits_mut() else { + let Some(edits) = self.region_capture.review_edits_mut() else { return; }; if edits.set_source_rect(rect) { @@ -867,7 +865,8 @@ impl WaylandState { pub(in crate::backend::wayland) fn region_cut_preview_pixels( &self, ) -> Option<&crate::screen_pixels::PackedArgb32> { - self.region_review_edits() + self.region_capture + .review_edits() .and_then(|edits| edits.ready_preview.as_ref()) .map(|preview| preview.pixels.as_ref()) } @@ -875,7 +874,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn region_cut_drag_overlay( &self, ) -> Option<(CutAxis, RegionSelection)> { - let edits = self.region_review_edits()?; + let edits = self.region_capture.review_edits()?; let drag = edits.drag?; let axis = drag.axis?; let display = self.region_cut_displayed_selection()?; diff --git a/src/backend/wayland/state/region_capture/delivery.rs b/src/backend/wayland/state/region_capture/delivery.rs index df793c9d9..755b27ff7 100644 --- a/src/backend/wayland/state/region_capture/delivery.rs +++ b/src/backend/wayland/state/region_capture/delivery.rs @@ -119,7 +119,8 @@ impl WaylandState { return false; }; let cuts = self - .region_review_edits() + .region_capture + .review_edits() .map(|edits| edits.cuts.clone()) .unwrap_or_default(); if !self.preview_permits_submit(&cuts) { @@ -163,7 +164,8 @@ impl WaylandState { fn preview_permits_submit(&self, cuts: &[crate::capture::CutBand]) -> bool { cuts.is_empty() || self - .region_review_edits() + .region_capture + .review_edits() .is_some_and(super::cut_review::RegionReviewEdits::preview_is_current) } @@ -176,7 +178,7 @@ impl WaylandState { if !limits.allows_pixels(output.0, output.1) { return Err(BoardSubmitError::TooLarge); } - let Some(ActiveScreenRegion::Ready { source, .. }) = self.data.active_screen_region else { + let Some(ActiveScreenRegion::Ready { source, .. }) = self.region_capture.active() else { return Err(BoardSubmitError::Unplaceable); }; let Some(source_world) = @@ -220,7 +222,7 @@ impl WaylandState { { return; } - self.clear_region_window_snap(); + self.region_capture.clear_window_snap(); self.retire_region_selection_owner(self.input_state.region_state().selection_owner()); match purpose { RegionPurposeTag::CaptureInteractive => { @@ -254,7 +256,7 @@ impl WaylandState { purpose, include_drawings, .. - }) = self.data.active_screen_region + }) = self.region_capture.active() else { self.cancel_region_capture_ui_and_lifecycle(); return; @@ -279,13 +281,13 @@ impl WaylandState { } }; if !cuts.is_empty() { - let fingerprint_ok = self.region_review_edits().is_some_and(|edits| { + let fingerprint_ok = self.region_capture.review_edits().is_some_and(|edits| { edits.ready_preview.as_ref().is_some_and(|preview| { preview.key.fingerprint == snapshot.fingerprint && preview.key.cuts == cuts }) }); if !fingerprint_ok { - if let Some(edits) = self.region_review_edits_mut() { + if let Some(edits) = self.region_capture.review_edits_mut() { edits.invalidate_base(snapshot.fingerprint); } self.schedule_region_cut_preview(); diff --git a/src/backend/wayland/state/region_capture/picker.rs b/src/backend/wayland/state/region_capture/picker.rs index 4322097fb..160d1e00b 100644 --- a/src/backend/wayland/state/region_capture/picker.rs +++ b/src/backend/wayland/state/region_capture/picker.rs @@ -148,7 +148,7 @@ impl WaylandState { return; } - if interactive && !self.frozen_enabled() { + if interactive && !self.frozen.enabled() { self.cancel_region_capture_ui_and_lifecycle(); self.input_state.push_toast( ToastPriority::Info, @@ -157,20 +157,20 @@ impl WaylandState { ); return; } - if !interactive && (region.picker == RegionPicker::Slurp || !self.frozen_enabled()) { + if !interactive && (region.picker == RegionPicker::Slurp || !self.frozen.enabled()) { self.handoff_region_capture_to_legacy(); return; } self.input_state.prepare_for_screen_modal(); self.zoom.stop_pan(); - self.stop_board_pan(); - self.set_board_pan_key_held(false); + self.pointer.stop_board_pan(); + self.pointer.set_board_pan_key_held(false); self.cancel_toolbar_move_drag(); self.unlock_pointer(); self.retire_stylus_contact(); - let generation = self.next_screen_region_generation(); + let generation = self.region_capture.next_generation(); let has_source = displayed_screen_image( &self.zoom, &self.frozen, @@ -182,7 +182,7 @@ impl WaylandState { self.input_state.board_is_transparent(), self.zoom.is_engaged(), self.zoom.active, - self.frozen_enabled(), + self.frozen.enabled(), ) { RegionPickerEntry::Activate => { if !self.activate_screen_region( @@ -208,7 +208,10 @@ impl WaylandState { } } RegionPickerEntry::AutoFreeze => { - match self.request_screen_acquisition(ScreenAcquisitionOwner::RegionCapture) { + match self + .acquisition + .request(ScreenAcquisitionOwner::RegionCapture) + { Ok(acquisition) => self.set_pending_screen_region( purpose, generation, @@ -264,7 +267,7 @@ impl WaylandState { source: ScreenCaptureSource, installed_generation: u64, ) -> bool { - let Some(region) = self.data.active_screen_region else { + let Some(region) = self.region_capture.active() else { return false; }; if !region.purpose().is_capture() { @@ -307,10 +310,11 @@ impl WaylandState { } pub(in crate::backend::wayland) fn cancel_region_capture(&mut self) -> bool { - let Some(region) = self.data.active_screen_region else { + let Some(region) = self.region_capture.active() else { let reserved = self.capture.active_region_action().is_some(); if reserved { - self.clear_zoom_waiter_for(ZoomWaiterOwner::RegionCapture); + self.acquisition + .clear_zoom_waiter(ZoomWaiterOwner::RegionCapture); self.clear_screen_region_ui_only(); self.capture.finish_capture_lifecycle(); } @@ -335,8 +339,8 @@ impl WaylandState { /// XDG restoration and unfreeze happen exactly once. pub(in crate::backend::wayland) fn cancel_region_capture_for_teardown(&mut self) { let ui_owns_region = self - .data - .active_screen_region + .region_capture + .active() .is_some_and(|region| region.purpose().is_capture()); if ui_owns_region || self.capture.active_region_action().is_some() { self.cancel_region_capture(); @@ -344,13 +348,15 @@ impl WaylandState { } pub(in crate::backend::wayland::state) fn cancel_region_capture_ui_and_lifecycle(&mut self) { - self.clear_zoom_waiter_for(ZoomWaiterOwner::RegionCapture); + self.acquisition + .clear_zoom_waiter(ZoomWaiterOwner::RegionCapture); self.clear_screen_region_ui_only(); self.capture.finish_capture_lifecycle(); } pub(super) fn clear_region_capture_ui_for_handoff(&mut self) { - self.clear_zoom_waiter_for(ZoomWaiterOwner::RegionCapture); + self.acquisition + .clear_zoom_waiter(ZoomWaiterOwner::RegionCapture); self.clear_screen_region_ui_only(); } diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs index 19b949a77..999048b3a 100644 --- a/src/backend/wayland/state/region_capture/runtime.rs +++ b/src/backend/wayland/state/region_capture/runtime.rs @@ -1,5 +1,200 @@ +use crate::backend::wayland::{RuntimeOperationController, RuntimeOperationIdSource}; + +use super::cut_review::review_edits_for_active_region; use super::*; +pub(in crate::backend::wayland) struct RegionCaptureRuntime { + active: Option, + window_snap: Option, + review_edits: Option, + next_generation: u64, + window_query: RuntimeOperationController< + WindowSnapQuery, + Result< + crate::capture::window_geometry::WindowQueryResult, + crate::capture::window_geometry::WindowGeometryError, + >, + >, + cut_preview: RuntimeOperationController, +} + +impl RegionCaptureRuntime { + pub(in crate::backend::wayland) fn new( + ids: RuntimeOperationIdSource, + wake: crate::backend::wayland::RuntimeWakeHandle, + ) -> Self { + Self { + active: None, + window_snap: None, + review_edits: None, + next_generation: 1, + window_query: RuntimeOperationController::new(ids.clone(), wake.clone()), + cut_preview: RuntimeOperationController::new(ids, wake), + } + } + + pub(in crate::backend::wayland) fn next_generation(&mut self) -> u64 { + let generation = self.next_generation; + self.next_generation = generation + .checked_add(1) + .expect("screen region generation space exhausted"); + generation + } + + pub(in crate::backend::wayland::state) fn active(&self) -> Option { + self.active + } + + pub(in crate::backend::wayland::state) fn active_mut( + &mut self, + ) -> Option<&mut ActiveScreenRegion> { + self.active.as_mut() + } + + pub(in crate::backend::wayland::state) fn active_slot_mut( + &mut self, + ) -> &mut Option { + &mut self.active + } + + pub(in crate::backend::wayland::state) fn begin_measure(&mut self, bounds: (u32, u32)) -> u64 { + let generation = self.next_generation(); + self.active = Some(ActiveScreenRegion::Measure { + generation, + bounds, + anchor: None, + edge: None, + }); + generation + } + + pub(in crate::backend::wayland::state) fn set_pending( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + source: ScreenCaptureSource, + acquisition: Option, + ) { + self.active = Some(match source { + ScreenCaptureSource::Frozen => ActiveScreenRegion::PendingFrozen { + purpose, + generation, + acquisition: acquisition.expect("frozen region wait has an acquisition id"), + }, + ScreenCaptureSource::Zoom => ActiveScreenRegion::PendingZoom { + purpose, + generation, + }, + }); + } + + pub(in crate::backend::wayland::state) fn set_ready( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + source: ScreenSourceToken, + freeze_ownership: FreezeOwnership, + square_modifier: bool, + include_drawings: bool, + ) { + self.active = Some(ActiveScreenRegion::Ready { + purpose, + generation, + source, + freeze_ownership, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier, + legend_dismissed: false, + include_drawings, + review_resize: None, + }); + } + + pub(in crate::backend::wayland) fn clear(&mut self) { + self.window_snap = None; + self.review_edits = None; + self.active = None; + } + + pub(in crate::backend::wayland) fn review_edits(&self) -> Option<&RegionReviewEdits> { + self.review_edits.as_ref() + } + + pub(in crate::backend::wayland) fn review_edits_mut( + &mut self, + ) -> Option<&mut RegionReviewEdits> { + self.review_edits.as_mut() + } + + pub(in crate::backend::wayland) fn review_edits_slot_mut( + &mut self, + ) -> &mut Option { + &mut self.review_edits + } + + pub(in crate::backend::wayland::state) fn selection_parts( + &mut self, + ) -> ( + &mut Option, + &mut Option, + ) { + (&mut self.active, &mut self.review_edits) + } + + pub(in crate::backend::wayland) fn set_review_edits_for(&mut self, rect: ImagePixelRect) { + self.review_edits = review_edits_for_active_region(self.active, rect); + } + + pub(in crate::backend::wayland::state) fn window_snap(&self) -> Option<&WindowSnapSession> { + self.window_snap.as_ref() + } + + pub(in crate::backend::wayland::state) fn window_snap_mut( + &mut self, + ) -> Option<&mut WindowSnapSession> { + self.window_snap.as_mut() + } + + pub(in crate::backend::wayland::state) fn set_window_snap( + &mut self, + session: WindowSnapSession, + ) { + self.window_snap = Some(session); + } + + pub(in crate::backend::wayland) fn clear_window_snap(&mut self) { + self.window_snap = None; + } + + pub(in crate::backend::wayland::state) fn window_query_parts( + &mut self, + ) -> ( + &mut RuntimeOperationController< + WindowSnapQuery, + Result< + crate::capture::window_geometry::WindowQueryResult, + crate::capture::window_geometry::WindowGeometryError, + >, + >, + &mut Option, + ) { + (&mut self.window_query, &mut self.window_snap) + } + + pub(in crate::backend::wayland) fn cut_preview_mut( + &mut self, + ) -> &mut RuntimeOperationController { + &mut self.cut_preview + } + + pub(in crate::backend::wayland) fn cut_preview_active(&self) -> bool { + self.cut_preview.is_active() + } +} + impl WaylandState { pub(in crate::backend::wayland::state) fn retire_region_selection_owner( &mut self, @@ -7,7 +202,7 @@ impl WaylandState { ) { match owner { Some(source @ (RegionInputSource::Pointer | RegionInputSource::Touch)) => { - self.suppress_next_release_from(source); + self.pointer.suppress_release(source); } Some(RegionInputSource::Stylus) => self.retire_stylus_contact(), None => {} @@ -38,19 +233,15 @@ impl WaylandState { self.input_state.prepare_for_screen_modal(); self.zoom.stop_pan(); - self.stop_board_pan(); - self.set_board_pan_key_held(false); + self.pointer.stop_board_pan(); + self.pointer.set_board_pan_key_held(false); self.cancel_toolbar_move_drag(); self.unlock_pointer(); self.retire_stylus_contact(); - let generation = self.next_screen_region_generation(); - self.data.active_screen_region = Some(ActiveScreenRegion::Measure { - generation, - bounds: (self.surface.width(), self.surface.height()), - anchor: None, - edge: None, - }); + let generation = self + .region_capture + .begin_measure((self.surface.width(), self.surface.height())); self.input_state.activate_measure_mode(generation); self.debug_assert_screen_region_invariant(); } @@ -64,8 +255,8 @@ impl WaylandState { } pub(in crate::backend::wayland) fn region_picker_include_drawings(&self) -> bool { - self.data - .active_screen_region + self.region_capture + .active() .is_some_and(ActiveScreenRegion::include_drawings) } @@ -74,20 +265,20 @@ impl WaylandState { return false; } let Some(checked) = self - .data - .active_screen_region - .as_mut() + .region_capture + .active_mut() .and_then(ActiveScreenRegion::toggle_include_drawings) else { return false; }; log::debug!("region picker include drawings: {checked}"); if self - .region_review_edits() + .region_capture + .review_edits() .is_some_and(|edits| !edits.cuts.is_empty()) { if let Some(fingerprint) = self.current_region_fingerprint() - && let Some(edits) = self.region_review_edits_mut() + && let Some(edits) = self.region_capture.review_edits_mut() { edits.invalidate_base(fingerprint); } @@ -97,14 +288,6 @@ impl WaylandState { true } - pub(in crate::backend::wayland::state) fn next_screen_region_generation(&mut self) -> u64 { - let generation = self.data.next_screen_region_generation; - self.data.next_screen_region_generation = generation - .checked_add(1) - .expect("screen region generation space exhausted"); - generation - } - pub(in crate::backend::wayland::state) fn set_pending_screen_region( &mut self, purpose: RegionPurposeTag, @@ -112,17 +295,8 @@ impl WaylandState { source: ScreenCaptureSource, acquisition: Option, ) { - self.data.active_screen_region = Some(match source { - ScreenCaptureSource::Frozen => ActiveScreenRegion::PendingFrozen { - purpose, - generation, - acquisition: acquisition.expect("frozen region wait has an acquisition id"), - }, - ScreenCaptureSource::Zoom => ActiveScreenRegion::PendingZoom { - purpose, - generation, - }, - }); + self.region_capture + .set_pending(purpose, generation, source, acquisition); self.input_state .set_region_pending_capture(purpose, generation, source); self.debug_assert_screen_region_invariant(); @@ -150,20 +324,14 @@ impl WaylandState { ) else { return false; }; - self.data.active_screen_region = Some(ActiveScreenRegion::Ready { + self.region_capture.set_ready( purpose, generation, - source: token, + token, freeze_ownership, - anchor: None, - raw_edge: None, - logical_anchor: None, - logical_edge: None, - square_modifier: initial_square_modifier(purpose, self.input_state.modifiers.shift), - legend_dismissed: false, + initial_square_modifier(purpose, self.input_state.modifiers.shift), include_drawings, - review_resize: None, - }); + ); self.input_state.activate_region(purpose, generation); self.start_region_window_query(purpose, generation, token, freeze_ownership); self.debug_assert_screen_region_invariant(); @@ -171,9 +339,7 @@ impl WaylandState { } pub(in crate::backend::wayland::state) fn clear_screen_region_ui_only(&mut self) { - self.clear_region_window_snap(); - self.data.region_review_edits = None; - self.data.active_screen_region = None; + self.region_capture.clear(); self.input_state.cancel_region_ui_only(); self.debug_assert_screen_region_invariant(); } @@ -189,7 +355,7 @@ impl WaylandState { return true; } begin_region_selection_event( - &mut self.data.active_screen_region, + self.region_capture.active_slot_mut(), &mut self.input_state, owner, (x, y), @@ -210,7 +376,7 @@ impl WaylandState { return; } update_region_selection_event( - &mut self.data.active_screen_region, + self.region_capture.active_slot_mut(), &mut self.input_state, owner, (x, y), @@ -226,7 +392,7 @@ impl WaylandState { if let Some(rect) = self.highlighted_region_window_rect() && let Some(ActiveScreenRegion::Ready { purpose, source, .. - }) = self.data.active_screen_region + }) = self.region_capture.active() { let display = super::super::screen_image::screen_rect_for_image_rect(&source, rect); return Some(RegionSelectionGeometry::authoritative( @@ -241,8 +407,8 @@ impl WaylandState { }, )); } - self.data - .active_screen_region + self.region_capture + .active() .and_then(ActiveScreenRegion::selection_geometry) .and_then(|geometry| { if !self.input_state.region_state().is_review() { @@ -251,22 +417,22 @@ impl WaylandState { let Some(display) = self.region_cut_displayed_selection() else { return Some(geometry); }; - let purpose = self.data.active_screen_region?.purpose(); + let purpose = self.region_capture.active()?.purpose(); let rect = geometry.image_rect()?; Some(RegionSelectionGeometry::review(purpose, rect, display)) }) } pub(in crate::backend::wayland) fn region_measure_selection(&self) -> Option { - self.data - .active_screen_region + self.region_capture + .active() .and_then(ActiveScreenRegion::measure_selection) } pub(in crate::backend::wayland::state) fn region_picker_source_token( &self, ) -> Option { - match self.data.active_screen_region { + match self.region_capture.active() { Some(ActiveScreenRegion::Ready { source, .. }) => Some(source), Some(ActiveScreenRegion::Measure { .. }) | Some(ActiveScreenRegion::PendingFrozen { .. }) @@ -276,16 +442,16 @@ impl WaylandState { } pub(in crate::backend::wayland) fn region_picker_legend_dismissed(&self) -> bool { - self.data - .active_screen_region + self.region_capture + .active() .is_some_and(ActiveScreenRegion::legend_dismissed) } pub(in crate::backend::wayland) fn whole_image_region_selection( &self, ) -> Option { - self.data - .active_screen_region + self.region_capture + .active() .and_then(ActiveScreenRegion::whole_image_selection) } @@ -293,7 +459,7 @@ impl WaylandState { &mut self, rect: ImagePixelRect, ) -> bool { - let Some(region) = self.data.active_screen_region.as_mut() else { + let Some(region) = self.region_capture.active_mut() else { return false; }; let purpose = region.purpose(); @@ -321,9 +487,8 @@ impl WaylandState { return true; } let Some(display) = self - .data - .active_screen_region - .as_mut() + .region_capture + .active_mut() .and_then(|region| region.nudge_review(delta_x, delta_y)) else { return false; @@ -340,8 +505,8 @@ impl WaylandState { if !self.input_state.region_state().is_review() { return None; } - self.data - .active_screen_region + self.region_capture + .active() .and_then(ActiveScreenRegion::review_resize_handle) } @@ -357,14 +522,13 @@ impl WaylandState { if self.region_review_crop_locked() || self.region_cut_mode_armed() { return None; } - self.data - .active_screen_region - .as_ref() - .and_then(|region| review_resize_handle_at(region, point)) + self.region_capture + .active() + .and_then(|region| review_resize_handle_at(®ion, point)) } pub(in crate::backend::wayland) fn region_review_rect(&self) -> Option { - match self.data.active_screen_region { + match self.region_capture.active() { Some(region) if self.input_state.region_state().is_review() => { region.stored_review_rect() } @@ -384,7 +548,8 @@ impl WaylandState { } if self.input_state.region_state().is_review() && let Some(size) = self - .region_review_edits() + .region_capture + .review_edits() .and_then(RegionReviewEdits::displayed_output_size) { return Some(RegionPickerMeasurement::Size { @@ -392,8 +557,8 @@ impl WaylandState { height: size.1, }); } - self.data - .active_screen_region + self.region_capture + .active() .and_then(|region| region.picker_measurement(pointer)) } @@ -401,7 +566,7 @@ impl WaylandState { /// Individual key events remain swallowed by the modal selector. pub(in crate::backend::wayland) fn sync_region_square_modifier(&mut self, shift: bool) -> bool { sync_region_square_modifier_event( - &mut self.data.active_screen_region, + self.region_capture.active_slot_mut(), &mut self.input_state, shift, ) @@ -418,7 +583,7 @@ impl WaylandState { return RegionOwnerLoss::Rearmed; } region_owner_lost_event( - &mut self.data.active_screen_region, + self.region_capture.active_slot_mut(), &mut self.input_state, source, ) @@ -426,7 +591,7 @@ impl WaylandState { pub(in crate::backend::wayland::state) fn debug_assert_screen_region_invariant(&self) { debug_assert!(screen_region_invariant( - self.data.active_screen_region, + self.region_capture.active(), self.input_state.region_state(), )); } @@ -453,11 +618,11 @@ impl WaylandState { ( active_eyedropper_source_changed( self.input_state.eyedropper_is_active(), - self.data.active_eyedropper_source, + self.acquisition.eyedropper_source(), &source_matches, ), active_region_source_changed( - self.data.active_screen_region, + self.region_capture.active(), (self.surface.width(), self.surface.height()), &source_matches, ), @@ -468,8 +633,8 @@ impl WaylandState { } if cancel_region { match self - .data - .active_screen_region + .region_capture + .active() .map(ActiveScreenRegion::purpose) { Some(RegionPurposeTag::Ocr) => { @@ -486,3 +651,44 @@ impl WaylandState { } } } + +#[cfg(test)] +mod owner_tests { + use super::*; + use crate::backend::wayland::RuntimeWakeSource; + + fn runtime() -> RegionCaptureRuntime { + let wake = RuntimeWakeSource::new().expect("runtime wake source"); + RegionCaptureRuntime::new(RuntimeOperationIdSource::new(), wake.handle()) + } + + #[test] + fn generations_are_monotonic() { + let mut runtime = runtime(); + + assert_eq!(runtime.next_generation(), 1); + assert_eq!(runtime.next_generation(), 2); + } + + #[test] + fn clearing_retires_the_active_region() { + let mut runtime = runtime(); + let generation = runtime.begin_measure((1920, 1080)); + assert_eq!(generation, 1); + + runtime.clear(); + + assert!(runtime.active().is_none()); + assert!(runtime.review_edits().is_none()); + assert!(runtime.window_snap().is_none()); + } + + #[test] + #[should_panic(expected = "screen region generation space exhausted")] + fn generation_exhaustion_is_explicit() { + let mut runtime = runtime(); + runtime.next_generation = u64::MAX; + + let _ = runtime.next_generation(); + } +} diff --git a/src/backend/wayland/state/region_capture/window_snap.rs b/src/backend/wayland/state/region_capture/window_snap.rs index 4856857df..608e186ba 100644 --- a/src/backend/wayland/state/region_capture/window_snap.rs +++ b/src/backend/wayland/state/region_capture/window_snap.rs @@ -317,25 +317,22 @@ fn output_logical_to_image_point( impl WaylandState { pub(in crate::backend::wayland) fn region_window_snap_available(&self) -> bool { - self.data - .window_snap - .as_ref() + self.region_capture + .window_snap() .is_some_and(WindowSnapSession::is_ready) } pub(in crate::backend::wayland) fn region_window_snap_active(&self) -> bool { - self.data - .window_snap - .as_ref() + self.region_capture + .window_snap() .is_some_and(WindowSnapSession::mode_active) } pub(in crate::backend::wayland) fn region_window_snap_display_selections( &self, ) -> &[RegionSelection] { - self.data - .window_snap - .as_ref() + self.region_capture + .window_snap() .map(WindowSnapSession::display_selections) .unwrap_or_default() } @@ -343,9 +340,8 @@ impl WaylandState { pub(in crate::backend::wayland) fn region_window_snap_highlighted_index( &self, ) -> Option { - self.data - .window_snap - .as_ref() + self.region_capture + .window_snap() .and_then(WindowSnapSession::hovered_index) } @@ -360,16 +356,15 @@ impl WaylandState { } let owner = ui.selection_owner(); let toggled = self - .data - .window_snap - .as_mut() + .region_capture + .window_snap_mut() .is_some_and(WindowSnapSession::toggle_mode); if !toggled { return false; } if owner.is_some() { super::events::rearm_region_selection_event( - &mut self.data.active_screen_region, + self.region_capture.active_slot_mut(), &mut self.input_state, ); self.retire_region_selection_owner(owner); @@ -386,9 +381,8 @@ impl WaylandState { self.cancel_screen_modals_if_source_changed(); let pointer = self.current_region_pointer(); let changed = self - .data - .window_snap - .as_mut() + .region_capture + .window_snap_mut() .is_some_and(|session| session.navigate(direction, pointer)); if changed { self.mark_region_window_snap_dirty(); @@ -417,9 +411,8 @@ impl WaylandState { return false; } let changed = self - .data - .window_snap - .as_mut() + .region_capture + .window_snap_mut() .is_some_and(|session| session.update_hover(point)); if changed { self.mark_region_window_snap_dirty(); @@ -436,23 +429,22 @@ impl WaylandState { self.update_region_window_hover(point); } let Some(rect) = self - .data - .window_snap - .as_ref() + .region_capture + .window_snap() .and_then(WindowSnapSession::hovered_target) .map(WindowSnapTarget::image_rect) else { return false; }; let Some(purpose) = self - .data - .active_screen_region + .region_capture + .active() .map(|region| region.purpose()) .filter(|purpose| purpose.is_capture()) else { return false; }; - self.clear_region_window_snap(); + self.region_capture.clear_window_snap(); self.retire_region_selection_owner(owner); if purpose == RegionPurposeTag::CaptureInteractive { self.enter_region_review(rect) @@ -463,16 +455,15 @@ impl WaylandState { } pub(super) fn highlighted_region_window_rect(&self) -> Option { - self.data - .window_snap - .as_ref() + self.region_capture + .window_snap() .filter(|session| session.mode_active()) .and_then(WindowSnapSession::hovered_target) .map(WindowSnapTarget::image_rect) } fn current_region_pointer(&self) -> (f64, f64) { - let (x, y) = self.current_mouse(); + let (x, y) = self.pointer.position(); (f64::from(x), f64::from(y)) } diff --git a/src/backend/wayland/state/region_capture/window_snap/query.rs b/src/backend/wayland/state/region_capture/window_snap/query.rs index 0345ef8c1..3deb9c737 100644 --- a/src/backend/wayland/state/region_capture/window_snap/query.rs +++ b/src/backend/wayland/state/region_capture/window_snap/query.rs @@ -141,7 +141,7 @@ impl WaylandState { source: ScreenSourceToken, freeze_ownership: FreezeOwnership, ) { - self.clear_region_window_snap(); + self.region_capture.clear_window_snap(); if !source_has_correlated_window_layout(purpose, freeze_ownership) || detect_backend().is_none() { @@ -151,12 +151,12 @@ impl WaylandState { return; }; let correlation = WindowSnapCorrelation::new(generation, source); - self.data.window_snap = Some(WindowSnapSession::queued(correlation, provider)); - let _ = submit_queued_window_query_with( - &mut self.window_query, - &mut self.data.window_snap, - |provider| query_window_targets(&provider), - ); + self.region_capture + .set_window_snap(WindowSnapSession::queued(correlation, provider)); + let (controller, session) = self.region_capture.window_query_parts(); + let _ = submit_queued_window_query_with(controller, session, |provider| { + query_window_targets(&provider) + }); } fn region_window_query_context(&self, source: ScreenSourceToken) -> Option { @@ -179,18 +179,13 @@ impl WaylandState { } pub(in crate::backend::wayland) fn poll_region_window_query_completion(&mut self) { - if poll_window_query_with( - &mut self.window_query, - &mut self.data.window_snap, - |provider| query_window_targets(&provider), - ) { + let (controller, session) = self.region_capture.window_query_parts(); + if poll_window_query_with(controller, session, |provider| { + query_window_targets(&provider) + }) { self.mark_region_window_snap_dirty(); } } - - pub(in crate::backend::wayland::state) fn clear_region_window_snap(&mut self) { - self.data.window_snap = None; - } } fn clear_matching_window_session( diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index d5bcae1b9..7a77ca73b 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -103,7 +103,7 @@ impl WaylandState { let layer_cache_ready = if !capture_picker_active && self.canvas_layer_cache_usable() { self.ensure_canvas_layer_cache(width, height, scale) } else { - self.canvas_layer_cache.clear(); + self.render.canvas_layer_cache_mut().clear(); false }; if let (Some(perf), Some(layer_cache_start)) = (perf.as_mut(), layer_cache_start) { @@ -190,7 +190,7 @@ impl WaylandState { // nothing the page holds: cancelling it leaves nothing behind, and // completing it warns through its own action instead. let spotlight_cursor = render_transients.then(|| { - let (screen_x, screen_y) = self.current_mouse(); + let (screen_x, screen_y) = self.pointer.position(); self.canvas_world_coords(screen_x as f64, screen_y as f64) }); let crate::input::state::SpotlightFrameRegions { @@ -278,8 +278,10 @@ impl WaylandState { self.render_selection_overlays(ctx); - let (mx, my) = - self.canvas_world_coords(self.current_mouse().0 as f64, self.current_mouse().1 as f64); + let (mx, my) = self.canvas_world_coords( + self.pointer.position().0 as f64, + self.pointer.position().1 as f64, + ); let (hover_mx, hover_my) = self .stylus_hover_cursor_position() .map(|(x, y)| self.canvas_world_coords(x, y)) @@ -345,7 +347,7 @@ impl WaylandState { mut perf: Option<&mut PerfRenderBreakdown>, ) { let shapes = &self.input_state.boards.active_frame().shapes; - if layer_cache_ready && self.canvas_layer_cache.blit(ctx) { + if layer_cache_ready && self.render.canvas_layer_cache().blit(ctx) { debug!("Rendered committed shapes from layer cache"); if let Some(perf) = perf.as_mut() { perf.shapes_total = shapes.len(); diff --git a/src/backend/wayland/state/render/measure_badge.rs b/src/backend/wayland/state/render/measure_badge.rs index 9eace9e61..ca540ad80 100644 --- a/src/backend/wayland/state/render/measure_badge.rs +++ b/src/backend/wayland/state/render/measure_badge.rs @@ -8,7 +8,7 @@ impl WaylandState { width: u32, height: u32, ) -> Option { - let pointer = self.current_mouse(); + let pointer = self.pointer.position(); let world = self.canvas_world_coords(pointer.0 as f64, pointer.1 as f64); let size = self.input_state.provisional_shape_size(world.0, world.1)?; crate::ui::measure_shape_badge( diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index e8672c83f..7830706d1 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -2,6 +2,9 @@ use super::*; mod canvas; mod measure_badge; +mod runtime; +pub(in crate::backend::wayland) use runtime::RenderRuntime; +use runtime::{UiEffect, UiEffectFlags}; mod tool_preview; mod ui; mod ui_effect_damage; @@ -68,8 +71,8 @@ impl WaylandState { debug!("=== RENDER START ==="); let board_is_transparent = self.input_state.board_is_transparent(); let suppression = self - .data - .overlay_suppression + .suppression + .reason() .effective_for_board(board_is_transparent); let render_canvas = suppression.renders_canvas(); let render_canvas_transients = suppression.renders_canvas_transients(); @@ -312,8 +315,10 @@ impl WaylandState { let canvas = unsafe { std::slice::from_raw_parts_mut(canvas_ptr as *mut u8, canvas_len) }; - self.data.render_profile_ui_baseline.resize(canvas_len, 0); - self.data.render_profile_ui_baseline.copy_from_slice(canvas); + self.render.profile_ui_baseline_mut().resize(canvas_len, 0); + self.render + .profile_ui_baseline_mut() + .copy_from_slice(canvas); has_ui_baseline = true; } } @@ -347,7 +352,7 @@ impl WaylandState { } else if !remap_canvas && remap_ui && has_ui_baseline { profile.remap_argb8888_regions_changed_from( canvas, - &self.data.render_profile_ui_baseline, + self.render.profile_ui_baseline(), phys_width as i32, phys_height as i32, stride, @@ -410,10 +415,7 @@ impl WaylandState { wl_surface.damage_buffer(region.x, region.y, region.width, region.height); } - let capture_generation = self - .data - .overlay_capture_barrier - .begin_main_surface_submission(); + let capture_generation = self.suppression.barrier.begin_main_surface_submission(); if self.config.performance.enable_vsync { debug!("Requesting frame callback (vsync enabled)"); let callback = self @@ -454,7 +456,7 @@ impl WaylandState { self.record_perf_render_breakdown(breakdown); } - if self.capture_suppressed() { + if self.suppression.capture_suppressed() { self.capture.mark_preflight_rendered(); } Ok(RenderOutcome::Committed { keep_rendering }) @@ -495,21 +497,37 @@ impl WaylandState { ), ..PerfDamageDiagnostics::default() }; - let ui_effect_damage = self.collect_ui_effect_damage( - animation.ui_toast, - animation.preset_feedback, - animation.blocked_feedback, - animation.text_edit_entry, - render_ui && self.input_state.ui_visibility.show_status_bar, - render_ui && self.zoom_chip_visible(), - render_ui && self.input_state.input_hud_visible(), - render_ui && self.input_state.command_palette_is_engaged(), - render_ui && self.input_state.is_color_picker_popup_open(), - render_ui && self.mouse_tool_preview_eligible(), - render_ui && !self.capture_picker_chrome_suppressed(), - width, - height, - ); + let ui_effects = UiEffectFlags::default() + .with(UiEffect::UiToast, animation.ui_toast) + .with(UiEffect::PresetToast, animation.preset_feedback) + .with(UiEffect::TextEditEntry, animation.text_edit_entry) + .with( + UiEffect::StatusHud, + render_ui && self.input_state.ui_visibility.show_status_bar, + ) + .with(UiEffect::ZoomChip, render_ui && self.zoom_chip_visible()) + .with( + UiEffect::InputHud, + render_ui && self.input_state.input_hud_visible(), + ) + .with( + UiEffect::CommandPalette, + render_ui && self.input_state.command_palette_is_engaged(), + ) + .with( + UiEffect::ColorPicker, + render_ui && self.input_state.is_color_picker_popup_open(), + ) + .with( + UiEffect::ToolPreview, + render_ui && self.mouse_tool_preview_eligible(), + ) + .with( + UiEffect::ShapeMeasureBadge, + render_ui && !self.capture_picker_chrome_suppressed(), + ) + .with_blocked_feedback(animation.blocked_feedback); + let ui_effect_damage = self.collect_ui_effect_damage(ui_effects, width, height); if let Some(reason) = self.render_force_full_damage_reason().or(input_full_reason) { self.buffer_damage.mark_all_full(reason); } else { diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs new file mode 100644 index 000000000..fd9f006df --- /dev/null +++ b/src/backend/wayland/state/render/runtime.rs @@ -0,0 +1,225 @@ +use crate::util::Rect; + +use super::super::canvas_layer::CanvasLayerCache; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(super) enum UiEffect { + UiToast, + PresetToast, + TextEditEntry, + StatusHud, + ZoomChip, + InputHud, + CommandPalette, + ColorPicker, + ToolPreview, + ShapeMeasureBadge, + OcrScan, +} + +impl UiEffect { + const COUNT: usize = 11; + + const fn index(self) -> usize { + self as usize + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct UiEffectFlags { + active: u16, + blocked_feedback: bool, +} + +impl UiEffectFlags { + pub(super) const fn with(mut self, effect: UiEffect, active: bool) -> Self { + let bit = 1 << effect.index(); + if active { + self.active |= bit; + } else { + self.active &= !bit; + } + self + } + + pub(super) const fn with_blocked_feedback(mut self, active: bool) -> Self { + self.blocked_feedback = active; + self + } + + pub(super) const fn active(self, effect: UiEffect) -> bool { + self.active & (1 << effect.index()) != 0 + } + + pub(super) const fn blocked_feedback(self) -> bool { + self.blocked_feedback + } +} + +#[derive(Debug, Default)] +pub(in crate::backend::wayland::state) struct UiDamageHistory { + prev: [Option; UiEffect::COUNT], + measure_picker: Vec, + blocked_feedback_was_active: bool, +} + +impl UiDamageHistory { + pub(super) fn previous(&self, effect: UiEffect) -> Option { + self.prev[effect.index()] + } + + /// Push old and new footprints, deduplicating an unchanged footprint, and + /// remember `current` for the next rendered frame. + pub(super) fn roll( + &mut self, + effect: UiEffect, + current: Option, + regions: &mut Vec, + ) { + let previous = std::mem::replace(&mut self.prev[effect.index()], current); + match (previous, current) { + (Some(previous), Some(current)) if previous == current => regions.push(current), + (previous, current) => { + regions.extend(previous); + regions.extend(current); + } + } + } + + pub(super) fn roll_status_hud( + &mut self, + current: Option, + surface: Option, + regions: &mut Vec, + ) { + let previous = self.previous(UiEffect::StatusHud); + if previous.is_some() != current.is_some() + && let Some(surface) = surface + { + self.prev[UiEffect::StatusHud.index()] = current; + regions.push(surface); + return; + } + self.roll(UiEffect::StatusHud, current, regions); + } + + pub(super) fn roll_measure_picker(&mut self, current: Vec, regions: &mut Vec) { + regions.extend(self.measure_picker.iter().copied()); + regions.extend(current.iter().copied()); + self.measure_picker = current; + } + + /// Returns whether blocked-feedback geometry must be damaged this frame. + pub(super) fn roll_blocked_feedback(&mut self, active: bool) -> bool { + let damage = active || self.blocked_feedback_was_active; + self.blocked_feedback_was_active = active; + damage + } +} + +pub(in crate::backend::wayland) struct RenderRuntime { + canvas_layer_cache: CanvasLayerCache, + ui_damage: UiDamageHistory, + profile_ui_baseline: Vec, +} + +impl RenderRuntime { + pub(in crate::backend::wayland) fn new() -> Self { + Self { + canvas_layer_cache: CanvasLayerCache::new(), + ui_damage: UiDamageHistory::default(), + profile_ui_baseline: Vec::new(), + } + } + + pub(in crate::backend::wayland::state) fn canvas_layer_cache(&self) -> &CanvasLayerCache { + &self.canvas_layer_cache + } + + pub(in crate::backend::wayland::state) fn canvas_layer_cache_mut( + &mut self, + ) -> &mut CanvasLayerCache { + &mut self.canvas_layer_cache + } + + pub(in crate::backend::wayland::state) fn ui_damage_mut(&mut self) -> &mut UiDamageHistory { + &mut self.ui_damage + } + + pub(in crate::backend::wayland::state) fn profile_ui_baseline(&self) -> &[u8] { + &self.profile_ui_baseline + } + + pub(in crate::backend::wayland::state) fn profile_ui_baseline_mut(&mut self) -> &mut Vec { + &mut self.profile_ui_baseline + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rect(x: i32) -> Rect { + Rect::new(x, 0, 10, 10).expect("test rectangle") + } + + #[test] + fn effect_slots_are_independent() { + const EFFECTS: [UiEffect; UiEffect::COUNT] = [ + UiEffect::UiToast, + UiEffect::PresetToast, + UiEffect::TextEditEntry, + UiEffect::StatusHud, + UiEffect::ZoomChip, + UiEffect::InputHud, + UiEffect::CommandPalette, + UiEffect::ColorPicker, + UiEffect::ToolPreview, + UiEffect::ShapeMeasureBadge, + UiEffect::OcrScan, + ]; + let mut history = UiDamageHistory::default(); + + for (index, effect) in EFFECTS.into_iter().enumerate() { + let mut regions = Vec::new(); + history.roll(effect, Some(rect(index as i32 * 20)), &mut regions); + assert_eq!(regions, vec![rect(index as i32 * 20)]); + } + for (index, effect) in EFFECTS.into_iter().enumerate() { + assert_eq!(history.previous(effect), Some(rect(index as i32 * 20))); + } + } + + #[test] + fn disappearing_effect_damages_its_old_footprint() { + let mut history = UiDamageHistory::default(); + let mut regions = Vec::new(); + history.roll(UiEffect::ZoomChip, Some(rect(5)), &mut regions); + regions.clear(); + + history.roll(UiEffect::ZoomChip, None, &mut regions); + + assert_eq!(regions, vec![rect(5)]); + } + + #[test] + fn blocked_feedback_cleanup_is_requested_once() { + let mut history = UiDamageHistory::default(); + + assert!(history.roll_blocked_feedback(true)); + assert!(history.roll_blocked_feedback(false)); + assert!(!history.roll_blocked_feedback(false)); + } + + #[test] + fn measure_picker_rolls_old_and_new_strips() { + let mut history = UiDamageHistory::default(); + let mut regions = Vec::new(); + history.roll_measure_picker(vec![rect(0)], &mut regions); + regions.clear(); + + history.roll_measure_picker(vec![rect(20)], &mut regions); + + assert_eq!(regions, vec![rect(0), rect(20)]); + } +} diff --git a/src/backend/wayland/state/render/tool_preview.rs b/src/backend/wayland/state/render/tool_preview.rs index ba619b40f..2dfdd8727 100644 --- a/src/backend/wayland/state/render/tool_preview.rs +++ b/src/backend/wayland/state/render/tool_preview.rs @@ -144,6 +144,7 @@ pub(super) struct MouseToolPreviewRedraw { pub rects: Vec, } +#[cfg(test)] pub(super) struct MouseToolPreviewDamageUpdate { pub current: Option, pub rects: Vec, @@ -152,6 +153,7 @@ pub(super) struct MouseToolPreviewDamageUpdate { /// Per-frame damage update for the preview bubble. Unlike the pointer-motion /// helper, this accepts the last rendered footprint explicitly, so a visible /// preview becoming hidden still damages and clears its old pixels. +#[cfg(test)] pub(super) fn mouse_tool_preview_damage_update( previous: Option, active: bool, @@ -218,7 +220,7 @@ pub(super) fn mouse_tool_preview_redraw( /// Screen-space damage rect for the preview bubble anchored at `pos`, expanded /// by the shared UI-effect anti-aliasing margin (matches the toast/HUD damage). -fn mouse_tool_preview_damage_rect( +pub(super) fn mouse_tool_preview_damage_rect( thickness: f64, pos: (f64, f64), width: u32, diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index b3b0ca790..8f284b813 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -49,7 +49,7 @@ impl WaylandState { ) { if !capture_picker && self.mouse_tool_preview_eligible() { let (cursor_x, cursor_y) = self.stylus_hover_cursor_position().unwrap_or_else(|| { - let (x, y) = self.current_mouse(); + let (x, y) = self.pointer.position(); (x as f64, y as f64) }); draw_tool_preview( @@ -198,7 +198,7 @@ impl WaylandState { &self.config.ui.help_overlay_style, width, height, - self.frozen_enabled(), + self.frozen.enabled(), self.input_state.help_overlay.page(), &bindings, self.input_state.help_overlay.query(), @@ -247,9 +247,10 @@ impl WaylandState { fn render_precision_entry(&mut self, ctx: &cairo::Context, width: u32, height: u32) { let snapshot = self.toolbar_snapshot(); let (_, top_h) = crate::backend::wayland::toolbar::top_size(&snapshot); + let top_offset = self.toolbar_chrome.top_offset(); let anchor = ( - self.inline_top_base_x() + self.data.toolbar_top_offset, - self.inline_top_base_y() + self.data.toolbar_top_offset_y + top_h as f64 + 8.0, + self.inline_top_base_x() + top_offset.0, + self.inline_top_base_y() + top_offset.1 + top_h as f64 + 8.0, ); crate::ui::render_precision_entry_popup(ctx, &self.input_state, width, height, anchor); } @@ -386,7 +387,7 @@ impl WaylandState { } else { geometry.map(|geometry| geometry.display_selection()) }; - let (pointer_x, pointer_y) = self.current_mouse(); + let (pointer_x, pointer_y) = self.pointer.position(); let pointer = (f64::from(pointer_x), f64::from(pointer_y)); let measurement = (measure_mode || options.is_some_and(|options| options.show_size_readout())) @@ -540,7 +541,7 @@ impl WaylandState { /// Damage the previous and current preview-bubble footprints and request a /// redraw so the bubble tracks idle pointer motion from `prev` to `next` - /// (screen-space, matching [`Self::current_mouse`]). + /// (screen-space, matching the pointer runtime position). /// /// Only the mouse-anchored bubble is handled here: when a stylus is /// hovering the preview follows the stylus position instead, and the tablet diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index aaa14a8b2..234d88646 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -7,7 +7,8 @@ //! disappearance are cleaned up correctly. use super::super::*; -use super::tool_preview::mouse_tool_preview_damage_update; +use super::tool_preview::mouse_tool_preview_damage_rect; +use super::{UiEffect, UiEffectFlags}; use crate::util::Rect; /// Safety margin around effect bounds for anti-aliasing bleed. @@ -37,18 +38,11 @@ fn chrome_cursor_can_rehit(has_cursor_focus: bool, cursor_blocked_by_toolbar: bo } /// Push damage covering an effect's previous and current footprint. +#[cfg(test)] fn push_effect_damage(regions: &mut Vec, prev: Option, current: Option) { - match (prev, current) { - (Some(prev), Some(current)) if prev == current => regions.push(current), - (prev, current) => { - if let Some(prev) = prev { - regions.push(prev); - } - if let Some(current) = current { - regions.push(current); - } - } - } + let mut history = super::runtime::UiDamageHistory::default(); + history.roll(UiEffect::UiToast, prev, &mut Vec::new()); + history.roll(UiEffect::UiToast, current, regions); } /// Damage the status HUD and any fallback chrome whose visibility is the @@ -58,6 +52,7 @@ fn push_effect_damage(regions: &mut Vec, prev: Option, current: Opti /// change, so the old and new fallback badge sets are not derivable from the /// current input state alone. Conservatively repaint the surface on that rare /// transition; steady visible layouts still use their targeted footprints. +#[cfg(test)] fn push_status_hud_damage( regions: &mut Vec, prev: Option, @@ -65,18 +60,18 @@ fn push_status_hud_damage( width: u32, height: u32, ) { - if prev.is_some() != current.is_some() - && let Some(surface) = Rect::new( + let mut history = super::runtime::UiDamageHistory::default(); + history.roll(UiEffect::StatusHud, prev, &mut Vec::new()); + history.roll_status_hud( + current, + Rect::new( 0, 0, width.min(i32::MAX as u32) as i32, height.min(i32::MAX as u32) as i32, - ) - { - regions.push(surface); - return; - } - push_effect_damage(regions, prev, current); + ), + regions, + ); } impl WaylandState { @@ -84,74 +79,61 @@ impl WaylandState { /// text-edit entry glow) for the current frame. Also updates the /// previous-frame tracking state, so this must be called exactly once per /// rendered frame, even on frames that force full damage for other reasons. - #[allow(clippy::too_many_arguments)] pub(super) fn collect_ui_effect_damage( &mut self, - ui_toast_active: bool, - preset_feedback_active: bool, - blocked_feedback_active: bool, - text_edit_entry_active: bool, - status_hud_active: bool, - zoom_chip_active: bool, - input_hud_active: bool, - command_palette_active: bool, - color_picker_active: bool, - tool_preview_active: bool, - shape_measure_badge_active: bool, + flags: UiEffectFlags, width: u32, height: u32, ) -> Vec { let mut regions = Vec::new(); - let toast_rect = if ui_toast_active { + let toast_rect = if flags.active(UiEffect::UiToast) { crate::ui::ui_toast_geometry(&self.input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; - push_effect_damage(&mut regions, self.data.prev_ui_toast_damage, toast_rect); - self.data.prev_ui_toast_damage = toast_rect; + self.render + .ui_damage_mut() + .roll(UiEffect::UiToast, toast_rect, &mut regions); - let preset_rect = if preset_feedback_active { + let preset_rect = if flags.active(UiEffect::PresetToast) { crate::ui::preset_toast_geometry(&self.input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; - push_effect_damage( - &mut regions, - self.data.prev_preset_toast_damage, - preset_rect, - ); - self.data.prev_preset_toast_damage = preset_rect; - - if blocked_feedback_active || self.data.blocked_feedback_was_active { + self.render + .ui_damage_mut() + .roll(UiEffect::PresetToast, preset_rect, &mut regions); + + if self + .render + .ui_damage_mut() + .roll_blocked_feedback(flags.blocked_feedback()) + { regions.extend( crate::ui::blocked_feedback_rects(width, height) .into_iter() .filter_map(|bounds| effect_rect(bounds, width, height)), ); } - self.data.blocked_feedback_was_active = blocked_feedback_active; - let entry_rect = if text_edit_entry_active { + let entry_rect = if flags.active(UiEffect::TextEditEntry) { self.text_edit_entry_screen_rect(width, height) } else { None }; - push_effect_damage( - &mut regions, - self.data.prev_text_edit_entry_damage, - entry_rect, - ); - self.data.prev_text_edit_entry_damage = entry_rect; + self.render + .ui_damage_mut() + .roll(UiEffect::TextEditEntry, entry_rect, &mut regions); // The status HUD layout is refreshed here, once per frame and before // rendering, so damage geometry, rendering, and pointer hit-testing // all read the same cache for the frame. let chrome_cursor_focused = chrome_cursor_can_rehit(self.has_cursor_focus(), self.cursor_blocked_by_toolbar()); - let status_hud_rect = if status_hud_active { + let status_hud_rect = if flags.active(UiEffect::StatusHud) { self.input_state.update_status_hud_layout_for_pointer( self.config.ui.status_bar_position, &self.config.ui.status_bar_style, @@ -165,20 +147,21 @@ impl WaylandState { self.input_state.clear_status_hud_layout(); None }; - push_status_hud_damage( - &mut regions, - self.data.prev_status_hud_damage, - status_hud_rect, - width, - height, + let surface = Rect::new( + 0, + 0, + width.min(i32::MAX as u32) as i32, + height.min(i32::MAX as u32) as i32, ); - self.data.prev_status_hud_damage = status_hud_rect; + self.render + .ui_damage_mut() + .roll_status_hud(status_hud_rect, surface, &mut regions); // The zoom chip follows the same once-per-frame layout refresh as the // status HUD, so damage geometry, rendering, and pointer hit-testing // all read the same cache for the frame; the appear → move → disappear // union keeps stale pixels cleaned up when the percentage changes. - let zoom_chip_rect = if zoom_chip_active { + let zoom_chip_rect = if flags.active(UiEffect::ZoomChip) { self.input_state.update_zoom_chip_layout_for_pointer( &self.config.ui.status_bar_style, width, @@ -191,85 +174,80 @@ impl WaylandState { self.input_state.clear_zoom_chip_layout(); None }; - push_effect_damage( - &mut regions, - self.data.prev_zoom_chip_damage, - zoom_chip_rect, - ); - self.data.prev_zoom_chip_damage = zoom_chip_rect; + self.render + .ui_damage_mut() + .roll(UiEffect::ZoomChip, zoom_chip_rect, &mut regions); // The input HUD's chip row grows, shrinks, and fades every few frames; // the same appear → resize → disappear union keeps the stale chips // cleaned up without escalating a keystroke to the full surface. - let input_hud_rect = if input_hud_active { + let input_hud_rect = if flags.active(UiEffect::InputHud) { crate::ui::input_hud_geometry(&self.input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; - push_effect_damage( - &mut regions, - self.data.prev_input_hud_damage, - input_hud_rect, - ); - self.data.prev_input_hud_damage = input_hud_rect; + self.render + .ui_damage_mut() + .roll(UiEffect::InputHud, input_hud_rect, &mut regions); // Opening and closing the palette force full damage because the // backdrop dimmer changes. While it remains open, only the panel and // optional action tooltip change, so typing and selection no longer // fall through to the full-surface empty-damage fallback. - let command_palette_rect = if command_palette_active { + let command_palette_rect = if flags.active(UiEffect::CommandPalette) { crate::ui::command_palette_visual_geometry(&self.input_state, width, height) .and_then(|bounds| effect_rect(bounds, width, height)) } else { None }; - push_effect_damage( - &mut regions, - self.data.prev_command_palette_damage, + self.render.ui_damage_mut().roll( + UiEffect::CommandPalette, command_palette_rect, + &mut regions, ); - self.data.prev_command_palette_damage = command_palette_rect; // Like the command palette, the color picker owns a stable full-screen // dimmer while open. Opening/closing already forces full damage; while // engaged, redraw only its panel and optional action tooltip so hex // typing cannot fall through to the full-screen empty-damage fallback. - let color_picker_rect = color_picker_active + let color_picker_rect = flags + .active(UiEffect::ColorPicker) .then(|| color_picker_effect_rect(&self.input_state, width, height)) .flatten(); - push_effect_damage( - &mut regions, - self.data.prev_color_picker_damage, - color_picker_rect, - ); - self.data.prev_color_picker_damage = color_picker_rect; + self.render + .ui_damage_mut() + .roll(UiEffect::ColorPicker, color_picker_rect, &mut regions); let preview_position = self.stylus_hover_cursor_position().unwrap_or_else(|| { - let (x, y) = self.current_mouse(); + let (x, y) = self.pointer.position(); (x as f64, y as f64) }); - let preview_update = mouse_tool_preview_damage_update( - self.data.prev_tool_preview_damage, - tool_preview_active, - self.input_state.thickness_for_active_tool(), - preview_position, - width, - height, - ); - regions.extend(preview_update.rects); - self.data.prev_tool_preview_damage = preview_update.current; + let preview_rect = flags + .active(UiEffect::ToolPreview) + .then(|| { + mouse_tool_preview_damage_rect( + self.input_state.thickness_for_active_tool(), + preview_position, + width, + height, + ) + }) + .flatten(); + self.render + .ui_damage_mut() + .roll(UiEffect::ToolPreview, preview_rect, &mut regions); - let measure_badge_rect = shape_measure_badge_active + let measure_badge_rect = flags + .active(UiEffect::ShapeMeasureBadge) .then(|| self.shape_measure_badge_visual(width, height)) .flatten() .and_then(|badge| effect_rect(badge.bounds, width, height)); - push_effect_damage( - &mut regions, - self.data.prev_shape_measure_badge_damage, + self.render.ui_damage_mut().roll( + UiEffect::ShapeMeasureBadge, measure_badge_rect, + &mut regions, ); - self.data.prev_shape_measure_badge_damage = measure_badge_rect; // The scan overlay spans its region and, once settled, the outcome card // beside it. Both move only when the phase changes, so the previous @@ -287,13 +265,14 @@ impl WaylandState { height, ) }); - push_effect_damage(&mut regions, self.data.prev_ocr_scan_damage, ocr_scan_rect); - self.data.prev_ocr_scan_damage = ocr_scan_rect; + self.render + .ui_damage_mut() + .roll(UiEffect::OcrScan, ocr_scan_rect, &mut regions); let measure_picker_damage = if self.input_state.region_state().purpose() == Some(crate::input::state::RegionPurposeTag::Measure) { - let (pointer_x, pointer_y) = self.current_mouse(); + let (pointer_x, pointer_y) = self.pointer.position(); crate::ui::measure_picker_damage( self.input_state.region_state().selection(), (f64::from(pointer_x), f64::from(pointer_y)), @@ -302,9 +281,9 @@ impl WaylandState { } else { Vec::new() }; - regions.extend(self.data.prev_measure_picker_damage.iter().copied()); - regions.extend(measure_picker_damage.iter().copied()); - self.data.prev_measure_picker_damage = measure_picker_damage; + self.render + .ui_damage_mut() + .roll_measure_picker(measure_picker_damage, &mut regions); regions } diff --git a/src/backend/wayland/state/text_clipboard.rs b/src/backend/wayland/state/text_clipboard.rs index 160dd4398..2699fb099 100644 --- a/src/backend/wayland/state/text_clipboard.rs +++ b/src/backend/wayland/state/text_clipboard.rs @@ -19,7 +19,8 @@ impl WaylandState { if request.text.is_empty() { return; } - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(std::time::Instant::now(), Duration::from_millis(1500)); if let Err(err) = self.clipboard.queue_text_copy(request) { log::warn!("Failed to start text clipboard copy: {err}"); self.input_state.push_toast( @@ -72,7 +73,8 @@ impl WaylandState { if !self.input_state.text_paste_target_is_current(target) { return; } - self.suppress_focus_exit_for(Duration::from_millis(1500)); + self.focus + .suppress_exit_for(std::time::Instant::now(), Duration::from_millis(1500)); if let Err(err) = self.clipboard.queue_text_paste(target) { log::warn!("Failed to start text clipboard paste: {err}"); self.push_text_paste_failure(); diff --git a/src/backend/wayland/state/toolbar.rs b/src/backend/wayland/state/toolbar.rs index ee355e36b..111125495 100644 --- a/src/backend/wayland/state/toolbar.rs +++ b/src/backend/wayland/state/toolbar.rs @@ -1,7 +1,10 @@ #[allow(unused_imports)] use super::*; +mod chrome; +pub(in crate::backend::wayland) use chrome::{ConfigureVerdict, ToolbarChrome}; mod drag; +pub(in crate::backend::wayland) use drag::{MoveDragKind, ToolbarDrag}; mod events; pub(in crate::backend::wayland) use events::{queue_preset_action, queue_quick_color_edit}; mod fade; diff --git a/src/backend/wayland/state/toolbar/chrome.rs b/src/backend/wayland/state/toolbar/chrome.rs new file mode 100644 index 000000000..384fd844c --- /dev/null +++ b/src/backend/wayland/state/toolbar/chrome.rs @@ -0,0 +1,561 @@ +use std::time::{Duration, Instant}; + +use crate::{ + backend::wayland::{ + toolbar::{ + ToolbarCursorHint, + hit::{ + HitRegion, drag_intent_for_hit, focus_hover_point, focused_event, intent_for_hit, + next_focus_index, quick_color_slot_for_hit, resolve_focus_index, + }, + render::TOOLTIP_DELAY, + }, + toolbar_intent::ToolbarIntent, + }, + ui::toolbar::{ + ToolbarEvent, + snapshot::fade::{TopStripFade, TopStripFadeInputs}, + }, +}; + +const TOOLBAR_CONFIGURE_FAIL_THRESHOLD: u32 = 180; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum ConfigureVerdict { + Ok, + StillWaiting, + FallBackToInline, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) struct HoverChange { + pub target_changed: bool, + pub position_changed: bool, +} + +#[derive(Debug, Default)] +struct InlineTopStrip { + hits: Vec, + rect: Option<(f64, f64, f64, f64)>, + hover: Option<(f64, f64)>, + hover_start: Option, + tooltip_pending: bool, + focus_index: Option, + focus_id: Option, +} + +impl InlineTopStrip { + fn hit_index_at(&self, position: (f64, f64)) -> Option { + self.hits + .iter() + .position(|hit| hit.contains(position.0, position.1)) + } + + fn contains(&self, position: (f64, f64)) -> bool { + self.rect.is_some_and(|(x, y, w, h)| { + super::geometry::point_in_rect(position.0, position.1, x, y, w, h) + }) + } + + fn primary_hit_at(&self, position: (f64, f64)) -> Option<(ToolbarIntent, bool)> { + self.hits + .iter() + .find_map(|hit| intent_for_hit(hit, position.0, position.1)) + } + + fn quick_color_slot_at(&self, position: (f64, f64)) -> Option { + self.hits + .iter() + .find_map(|hit| quick_color_slot_for_hit(hit, position.0, position.1)) + } + + fn drag_hit_at(&self, position: (f64, f64)) -> Option { + self.hits + .iter() + .find_map(|hit| drag_intent_for_hit(hit, position.0, position.1)) + } + + fn set_hover(&mut self, hover: Option<(f64, f64)>, now: Instant) -> HoverChange { + let previous_hover = self.hover; + let previous_hit = previous_hover.and_then(|position| self.hit_index_at(position)); + + if hover.is_some() && previous_hover.is_none() { + self.hover_start = Some(now); + } else if hover.is_none() { + self.hover_start = None; + } + self.hover = hover; + + let hit = hover.and_then(|position| self.hit_index_at(position)); + let target_changed = previous_hover.is_some() != hover.is_some() || previous_hit != hit; + if target_changed { + let hit_has_tooltip = hit + .and_then(|index| self.hits.get(index)) + .is_some_and(|hit| hit.tooltip.is_some()); + self.tooltip_pending = hit_has_tooltip + && self + .hover_start + .is_some_and(|start| now.saturating_duration_since(start) < TOOLTIP_DELAY); + } + + HoverChange { + target_changed, + position_changed: previous_hover != hover, + } + } + + fn clear_hover(&mut self) -> bool { + let changed = self.hover.is_some() || self.tooltip_pending; + self.hover = None; + self.hover_start = None; + self.tooltip_pending = false; + changed + } + + fn clear_hits(&mut self) { + self.hits.clear(); + self.rect = None; + } + + fn set_rendered(&mut self, hits: Vec, rect: (f64, f64, f64, f64)) { + self.hits = hits; + self.rect = Some(rect); + } + + fn resolved_focus_index(&self) -> Option { + resolve_focus_index(&self.hits, self.focus_index, self.focus_id.as_deref()) + } + + fn focus_next(&mut self, reverse: bool) -> bool { + let current = self.resolved_focus_index(); + let mut next = next_focus_index(&self.hits, current, reverse); + for _ in 0..self.hits.len() { + let Some(index) = next else { + break; + }; + if self.hits[index].focus_id.is_some() { + break; + } + next = next_focus_index(&self.hits, next, reverse); + } + if next == current || next.is_some_and(|index| self.hits[index].focus_id.is_none()) { + return false; + } + self.focus_id = next.and_then(|index| self.hits[index].focus_id.clone()); + self.focus_index = next; + true + } + + fn clear_focus(&mut self) -> bool { + let changed = self.focus_index.is_some() || self.focus_id.is_some(); + self.focus_index = None; + self.focus_id = None; + changed + } + + fn focused_event(&self) -> Option { + focused_event(&self.hits, self.resolved_focus_index()) + } + + fn focus_hover(&self) -> Option<(f64, f64)> { + focus_hover_point(&self.hits, self.resolved_focus_index()) + } + + fn cursor_hint(&self) -> Option { + let (x, y) = self.hover?; + self.hits + .iter() + .find(|hit| hit.contains(x, y)) + .map_or(Some(ToolbarCursorHint::Default), |hit| { + Some(hit.kind.cursor_hint()) + }) + } + + fn tooltip_timeout(&self, now: Instant) -> Option { + if !self.tooltip_pending { + return None; + } + self.hover_start.map(|start| { + start + .checked_add(TOOLTIP_DELAY) + .unwrap_or(start) + .saturating_duration_since(now) + }) + } + + fn take_tooltip_due(&mut self, now: Instant) -> bool { + if self.tooltip_timeout(now) != Some(Duration::ZERO) { + return false; + } + self.tooltip_pending = false; + true + } +} + +pub(in crate::backend::wayland) struct ToolbarChrome { + pointer_over_toolbar: bool, + needs_recreate: bool, + layer_shell_missing_logged: bool, + inline_toolbars: bool, + top_offset: (f64, f64), + configure_miss_count: u32, + last_applied_top_margin: Option<(i32, i32)>, + top_strip_fade: TopStripFade, + gtk_top_hover: bool, + focus_active: bool, + inline: InlineTopStrip, +} + +impl ToolbarChrome { + pub(in crate::backend::wayland) fn new(inline_toolbars: bool, top_offset: (f64, f64)) -> Self { + Self { + pointer_over_toolbar: false, + needs_recreate: true, + layer_shell_missing_logged: false, + inline_toolbars, + top_offset, + configure_miss_count: 0, + last_applied_top_margin: None, + top_strip_fade: TopStripFade::new(), + gtk_top_hover: false, + focus_active: false, + inline: InlineTopStrip::default(), + } + } + + pub(in crate::backend::wayland) fn pointer_over_toolbar(&self) -> bool { + self.pointer_over_toolbar + } + + pub(in crate::backend::wayland) fn set_pointer_over_toolbar(&mut self, value: bool) { + self.pointer_over_toolbar = value; + } + + pub(in crate::backend::wayland) fn needs_recreate(&self) -> bool { + self.needs_recreate + } + + pub(in crate::backend::wayland) fn set_needs_recreate(&mut self, value: bool) { + self.needs_recreate = value; + } + + pub(in crate::backend::wayland) fn inline_toolbars(&self) -> bool { + self.inline_toolbars + } + + pub(in crate::backend::wayland) fn top_offset(&self) -> (f64, f64) { + self.top_offset + } + + pub(in crate::backend::wayland) fn set_top_offset(&mut self, offset: (f64, f64)) { + self.top_offset = offset; + } + + pub(in crate::backend::wayland) fn add_top_offset(&mut self, delta: (f64, f64)) { + self.top_offset.0 += delta.0; + self.top_offset.1 += delta.1; + } + + pub(in crate::backend::wayland) fn note_layer_shell_missing(&mut self) -> bool { + if self.layer_shell_missing_logged { + return false; + } + self.layer_shell_missing_logged = true; + true + } + + pub(in crate::backend::wayland) fn note_configure_result( + &mut self, + configured: bool, + ) -> ConfigureVerdict { + if configured { + self.configure_miss_count = 0; + return ConfigureVerdict::Ok; + } + self.configure_miss_count = self.configure_miss_count.saturating_add(1); + if self.configure_miss_count > TOOLBAR_CONFIGURE_FAIL_THRESHOLD { + self.configure_miss_count = 0; + self.inline_toolbars = true; + ConfigureVerdict::FallBackToInline + } else { + ConfigureVerdict::StillWaiting + } + } + + pub(in crate::backend::wayland) fn configure_miss_count(&self) -> u32 { + self.configure_miss_count + } + + pub(in crate::backend::wayland) fn reset_configure_misses(&mut self) { + self.configure_miss_count = 0; + } + + pub(in crate::backend::wayland) fn apply_margins(&mut self, margins: (i32, i32)) -> bool { + if self.last_applied_top_margin == Some(margins) { + return false; + } + self.last_applied_top_margin = Some(margins); + true + } + + pub(in crate::backend::wayland) fn last_applied_margins(&self) -> Option<(i32, i32)> { + self.last_applied_top_margin + } + + pub(in crate::backend::wayland) fn reset_margins(&mut self) { + self.last_applied_top_margin = None; + } + + pub(in crate::backend::wayland) fn set_gtk_top_hover(&mut self, hovered: bool) { + self.gtk_top_hover = hovered; + } + + pub(in crate::backend::wayland) fn focus_active(&self) -> bool { + self.focus_active + } + + pub(in crate::backend::wayland) fn set_focus_active(&mut self, active: bool) { + self.focus_active = active; + } + + pub(in crate::backend::wayland) fn inline_rect(&self) -> Option<(f64, f64, f64, f64)> { + self.inline.rect + } + + pub(in crate::backend::wayland) fn inline_hover(&self) -> Option<(f64, f64)> { + self.inline.hover + } + + pub(in crate::backend::wayland) fn inline_hover_start(&self) -> Option { + self.inline.hover_start + } + + pub(in crate::backend::wayland) fn inline_contains(&self, position: (f64, f64)) -> bool { + self.inline.contains(position) + } + + pub(in crate::backend::wayland) fn inline_primary_hit_at( + &self, + position: (f64, f64), + ) -> Option<(ToolbarIntent, bool)> { + self.inline.primary_hit_at(position) + } + + pub(in crate::backend::wayland) fn inline_quick_color_slot_at( + &self, + position: (f64, f64), + ) -> Option { + self.inline.quick_color_slot_at(position) + } + + pub(in crate::backend::wayland) fn inline_drag_hit_at( + &self, + position: (f64, f64), + ) -> Option { + self.inline.drag_hit_at(position) + } + + pub(in crate::backend::wayland) fn set_inline_hover( + &mut self, + hover: Option<(f64, f64)>, + now: Instant, + ) -> HoverChange { + self.inline.set_hover(hover, now) + } + + pub(in crate::backend::wayland) fn clear_inline_hover(&mut self) -> bool { + self.inline.clear_hover() + } + + pub(in crate::backend::wayland) fn clear_inline_hits(&mut self) { + self.inline.clear_hits(); + } + + pub(in crate::backend::wayland) fn set_inline_rendered( + &mut self, + hits: Vec, + rect: (f64, f64, f64, f64), + ) { + self.inline.set_rendered(hits, rect); + } + + pub(in crate::backend::wayland) fn inline_focus_next(&mut self, reverse: bool) -> bool { + self.inline.focus_next(reverse) + } + + pub(in crate::backend::wayland) fn clear_inline_focus(&mut self) -> bool { + self.inline.clear_focus() + } + + pub(in crate::backend::wayland) fn inline_focused_event(&self) -> Option { + self.inline.focused_event() + } + + pub(in crate::backend::wayland) fn inline_focus_hover(&self) -> Option<(f64, f64)> { + self.inline.focus_hover() + } + + pub(in crate::backend::wayland) fn inline_cursor_hint(&self) -> Option { + self.inline.cursor_hint() + } + + pub(in crate::backend::wayland) fn inline_tooltip_timeout( + &self, + now: Instant, + ) -> Option { + self.inline.tooltip_timeout(now) + } + + pub(in crate::backend::wayland) fn take_inline_tooltip_due(&mut self, now: Instant) -> bool { + self.inline.take_tooltip_due(now) + } + + pub(in crate::backend::wayland) fn fade(&self) -> &TopStripFade { + &self.top_strip_fade + } + + pub(in crate::backend::wayland) fn fade_mut(&mut self) -> &mut TopStripFade { + &mut self.top_strip_fade + } + + pub(in crate::backend::wayland) fn fade_inputs( + &self, + toolbar_pointer_present: bool, + idle_for: Duration, + menus_open: bool, + reduced_chrome: bool, + idle_fade_enabled: bool, + ) -> TopStripFadeInputs { + TopStripFadeInputs { + idle_for, + pointer_near: self.pointer_over_toolbar + || toolbar_pointer_present + || self.inline.hover.is_some() + || self.gtk_top_hover, + menus_open, + reduced_chrome, + idle_fade_enabled, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::toolbar::events::HitKind; + + fn hit(id: Option<&str>, x: f64, tooltip: bool) -> HitRegion { + HitRegion { + focus_id: id.map(str::to_owned), + rect: (x, 0.0, 20.0, 20.0), + event: ToolbarEvent::Undo, + kind: HitKind::Click, + tooltip: tooltip.then(|| "Undo".to_owned()), + } + } + + #[test] + fn configure_fallback_occurs_once_after_the_waiting_threshold() { + let mut chrome = ToolbarChrome::new(false, (0.0, 0.0)); + assert_eq!( + chrome.note_configure_result(false), + ConfigureVerdict::StillWaiting + ); + assert_eq!(chrome.note_configure_result(true), ConfigureVerdict::Ok); + assert_eq!(chrome.configure_miss_count(), 0); + + for _ in 0..TOOLBAR_CONFIGURE_FAIL_THRESHOLD { + assert_eq!( + chrome.note_configure_result(false), + ConfigureVerdict::StillWaiting + ); + } + assert_eq!( + chrome.note_configure_result(false), + ConfigureVerdict::FallBackToInline + ); + assert!(chrome.inline_toolbars()); + assert_eq!(chrome.configure_miss_count(), 0); + assert_eq!(chrome.note_configure_result(true), ConfigureVerdict::Ok); + } + + #[test] + fn applying_margins_reports_only_real_changes() { + let mut chrome = ToolbarChrome::new(false, (0.0, 0.0)); + assert!(chrome.apply_margins((10, 20))); + assert!(!chrome.apply_margins((10, 20))); + assert!(chrome.apply_margins((11, 20))); + assert!(chrome.apply_margins((11, 21))); + } + + #[test] + fn inline_bounds_include_edges_and_reject_points_beyond_them() { + let mut chrome = ToolbarChrome::new(true, (0.0, 0.0)); + chrome.set_inline_rendered(Vec::new(), (10.0, 20.0, 100.0, 50.0)); + + assert!(!chrome.inline_contains((9.9, 45.0))); + assert!(!chrome.inline_contains((110.1, 45.0))); + assert!(!chrome.inline_contains((60.0, 19.9))); + assert!(!chrome.inline_contains((60.0, 70.1))); + assert!(chrome.inline_contains((10.0, 20.0))); + assert!(chrome.inline_contains((110.0, 70.0))); + } + + #[test] + fn hover_change_tracks_hit_identity_instead_of_pointer_pixels() { + let mut chrome = ToolbarChrome::new(true, (0.0, 0.0)); + chrome.set_inline_rendered( + vec![hit(Some("one"), 0.0, false), hit(Some("two"), 40.0, false)], + (0.0, 0.0, 100.0, 20.0), + ); + let now = Instant::now(); + assert!( + chrome + .set_inline_hover(Some((5.0, 5.0)), now) + .target_changed + ); + assert!( + !chrome + .set_inline_hover(Some((6.0, 5.0)), now) + .target_changed + ); + assert!( + chrome + .set_inline_hover(Some((45.0, 5.0)), now) + .target_changed + ); + } + + #[test] + fn tooltip_is_pending_only_during_its_delay_window() { + let mut chrome = ToolbarChrome::new(true, (0.0, 0.0)); + chrome.set_inline_rendered(vec![hit(Some("one"), 0.0, true)], (0.0, 0.0, 20.0, 20.0)); + let start = Instant::now(); + chrome.set_inline_hover(Some((5.0, 5.0)), start); + assert_eq!(chrome.inline_tooltip_timeout(start), Some(TOOLTIP_DELAY)); + assert!(chrome.take_inline_tooltip_due(start + TOOLTIP_DELAY)); + assert_eq!(chrome.inline_tooltip_timeout(start + TOOLTIP_DELAY), None); + } + + #[test] + fn focus_cycling_wraps_and_skips_hits_without_an_id() { + let mut chrome = ToolbarChrome::new(true, (0.0, 0.0)); + chrome.set_inline_rendered( + vec![ + hit(Some("one"), 0.0, false), + hit(None, 30.0, false), + hit(Some("two"), 60.0, false), + ], + (0.0, 0.0, 100.0, 20.0), + ); + + assert!(chrome.inline_focus_next(false)); + assert_eq!(chrome.inline_focus_hover(), Some((10.0, 10.0))); + assert!(chrome.inline_focus_next(false)); + assert_eq!(chrome.inline_focus_hover(), Some((70.0, 10.0))); + assert!(chrome.inline_focus_next(false)); + assert_eq!(chrome.inline_focus_hover(), Some((10.0, 10.0))); + assert!(chrome.inline_focus_next(true)); + assert_eq!(chrome.inline_focus_hover(), Some((70.0, 10.0))); + } +} diff --git a/src/backend/wayland/state/toolbar/drag/base.rs b/src/backend/wayland/state/toolbar/drag/base.rs index c91f9ecf2..748dae4b5 100644 --- a/src/backend/wayland/state/toolbar/drag/base.rs +++ b/src/backend/wayland/state/toolbar/drag/base.rs @@ -1,28 +1,13 @@ use super::*; -fn active_drag_top_base_x( - move_dragging: bool, - gtk_drag_preview_active: bool, - frozen_base_x: Option, -) -> Option { - (move_dragging || gtk_drag_preview_active) - .then_some(frozen_base_x) - .flatten() -} - impl WaylandState { /// Base X position for the top toolbar when laid out inline. /// A drag freezes this base so the resting layout cannot shift underneath /// the surface being moved. pub(in crate::backend::wayland::state) fn inline_top_base_x(&self) -> f64 { - if let Some(x) = active_drag_top_base_x( - self.is_move_dragging(), - self.data.gtk_drag_preview.is_some(), - self.data.drag_top_base_x, - ) { - return x; - } - Self::INLINE_TOP_X + self.toolbar_drag + .frozen_base_x() + .unwrap_or(Self::INLINE_TOP_X) } /// Preserve the top strip's screen X while switching from the base frozen @@ -32,33 +17,31 @@ impl WaylandState { /// (`finish_toolbar_move_drag` with `commit`, and `finish_gtk_offset_change`), /// each immediately before committing the drag's position override. Every /// other implicit toolbar move — a layout-mode switch, an output resize, a - /// relayout clamp — adjusts the live offsets in `self.data` only and never + /// relayout clamp — adjusts the live offsets in `toolbar_chrome` only and never /// stages an override. - pub(in crate::backend::wayland::state::toolbar) fn reconcile_top_base_after_drag(&mut self) { - let Some(old_base_x) = self.data.drag_top_base_x else { - return; - }; + pub(in crate::backend::wayland::state::toolbar) fn reconcile_top_base_after_drag( + &mut self, + old_base_x: f64, + ) { let new_base_x = Self::INLINE_TOP_X; let delta = old_base_x - new_base_x; if delta.abs() <= 0.01 { return; } - self.data.toolbar_top_offset += delta; + self.toolbar_chrome.add_top_offset((delta, 0.0)); drag_log(|| { format!( "end move drag: preserve top position, old_base_x={old_base_x:.3}, new_base_x={new_base_x:.3}, delta={delta:.3}, top_offset=({}, {})", - self.data.toolbar_top_offset, self.data.toolbar_top_offset_y, + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1, ) }); } pub(in crate::backend::wayland::state) fn inline_top_base_y(&self) -> f64 { - if self.is_move_dragging() - && let Some(y) = self.data.drag_top_base_y - { - return y; - } - Self::INLINE_TOP_Y + self.toolbar_drag + .frozen_base_y() + .unwrap_or(Self::INLINE_TOP_Y) } /// Convert a toolbar-local coordinate into a screen-relative coordinate so that @@ -70,24 +53,9 @@ impl WaylandState { ) -> (f64, f64) { match kind { MoveDragKind::Top => ( - self.inline_top_base_x() + self.data.toolbar_top_offset + local_coord.0, - self.inline_top_base_y() + self.data.toolbar_top_offset_y + local_coord.1, + self.inline_top_base_x() + self.toolbar_chrome.top_offset().0 + local_coord.0, + self.inline_top_base_y() + self.toolbar_chrome.top_offset().1 + local_coord.1, ), } } } - -#[cfg(test)] -mod tests { - use super::active_drag_top_base_x; - - #[test] - fn gtk_preview_uses_the_base_frozen_at_drag_start() { - assert_eq!(active_drag_top_base_x(false, true, Some(24.0)), Some(24.0)); - } - - #[test] - fn idle_layout_does_not_reuse_a_stale_frozen_base() { - assert_eq!(active_drag_top_base_x(false, false, Some(24.0)), None); - } -} diff --git a/src/backend/wayland/state/toolbar/drag/clamp.rs b/src/backend/wayland/state/toolbar/drag/clamp.rs index 1fa387f00..ed5bb9bbb 100644 --- a/src/backend/wayland/state/toolbar/drag/clamp.rs +++ b/src/backend/wayland/state/toolbar/drag/clamp.rs @@ -3,38 +3,21 @@ use std::time::Instant; use super::*; impl WaylandState { - fn toolbar_drag_should_apply(&mut self) -> bool { - let Some(interval) = toolbar_drag_throttle_interval() else { - return true; - }; - let now = Instant::now(); - let should_apply = match self.data.last_toolbar_drag_apply { - Some(last) => now.duration_since(last) >= interval, - None => true, - }; - if should_apply { - self.data.last_toolbar_drag_apply = Some(now); - } - should_apply - } - pub(in crate::backend::wayland::state::toolbar) fn apply_toolbar_offsets_throttled( &mut self, snapshot: &ToolbarSnapshot, ) { - if self.toolbar_drag_preview_active() || toolbar_drag_throttle_interval().is_none() { + let now = Instant::now(); + let Some(interval) = toolbar_drag_throttle_interval() else { let _ = self.apply_toolbar_offsets(snapshot); - self.data.toolbar_drag_pending_apply = false; - self.data.last_toolbar_drag_apply = Some(Instant::now()); + self.toolbar_drag.note_applied(now); return; - } - - if self.toolbar_drag_should_apply() { + }; + if self.toolbar_drag.preview_active() || self.toolbar_drag.should_apply(now, interval) { let _ = self.apply_toolbar_offsets(snapshot); - self.data.toolbar_drag_pending_apply = false; + self.toolbar_drag.note_applied(now); } else { let _ = self.clamp_toolbar_offsets(snapshot); - self.data.toolbar_drag_pending_apply = true; } } @@ -57,7 +40,7 @@ impl WaylandState { let top_base_x = self.inline_top_base_x(); let top_base_y = self.inline_top_base_y(); - let before_top = (self.data.toolbar_top_offset, self.data.toolbar_top_offset_y); + let before_top = self.toolbar_chrome.top_offset(); let input = geometry::ToolbarClampInput { width, height, @@ -67,20 +50,21 @@ impl WaylandState { top_margin_right: Self::TOP_MARGIN_RIGHT, top_margin_bottom: Self::TOP_MARGIN_BOTTOM, }; + let top_offset = self.toolbar_chrome.top_offset(); let offsets = geometry::ToolbarOffsets { - top_x: self.data.toolbar_top_offset, - top_y: self.data.toolbar_top_offset_y, + top_x: top_offset.0, + top_y: top_offset.1, }; let (clamped, bounds) = geometry::clamp_toolbar_offsets(offsets, input); - self.data.toolbar_top_offset = clamped.top_x; - self.data.toolbar_top_offset_y = clamped.top_y; + self.toolbar_chrome + .set_top_offset((clamped.top_x, clamped.top_y)); drag_log(|| { format!( "clamp offsets: before=({:.3}, {:.3}), after=({:.3}, {:.3}), max=({:.3}, {:.3}), size=({}, {}), top_base_x={:.3}, top_base_y={:.3}", before_top.0, before_top.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1, bounds.max_top_x, bounds.max_top_y, width, @@ -111,7 +95,7 @@ impl WaylandState { // to avoid drift as the source surface moves under the pointer. Pointer-locked // drags use relative deltas instead, so the suppressed real surface can track // the preview and avoid a visible catch-up animation on release. - if self.toolbar_drag_preview_active() && !self.pointer_lock_active() { + if self.toolbar_drag.preview_active() && !self.pointer_lock_active() { drag_log(|| "skip apply_toolbar_offsets: drag preview active without pointer lock"); return false; } @@ -119,12 +103,13 @@ impl WaylandState { return false; } let top_base_x = self.inline_top_base_x(); + let top_offset = self.toolbar_chrome.top_offset(); let (top_margin_left, top_margin_top) = geometry::compute_layer_margins( top_base_x, Self::TOP_BASE_MARGIN_TOP, geometry::ToolbarOffsets { - top_x: self.data.toolbar_top_offset, - top_y: self.data.toolbar_top_offset_y, + top_x: top_offset.0, + top_y: top_offset.1, }, ); drag_log(|| { @@ -132,35 +117,35 @@ impl WaylandState { "apply_toolbar_offsets: top_margin_left={}, top_margin_top={}, offsets=({}, {}), scale={}, top_base_x={}", top_margin_left, top_margin_top, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, + top_offset.0, + top_offset.1, self.surface.scale(), top_base_x ) }); if debug_toolbar_drag_logging_enabled() { + let last = self.toolbar_chrome.last_applied_margins(); debug!( "apply_toolbar_offsets: top_margin_left={} (last={:?}), top_margin_top={} (last={:?}), offsets=({}, {}), top_base_x={}", top_margin_left, - self.data.last_applied_top_margin, + last.map(|(_, left)| left), top_margin_top, - self.data.last_applied_top_margin_top, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, + last.map(|(top, _)| top), + top_offset.0, + top_offset.1, top_base_x ); } - let top_changed = self.data.last_applied_top_margin != Some(top_margin_left) - || self.data.last_applied_top_margin_top != Some(top_margin_top); + let top_changed = self + .toolbar_chrome + .apply_margins((top_margin_top, top_margin_left)); if !top_changed { return false; } - self.data.last_applied_top_margin = Some(top_margin_left); - self.data.last_applied_top_margin_top = Some(top_margin_top); self.toolbar .set_top_margins(top_margin_top, top_margin_left); - if self.toolbar_drag_preview_active() && self.pointer_lock_active() { - self.request_toolbar_drag_flush(); + if self.toolbar_drag.preview_active() && self.pointer_lock_active() { + self.toolbar_drag.request_flush(); } top_changed } diff --git a/src/backend/wayland/state/toolbar/drag/handoff.rs b/src/backend/wayland/state/toolbar/drag/handoff.rs index 9a4539d6a..645ae82bb 100644 --- a/src/backend/wayland/state/toolbar/drag/handoff.rs +++ b/src/backend/wayland/state/toolbar/drag/handoff.rs @@ -1,47 +1,22 @@ use super::*; use crate::backend::wayland::state::helpers::toolbar_drag_handoff_delay; -fn reset_gtk_drag_lifecycle( - preview: &mut Option, - handoff_at: &mut Option, - frozen_top_base_x: &mut Option, - top_rebase: &mut Option<(f64, f64)>, - top_blocked: &mut bool, -) -> bool { - let had_state = preview.is_some() - || handoff_at.is_some() - || frozen_top_base_x.is_some() - || top_rebase.is_some() - || *top_blocked; - *preview = None; - *handoff_at = None; - *frozen_top_base_x = None; - *top_rebase = None; - *top_blocked = false; - had_state -} - impl WaylandState { pub(in crate::backend::wayland) fn toolbar_drag_handoff_timeout( &self, now: Instant, ) -> Option { - self.data - .toolbar_drag_handoff_at - .map(|deadline| deadline.saturating_duration_since(now)) + self.toolbar_drag.handoff_timeout(now) } pub(in crate::backend::wayland) fn finish_toolbar_drag_handoff_if_due( &mut self, now: Instant, ) -> bool { - let Some(deadline) = self.data.toolbar_drag_handoff_at else { + let Some(end) = self.toolbar_drag.finish_handoff_if_due(now) else { return false; }; - if now < deadline { - return false; - } - self.finish_toolbar_drag_handoff(); + self.apply_toolbar_drag_handoff_end(end); true } @@ -57,8 +32,8 @@ impl WaylandState { delay.as_millis() ) }); - self.data.toolbar_drag_handoff_at = Some(Instant::now() + delay); - self.clear_inline_toolbar_hover(); + self.toolbar_drag.begin_handoff(Instant::now() + delay); + self.toolbar_chrome.clear_inline_hover(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; } @@ -67,7 +42,7 @@ impl WaylandState { drag_log(|| "begin toolbar drag handoff (keep inline preview while layer surface settles)"); let snapshot = self.toolbar_snapshot(); let _ = self.apply_toolbar_offsets(&snapshot); - self.request_toolbar_drag_flush(); + self.toolbar_drag.request_flush(); self.schedule_toolbar_drag_handoff(); } @@ -82,40 +57,33 @@ impl WaylandState { kind, ) }); - self.data.toolbar_drag_handoff_at = None; - self.data.drag_top_base_x = Some(frozen_top_base_x); - self.data.gtk_drag_preview = Some(kind); + self.toolbar_drag.begin_gtk_preview(kind, frozen_top_base_x); self.toolbar.mark_dirty(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; } pub(in crate::backend::wayland) fn begin_gtk_toolbar_drag_handoff(&mut self) { - if self.data.gtk_drag_preview.is_none() { + if self.toolbar_drag.gtk_preview_kind().is_none() { return; } drag_log(|| "begin GTK drag handoff (move transparent surface before reveal)"); - self.request_toolbar_drag_flush(); + self.toolbar_drag.request_flush(); self.schedule_toolbar_drag_handoff(); } pub(in crate::backend::wayland) fn cancel_gtk_toolbar_drag_lifecycle(&mut self) { - if self.data.gtk_drag_preview.is_some() { + let had_preview = self.toolbar_drag.gtk_preview_kind().is_some(); + if had_preview { self.finish_toolbar_position_preview(false); } - let had_state = reset_gtk_drag_lifecycle( - &mut self.data.gtk_drag_preview, - &mut self.data.toolbar_drag_handoff_at, - &mut self.data.drag_top_base_x, - &mut self.data.gtk_top_drag_rebase, - &mut self.data.gtk_top_drag_blocked, - ); + let had_state = self.toolbar_drag.cancel_gtk(); if had_state { drag_log(|| "cancel GTK drag lifecycle (restore built-in toolbar rendering)"); } - self.request_toolbar_drag_flush(); - self.clear_inline_toolbar_hits(); - self.clear_inline_toolbar_hover(); + self.toolbar_drag.request_flush(); + self.toolbar_chrome.clear_inline_hits(); + self.toolbar_chrome.clear_inline_hover(); self.toolbar.mark_dirty(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; @@ -124,56 +92,32 @@ impl WaylandState { pub(in crate::backend::wayland::state::toolbar::drag) fn finish_toolbar_drag_handoff( &mut self, ) { - self.data.toolbar_drag_handoff_at = None; - if self.data.gtk_drag_preview.take().is_some() { + let Some(end) = self.toolbar_drag.finish_handoff() else { + return; + }; + self.apply_toolbar_drag_handoff_end(end); + } + + fn apply_toolbar_drag_handoff_end(&mut self, end: HandoffEnd) { + if end == HandoffEnd::Gtk { drag_log(|| "finish GTK drag handoff (reveal surface at final position)"); - self.request_toolbar_drag_flush(); - self.clear_inline_toolbar_hits(); - self.clear_inline_toolbar_hover(); + self.toolbar_drag.request_flush(); + self.toolbar_chrome.clear_inline_hits(); + self.toolbar_chrome.clear_inline_hover(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; return; } - if !self.toolbar_drag_preview_active() { - return; - } + drag_log(|| "finish toolbar drag handoff (restore layer-shell toolbars)"); - self.set_toolbar_drag_preview_active(false); let snapshot = self.toolbar_snapshot(); let _ = self.apply_toolbar_offsets(&snapshot); self.toolbar .set_suppressed(self.protocol.compositor(), false); - self.request_toolbar_drag_flush(); - self.clear_inline_toolbar_hits(); - self.clear_inline_toolbar_hover(); + self.toolbar_drag.request_flush(); + self.toolbar_chrome.clear_inline_hits(); + self.toolbar_chrome.clear_inline_hover(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn gtk_fallback_clears_the_entire_drag_lifecycle() { - let mut preview = Some(crate::toolbar_gtk::GtkToolbarKind::Top); - let mut handoff_at = Some(Instant::now()); - let mut frozen_top_base_x = Some(42.0); - let mut top_rebase = Some((1.0, 2.0)); - let mut top_blocked = true; - - assert!(reset_gtk_drag_lifecycle( - &mut preview, - &mut handoff_at, - &mut frozen_top_base_x, - &mut top_rebase, - &mut top_blocked, - )); - assert_eq!(preview, None); - assert_eq!(handoff_at, None); - assert_eq!(frozen_top_base_x, None); - assert_eq!(top_rebase, None); - assert!(!top_blocked); - } -} diff --git a/src/backend/wayland/state/toolbar/drag/mod.rs b/src/backend/wayland/state/toolbar/drag/mod.rs index 7c7fdf789..69138dc9a 100644 --- a/src/backend/wayland/state/toolbar/drag/mod.rs +++ b/src/backend/wayland/state/toolbar/drag/mod.rs @@ -5,3 +5,6 @@ mod clamp; mod handoff; mod move_drag; mod relative; +mod state; + +pub(in crate::backend::wayland) use state::{HandoffEnd, MoveDragKind, ToolbarDrag}; diff --git a/src/backend/wayland/state/toolbar/drag/move_drag.rs b/src/backend/wayland/state/toolbar/drag/move_drag.rs index e2e5d610b..619d45eaf 100644 --- a/src/backend/wayland/state/toolbar/drag/move_drag.rs +++ b/src/backend/wayland/state/toolbar/drag/move_drag.rs @@ -7,17 +7,17 @@ impl WaylandState { coord: (f64, f64), coord_is_screen: bool, ) -> bool { - if self.data.toolbar_move_drag.is_none() { + if !self.toolbar_drag.is_moving() { if !self.begin_toolbar_position_preview(kind) { return false; } if toolbar_drag_preview_enabled() && self.protocol.layer_shell().is_some() - && !self.inline_toolbars_active() - && !self.toolbar_drag_preview_active() + && !self.toolbar_chrome.inline_toolbars() + && !self.toolbar_drag.preview_active() { drag_log(|| "enable inline drag preview (layer-shell toolbars hidden)"); - self.set_toolbar_drag_preview_active(true); + self.toolbar_drag.set_preview_active(true); self.toolbar .set_suppressed(self.protocol.compositor(), true); self.input_state.dirty_tracker.mark_full(); @@ -37,18 +37,10 @@ impl WaylandState { coord.0, coord.1, coord_is_screen, - self.inline_toolbars_active(), + self.toolbar_chrome.inline_toolbars(), self.protocol.layer_shell().is_some() ) }); - // Store initial coord with explicit coordinate space (screen vs toolbar-local). - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: coord, - coord_is_screen, - }); - self.data.toolbar_drag_pending_apply = false; - self.data.last_toolbar_drag_apply = None; // Freeze the base position so a relayout cannot shift the surface // under the pointer mid-drag. let top_base_x = self.inline_top_base_x(); @@ -59,18 +51,17 @@ impl WaylandState { kind, top_base_x, top_base_y, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1, self.surface.width(), self.surface.height(), self.surface.scale() ) }); - self.data.drag_top_base_x = Some(top_base_x); - self.data.drag_top_base_y = Some(top_base_y); + self.toolbar_drag + .begin_move(kind, coord, coord_is_screen, (top_base_x, top_base_y)); } - self.data.active_drag_kind = Some(kind); - self.set_toolbar_dragging(true); + self.toolbar_drag.set_item_dragging(true); true } @@ -86,15 +77,7 @@ impl WaylandState { // Consume the coordinate baseline without moving the toolbar. If // the exact same authority resumes this untouched preview, the // next accepted event applies only post-barrier movement. - if let Some(drag) = self - .data - .toolbar_move_drag - .as_mut() - .filter(|drag| drag.kind == kind) - { - drag.last_coord = local_coord; - drag.coord_is_screen = false; - } + self.toolbar_drag.note_move(kind, local_coord, false); return; } if self.pointer_lock_active() { @@ -112,8 +95,8 @@ impl WaylandState { kind, local_coord.0, local_coord.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); // For layer-shell surfaces, use local coordinates directly since they're @@ -132,37 +115,21 @@ impl WaylandState { // When inline drag preview is active we keep the layer-shell toolbars // suppressed and only move the inline-rendered preview. - if self.toolbar_drag_preview_active() { - let last_local = match &self.data.toolbar_move_drag { - Some(d) if d.kind == kind && !d.coord_is_screen => d.last_coord, - _ => local_coord, - }; - - self.data.active_drag_kind = Some(kind); - - let delta = (local_coord.0 - last_local.0, local_coord.1 - last_local.1); + if self.toolbar_drag.preview_active() { + let delta = self + .toolbar_drag + .move_to(kind, local_coord, false) + .unwrap_or((0.0, 0.0)); if delta.0 == 0.0 && delta.1 == 0.0 { - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: local_coord, - coord_is_screen: false, - }); return; } match kind { MoveDragKind::Top => { - self.data.toolbar_top_offset += delta.0; - self.data.toolbar_top_offset_y += delta.1; + self.toolbar_chrome.add_top_offset(delta); } } - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: local_coord, - coord_is_screen: false, - }); - // Clamp offsets; pointer-locked preview drags also move the suppressed // layer surface so release does not visibly replay the drag. self.apply_toolbar_offsets_throttled(&snapshot); @@ -174,16 +141,18 @@ impl WaylandState { self.input_state.needs_redraw = true; } if self.protocol.layer_shell().is_none() || inline_render_active { - self.clear_inline_toolbar_hits(); + self.toolbar_chrome.clear_inline_hits(); } return; } // Check if we need to transition coordinate systems - let (last_coord, coord_is_screen) = match &self.data.toolbar_move_drag { - Some(d) if d.kind == kind => (d.last_coord, d.coord_is_screen), - _ => (local_coord, false), // Start fresh with local coords - }; + let (last_coord, coord_is_screen) = self + .toolbar_drag + .move_sample() + .map_or((local_coord, false), |sample| { + (sample.coord, sample.is_screen) + }); // If last coord was screen-based, convert current local to screen for comparison let last_screen = if coord_is_screen { @@ -193,12 +162,13 @@ impl WaylandState { }; let effective_coord = self.local_to_screen_coords(kind, local_coord); - self.data.active_drag_kind = Some(kind); - - let delta = ( - effective_coord.0 - last_screen.0, - effective_coord.1 - last_screen.1, - ); + if !coord_is_screen { + self.toolbar_drag.note_move(kind, last_screen, true); + } + let delta = self + .toolbar_drag + .move_to(kind, effective_coord, true) + .unwrap_or((0.0, 0.0)); drag_log(|| { format!( "move_local delta: kind={:?}, local=({:.3}, {:.3}), effective=({:.3}, {:.3}), last_screen=({:.3}, {:.3}), delta=({:.3}, {:.3}), offsets_before=({}, {})", @@ -211,8 +181,8 @@ impl WaylandState { last_screen.1, delta.0, delta.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); log::debug!( @@ -226,41 +196,32 @@ impl WaylandState { last_screen.1, delta.0, delta.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ); if delta.0 == 0.0 && delta.1 == 0.0 { - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: effective_coord, - coord_is_screen: true, - }); return; } match kind { MoveDragKind::Top => { - self.data.toolbar_top_offset += delta.0; - self.data.toolbar_top_offset_y += delta.1; + self.toolbar_chrome.add_top_offset(delta); } } drag_log(|| { format!( "move_local applied: kind={:?}, offsets_after=({}, {})", - kind, self.data.toolbar_top_offset, self.data.toolbar_top_offset_y + kind, + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); log::debug!( "After update offsets: top=({}, {})", - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ); - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: effective_coord, - coord_is_screen: true, - }); self.apply_toolbar_offsets_throttled(&snapshot); let inline_render_active = self.inline_toolbars_render_active(); if inline_render_active { @@ -269,7 +230,7 @@ impl WaylandState { self.input_state.needs_redraw = true; } if self.protocol.layer_shell().is_none() || inline_render_active { - self.clear_inline_toolbar_hits(); + self.toolbar_chrome.clear_inline_hits(); } } @@ -281,15 +242,7 @@ impl WaylandState { screen_coord: (f64, f64), ) { if !self.toolbar_position_drag_update_allowed(kind) { - if let Some(drag) = self - .data - .toolbar_move_drag - .as_mut() - .filter(|drag| drag.kind == kind) - { - drag.last_coord = screen_coord; - drag.coord_is_screen = true; - } + self.toolbar_drag.note_move(kind, screen_coord, true); return; } if self.pointer_lock_active() { @@ -307,8 +260,8 @@ impl WaylandState { kind, screen_coord.0, screen_coord.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); let snapshot = self @@ -318,23 +271,23 @@ impl WaylandState { .unwrap_or_else(|| self.toolbar_snapshot()); // Get last coord, converting from local to screen if needed - let last_screen_coord = match self.data.toolbar_move_drag { - Some(d) if d.kind == kind => { - if d.coord_is_screen { - d.last_coord - } else { - self.local_to_screen_coords(kind, d.last_coord) - } - } - _ => screen_coord, // Start fresh + let last_screen_coord = match self.toolbar_drag.move_sample() { + Some(sample) if sample.is_screen => sample.coord, + Some(sample) => self.local_to_screen_coords(kind, sample.coord), + None => screen_coord, }; - self.data.active_drag_kind = Some(kind); - - let delta = ( - screen_coord.0 - last_screen_coord.0, - screen_coord.1 - last_screen_coord.1, - ); + if self + .toolbar_drag + .move_sample() + .is_some_and(|sample| !sample.is_screen) + { + self.toolbar_drag.note_move(kind, last_screen_coord, true); + } + let delta = self + .toolbar_drag + .move_to(kind, screen_coord, true) + .unwrap_or((0.0, 0.0)); drag_log(|| { format!( "move_screen delta: kind={:?}, screen=({:.3}, {:.3}), last_screen=({:.3}, {:.3}), delta=({:.3}, {:.3}), offsets_before=({}, {})", @@ -345,8 +298,8 @@ impl WaylandState { last_screen_coord.1, delta.0, delta.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); log::debug!( @@ -358,35 +311,26 @@ impl WaylandState { last_screen_coord.1, delta.0, delta.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ); if delta.0 == 0.0 && delta.1 == 0.0 { - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: screen_coord, - coord_is_screen: true, - }); return; } match kind { MoveDragKind::Top => { - self.data.toolbar_top_offset += delta.0; - self.data.toolbar_top_offset_y += delta.1; + self.toolbar_chrome.add_top_offset(delta); } } drag_log(|| { format!( "move_screen applied: kind={:?}, offsets_after=({}, {})", - kind, self.data.toolbar_top_offset, self.data.toolbar_top_offset_y + kind, + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); - self.data.toolbar_move_drag = Some(MoveDrag { - kind, - last_coord: screen_coord, - coord_is_screen: true, - }); self.apply_toolbar_offsets_throttled(&snapshot); let inline_render_active = self.inline_toolbars_render_active(); if inline_render_active { @@ -396,7 +340,7 @@ impl WaylandState { } if self.protocol.layer_shell().is_none() || inline_render_active { // Inline mode uses cached rects, so force a relayout. - self.clear_inline_toolbar_hits(); + self.toolbar_chrome.clear_inline_hits(); } } } diff --git a/src/backend/wayland/state/toolbar/drag/relative.rs b/src/backend/wayland/state/toolbar/drag/relative.rs index e4b253890..eb5cbdab9 100644 --- a/src/backend/wayland/state/toolbar/drag/relative.rs +++ b/src/backend/wayland/state/toolbar/drag/relative.rs @@ -16,8 +16,8 @@ impl WaylandState { kind, delta.0, delta.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); let snapshot = self @@ -28,8 +28,7 @@ impl WaylandState { match kind { MoveDragKind::Top => { - self.data.toolbar_top_offset += delta.0; - self.data.toolbar_top_offset_y += delta.1; + self.toolbar_chrome.add_top_offset(delta); } } @@ -41,8 +40,8 @@ impl WaylandState { kind, delta.0, delta.1, - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1 ) }); @@ -61,40 +60,35 @@ impl WaylandState { } fn finish_toolbar_move_drag(&mut self, commit: bool) { - if self.data.toolbar_move_drag.is_some() { + let active_kind = self.toolbar_drag.kind(); + let Some(end) = self.toolbar_drag.end_move() else { + return; + }; + if commit && let Some(old_base_x) = end.commit_base { + self.reconcile_top_base_after_drag(old_base_x); + } + drag_log(|| { + format!( + "end move drag: offsets=({}, {}), active_kind={:?}, pointer_locked={}", + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1, + active_kind, + self.pointer_lock_active() + ) + }); + self.toolbar_chrome.set_pointer_over_toolbar(false); + if end.pending_apply { + let snapshot = self.toolbar_snapshot(); + let _ = self.apply_toolbar_offsets(&snapshot); + } + if end.had_preview { if commit { - self.reconcile_top_base_after_drag(); - } - drag_log(|| { - format!( - "end move drag: offsets=({}, {}), active_kind={:?}, pointer_locked={}", - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, - self.data.active_drag_kind, - self.pointer_lock_active() - ) - }); - self.data.toolbar_move_drag = None; - self.set_toolbar_dragging(false); - self.set_pointer_over_toolbar(false); - self.data.active_drag_kind = None; - self.data.drag_top_base_x = None; - self.data.drag_top_base_y = None; - self.data.last_toolbar_drag_apply = None; - if self.data.toolbar_drag_pending_apply { - let snapshot = self.toolbar_snapshot(); - let _ = self.apply_toolbar_offsets(&snapshot); - self.data.toolbar_drag_pending_apply = false; - } - if self.toolbar_drag_preview_active() { - if commit { - self.begin_toolbar_drag_handoff(); - } else { - self.finish_toolbar_drag_handoff(); - } + self.begin_toolbar_drag_handoff(); + } else { + self.finish_toolbar_drag_handoff(); } - self.finish_toolbar_position_preview(commit); - self.unlock_pointer(); } + self.finish_toolbar_position_preview(commit); + self.unlock_pointer(); } } diff --git a/src/backend/wayland/state/toolbar/drag/state.rs b/src/backend/wayland/state/toolbar/drag/state.rs new file mode 100644 index 000000000..5cc188a47 --- /dev/null +++ b/src/backend/wayland/state/toolbar/drag/state.rs @@ -0,0 +1,539 @@ +use std::time::{Duration, Instant}; + +use crate::toolbar_gtk::{GtkToolbarFeedback, GtkToolbarKind}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum MoveDragKind { + Top, +} + +#[derive(Debug, Clone, Copy)] +struct ApplyThrottle { + pending_apply: bool, + last_apply: Option, +} + +impl ApplyThrottle { + fn new() -> Self { + Self { + pending_apply: false, + last_apply: None, + } + } +} + +#[derive(Debug, Clone, Copy)] +enum MoveDragPhase { + Idle, + Moving { + kind: MoveDragKind, + last_coord: (f64, f64), + coord_is_screen: bool, + frozen_base: (f64, f64), + throttle: ApplyThrottle, + }, + Handoff { + deadline: Instant, + }, + GtkPreview { + kind: Option, + frozen_base_x: f64, + rebase: Option<(f64, f64)>, + blocked: bool, + handoff_deadline: Option, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland) struct MoveSample { + pub coord: (f64, f64), + pub is_screen: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland) struct MoveEnd { + pub commit_base: Option, + pub pending_apply: bool, + pub had_preview: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum HandoffEnd { + BuiltIn, + Gtk, +} + +pub(in crate::backend::wayland) struct ToolbarDrag { + item_drag: bool, + preview: bool, + flush_requested: bool, + gtk_top_offset_seq: u64, + phase: MoveDragPhase, +} + +impl ToolbarDrag { + pub(in crate::backend::wayland) fn new() -> Self { + Self { + item_drag: false, + preview: false, + flush_requested: false, + gtk_top_offset_seq: 0, + phase: MoveDragPhase::Idle, + } + } + + pub(in crate::backend::wayland) fn item_dragging(&self) -> bool { + self.item_drag + } + + pub(in crate::backend::wayland) fn set_item_dragging(&mut self, dragging: bool) { + self.item_drag = dragging; + } + + pub(in crate::backend::wayland) fn preview_active(&self) -> bool { + self.preview + } + + pub(in crate::backend::wayland) fn set_preview_active(&mut self, active: bool) { + self.preview = active; + } + + pub(in crate::backend::wayland) fn request_flush(&mut self) { + self.flush_requested = true; + } + + pub(in crate::backend::wayland) fn take_flush_requested(&mut self) -> bool { + std::mem::take(&mut self.flush_requested) + } + + pub(in crate::backend::wayland) fn begin_move( + &mut self, + kind: MoveDragKind, + coord: (f64, f64), + coord_is_screen: bool, + frozen_base: (f64, f64), + ) { + self.phase = MoveDragPhase::Moving { + kind, + last_coord: coord, + coord_is_screen, + frozen_base, + throttle: ApplyThrottle::new(), + }; + } + + pub(in crate::backend::wayland) fn is_moving(&self) -> bool { + matches!(self.phase, MoveDragPhase::Moving { .. }) + } + + pub(in crate::backend::wayland) fn kind(&self) -> Option { + match self.phase { + MoveDragPhase::Moving { kind, .. } => Some(kind), + _ => None, + } + } + + pub(in crate::backend::wayland) fn frozen_base_x(&self) -> Option { + match self.phase { + MoveDragPhase::Moving { frozen_base, .. } => Some(frozen_base.0), + MoveDragPhase::GtkPreview { + kind: Some(_), + frozen_base_x, + .. + } => Some(frozen_base_x), + MoveDragPhase::Idle + | MoveDragPhase::Handoff { .. } + | MoveDragPhase::GtkPreview { kind: None, .. } => None, + } + } + + pub(in crate::backend::wayland) fn frozen_base_y(&self) -> Option { + match self.phase { + MoveDragPhase::Moving { frozen_base, .. } => Some(frozen_base.1), + _ => None, + } + } + + pub(in crate::backend::wayland) fn move_sample(&self) -> Option { + match self.phase { + MoveDragPhase::Moving { + last_coord, + coord_is_screen, + .. + } => Some(MoveSample { + coord: last_coord, + is_screen: coord_is_screen, + }), + _ => None, + } + } + + pub(in crate::backend::wayland) fn note_move( + &mut self, + kind: MoveDragKind, + coord: (f64, f64), + coord_is_screen: bool, + ) -> Option { + let MoveDragPhase::Moving { + kind: active_kind, + last_coord, + coord_is_screen: active_is_screen, + .. + } = &mut self.phase + else { + return None; + }; + if *active_kind != kind { + return None; + } + let previous = MoveSample { + coord: *last_coord, + is_screen: *active_is_screen, + }; + *last_coord = coord; + *active_is_screen = coord_is_screen; + Some(previous) + } + + pub(in crate::backend::wayland) fn move_to( + &mut self, + kind: MoveDragKind, + coord: (f64, f64), + coord_is_screen: bool, + ) -> Option<(f64, f64)> { + let previous = self.note_move(kind, coord, coord_is_screen)?; + if previous.is_screen != coord_is_screen { + return None; + } + Some((coord.0 - previous.coord.0, coord.1 - previous.coord.1)) + } + + pub(in crate::backend::wayland) fn should_apply( + &mut self, + now: Instant, + interval: Duration, + ) -> bool { + let MoveDragPhase::Moving { throttle, .. } = &mut self.phase else { + return true; + }; + let should_apply = throttle + .last_apply + .is_none_or(|last| now.saturating_duration_since(last) >= interval); + if should_apply { + throttle.last_apply = Some(now); + throttle.pending_apply = false; + } else { + throttle.pending_apply = true; + } + should_apply + } + + pub(in crate::backend::wayland) fn note_applied(&mut self, now: Instant) { + if let MoveDragPhase::Moving { throttle, .. } = &mut self.phase { + throttle.last_apply = Some(now); + throttle.pending_apply = false; + } + } + + pub(in crate::backend::wayland) fn end_move(&mut self) -> Option { + let MoveDragPhase::Moving { + frozen_base, + throttle, + .. + } = self.phase + else { + return None; + }; + self.phase = MoveDragPhase::Idle; + self.item_drag = false; + Some(MoveEnd { + commit_base: Some(frozen_base.0), + pending_apply: throttle.pending_apply, + had_preview: self.preview, + }) + } + + pub(in crate::backend::wayland) fn begin_handoff(&mut self, deadline: Instant) { + match &mut self.phase { + MoveDragPhase::GtkPreview { + handoff_deadline, .. + } => *handoff_deadline = Some(deadline), + _ => self.phase = MoveDragPhase::Handoff { deadline }, + } + } + + pub(in crate::backend::wayland) fn handoff_timeout(&self, now: Instant) -> Option { + let deadline = match self.phase { + MoveDragPhase::Handoff { deadline } => Some(deadline), + MoveDragPhase::GtkPreview { + handoff_deadline, .. + } => handoff_deadline, + _ => None, + }?; + Some(deadline.saturating_duration_since(now)) + } + + pub(in crate::backend::wayland) fn finish_handoff_if_due( + &mut self, + now: Instant, + ) -> Option { + if self.handoff_timeout(now) != Some(Duration::ZERO) { + return None; + } + self.finish_handoff() + } + + pub(in crate::backend::wayland) fn finish_handoff(&mut self) -> Option { + let result = match self.phase { + MoveDragPhase::Handoff { .. } => Some(HandoffEnd::BuiltIn), + MoveDragPhase::GtkPreview { .. } => Some(HandoffEnd::Gtk), + _ if self.preview => Some(HandoffEnd::BuiltIn), + _ => None, + }; + match result { + Some(HandoffEnd::Gtk) => self.phase = MoveDragPhase::Idle, + Some(HandoffEnd::BuiltIn) => { + self.phase = MoveDragPhase::Idle; + self.preview = false; + } + None => {} + } + result + } + + pub(in crate::backend::wayland) fn block_gtk_drag(&mut self) { + self.phase = MoveDragPhase::GtkPreview { + kind: None, + frozen_base_x: 0.0, + rebase: None, + blocked: true, + handoff_deadline: None, + }; + } + + pub(in crate::backend::wayland) fn begin_gtk_preview( + &mut self, + kind: GtkToolbarKind, + frozen_base_x: f64, + ) { + self.phase = MoveDragPhase::GtkPreview { + kind: Some(kind), + frozen_base_x, + rebase: None, + blocked: false, + handoff_deadline: None, + }; + } + + pub(in crate::backend::wayland) fn gtk_preview_kind(&self) -> Option { + match self.phase { + MoveDragPhase::GtkPreview { kind, .. } => kind, + _ => None, + } + } + + pub(in crate::backend::wayland) fn gtk_rebase(&self) -> Option<(f64, f64)> { + match self.phase { + MoveDragPhase::GtkPreview { rebase, .. } => rebase, + _ => None, + } + } + + pub(in crate::backend::wayland) fn set_gtk_rebase(&mut self, value: Option<(f64, f64)>) { + if let MoveDragPhase::GtkPreview { rebase, .. } = &mut self.phase { + *rebase = value; + } + } + + pub(in crate::backend::wayland) fn release_gtk_frozen_base(&mut self, resting_base_x: f64) { + if let MoveDragPhase::GtkPreview { frozen_base_x, .. } = &mut self.phase { + *frozen_base_x = resting_base_x; + } + } + + pub(in crate::backend::wayland) fn gtk_note_feedback( + &mut self, + modal_engaged: bool, + feedback: &GtkToolbarFeedback, + ) -> bool { + match feedback { + GtkToolbarFeedback::CaptureSuppressionReady { .. } + | GtkToolbarFeedback::CaptureSuppressionFailed { .. } + | GtkToolbarFeedback::TopHover { .. } => false, + GtkToolbarFeedback::Event { .. } | GtkToolbarFeedback::PointerShortcut { .. } => { + modal_engaged + } + GtkToolbarFeedback::SetTopOffset { phase, seq, .. } => { + if modal_engaged && !matches!(self.phase, MoveDragPhase::GtkPreview { .. }) { + self.block_gtk_drag(); + } + let blocked = match &mut self.phase { + MoveDragPhase::GtkPreview { blocked, .. } => { + let result = modal_engaged || *blocked; + if result { + *blocked = !phase.is_end(); + } + result + } + _ => modal_engaged, + }; + if blocked { + self.gtk_top_offset_seq = self.gtk_top_offset_seq.max(*seq); + if phase.is_end() + && matches!(self.phase, MoveDragPhase::GtkPreview { kind: None, .. }) + { + self.phase = MoveDragPhase::Idle; + } + } + blocked + } + } + } + + pub(in crate::backend::wayland) fn note_gtk_offset_seq(&mut self, seq: u64) { + self.gtk_top_offset_seq = seq; + } + + pub(in crate::backend::wayland) fn gtk_offset_seq(&self) -> u64 { + self.gtk_top_offset_seq + } + + pub(in crate::backend::wayland) fn cancel_gtk(&mut self) -> bool { + if !matches!(self.phase, MoveDragPhase::GtkPreview { .. }) { + return false; + } + self.phase = MoveDragPhase::Idle; + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::toolbar_gtk::{GtkToolbarDragPhase, GtkToolbarSurfaceSize}; + + const TEST_SURFACE_SIZE: GtkToolbarSurfaceSize = GtkToolbarSurfaceSize { + width: 260, + height: 789, + }; + + fn gtk_offset(phase: GtkToolbarDragPhase, seq: u64) -> GtkToolbarFeedback { + GtkToolbarFeedback::SetTopOffset { + x: 10.0, + y: 20.0, + surface_size: TEST_SURFACE_SIZE, + seq, + phase, + } + } + + fn moving(preview: bool) -> ToolbarDrag { + let mut drag = ToolbarDrag::new(); + drag.set_preview_active(preview); + drag.begin_move(MoveDragKind::Top, (1.0, 2.0), false, (24.0, 12.0)); + drag + } + + #[test] + fn move_and_handoff_transition_table_is_explicit() { + let now = Instant::now(); + let mut drag = moving(true); + let ended = drag.end_move().unwrap(); + assert_eq!(ended.commit_base, Some(24.0)); + assert!(ended.had_preview); + + drag.begin_handoff(now + Duration::from_millis(10)); + assert_eq!(drag.finish_handoff_if_due(now), None); + assert_eq!( + drag.finish_handoff_if_due(now + Duration::from_millis(10)), + Some(HandoffEnd::BuiltIn) + ); + assert!(!drag.preview_active()); + } + + #[test] + fn move_cancel_can_return_directly_to_idle() { + let mut drag = moving(false); + assert!(drag.end_move().is_some()); + assert!(!drag.is_moving()); + assert_eq!(drag.finish_handoff(), None); + } + + #[test] + fn gtk_preview_handoff_and_cancel_follow_their_own_phase() { + let now = Instant::now(); + let mut drag = ToolbarDrag::new(); + drag.begin_handoff(now); + drag.begin_gtk_preview(GtkToolbarKind::Top, 24.0); + assert_eq!(drag.handoff_timeout(now), None); + drag.begin_handoff(now + Duration::from_millis(10)); + assert_eq!( + drag.finish_handoff_if_due(now + Duration::from_millis(10)), + Some(HandoffEnd::Gtk) + ); + + drag.begin_gtk_preview(GtkToolbarKind::Top, 24.0); + assert!(drag.cancel_gtk()); + assert!(!drag.cancel_gtk()); + } + + #[test] + fn throttle_reports_a_pending_terminal_apply() { + let start = Instant::now(); + let mut drag = moving(false); + let interval = Duration::from_millis(20); + assert!(drag.should_apply(start, interval)); + assert!(!drag.should_apply(start + Duration::from_millis(5), interval)); + assert!(drag.end_move().unwrap().pending_apply); + } + + #[test] + fn blocked_gtk_drag_advances_sequence_and_stays_blocked_until_end() { + let mut drag = ToolbarDrag::new(); + drag.note_gtk_offset_seq(4); + + assert!(drag.gtk_note_feedback(true, >k_offset(GtkToolbarDragPhase::Start, 9))); + assert!(drag.gtk_note_feedback(false, >k_offset(GtkToolbarDragPhase::Move, 8))); + assert_eq!(drag.gtk_offset_seq(), 9); + assert!(drag.gtk_note_feedback(false, >k_offset(GtkToolbarDragPhase::End, 10))); + assert_eq!(drag.gtk_offset_seq(), 10); + assert!(!drag.gtk_note_feedback(false, >k_offset(GtkToolbarDragPhase::Start, 11))); + } + + #[test] + fn passive_and_capture_feedback_follow_modal_policy() { + let mut drag = ToolbarDrag::new(); + drag.block_gtk_drag(); + assert!(!drag.gtk_note_feedback( + true, + &GtkToolbarFeedback::CaptureSuppressionReady { generation: 7 } + )); + let shortcut = GtkToolbarFeedback::PointerShortcut { + button: 8, + ctrl: false, + shift: false, + alt: false, + logo: false, + }; + assert!(drag.gtk_note_feedback(true, &shortcut)); + assert!(!drag.gtk_note_feedback(false, &shortcut)); + assert!(drag.gtk_note_feedback(false, >k_offset(GtkToolbarDragPhase::Move, 1))); + } + + #[test] + fn move_samples_are_updated_with_their_coordinate_space() { + let mut drag = moving(false); + assert_eq!( + drag.move_to(MoveDragKind::Top, (3.0, 4.0), false), + Some((2.0, 2.0)) + ); + assert_eq!(drag.move_to(MoveDragKind::Top, (5.0, 7.0), true), None); + assert_eq!( + drag.move_sample(), + Some(MoveSample { + coord: (5.0, 7.0), + is_screen: true, + }) + ); + } +} diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index b63ecc198..245ba9018 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -101,7 +101,7 @@ impl WaylandState { .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(); + snapshot.top_fade = self.toolbar_chrome.fade().value(); snapshot } @@ -133,7 +133,7 @@ impl WaylandState { } else { 1.0 }; - let surface_y = self.inline_top_base_y() + self.data.toolbar_top_offset_y; + let surface_y = self.inline_top_base_y() + self.toolbar_chrome.top_offset().1; super::geometry::remaining_top_height(screen_height, surface_y, scale) } @@ -162,7 +162,7 @@ impl WaylandState { // toolbars are inline. Refresh every SHM slot even for early-returning // event paths (popover dismissal, rebind capture, session actions) so // slot rotation cannot restore stale toolbar pixels. - if self.inline_toolbars_active() { + if self.toolbar_chrome.inline_toolbars() { self.mark_inline_toolbar_full_damage(); } self.handle_toolbar_event_with_rebind(event, rebind_requested, conn, qh); @@ -383,7 +383,7 @@ impl WaylandState { else { return false; }; - let inline_active = self.inline_toolbars_active(); + let inline_active = self.toolbar_chrome.inline_toolbars(); let coord_is_screen = inline_active; drag_log(|| { format!( diff --git a/src/backend/wayland/state/toolbar/fade.rs b/src/backend/wayland/state/toolbar/fade.rs index f4ee03e7a..821e485ae 100644 --- a/src/backend/wayland/state/toolbar/fade.rs +++ b/src/backend/wayland/state/toolbar/fade.rs @@ -17,13 +17,13 @@ impl WaylandState { /// `push_gtk_toolbar_update`) read `top_fade`. pub(in crate::backend::wayland) fn update_top_strip_fade(&mut self, now: Instant) { let inputs = self.top_strip_fade_inputs(now); - let before = self.data.top_strip_fade.value(); - let after = self.data.top_strip_fade.update(&inputs, now); + let before = self.toolbar_chrome.fade().value(); + let after = self.toolbar_chrome.fade_mut().update(&inputs, now); // Layer-shell (and GTK) toolbars repaint from the changed snapshot on // their own; inline toolbars live on the canvas surface, so a fade // step must damage their rect and request a canvas redraw itself. if before != after && self.inline_toolbars_render_active() { - if let Some((x, y, w, h)) = self.data.inline_top_rect + if let Some((x, y, w, h)) = self.toolbar_chrome.inline_rect() && let Some(rect) = crate::util::Rect::new( x.floor() as i32 - 1, y.floor() as i32 - 1, @@ -43,8 +43,8 @@ impl WaylandState { &self, now: Instant, ) -> Option { - self.data - .top_strip_fade + self.toolbar_chrome + .fade() .wake_after(&self.top_strip_fade_inputs(now)) } @@ -55,17 +55,13 @@ impl WaylandState { let reduced_chrome = !input.toolbar_top_visible() || input.toolbar_top_minimized() || input.toolbar_top_display_mode() == crate::config::TopDisplayMode::Micro; - let pointer_near = self.pointer_over_toolbar() - || self.toolbar.top_pointer_present() - || self.data.inline_top_hover.is_some() - || self.data.gtk_top_hover; - TopStripFadeInputs { - idle_for: now.saturating_duration_since(input.last_draw_activity()), - pointer_near, - menus_open: top_menus_open(input), + self.toolbar_chrome.fade_inputs( + self.toolbar.top_pointer_present(), + now.saturating_duration_since(input.last_draw_activity()), + top_menus_open(input), reduced_chrome, - idle_fade_enabled: input.ui_visibility.idle_fade, - } + input.ui_visibility.idle_fade, + ) } } diff --git a/src/backend/wayland/state/toolbar/gtk_feedback.rs b/src/backend/wayland/state/toolbar/gtk_feedback.rs index 9271f0a74..3fea19a38 100644 --- a/src/backend/wayland/state/toolbar/gtk_feedback.rs +++ b/src/backend/wayland/state/toolbar/gtk_feedback.rs @@ -65,38 +65,37 @@ impl WaylandState { phase: GtkToolbarDragPhase, ) { if phase == GtkToolbarDragPhase::Start { - self.data.gtk_top_drag_rebase = None; + self.toolbar_drag.set_gtk_rebase(None); if !self.begin_toolbar_position_preview(MoveDragKind::Top) { - self.data.gtk_top_drag_blocked = true; + self.toolbar_drag.block_gtk_drag(); return; } self.begin_gtk_toolbar_drag_preview(GtkToolbarKind::Top); } else if !self.toolbar_position_drag_update_allowed(MoveDragKind::Top) { if phase.is_end() { - self.data.gtk_top_drag_rebase = None; + self.toolbar_drag.set_gtk_rebase(None); self.clamp_gtk_top_offset(surface_size); self.finish_gtk_offset_change(); } else { - self.data.gtk_top_drag_rebase = Some(gtk_drag_rebase( - (self.data.toolbar_top_offset, self.data.toolbar_top_offset_y), + self.toolbar_drag.set_gtk_rebase(Some(gtk_drag_rebase( + self.toolbar_chrome.top_offset(), (x, y), - )); + ))); } return; } - let (x, y) = apply_gtk_drag_rebase((x, y), self.data.gtk_top_drag_rebase); - self.data.toolbar_top_offset = x; - self.data.toolbar_top_offset_y = y; + let (x, y) = apply_gtk_drag_rebase((x, y), self.toolbar_drag.gtk_rebase()); + self.toolbar_chrome.set_top_offset((x, y)); self.mark_gtk_drag_preview_dirty(); if phase.is_end() { - self.data.gtk_top_drag_rebase = None; + self.toolbar_drag.set_gtk_rebase(None); self.clamp_gtk_top_offset(surface_size); self.finish_gtk_offset_change(); } } fn mark_gtk_drag_preview_dirty(&mut self) { - if self.data.gtk_drag_preview.is_none() { + if self.toolbar_drag.gtk_preview_kind().is_none() { return; } self.toolbar.mark_dirty(); @@ -106,9 +105,9 @@ impl WaylandState { fn clamp_gtk_top_offset(&mut self, surface_size: GtkToolbarSurfaceSize) { let base_x = self.inline_top_base_x(); - let before = (self.data.toolbar_top_offset, self.data.toolbar_top_offset_y); + let before = self.toolbar_chrome.top_offset(); let Some((x, y)) = clamp_gtk_surface_offset( - (self.data.toolbar_top_offset, self.data.toolbar_top_offset_y), + self.toolbar_chrome.top_offset(), (self.surface.width(), self.surface.height()), surface_size, (base_x, Self::TOP_BASE_MARGIN_TOP), @@ -118,8 +117,7 @@ impl WaylandState { self.clamp_toolbar_offsets(&snapshot); return; }; - self.data.toolbar_top_offset = x; - self.data.toolbar_top_offset_y = y; + self.toolbar_chrome.set_top_offset((x, y)); drag_log(|| { format!( "gtk top final clamp before=({:.3},{:.3}) after=({x:.3},{y:.3}) viewport={}x{} surface={}x{} base=({base_x:.3},{:.3}) end=({:.3},{:.3})", @@ -139,8 +137,11 @@ impl WaylandState { /// On drag end, persist the offset accepted against GTK's measured /// surface. Intermediate positions are mirrored without disk writes. fn finish_gtk_offset_change(&mut self) { - self.reconcile_top_base_after_drag(); - self.data.drag_top_base_x = None; + if let Some(old_base_x) = self.toolbar_drag.frozen_base_x() { + self.reconcile_top_base_after_drag(old_base_x); + self.toolbar_drag + .release_gtk_frozen_base(Self::INLINE_TOP_X); + } self.finish_toolbar_position_preview(true); self.begin_gtk_toolbar_drag_handoff(); } diff --git a/src/backend/wayland/state/toolbar/inline/drag.rs b/src/backend/wayland/state/toolbar/inline/drag.rs index 15001c7ea..68eba3553 100644 --- a/src/backend/wayland/state/toolbar/inline/drag.rs +++ b/src/backend/wayland/state/toolbar/inline/drag.rs @@ -12,20 +12,8 @@ impl WaylandState { use crate::backend::wayland::toolbar_intent::ToolbarIntent; use crate::ui::toolbar::ToolbarEvent; - self.data.toolbar_move_drag.as_ref().map( - |MoveDrag { - kind: MoveDragKind::Top, - .. - }| ToolbarIntent(ToolbarEvent::MoveTopToolbar { x, y }), - ) - } - - /// Returns true if we're currently in a toolbar move drag operation. - pub(in crate::backend::wayland) fn is_move_dragging(&self) -> bool { - self.data.toolbar_move_drag.is_some() - } - - pub(in crate::backend::wayland) fn active_move_drag_kind(&self) -> Option { - self.data.active_drag_kind + self.toolbar_drag + .kind() + .map(|MoveDragKind::Top| ToolbarIntent(ToolbarEvent::MoveTopToolbar { x, y })) } } diff --git a/src/backend/wayland/state/toolbar/inline/focus.rs b/src/backend/wayland/state/toolbar/inline/focus.rs index d8202df2e..4e3bc1e42 100644 --- a/src/backend/wayland/state/toolbar/inline/focus.rs +++ b/src/backend/wayland/state/toolbar/inline/focus.rs @@ -1,63 +1,28 @@ use super::*; -use crate::backend::wayland::toolbar::hit::{ - focus_hover_point, focused_event, next_focus_index, resolve_focus_index, -}; use crate::input::Key; impl WaylandState { - fn inline_focus_index(&self) -> Option { - self.data.inline_top_focus_index - } - - fn inline_focus_id(&self) -> Option<&str> { - self.data.inline_top_focus_id.as_deref() - } - pub(in crate::backend::wayland) fn inline_toolbar_focus_hover(&self) -> Option<(f64, f64)> { - let hits = &self.data.inline_top_hits; - focus_hover_point( - hits, - resolve_focus_index(hits, self.inline_focus_index(), self.inline_focus_id()), - ) + self.toolbar_chrome.inline_focus_hover() } pub(in crate::backend::wayland) fn inline_toolbar_focus_next(&mut self, reverse: bool) -> bool { - let hits = &self.data.inline_top_hits; - let current = resolve_focus_index(hits, self.inline_focus_index(), self.inline_focus_id()); - let next = next_focus_index(hits, current, reverse); - if next != current { - let id = next.and_then(|index| hits[index].focus_id.clone()); - self.data.inline_top_focus_index = next; - self.data.inline_top_focus_id = id; - self.mark_inline_toolbar_full_damage(); - return true; + if !self.toolbar_chrome.inline_focus_next(reverse) { + return false; } - false + self.mark_inline_toolbar_full_damage(); + true } pub(in crate::backend::wayland) fn inline_toolbar_focused_event(&self) -> Option { - let hits = &self.data.inline_top_hits; - focused_event( - hits, - resolve_focus_index(hits, self.inline_focus_index(), self.inline_focus_id()), - ) - } - - pub(in crate::backend::wayland) fn toolbar_focus_active(&self) -> bool { - self.data.toolbar_focus_active - } - - pub(in crate::backend::wayland) fn set_toolbar_focus_active(&mut self, active: bool) { - self.data.toolbar_focus_active = active; + self.toolbar_chrome.inline_focused_event() } pub(in crate::backend::wayland) fn clear_toolbar_focus(&mut self) { - self.data.toolbar_focus_active = false; + self.toolbar_chrome.set_focus_active(false); self.toolbar.clear_focus(); - let had_inline_focus = - self.data.inline_top_focus_index.is_some() || self.data.inline_top_focus_id.is_some(); - self.clear_inline_toolbar_focus(); - if self.inline_toolbars_active() && had_inline_focus { + let had_inline_focus = self.toolbar_chrome.clear_inline_focus(); + if self.toolbar_chrome.inline_toolbars() && had_inline_focus { self.mark_inline_toolbar_full_damage(); } } @@ -65,8 +30,8 @@ impl WaylandState { /// Whether the pointer currently hovers the toolbar in the active /// placement, which is what seeds keyboard focus on the first Tab. pub(in crate::backend::wayland) fn toolbar_hovered(&self) -> bool { - if self.inline_toolbars_active() { - self.data.inline_top_hover.is_some() + if self.toolbar_chrome.inline_toolbars() { + self.toolbar_chrome.inline_hover().is_some() } else { self.toolbar.is_hovered() } @@ -80,7 +45,7 @@ impl WaylandState { ) -> bool { if matches!(key, Key::Escape) && self.input_state.toolbar_top_menu().is_flyout() { self.input_state.close_top_toolbar_menus(); - if self.inline_toolbars_active() { + if self.toolbar_chrome.inline_toolbars() { self.mark_inline_toolbar_full_damage(); } else { self.toolbar.mark_dirty(); @@ -98,11 +63,11 @@ impl WaylandState { let is_tab = matches!(key, Key::Tab); let is_activate = matches!(key, Key::Return | Key::Space); - if !self.toolbar_focus_active() { + if !self.toolbar_chrome.focus_active() { if !self.toolbar_hovered() { return false; } - self.data.toolbar_focus_active = true; + self.toolbar_chrome.set_focus_active(true); } if !self.toolbar.is_top_visible() { @@ -112,7 +77,7 @@ impl WaylandState { if is_tab { let reverse = self.input_state.modifiers.shift; - if self.inline_toolbars_active() { + if self.toolbar_chrome.inline_toolbars() { self.inline_toolbar_focus_next(reverse); } else { self.toolbar.focus_next(reverse); @@ -121,7 +86,7 @@ impl WaylandState { } if is_activate { - let event = if self.inline_toolbars_active() { + let event = if self.toolbar_chrome.inline_toolbars() { self.inline_toolbar_focused_event() } else { self.toolbar.focused_event() diff --git a/src/backend/wayland/state/toolbar/inline/input.rs b/src/backend/wayland/state/toolbar/inline/input.rs index 41021e5a6..28277027d 100644 --- a/src/backend/wayland/state/toolbar/inline/input.rs +++ b/src/backend/wayland/state/toolbar/inline/input.rs @@ -7,33 +7,25 @@ impl WaylandState { &self, position: (f64, f64), ) -> Option<(crate::backend::wayland::toolbar_intent::ToolbarIntent, bool)> { - if !self.inline_toolbars_active() || !self.toolbar.is_visible() { + if !self.toolbar_chrome.inline_toolbars() || !self.toolbar.is_visible() { return None; } - if !self.toolbar.is_top_visible() || !point_in_surface(self.data.inline_top_rect, position) - { + if !self.toolbar.is_top_visible() || !self.toolbar_chrome.inline_contains(position) { return None; } - self.data - .inline_top_hits - .iter() - .find_map(|hit| intent_for_hit(hit, position.0, position.1)) + self.toolbar_chrome.inline_primary_hit_at(position) } /// The quick-color slot an inline-toolbar secondary press targets, read /// from the same hit regions as the primary path. fn inline_quick_color_slot_at(&self, position: (f64, f64)) -> Option { - if !self.inline_toolbars_active() || !self.toolbar.is_visible() { + if !self.toolbar_chrome.inline_toolbars() || !self.toolbar.is_visible() { return None; } - if !self.toolbar.is_top_visible() || !point_in_surface(self.data.inline_top_rect, position) - { + if !self.toolbar.is_top_visible() || !self.toolbar_chrome.inline_contains(position) { return None; } - self.data - .inline_top_hits - .iter() - .find_map(|hit| quick_color_slot_for_hit(hit, position.0, position.1)) + self.toolbar_chrome.inline_quick_color_slot_at(position) } /// Secondary press on an inline-toolbar swatch: opens the picker bound to @@ -48,7 +40,7 @@ impl WaylandState { return false; }; self.handle_toolbar_event(ToolbarEvent::EditQuickColor { index }, conn, qh); - self.set_pointer_over_toolbar(true); + self.toolbar_chrome.set_pointer_over_toolbar(true); true } @@ -56,7 +48,7 @@ impl WaylandState { &self, position: (f64, f64), ) -> Option { - if !self.inline_toolbars_active() || !self.toolbar.is_visible() { + if !self.toolbar_chrome.inline_toolbars() || !self.toolbar.is_visible() { return None; } // If we have an active move drag, generate intent directly from it @@ -64,92 +56,53 @@ impl WaylandState { if let Some(intent) = self.move_drag_intent(position.0, position.1) { return Some(intent); } - if !self.toolbar.is_top_visible() || !point_in_surface(self.data.inline_top_rect, position) - { + if !self.toolbar.is_top_visible() || !self.toolbar_chrome.inline_contains(position) { return None; } - self.data - .inline_top_hits - .iter() - .find_map(|hit| drag_intent_for_hit(hit, position.0, position.1)) + self.toolbar_chrome.inline_drag_hit_at(position) } pub(in crate::backend::wayland) fn inline_toolbar_motion( &mut self, position: (f64, f64), ) -> bool { - if !self.inline_toolbars_active() || !self.toolbar.is_visible() { + if !self.toolbar_chrome.inline_toolbars() || !self.toolbar.is_visible() { return false; } - self.set_current_mouse(position.0 as i32, position.1 as i32); - let (mx, my) = self.current_mouse(); + self.pointer + .set_position((position.0 as i32, position.1 as i32)); + let (mx, my) = self.pointer.position(); self.input_state.update_pointer_position(mx, my); - let was_top_hover = self.data.inline_top_hover; - let was_top_hit = was_top_hover.and_then(|(x, y)| { - self.data - .inline_top_hits - .iter() - .position(|hit| hit.contains(x, y)) - }); - - self.data.inline_top_hover = None; - - let top_visible = self.toolbar.is_top_visible(); - let mut over_toolbar = false; - - if top_visible - && let Some((x, y, w, h)) = self.data.inline_top_rect - && geometry::point_in_rect(position.0, position.1, x, y, w, h) - { - over_toolbar = true; - if was_top_hover.is_none() { - self.data.inline_top_hover_start = Some(Instant::now()); - } - self.data.inline_top_hover = Some(position); - } else { - self.data.inline_top_hover_start = None; - } + let top_hover = (self.toolbar.is_top_visible() + && self.toolbar_chrome.inline_contains(position)) + .then_some(position); + let hover_change = self + .toolbar_chrome + .set_inline_hover(top_hover, Instant::now()); + let mut over_toolbar = top_hover.is_some(); - if self.toolbar_dragging() + if self.toolbar_drag.item_dragging() && let Some(intent) = self.inline_toolbar_drag_at(position) { let evt = intent_to_event(intent, self.toolbar.last_snapshot()); self.handle_toolbar_event(evt, None, None); over_toolbar = true; - } else if self.toolbar_dragging() { - if let Some(kind) = self.active_move_drag_kind() { + } else if self.toolbar_drag.item_dragging() { + if let Some(kind) = self.toolbar_drag.kind() { self.handle_toolbar_move(kind, position); } over_toolbar = true; } - let top_hit = self.data.inline_top_hover.and_then(|(x, y)| { - self.data - .inline_top_hits - .iter() - .position(|hit| hit.contains(x, y)) - }); - let top_target_changed = inline_hover_target_changed( - was_top_hover, - was_top_hit, - self.data.inline_top_hover, - top_hit, - ); - if top_target_changed { - self.data.inline_top_tooltip_pending = inline_tooltip_pending( - self.data.inline_top_hover_start, - hit_has_tooltip(&self.data.inline_top_hits, top_hit), - ); - } - if top_target_changed { + if hover_change.target_changed { // The inline toolbar and annotations share the main surface's SHM // swapchain. Refresh every slot when hover visuals change so a // compositor cannot resurface a buffer containing older toolbar or // annotation pixels during rapid pointer motion. self.mark_inline_toolbar_full_damage(); - } else if was_top_hover != self.data.inline_top_hover { + } else if hover_change.position_changed { // Same hit region, new pointer position - which still changes what // is painted: hit regions are inflated to MIN_HIT_TARGET, so // moving from that inflated margin onto the control itself keeps @@ -161,12 +114,12 @@ impl WaylandState { } if over_toolbar { - self.set_pointer_over_toolbar(true); - } else if !self.toolbar_dragging() { - self.set_pointer_over_toolbar(false); - if self.data.toolbar_focus_active { - self.data.toolbar_focus_active = false; - self.clear_inline_toolbar_focus(); + self.toolbar_chrome.set_pointer_over_toolbar(true); + } else if !self.toolbar_drag.item_dragging() { + self.toolbar_chrome.set_pointer_over_toolbar(false); + if self.toolbar_chrome.focus_active() { + self.toolbar_chrome.set_focus_active(false); + self.toolbar_chrome.clear_inline_focus(); self.mark_inline_toolbar_full_damage(); } } @@ -180,7 +133,7 @@ impl WaylandState { conn: Option<&wayland_client::Connection>, qh: Option<&wayland_client::QueueHandle>, ) -> bool { - if !self.inline_toolbars_active() || !self.toolbar.is_visible() { + if !self.toolbar_chrome.inline_toolbars() || !self.toolbar.is_visible() { return false; } if let Some((intent, drag)) = self.inline_toolbar_hit_at(position) { @@ -192,32 +145,27 @@ impl WaylandState { ) }); } - self.set_toolbar_dragging(drag); + self.toolbar_drag.set_item_dragging(drag); let evt = intent_to_event(intent, self.toolbar.last_snapshot()); self.handle_toolbar_event(evt, conn, qh); - self.set_pointer_over_toolbar(true); + self.toolbar_chrome.set_pointer_over_toolbar(true); return true; } false } pub(in crate::backend::wayland) fn inline_toolbar_leave(&mut self) { - if !self.inline_toolbars_active() { + if !self.toolbar_chrome.inline_toolbars() { return; } - let had_hover = self.data.inline_top_hover.is_some(); - let had_focus = - self.data.inline_top_focus_index.is_some() || self.data.inline_top_focus_id.is_some(); - self.data.inline_top_hover = None; - self.data.inline_top_hover_start = None; - self.data.inline_top_tooltip_pending = false; - self.data.toolbar_focus_active = false; - self.clear_inline_toolbar_focus(); - self.set_pointer_over_toolbar(false); + let had_hover = self.toolbar_chrome.clear_inline_hover(); + let had_focus = self.toolbar_chrome.clear_inline_focus(); + self.toolbar_chrome.set_focus_active(false); + self.toolbar_chrome.set_pointer_over_toolbar(false); // Don't clear drag state if we're in a move drag - the drag continues outside - if !self.is_move_dragging() { + if !self.toolbar_drag.is_moving() { self.finish_toolbar_item_drag(false); - self.set_toolbar_dragging(false); + self.toolbar_drag.set_item_dragging(false); self.cancel_toolbar_move_drag(); } if had_hover || had_focus { @@ -229,11 +177,11 @@ impl WaylandState { &mut self, position: (f64, f64), ) -> bool { - if !self.inline_toolbars_active() || !self.toolbar.is_visible() { + if !self.toolbar_chrome.inline_toolbars() || !self.toolbar.is_visible() { return false; } - if self.pointer_over_toolbar() || self.toolbar_dragging() { - if self.toolbar_dragging() + if self.toolbar_chrome.pointer_over_toolbar() || self.toolbar_drag.item_dragging() { + if self.toolbar_drag.item_dragging() && !self.pointer_lock_active() && let Some(intent) = self.inline_toolbar_drag_at(position) { @@ -245,97 +193,16 @@ impl WaylandState { "inline release: pos=({:.3}, {:.3}), drag_active={}, pointer_over_toolbar={}", position.0, position.1, - self.toolbar_dragging(), - self.pointer_over_toolbar() + self.toolbar_drag.item_dragging(), + self.toolbar_chrome.pointer_over_toolbar() ) }); self.finish_toolbar_item_drag(true); - self.set_toolbar_dragging(false); - self.set_pointer_over_toolbar(false); + self.toolbar_drag.set_item_dragging(false); + self.toolbar_chrome.set_pointer_over_toolbar(false); self.end_toolbar_move_drag(); return true; } false } } - -fn point_in_surface(rect: Option<(f64, f64, f64, f64)>, position: (f64, f64)) -> bool { - rect.is_some_and(|(x, y, w, h)| geometry::point_in_rect(position.0, position.1, x, y, w, h)) -} - -fn inline_hover_target_changed( - previous_hover: Option<(f64, f64)>, - previous_hit: Option, - hover: Option<(f64, f64)>, - hit: Option, -) -> bool { - previous_hover.is_some() != hover.is_some() || previous_hit != hit -} - -fn hit_has_tooltip( - hits: &[crate::backend::wayland::toolbar::hit::HitRegion], - hit: Option, -) -> bool { - hit.and_then(|index| hits.get(index)) - .is_some_and(|hit| hit.tooltip.is_some()) -} - -fn inline_tooltip_pending(hover_start: Option, hit_has_tooltip: bool) -> bool { - hit_has_tooltip - && hover_start.is_some_and(|start| { - start.elapsed() < crate::backend::wayland::toolbar::render::TOOLTIP_DELAY - }) -} - -#[cfg(test)] -mod tests { - use super::{inline_hover_target_changed, inline_tooltip_pending, point_in_surface}; - use std::time::Instant; - - #[test] - fn inline_hover_damage_tracks_control_transitions_not_pointer_pixels() { - assert!(!inline_hover_target_changed( - Some((10.0, 10.0)), - Some(2), - Some((11.0, 10.0)), - Some(2), - )); - assert!(inline_hover_target_changed( - Some((10.0, 10.0)), - Some(2), - Some((20.0, 10.0)), - Some(3), - )); - assert!(inline_hover_target_changed( - None, - None, - Some((10.0, 10.0)), - None, - )); - assert!(inline_hover_target_changed( - Some((10.0, 10.0)), - None, - None, - None, - )); - } - - #[test] - fn inline_tooltip_is_pending_only_during_the_delay() { - let recent = Instant::now(); - assert!(inline_tooltip_pending(Some(recent), true)); - assert!(!inline_tooltip_pending(Some(recent), false)); - assert!(!inline_tooltip_pending(None, true)); - } - - #[test] - fn inline_surface_gate_rejects_clicks_beyond_all_edges() { - let rect = Some((10.0, 20.0, 100.0, 50.0)); - assert!(!point_in_surface(rect, (9.9, 45.0))); - assert!(!point_in_surface(rect, (110.1, 45.0))); - assert!(!point_in_surface(rect, (60.0, 19.9))); - assert!(!point_in_surface(rect, (60.0, 70.1))); - assert!(point_in_surface(rect, (10.0, 20.0))); - assert!(point_in_surface(rect, (110.0, 70.0))); - } -} diff --git a/src/backend/wayland/state/toolbar/inline/mod.rs b/src/backend/wayland/state/toolbar/inline/mod.rs index 94a5850a7..1f6abc7d3 100644 --- a/src/backend/wayland/state/toolbar/inline/mod.rs +++ b/src/backend/wayland/state/toolbar/inline/mod.rs @@ -14,7 +14,7 @@ impl WaylandState { /// repaints the whole surface. Damage the strip's own rect instead and /// leave the canvas alone. pub(in crate::backend::wayland) fn mark_inline_toolbar_rect_damage(&mut self) { - if let Some((x, y, w, h)) = self.data.inline_top_rect + if let Some((x, y, w, h)) = self.toolbar_chrome.inline_rect() && let Some(rect) = crate::util::Rect::new( x.floor() as i32 - 1, y.floor() as i32 - 1, @@ -40,89 +40,19 @@ impl WaylandState { &self, now: Instant, ) -> Option { - inline_tooltip_timeout( - self.data.inline_top_tooltip_pending, - self.data.inline_top_hover_start, - now, - ) + self.toolbar_chrome.inline_tooltip_timeout(now) } pub(in crate::backend::wayland) fn update_inline_toolbar_tooltip(&mut self, now: Instant) { - let due = inline_tooltip_due( - self.data.inline_top_tooltip_pending, - self.data.inline_top_hover_start, - now, - ); - if due { - self.data.inline_top_tooltip_pending = false; + if self.toolbar_chrome.take_inline_tooltip_due(now) { self.mark_inline_toolbar_full_damage(); } } - pub(super) fn clear_inline_toolbar_hits(&mut self) { - self.data.inline_top_hits.clear(); - self.data.inline_top_rect = None; - } - - pub(super) fn clear_inline_toolbar_hover(&mut self) { - self.data.inline_top_hover = None; - self.data.inline_top_tooltip_pending = false; - } - - pub(super) fn clear_inline_toolbar_focus(&mut self) { - self.data.inline_top_focus_index = None; - self.data.inline_top_focus_id = None; - } - /// Get cursor hint for inline toolbar hover position. pub(in crate::backend::wayland) fn inline_toolbar_cursor_hint( &self, ) -> Option { - let (hx, hy) = self.data.inline_top_hover?; - for hit in &self.data.inline_top_hits { - if hit.contains(hx, hy) { - return Some(hit.kind.cursor_hint()); - } - } - Some(ToolbarCursorHint::Default) - } -} - -fn inline_tooltip_timeout( - pending: bool, - hover_start: Option, - now: Instant, -) -> Option { - if !pending { - return None; - } - hover_start.map(|start| { - start - .checked_add(crate::backend::wayland::toolbar::render::TOOLTIP_DELAY) - .unwrap_or(start) - .saturating_duration_since(now) - }) -} - -fn inline_tooltip_due(pending: bool, hover_start: Option, now: Instant) -> bool { - inline_tooltip_timeout(pending, hover_start, now) == Some(Duration::ZERO) -} - -#[cfg(test)] -mod tests { - use super::{inline_tooltip_due, inline_tooltip_timeout}; - use std::time::Instant; - - #[test] - fn inline_tooltip_timeout_reaches_zero_at_the_deadline() { - let start = Instant::now(); - assert_eq!( - inline_tooltip_timeout(true, Some(start), start), - Some(crate::backend::wayland::toolbar::render::TOOLTIP_DELAY) - ); - let due = start + crate::backend::wayland::toolbar::render::TOOLTIP_DELAY; - assert!(inline_tooltip_due(true, Some(start), due)); - assert_eq!(inline_tooltip_timeout(false, Some(start), due), None); - assert_eq!(inline_tooltip_timeout(true, None, due), None); + self.toolbar_chrome.inline_cursor_hint() } } diff --git a/src/backend/wayland/state/toolbar/inline/render.rs b/src/backend/wayland/state/toolbar/inline/render.rs index 8c3d31a7d..fc8668302 100644 --- a/src/backend/wayland/state/toolbar/inline/render.rs +++ b/src/backend/wayland/state/toolbar/inline/render.rs @@ -7,13 +7,13 @@ impl WaylandState { snapshot: &ToolbarSnapshot, ) { if !self.inline_toolbars_render_active() || !self.toolbar.is_top_visible() { - self.clear_inline_toolbar_hits(); - self.clear_inline_toolbar_hover(); + self.toolbar_chrome.clear_inline_hits(); + self.toolbar_chrome.clear_inline_hover(); return; } let focus_hover = self.inline_toolbar_focus_hover(); - self.clear_inline_toolbar_hits(); + self.toolbar_chrome.clear_inline_hits(); self.clamp_toolbar_offsets(snapshot); let ui_scale = if snapshot.toolbar_scale.is_finite() { snapshot.toolbar_scale.clamp(0.5, 3.0) @@ -21,17 +21,18 @@ impl WaylandState { 1.0 }; + let authored_offset = self.toolbar_chrome.top_offset(); let top_offset = ( - self.inline_top_base_x() + self.data.toolbar_top_offset, - self.inline_top_base_y() + self.data.toolbar_top_offset_y, + self.inline_top_base_x() + authored_offset.0, + self.inline_top_base_y() + authored_offset.1, ); let top_size = top_size(snapshot); let top_base_w = top_size.0 as f64 / ui_scale; let top_base_h = top_size.1 as f64 / ui_scale; let top_hover_local = self - .data - .inline_top_hover + .toolbar_chrome + .inline_hover() .or(focus_hover) .map(|(x, y)| (x - top_offset.0, y - top_offset.1)) .map(|(x, y)| (x / ui_scale, y / ui_scale)); @@ -40,19 +41,20 @@ impl WaylandState { if (ui_scale - 1.0).abs() > f64::EPSILON { ctx.scale(ui_scale, ui_scale); } + let mut hits = Vec::new(); if let Err(err) = render_top_strip( ctx, top_base_w, top_base_h, snapshot, - &mut self.data.inline_top_hits, + &mut hits, top_hover_local, - self.data.inline_top_hover_start, + self.toolbar_chrome.inline_hover_start(), ) { log::warn!("Failed to render inline top toolbar: {}", err); } let _ = ctx.restore(); - for hit in &mut self.data.inline_top_hits { + for hit in &mut hits { hit.rect.0 = hit.rect.0 * ui_scale + top_offset.0; hit.rect.1 = hit.rect.1 * ui_scale + top_offset.1; hit.rect.2 *= ui_scale; @@ -64,11 +66,7 @@ impl WaylandState { top_size.0 as f64, top_size.1 as f64, ); - self.data.inline_top_rect = Some(top_rect); - crate::backend::wayland::toolbar::hit::clip_hit_regions_to_bounds( - &mut self.data.inline_top_hits, - 0, - top_rect, - ); + crate::backend::wayland::toolbar::hit::clip_hit_regions_to_bounds(&mut hits, 0, top_rect); + self.toolbar_chrome.set_inline_rendered(hits, top_rect); } } diff --git a/src/backend/wayland/state/toolbar/scroll.rs b/src/backend/wayland/state/toolbar/scroll.rs index 8a154afca..2e168ca1c 100644 --- a/src/backend/wayland/state/toolbar/scroll.rs +++ b/src/backend/wayland/state/toolbar/scroll.rs @@ -20,10 +20,13 @@ impl WaylandState { if self.toolbar.is_focusable_surface(surface) { return true; } - self.inline_toolbars_active() - && self.data.inline_top_rect.is_some_and(|(x, y, w, h)| { - geometry::point_in_rect(position.0, position.1, x, y, w, h) - }) + self.toolbar_chrome.inline_toolbars() + && self + .toolbar_chrome + .inline_rect() + .is_some_and(|(x, y, w, h)| { + geometry::point_in_rect(position.0, position.1, x, y, w, h) + }) } /// Scrolls the open Canvas/Session/Settings popover by wheel notches when its diff --git a/src/backend/wayland/state/toolbar/visibility/access.rs b/src/backend/wayland/state/toolbar/visibility/access.rs index 13ee34c63..f9f90c38d 100644 --- a/src/backend/wayland/state/toolbar/visibility/access.rs +++ b/src/backend/wayland/state/toolbar/visibility/access.rs @@ -1,140 +1,9 @@ use super::*; -use wayland_client::protocol::wl_surface; impl WaylandState { - pub(in crate::backend::wayland) fn pointer_over_toolbar(&self) -> bool { - self.data.pointer_over_toolbar - } - - pub(in crate::backend::wayland) fn set_pointer_over_toolbar(&mut self, value: bool) { - self.data.pointer_over_toolbar = value; - } - - pub(in crate::backend::wayland) fn toolbar_dragging(&self) -> bool { - self.data.toolbar_dragging - } - - pub(in crate::backend::wayland) fn set_toolbar_dragging(&mut self, value: bool) { - self.data.toolbar_dragging = value; - } - - pub(in crate::backend::wayland) fn toolbar_drag_preview_active(&self) -> bool { - self.data.toolbar_drag_preview - } - - pub(in crate::backend::wayland) fn set_toolbar_drag_preview_active(&mut self, value: bool) { - self.data.toolbar_drag_preview = value; - } - - pub(in crate::backend::wayland) fn request_toolbar_drag_flush(&mut self) { - self.data.toolbar_drag_flush_requested = true; - } - - pub(in crate::backend::wayland) fn take_toolbar_drag_flush_requested(&mut self) -> bool { - let requested = self.data.toolbar_drag_flush_requested; - self.data.toolbar_drag_flush_requested = false; - requested - } - - pub(in crate::backend::wayland) fn toolbar_needs_recreate(&self) -> bool { - self.data.toolbar_needs_recreate - } - - pub(in crate::backend::wayland) fn set_toolbar_needs_recreate(&mut self, value: bool) { - self.data.toolbar_needs_recreate = value; - } - - pub(in crate::backend::wayland) fn toolbar_top_offset(&self) -> f64 { - self.data.toolbar_top_offset - } - - pub(in crate::backend::wayland) fn toolbar_top_offset_y(&self) -> f64 { - self.data.toolbar_top_offset_y - } - - pub(in crate::backend::wayland) fn restore_toolbar_offsets(&mut self, top: (f64, f64)) { - self.data.toolbar_top_offset = top.0; - self.data.toolbar_top_offset_y = top.1; - } - - pub(in crate::backend::wayland) fn inline_toolbars_active(&self) -> bool { - self.data.inline_toolbars - } - pub(in crate::backend::wayland) fn inline_toolbars_render_active(&self) -> bool { - self.inline_toolbars_active() - || self.toolbar_drag_preview_active() - || self.data.gtk_drag_preview.is_some() - } - - pub(in crate::backend::wayland) fn toolbar_surface_screen_coords( - &self, - surface: &wl_surface::WlSurface, - position: (f64, f64), - ) -> Option<(f64, f64)> { - if !self.toolbar.is_focusable_surface(surface) { - return None; - } - Some(self.local_to_screen_coords(MoveDragKind::Top, position)) - } - - pub(in crate::backend::wayland) fn suppress_next_release_from( - &mut self, - source: crate::input::state::RegionInputSource, - ) { - self.data.release_suppression.arm(source); - } - - pub(in crate::backend::wayland) fn clear_suppressed_release_from( - &mut self, - source: crate::input::state::RegionInputSource, - ) { - self.data.release_suppression.clear(source); - } - - pub(in crate::backend::wayland) fn take_suppressed_release_from( - &mut self, - source: crate::input::state::RegionInputSource, - ) -> bool { - self.data.release_suppression.take(source) - } - - pub(in crate::backend::wayland) fn set_pending_toast_press( - &mut self, - value: Option, - ) { - self.data.pending_toast_press = value; - } - - pub(in crate::backend::wayland) fn take_pending_toast_press( - &mut self, - ) -> Option { - self.data.pending_toast_press.take() - } - - pub(in crate::backend::wayland) fn set_pending_status_hud_press(&mut self, value: bool) { - self.data.pending_status_hud_press = value; - } - - pub(in crate::backend::wayland) fn take_pending_status_hud_press(&mut self) -> bool { - let value = self.data.pending_status_hud_press; - self.data.pending_status_hud_press = false; - value - } - - pub(in crate::backend::wayland) fn set_pending_zoom_chip_press( - &mut self, - value: crate::ui::ZoomChipPress, - ) { - self.data.pending_zoom_chip_press = value; - } - - pub(in crate::backend::wayland) fn take_pending_zoom_chip_press( - &mut self, - ) -> crate::ui::ZoomChipPress { - std::mem::replace( - &mut self.data.pending_zoom_chip_press, - crate::ui::ZoomChipPress::None, - ) + self.toolbar_chrome.inline_toolbars() + || self.toolbar_drag.preview_active() + || self.toolbar_drag.gtk_preview_kind().is_some() } } diff --git a/src/backend/wayland/state/toolbar/visibility/pointer.rs b/src/backend/wayland/state/toolbar/visibility/pointer.rs index a30d11da9..df2d1be60 100644 --- a/src/backend/wayland/state/toolbar/visibility/pointer.rs +++ b/src/backend/wayland/state/toolbar/visibility/pointer.rs @@ -17,7 +17,7 @@ impl WaylandState { drag_log(|| { format!( "lock_pointer_for_drag: inline_active={}, locked={}, surface={}", - self.inline_toolbars_active(), + self.toolbar_chrome.inline_toolbars(), self.pointer_lock_active(), surface_id(surface) ) @@ -34,7 +34,7 @@ impl WaylandState { log::info!("pointer lock unavailable: constraints global missing"); return; } - let Some(pointer) = self.current_pointer() else { + let Some(pointer) = self.pointer.current_pointer() else { log::info!("pointer lock unavailable: no current pointer"); return; }; @@ -51,7 +51,7 @@ impl WaylandState { drag_log(|| { format!( "pointer lock requested: seat={:?}, surface={}, pointer_id={}", - self.current_seat_id(), + self.focus.current_seat_id(), surface_id(surface), pointer.id().protocol_id() ) diff --git a/src/backend/wayland/state/toolbar/visibility/sync.rs b/src/backend/wayland/state/toolbar/visibility/sync.rs index fab45ed29..68b814eea 100644 --- a/src/backend/wayland/state/toolbar/visibility/sync.rs +++ b/src/backend/wayland/state/toolbar/visibility/sync.rs @@ -30,16 +30,16 @@ impl WaylandState { || (self.gtk_toolbars_active() && self.input_state.toolbar_top_visible())); keyboard_interactivity_for(KeyboardInteractivityPolicyInput { keyboard_release_requested: self.overlay_keyboard_passthrough_requested(), - main_layer_focus_acquiring: self.main_layer_focus_acquiring(), + main_layer_focus_acquiring: self.focus.main_layer_acquiring(), layer_shell_available: self.protocol.layer_shell().is_some(), separate_toolbar_visible: toolbar_visible, - inline_toolbars_active: self.inline_toolbars_active(), + inline_toolbars_active: self.toolbar_chrome.inline_toolbars(), canvas_modal_active: self.input_state.is_color_picker_popup_open(), }) } fn log_toolbar_layer_shell_missing_once(&mut self) { - if self.data.toolbar_layer_shell_missing_logged { + if !self.toolbar_chrome.note_layer_shell_missing() { return; } @@ -52,13 +52,12 @@ impl WaylandState { desktop_env, session_env ); - self.data.toolbar_layer_shell_missing_logged = true; } /// Applies and commits keyboard interactivity when the desired mode changes. pub(in crate::backend::wayland) fn refresh_keyboard_interactivity(&mut self) { let desired = self.desired_keyboard_interactivity(); - let current = self.current_keyboard_interactivity(); + let current = self.focus.current_keyboard_interactivity(); let updated = if let Some(layer) = self.surface.layer_surface_mut() { if current != Some(desired) { @@ -69,12 +68,12 @@ impl WaylandState { false } } else { - self.set_current_keyboard_interactivity(None); + self.focus.set_keyboard_interactivity(None); return; }; if updated { - self.set_current_keyboard_interactivity(Some(desired)); + self.focus.set_keyboard_interactivity(Some(desired)); } } @@ -86,12 +85,12 @@ impl WaylandState { let top_visible = toolbar_visibility_for_frontend( self.input_state.toolbar_top_visible(), gtk_active, - self.data.gtk_drag_preview, + self.toolbar_drag.gtk_preview_kind(), self.capture_picker_chrome_suppressed(), ); - let inline_active = self.inline_toolbars_active(); + let inline_active = self.toolbar_chrome.inline_toolbars(); let drag_preview = - self.toolbar_drag_preview_active() || self.data.gtk_drag_preview.is_some(); + self.toolbar_drag.preview_active() || self.toolbar_drag.gtk_preview_kind().is_some(); if top_visible != self.toolbar.is_top_visible() { self.toolbar.set_top_visible(top_visible); @@ -100,9 +99,9 @@ impl WaylandState { let any_visible = self.toolbar.is_visible(); if !any_visible { - self.set_pointer_over_toolbar(false); - self.data.toolbar_configure_miss_count = 0; - self.reset_toolbar_margin_cache(); + self.toolbar_chrome.set_pointer_over_toolbar(false); + self.toolbar_chrome.reset_configure_misses(); + self.toolbar_chrome.reset_margins(); self.clear_toolbar_focus(); } @@ -113,17 +112,17 @@ impl WaylandState { self.protocol.layer_shell().is_some(), inline_active, self.toolbar.top_created(), - self.toolbar_needs_recreate(), + self.toolbar_chrome.needs_recreate(), self.surface.scale() ); drag_log(|| { format!( "toolbar sync: top_offset=({}, {}), inline_active={}, layer_shell={}, needs_recreate={}", - self.data.toolbar_top_offset, - self.data.toolbar_top_offset_y, + self.toolbar_chrome.top_offset().0, + self.toolbar_chrome.top_offset().1, inline_active, self.protocol.layer_shell().is_some(), - self.toolbar_needs_recreate() + self.toolbar_chrome.needs_recreate() ) }); } @@ -138,10 +137,10 @@ impl WaylandState { // focus/input conflicts on compositors that support layer-shell. if self.toolbar.top_created() { self.toolbar.destroy_all(); - self.set_toolbar_needs_recreate(true); - self.reset_toolbar_margin_cache(); + self.toolbar_chrome.set_needs_recreate(true); + self.toolbar_chrome.reset_margins(); } - self.data.toolbar_configure_miss_count = 0; + self.toolbar_chrome.reset_configure_misses(); } if any_visible && self.protocol.layer_shell().is_some() && !inline_active && !drag_preview { @@ -149,43 +148,38 @@ impl WaylandState { // never configure after repeated attempts, fall back to inline toolbars automatically. let top_configured = self.toolbar.top_configured(); let expected_top = self.toolbar.is_top_visible(); - if expected_top && !top_configured { - self.data.toolbar_configure_miss_count = - self.data.toolbar_configure_miss_count.saturating_add(1); - if debug_toolbar_drag_logging_enabled() - && self.data.toolbar_configure_miss_count.is_multiple_of(60) - { + let verdict = self + .toolbar_chrome + .note_configure_result(!expected_top || top_configured); + if verdict == ConfigureVerdict::StillWaiting { + let misses = self.toolbar_chrome.configure_miss_count(); + if debug_toolbar_drag_logging_enabled() && misses.is_multiple_of(60) { debug!( "Toolbar configure pending: count={}, expected_top={}, configured_top={}", - self.data.toolbar_configure_miss_count, expected_top, top_configured + misses, expected_top, top_configured ); } - } else { - self.data.toolbar_configure_miss_count = 0; } - if self.data.toolbar_configure_miss_count > Self::TOOLBAR_CONFIGURE_FAIL_THRESHOLD { + if verdict == ConfigureVerdict::FallBackToInline { warn!( - "Toolbar layer surface did not configure after {} frames; falling back to the inline toolbar", - self.data.toolbar_configure_miss_count + "Toolbar layer surface did not configure after repeated frames; falling back to the inline toolbar" ); self.toolbar.destroy_all(); - self.reset_toolbar_margin_cache(); - self.data.inline_toolbars = true; - self.set_toolbar_needs_recreate(true); - self.data.toolbar_configure_miss_count = 0; + self.toolbar_chrome.reset_margins(); + self.toolbar_chrome.set_needs_recreate(true); // Re-run visibility sync with inline mode enabled. self.sync_toolbar_visibility(qh); return; } - if self.toolbar_needs_recreate() { + if self.toolbar_chrome.needs_recreate() { self.toolbar.destroy_all(); - self.set_toolbar_needs_recreate(false); - self.reset_toolbar_margin_cache(); + self.toolbar_chrome.set_needs_recreate(false); + self.toolbar_chrome.reset_margins(); } let snapshot = self.toolbar_snapshot(); - if !self.is_move_dragging() { + if !self.toolbar_drag.is_moving() { let _ = self.apply_toolbar_offsets(&snapshot); } if let Some(layer_shell) = self.protocol.layer_shell() { @@ -203,8 +197,8 @@ impl WaylandState { } if !any_visible { - self.clear_inline_toolbar_hits(); - self.clear_inline_toolbar_hover(); + self.toolbar_chrome.clear_inline_hits(); + self.toolbar_chrome.clear_inline_hover(); } self.refresh_keyboard_interactivity(); @@ -239,12 +233,6 @@ impl WaylandState { self.render_toolbars(&snapshot); } } - - /// Clear cached margins so recreated/hidden toolbars reapply offsets once. - fn reset_toolbar_margin_cache(&mut self) { - self.data.last_applied_top_margin = None; - self.data.last_applied_top_margin_top = None; - } } #[cfg(test)] diff --git a/src/backend/wayland/state/toolbar/visibility/tests.rs b/src/backend/wayland/state/toolbar/visibility/tests.rs index 6dbedf69c..63b7ba1f2 100644 --- a/src/backend/wayland/state/toolbar/visibility/tests.rs +++ b/src/backend/wayland/state/toolbar/visibility/tests.rs @@ -1,8 +1,5 @@ use super::*; -use crate::backend::wayland::state::{ - core::focus::{MainLayerEnterFacts, can_complete_main_layer_focus_acquisition}, - data::MainLayerFocusPhase, -}; +use crate::backend::wayland::state::focus::FocusState; fn separate_toolbar_policy_input() -> KeyboardInteractivityPolicyInput { KeyboardInteractivityPolicyInput { @@ -202,52 +199,34 @@ fn retained_suppression_follows_acquisition_and_steady_state_policy() { #[test] fn stale_enter_sequence_preserves_acquisition_until_a_valid_exclusive_enter() { - let mut phase = MainLayerFocusPhase::default(); + let mut focus = FocusState::new(None); let mut input = KeyboardInteractivityPolicyInput { - main_layer_focus_acquiring: phase.is_acquiring(), + main_layer_focus_acquiring: focus.main_layer_acquiring(), ..separate_toolbar_policy_input() }; let mut committed = keyboard_interactivity_for(input); assert_eq!(committed, KeyboardInteractivity::Exclusive); + focus.set_keyboard_interactivity(Some(committed)); input.keyboard_release_requested = true; committed = keyboard_interactivity_for(input); assert_eq!(committed, KeyboardInteractivity::None); - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase, - committed_keyboard_interactivity: Some(committed), - keyboard_release_requested: input.keyboard_release_requested, - } - )); - assert!(phase.is_acquiring()); + focus.set_keyboard_interactivity(Some(committed)); + assert!(!focus.can_complete_main_layer_acquisition(true, input.keyboard_release_requested,)); + assert!(focus.main_layer_acquiring()); input.keyboard_release_requested = false; - assert!(!can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase, - committed_keyboard_interactivity: Some(committed), - keyboard_release_requested: input.keyboard_release_requested, - } - )); - assert!(phase.is_acquiring()); + assert!(!focus.can_complete_main_layer_acquisition(true, input.keyboard_release_requested,)); + assert!(focus.main_layer_acquiring()); committed = keyboard_interactivity_for(input); assert_eq!(committed, KeyboardInteractivity::Exclusive); - assert!(can_complete_main_layer_focus_acquisition( - MainLayerEnterFacts { - is_current_main_layer_surface: true, - phase, - committed_keyboard_interactivity: Some(committed), - keyboard_release_requested: input.keyboard_release_requested, - } - )); - assert!(phase.complete()); - assert!(!phase.is_acquiring()); - - input.main_layer_focus_acquiring = phase.is_acquiring(); + focus.set_keyboard_interactivity(Some(committed)); + assert!(focus.can_complete_main_layer_acquisition(true, input.keyboard_release_requested,)); + assert!(focus.complete_main_layer_acquisition()); + assert!(!focus.main_layer_acquiring()); + + input.main_layer_focus_acquiring = focus.main_layer_acquiring(); committed = keyboard_interactivity_for(input); assert_eq!(committed, KeyboardInteractivity::OnDemand); } diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index b2ba8d7fb..de8046d1f 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -13,7 +13,7 @@ impl WaylandState { pub(in crate::backend::wayland) fn sync_zoom_board_mode(&mut self) { let board_is_transparent = self.input_state.board_is_transparent(); if !board_is_transparent { - if self.data.overlay_suppression == OverlaySuppression::Zoom { + if self.suppression.reason() == OverlaySuppression::Zoom { self.exit_overlay_suppression(OverlaySuppression::Zoom); } if self.zoom.abort_capture() { @@ -88,8 +88,8 @@ impl WaylandState { } fn zoom_keyboard_anchor(&self) -> (f64, f64) { - if self.has_pointer_focus() { - let (sx, sy) = self.current_mouse(); + if self.focus.pointer_focused() { + let (sx, sy) = self.pointer.position(); (sx as f64, sy as f64) } else { let cx = (self.surface.width() as f64) * 0.5; diff --git a/src/backend/wayland/surface.rs b/src/backend/wayland/surface.rs index 84a7095e1..8d9b5f742 100644 --- a/src/backend/wayland/surface.rs +++ b/src/backend/wayland/surface.rs @@ -4,6 +4,8 @@ //! pool. WaylandState asks SurfaceState for buffers and size information //! instead of juggling the raw objects directly. +use std::time::{Duration, Instant}; + use anyhow::{Context, Result}; use log::info; use smithay_client_toolkit::{ @@ -18,6 +20,108 @@ use wayland_client::{ protocol::{wl_output, wl_shm, wl_surface}, }; +const XDG_FROZEN_FULLSCREEN_TIMEOUT: Duration = Duration::from_millis(1500); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum XdgFrozenFullscreenState { + #[default] + Inactive, + PendingConfigure, + Active, +} + +#[derive(Debug, Default)] +pub(in crate::backend::wayland) struct XdgFrozenFullscreen { + state: XdgFrozenFullscreenState, + requested_at: Option, +} + +impl XdgFrozenFullscreen { + pub(in crate::backend::wayland) fn request(&mut self, now: Instant) { + self.state = XdgFrozenFullscreenState::PendingConfigure; + self.requested_at = Some(now); + } + + pub(in crate::backend::wayland) fn activate(&mut self) { + self.state = XdgFrozenFullscreenState::Active; + self.requested_at = None; + } + + pub(in crate::backend::wayland) fn finish(&mut self) { + self.state = XdgFrozenFullscreenState::Inactive; + self.requested_at = None; + } + + pub(in crate::backend::wayland) fn timeout(&self, now: Instant) -> Option { + if !self.pending_configure() { + return None; + } + Some( + self.requested_at + .and_then(|requested_at| requested_at.checked_add(XDG_FROZEN_FULLSCREEN_TIMEOUT)) + .map(|deadline| deadline.saturating_duration_since(now)) + .unwrap_or(Duration::ZERO), + ) + } + + pub(in crate::backend::wayland) fn pending_configure(&self) -> bool { + self.state == XdgFrozenFullscreenState::PendingConfigure + } + + pub(in crate::backend::wayland) fn requested(&self) -> bool { + self.state != XdgFrozenFullscreenState::Inactive + } +} + +#[derive(Debug)] +pub(in crate::backend::wayland) struct SurfacePlacement { + preferred_output_identity: Option, + xdg_fullscreen: bool, + main_surface_uses_overlay_layer: bool, + xdg_frozen: XdgFrozenFullscreen, +} + +impl SurfacePlacement { + pub(in crate::backend::wayland) fn new( + preferred_output_identity: Option, + xdg_fullscreen: bool, + main_surface_uses_overlay_layer: bool, + ) -> Self { + Self { + preferred_output_identity, + xdg_fullscreen, + main_surface_uses_overlay_layer, + xdg_frozen: XdgFrozenFullscreen::default(), + } + } + + pub(in crate::backend::wayland) fn preferred_output_identity(&self) -> Option<&str> { + self.preferred_output_identity.as_deref() + } + + pub(in crate::backend::wayland) fn xdg_fullscreen(&self) -> bool { + self.xdg_fullscreen + } + + pub(in crate::backend::wayland) fn layer( + &self, + ) -> smithay_client_toolkit::shell::wlr_layer::Layer { + if self.main_surface_uses_overlay_layer { + smithay_client_toolkit::shell::wlr_layer::Layer::Overlay + } else { + smithay_client_toolkit::shell::wlr_layer::Layer::Top + } + } + + pub(in crate::backend::wayland) fn xdg_frozen(&self) -> &XdgFrozenFullscreen { + &self.xdg_frozen + } + + pub(in crate::backend::wayland) fn xdg_frozen_mut(&mut self) -> &mut XdgFrozenFullscreen { + &mut self.xdg_frozen + } +} + /// A buffer handed out for one frame, plus the pool identity the damage /// tracker needs to tell slot reuse from pool reallocation. pub struct AcquiredBuffer { @@ -75,6 +179,7 @@ pub(super) struct MainSurfaceFrameCallback { /// Tracks the active layer surface, buffer pool, and associated sizing state. pub struct SurfaceState { + placement: SurfacePlacement, kind: Option, wl_surface: Option, pool: Option, @@ -97,8 +202,9 @@ pub struct SurfaceState { impl SurfaceState { /// Creates a new, unconfigured surface state. - pub fn new() -> Self { + pub(in crate::backend::wayland) fn new(placement: SurfacePlacement) -> Self { Self { + placement, kind: None, wl_surface: None, pool: None, @@ -114,6 +220,14 @@ impl SurfaceState { } } + pub(in crate::backend::wayland) fn placement(&self) -> &SurfacePlacement { + &self.placement + } + + pub(in crate::backend::wayland) fn placement_mut(&mut self) -> &mut SurfacePlacement { + &mut self.placement + } + /// Assigns the layer surface produced during startup. pub fn set_layer_surface(&mut self, surface: LayerSurface) { self.wl_surface = Some(surface.wl_surface().clone()); @@ -390,7 +504,50 @@ impl SurfaceState { #[cfg(test)] mod tests { - use super::FrameCallbackTracker; + use super::*; + + #[test] + fn frozen_fullscreen_deadline_uses_injected_time() { + let start = Instant::now(); + let mut state = XdgFrozenFullscreen::default(); + state.request(start); + + assert_eq!(state.timeout(start), Some(XDG_FROZEN_FULLSCREEN_TIMEOUT)); + assert_eq!( + state.timeout(start + XDG_FROZEN_FULLSCREEN_TIMEOUT), + Some(Duration::ZERO) + ); + assert!(state.pending_configure()); + assert!(state.requested()); + } + + #[test] + fn frozen_fullscreen_activate_and_finish_clear_pending_timeout() { + let start = Instant::now(); + let mut state = XdgFrozenFullscreen::default(); + state.request(start); + state.activate(); + + assert!(state.requested()); + assert!(!state.pending_configure()); + assert_eq!(state.timeout(start + XDG_FROZEN_FULLSCREEN_TIMEOUT), None); + + state.finish(); + assert!(!state.requested()); + assert_eq!(state.timeout(start), None); + } + + #[test] + fn placement_keeps_output_fullscreen_and_layer_policy_together() { + let placement = SurfacePlacement::new(Some("DP-1".to_owned()), true, true); + + assert_eq!(placement.preferred_output_identity(), Some("DP-1")); + assert!(placement.xdg_fullscreen()); + assert_eq!( + placement.layer(), + smithay_client_toolkit::shell::wlr_layer::Layer::Overlay + ); + } #[test] fn retired_callback_cannot_clear_a_newer_render_throttle() { diff --git a/src/backend/wayland/toolbar/main/state.rs b/src/backend/wayland/toolbar/main/state.rs index b709102b0..21b8006e4 100644 --- a/src/backend/wayland/toolbar/main/state.rs +++ b/src/backend/wayland/toolbar/main/state.rs @@ -32,8 +32,8 @@ impl ToolbarSurfaceManager { self.set_visible(visible); } - pub fn is_toolbar_surface(&self, surface: &wl_surface::WlSurface) -> bool { - self.top.is_surface(surface) + pub(in crate::backend::wayland) fn wl_surface(&self) -> Option<&wl_surface::WlSurface> { + self.top.wl_surface() } /// Whether the pointer (or keyboard focus hover) is currently on the diff --git a/src/backend/wayland/toolbar/surfaces/structs.rs b/src/backend/wayland/toolbar/surfaces/structs.rs index 8dc8e025a..9304ebfe3 100644 --- a/src/backend/wayland/toolbar/surfaces/structs.rs +++ b/src/backend/wayland/toolbar/surfaces/structs.rs @@ -74,6 +74,10 @@ impl ToolbarSurface { .unwrap_or(false) } + pub(in crate::backend::wayland) fn wl_surface(&self) -> Option<&wl_surface::WlSurface> { + self.wl_surface.as_ref() + } + pub fn is_surface(&self, surface: &wl_surface::WlSurface) -> bool { self.wl_surface .as_ref()