From 652a10fe69ac8d038ee4ced95f8e448fa8f04879 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 10 Jun 2026 12:52:26 +0200 Subject: [PATCH 1/2] fix: support GNOME freeze via portal fallback --- README.md | 9 +- docs/CONFIG.md | 2 +- docs/SETUP.md | 6 +- .../wayland/backend/event_loop/capture.rs | 49 ++++++- src/backend/wayland/backend/setup.rs | 2 +- src/backend/wayland/backend/state_init/mod.rs | 43 +++++- src/backend/wayland/frozen/capture.rs | 48 +++---- src/backend/wayland/frozen/mod.rs | 9 +- src/backend/wayland/frozen/portal.rs | 63 +++++---- src/backend/wayland/frozen/state.rs | 123 +++++++++++++++++- src/backend/wayland/handlers/compositor.rs | 6 +- src/backend/wayland/handlers/xdg.rs | 20 ++- src/backend/wayland/portal_capture.rs | 33 ++++- src/backend/wayland/state.rs | 2 +- src/backend/wayland/state/core/accessors.rs | 90 ++++++++++++- src/backend/wayland/state/data.rs | 12 ++ src/backend/wayland/zoom/portal.rs | 6 +- src/input/state/core/base/mod.rs | 11 +- src/input/state/core/base/types.rs | 53 +++++++- src/input/state/core/mod.rs | 10 +- src/input/state/core/utility/light_mode.rs | 34 +++-- src/input/state/mod.rs | 15 ++- 22 files changed, 530 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index e1c06b03..22990843 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ For distro-specific package details, see [Installation](#installation). For keyb | Platform | Status | Notes | |----------|--------|-------| | Wayland (layer-shell) | ✅ Supported | Hyprland, Sway, River, Wayfire, Niri/Cosmic, Plasma/KWin | -| GNOME | ⚠️ Partial | Portal fallback; overlay windowed | +| GNOME | ⚠️ Partial | Freeze via portal when available; stock GNOME light passthrough unavailable | | X11 | ❌ | Not supported |
@@ -256,8 +256,8 @@ For distro-specific package details, see [Installation](#installation). For keyb - Click highlights with configurable colors/radius/duration - Persistent ring while click highlight tool is active - Presenter mode (Ctrl+Shift+M): hides UI, forces click highlights -- Light passthrough mode (layer-shell): Ctrl+Shift+L enters from the focused overlay; compositor/global shortcuts such as `wayscriber --light-toggle` keep control reliable while input is passed through -- Screen freeze (Ctrl+Shift+F): pause display while apps run +- Light passthrough mode (layer-shell): Ctrl+Shift+L enters from the focused overlay; compositor/global shortcuts such as `wayscriber --light-toggle` keep control reliable while input is passed through. Stock GNOME Wayland does not expose the overlay behavior this mode needs. +- Screen freeze (Ctrl+Shift+F): pause display while apps run. On GNOME, this uses the screenshot portal when available. ### Callouts & Zoom - **Numbered callouts:** auto-numbered arrow labels, step markers @@ -510,6 +510,7 @@ Light passthrough controls: - Ctrl+Shift+L is a Wayscriber in-overlay shortcut, not an OS/global shortcut. It works while the overlay is focused. - Once light passthrough is active, normal keyboard and pointer input goes to the app underneath. Bind compositor/global shortcuts to `wayscriber --light-toggle` and `wayscriber --light-draw-toggle` for reliable control. - Use `wayscriber --light-draw-on` on press and `wayscriber --light-draw-off` on release for draw-while-held shortcuts. +- Stock GNOME Wayland does not support this regular-app passthrough mode. Use Freeze for still-frame annotation workflows, or a GNOME Shell extension approach for true shell-level passthrough. Use `--no-tray` or `WAYSCRIBER_NO_TRAY=1` if you don't have a system tray; otherwise right-click the tray icon for options: - Toggle overlay visibility @@ -735,7 +736,7 @@ The polygon tools are available from the toolbar picker; their default keybindin
-For light passthrough, Ctrl+Shift+L is the default Wayscriber-level binding only while the overlay has focus. Use compositor/global shortcuts that run `wayscriber --light-toggle` and related light-draw commands once passthrough is active. +For light passthrough, Ctrl+Shift+L is the default Wayscriber-level binding only while the overlay has focus. Use compositor/global shortcuts that run `wayscriber --light-toggle` and related light-draw commands once passthrough is active. On stock GNOME Wayland, regular app windows cannot provide the required click-through shell overlay; Freeze remains the supported GNOME fallback when portal capture is available. Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R. Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`). Preset slots can be saved/cleared from the toolbar; edit names and advanced fields in `config.toml`. Blur has no default keyboard shortcut; bind `select_blur_tool` in `config.toml` if you want direct keyboard access. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index a1ba3dc5..5be0b655 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -431,7 +431,7 @@ show_toast = true Light mode hides UI chrome and sets the overlay to click-through passthrough until drawing is explicitly enabled. `toggle_light_mode` defaults to Ctrl+Shift+L, but that is a Wayscriber in-overlay shortcut: it works while the overlay still has focus. Once passthrough is active, normal keyboard and pointer input goes to the app underneath, so compositor/global shortcuts should call the daemon commands below for reliable control. -This mode requires layer-shell support; it is disabled on the xdg fallback because keyboard input cannot be passed through reliably there. +This mode requires compositor overlay support through layer-shell. It is disabled on the xdg fallback because regular app windows cannot reliably stay visible as click-through shell overlays while keyboard and pointer input go to apps underneath. On stock GNOME Wayland, use Freeze for still-frame annotation workflows when portal capture is available; true passthrough would require a GNOME Shell extension companion. For compositor/global shortcuts while passthrough is active, run: diff --git a/docs/SETUP.md b/docs/SETUP.md index 72ae9c75..9ba6e41f 100644 --- a/docs/SETUP.md +++ b/docs/SETUP.md @@ -137,7 +137,7 @@ wayscriber --light-draw-off Use `--light-toggle` for passthrough on/off and `--light-draw-toggle` for sticky drawing. Draw-while-held needs a shortcut system that can run one command on press and another on release; if your KDE shortcut UI only supports activation commands, use the sticky draw toggle instead. -Light passthrough still requires layer-shell support. If Wayscriber reports that light mode requires layer-shell, your session is using a fallback path where passthrough is not available. +Light passthrough requires compositor overlay support through layer-shell. If Wayscriber reports that passthrough is unavailable, your session is using a fallback path where regular app windows cannot provide a reliable click-through overlay. ### GNOME: Fedora Workstation and Ubuntu GNOME @@ -153,7 +153,9 @@ Then use the configurator's Daemon tab, or create a GNOME custom shortcut that r wayscriber --daemon-toggle ``` -Light passthrough mode is not currently available on GNOME sessions where Wayscriber uses the xdg-shell fallback. In that fallback, keyboard passthrough cannot be made reliable, so `--light-toggle` is intentionally disabled instead of pretending to pass input through. +Freeze works on GNOME when the screenshot portal is available and responsive; the first use may show a desktop permission prompt. Portal capture can be slower than compositor screencopy, and mixed-DPI or multi-monitor setups may depend on client-side crop behavior. + +Light passthrough mode is not available in the regular app on stock GNOME Wayland. GNOME's xdg-shell fallback does not expose the shell-level overlay behavior needed to keep annotations visible while input goes to apps underneath, so `--light-toggle` is intentionally disabled instead of pretending to pass input through. A GNOME Shell extension companion would be the real path for that workflow. ### Method 3: One-Shot Mode (Alternative) diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index 3e08e265..a24f3292 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -12,9 +12,40 @@ use crate::notification; pub(super) fn poll_portal_captures(state: &mut WaylandState) { // Apply any completed portal fallback captures without blocking. state.frozen.poll_portal_capture(&mut state.input_state); + handle_pending_frozen_image(state); state.zoom.poll_portal_capture(&mut state.input_state); } +fn handle_pending_frozen_image(state: &mut WaylandState) { + if !state.frozen.has_pending_image() { + return; + } + if state.surface.is_xdg_window() { + if state.xdg_fullscreen() { + state.activate_pending_frozen_image_for_current_surface(); + return; + } + if !state.xdg_frozen_fullscreen_requested() && state.begin_xdg_frozen_fullscreen() { + return; + } + if state.xdg_frozen_fullscreen_pending_configure() { + if state.xdg_frozen_fullscreen_timed_out() { + warn!("Frozen xdg fullscreen configure timed out; cancelling freeze"); + state.input_state.set_ui_toast( + UiToastKind::Error, + "Freeze failed because fullscreen was not confirmed", + ); + state.restore_xdg_after_frozen(); + state.frozen.cancel(&mut state.input_state); + } + return; + } + state.activate_pending_frozen_image_for_current_surface(); + return; + } + state.activate_pending_frozen_image_for_current_surface(); +} + pub(super) fn flush_if_capture_active(conn: &Connection, capture_active: bool) { if capture_active { let _ = conn.flush(); @@ -61,10 +92,17 @@ fn handle_frozen_toggle(state: &mut WaylandState) { } if !state.frozen_enabled() { - warn!("Frozen mode disabled on this compositor (xdg fallback); ignoring toggle"); + warn!( + "Frozen mode unavailable: no screencopy backend and no screenshot portal backend; ignoring toggle" + ); + state.input_state.set_ui_toast( + UiToastKind::Warning, + "Freeze unavailable: no screenshot backend is available.", + ); } else if state.frozen.is_in_progress() { warn!("Frozen capture already in progress; ignoring toggle"); } else if state.input_state.frozen_active() { + state.restore_xdg_after_frozen(); state.frozen.unfreeze(&mut state.input_state); } else { let use_fallback = !state.frozen.manager_available(); @@ -73,7 +111,14 @@ fn handle_frozen_toggle(state: &mut WaylandState) { } else { info!("Frozen mode: using screencopy fast path"); } - state.enter_overlay_suppression(OverlaySuppression::Frozen); + if !state.enter_overlay_suppression(OverlaySuppression::Frozen) { + warn!("Frozen mode requested while overlay is suppressed; ignoring toggle"); + state.input_state.set_ui_toast( + UiToastKind::Warning, + "Freeze is already preparing another overlay operation.", + ); + return; + } if let Err(err) = state .frozen .start_capture(use_fallback, &state.tokio_handle) diff --git a/src/backend/wayland/backend/setup.rs b/src/backend/wayland/backend/setup.rs index 50aab128..c5e5918a 100644 --- a/src/backend/wayland/backend/setup.rs +++ b/src/backend/wayland/backend/setup.rs @@ -124,7 +124,7 @@ pub(super) fn setup_wayland() -> Result { } Err(err) => { warn!( - "zwlr_screencopy_manager_v1 not available (frozen mode disabled): {}", + "zwlr_screencopy_manager_v1 not available; frozen mode may use portal fallback: {}", err ); None diff --git a/src/backend/wayland/backend/state_init/mod.rs b/src/backend/wayland/backend/state_init/mod.rs index 508c06c5..3a7ffe89 100644 --- a/src/backend/wayland/backend/state_init/mod.rs +++ b/src/backend/wayland/backend/state_init/mod.rs @@ -1,15 +1,18 @@ use anyhow::Result; use log::{info, warn}; use smithay_client_toolkit::globals::ProvidesBoundGlobal; +use std::env; use super::super::state::{WaylandState, WaylandStateInit}; use super::WaylandBackend; use super::setup::WaylandSetup; use super::tray::process_tray_action; +use crate::backend::wayland::portal_capture::screenshot_portal_available; use crate::{ capture::CaptureManager, config::Config, - input::{InputState, state::CompositorCapabilities}, + input::InputState, + input::state::{CompositorCapabilities, DesktopEnvironment, ShellMode}, onboarding::{DEFERRED_HINT_REPEAT_MAX, OnboardingStore}, }; @@ -43,16 +46,29 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let mut input_state = input_state::build_input_state(&config); input_state.set_session_preflight_options(session_options.clone()); + let screencopy_supported = setup.screencopy_manager.is_some(); + let portal_freeze_supported = screenshot_portal_available(&backend.tokio_runtime); + let frozen_supported = screencopy_supported || portal_freeze_supported; + let tokio_handle = backend.tokio_runtime.handle().clone(); // Set compositor capabilities based on detected Wayland protocols input_state.compositor_capabilities = CompositorCapabilities { layer_shell: setup.layer_shell_available, - screencopy: setup.screencopy_manager.is_some(), + screencopy: screencopy_supported, + freeze_capture: frozen_supported, pointer_constraints: setup .state_globals .pointer_constraints_state .bound_global() .is_ok(), + desktop_environment: desktop_environment_from_env(), + shell_mode: if setup.layer_shell_available { + ShellMode::LayerShell + } else if setup.state_globals.xdg_shell.is_some() { + ShellMode::XdgFallback + } else { + ShellMode::Unknown + }, }; let mut onboarding = OnboardingStore::load(); @@ -91,11 +107,10 @@ pub(super) fn init_state(backend: &WaylandBackend, setup: WaylandSetup) -> Resul let capture_manager = CaptureManager::new(backend.tokio_runtime.handle()); info!("Capture manager initialized"); - let tokio_handle = backend.tokio_runtime.handle().clone(); - - let frozen_supported = setup.layer_shell_available; let freeze_on_start = if backend.freeze_on_start && !frozen_supported { - warn!("Frozen mode is not supported on GNOME xdg fallback; ignoring --freeze"); + warn!( + "Frozen mode unavailable: no screencopy backend and no screenshot portal backend; ignoring --freeze" + ); false } else { backend.freeze_on_start @@ -144,3 +159,19 @@ fn apply_initial_mode(backend: &WaylandBackend, _config: &Config, input_state: & } } } + +fn desktop_environment_from_env() -> DesktopEnvironment { + let values = [ + env::var("XDG_CURRENT_DESKTOP").unwrap_or_default(), + env::var("XDG_SESSION_DESKTOP").unwrap_or_default(), + env::var("DESKTOP_SESSION").unwrap_or_default(), + ]; + if values + .iter() + .any(|value| value.to_ascii_uppercase().contains("GNOME")) + { + DesktopEnvironment::Gnome + } else { + DesktopEnvironment::Unknown + } +} diff --git a/src/backend/wayland/frozen/capture.rs b/src/backend/wayland/frozen/capture.rs index f45a3ca0..3c259d48 100644 --- a/src/backend/wayland/frozen/capture.rs +++ b/src/backend/wayland/frozen/capture.rs @@ -4,10 +4,7 @@ use smithay_client_toolkit::shm::{ Shm, slot::{Buffer, SlotPool}, }; -use wayland_client::{ - Dispatch, QueueHandle, WEnum, - protocol::{wl_output, wl_shm}, -}; +use wayland_client::{Dispatch, QueueHandle, WEnum, protocol::wl_shm}; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_frame_v1::{ Event as FrameEvent, Flags, ZwlrScreencopyFrameV1, }; @@ -75,7 +72,7 @@ impl FrozenState { pub fn start_capture( &mut self, use_fallback: bool, - tokio_handle: &tokio::runtime::Handle, + _tokio_handle: &tokio::runtime::Handle, ) -> Result<()> { if self.capture.is_some() || self.portal_in_progress || self.preflight_pending { warn!("Frozen-mode capture already in progress; ignoring toggle"); @@ -83,14 +80,28 @@ impl FrozenState { } self.capture_done = false; + self.preflight_use_fallback = use_fallback || self.manager.is_none(); + self.preflight_pending = true; + Ok(()) + } + pub fn begin_preflight_capture( + &mut self, + use_fallback: bool, + shm: &Shm, + qh: &QueueHandle, + tokio_handle: &tokio::runtime::Handle, + ) -> Result<()> + where + State: + Dispatch + Dispatch + 'static, + { if use_fallback || self.manager.is_none() { - info!("Screencopy unavailable; using fallback portal capture for frozen mode"); - return self.capture_via_portal(tokio_handle); + info!("Suppression frame committed; using fallback portal capture for frozen mode"); + self.capture_via_portal(tokio_handle) + } else { + self.begin_screencopy(shm, qh) } - - self.preflight_pending = true; - Ok(()) } pub fn begin_screencopy(&mut self, shm: &Shm, qh: &QueueHandle) -> Result<()> @@ -168,10 +179,7 @@ impl FrozenState { return; } - input_state.set_frozen_active(true); - input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; - self.capture_done = true; } FrameEvent::Failed => { warn!("Frozen capture failed"); @@ -268,20 +276,16 @@ impl FrozenState { capture.frame.destroy(); - let output_transform = self - .active_geometry - .as_ref() - .map(|geo| geo.transform) - .unwrap_or(wl_output::Transform::Normal); - - self.set_image( + let source_geometry = self.active_geometry.clone(); + self.set_pending_image( FrozenImage { width: capture.width, height: capture.height, stride: (capture.width * 4) as i32, data, - } - .with_output_transform(output_transform), + }, + source_geometry, + true, ); Ok(()) diff --git a/src/backend/wayland/frozen/mod.rs b/src/backend/wayland/frozen/mod.rs index 3cc57abf..41833cb1 100644 --- a/src/backend/wayland/frozen/mod.rs +++ b/src/backend/wayland/frozen/mod.rs @@ -6,5 +6,12 @@ mod state; pub use image::FrozenImage; pub use state::FrozenState; -type PortalCaptureResult = Result<(Option, self::image::FrozenImage), String>; +type PortalCaptureResult = Result< + ( + Option, + Option, + self::image::FrozenImage, + ), + String, +>; type PortalCaptureRx = std::sync::mpsc::Receiver; diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index 8dc7ecc8..ba442025 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -4,10 +4,11 @@ use std::sync::mpsc; use crate::backend::wayland::frozen::FrozenImage; use crate::backend::wayland::portal_capture::{ - capture_via_portal_fullscreen_bytes, crop_argb, portal_output_matches, + capture_via_portal_fullscreen_bytes, portal_output_matches, }; use crate::capture::sources::frozen::decode_image_to_argb; use crate::input::InputState; +use crate::input::state::UiToastKind; use super::PortalCaptureResult; use super::state::FrozenState; @@ -29,7 +30,7 @@ impl FrozenState { self.portal_rx = Some(rx); self.portal_target_output_id = self.active_output_id; - let geo = self.active_geometry.clone(); + let source_geometry = self.active_geometry.clone(); let target_output_id = self.active_output_id; // Notify user that portal fallback is in progress crate::notification::send_notification_async( @@ -42,34 +43,12 @@ impl FrozenState { let result = async { let bytes = capture_via_portal_fullscreen_bytes().await?; - let (mut data, mut width, mut height) = + let (data, width, height) = decode_image_to_argb(&bytes).map_err(|e| format!("Decode failed: {}", e))?; - if let Some(geo) = geo { - let (phys_w, phys_h) = geo.physical_size(); - let (origin_x, origin_y) = geo.physical_origin(); - if origin_x >= 0 - && origin_y >= 0 - && phys_w > 0 - && phys_h > 0 - && let Some(cropped) = crop_argb( - &data, - width, - height, - origin_x as u32, - origin_y as u32, - phys_w, - phys_h, - ) - { - data = cropped; - width = phys_w; - height = phys_h; - } - } - Ok(( target_output_id, + source_geometry, FrozenImage { width, height, @@ -97,6 +76,10 @@ impl FrozenState { && start.elapsed() > std::time::Duration::from_secs(10) { warn!("Portal frozen capture timed out; restoring overlay"); + input_state.set_ui_toast( + UiToastKind::Error, + "Freeze timed out waiting for the screenshot portal", + ); input_state.set_frozen_active(false); self.portal_in_progress = false; self.portal_rx = None; @@ -108,28 +91,29 @@ impl FrozenState { if let Some(rx) = self.portal_rx.as_ref() { match rx.try_recv() { - Ok(Ok((target_output, image))) => { + Ok(Ok((target_output, source_geometry, image))) => { let output_matches = portal_output_matches(target_output, self.active_output_id); if output_matches { - self.set_image(image); - input_state.set_frozen_active(true); - input_state.dirty_tracker.mark_full(); - input_state.needs_redraw = true; + self.set_pending_image(image, source_geometry, false); } else { warn!("Portal capture for inactive output discarded"); input_state.set_frozen_active(false); + self.capture_done = true; } self.portal_in_progress = false; self.portal_rx = None; self.portal_target_output_id = None; self.portal_started_at = None; - self.capture_done = true; } Ok(Err(err)) => { warn!("Portal frozen capture failed: {}", err); + input_state.set_ui_toast( + UiToastKind::Error, + "Freeze failed through the screenshot portal", + ); input_state.set_frozen_active(false); self.portal_in_progress = false; self.portal_rx = None; @@ -140,6 +124,10 @@ impl FrozenState { Err(mpsc::TryRecvError::Empty) => {} Err(mpsc::TryRecvError::Disconnected) => { warn!("Portal frozen capture channel disconnected"); + input_state.set_ui_toast( + UiToastKind::Error, + "Freeze failed because the screenshot portal stopped responding", + ); input_state.set_frozen_active(false); self.portal_in_progress = false; self.portal_rx = None; @@ -193,6 +181,7 @@ mod tests { frozen.portal_rx = Some(rx); frozen.portal_in_progress = true; tx.send(Ok(( + None, None, FrozenImage { width: 2, @@ -205,9 +194,17 @@ mod tests { frozen.poll_portal_capture(&mut input); - assert!(input.frozen_active()); + assert!(!input.frozen_active()); + assert!(frozen.has_pending_image()); assert!(!frozen.portal_in_progress); assert!(frozen.portal_rx.is_none()); + assert!(!frozen.take_capture_done()); + + frozen + .activate_pending_image(2, 1, &mut input) + .expect("activate pending image"); + + assert!(input.frozen_active()); assert!(frozen.image.is_some()); assert!(frozen.take_capture_done()); } diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index 6f6bc50a..146c81f1 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -4,11 +4,18 @@ use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::Z use crate::backend::wayland::frozen::FrozenImage; use crate::backend::wayland::frozen_geometry::OutputGeometry; +use crate::backend::wayland::portal_capture::crop_argb; use crate::input::InputState; use super::PortalCaptureRx; use super::capture::CaptureSession; +struct PendingFrozenImage { + image: FrozenImage, + source_geometry: Option, + needs_output_transform: bool, +} + /// End-to-end controller for frozen mode capture and image storage. #[allow(clippy::type_complexity)] pub struct FrozenState { @@ -24,7 +31,9 @@ pub struct FrozenState { pub(super) portal_target_output_id: Option, pub(super) portal_started_at: Option, pub(super) preflight_pending: bool, + pub(super) preflight_use_fallback: bool, pub(super) capture_done: bool, + pending_image: Option, } impl FrozenState { @@ -42,7 +51,9 @@ impl FrozenState { portal_target_output_id: None, portal_started_at: None, preflight_pending: false, + preflight_use_fallback: false, capture_done: false, + pending_image: None, } } @@ -77,18 +88,42 @@ impl FrozenState { self.bump_image_generation(); } + pub fn set_pending_image( + &mut self, + image: FrozenImage, + source_geometry: Option, + needs_output_transform: bool, + ) { + self.pending_image = Some(PendingFrozenImage { + image, + source_geometry, + needs_output_transform, + }); + } + + pub fn has_pending_image(&self) -> bool { + self.pending_image.is_some() + } + pub fn is_in_progress(&self) -> bool { - self.capture.is_some() || self.portal_in_progress || self.preflight_pending + self.capture.is_some() + || self.portal_in_progress + || self.preflight_pending + || self.pending_image.is_some() } pub fn preflight_pending(&self) -> bool { self.preflight_pending } - pub fn take_preflight_pending(&mut self) -> bool { - let pending = self.preflight_pending; + pub fn take_preflight_pending(&mut self) -> Option { + if !self.preflight_pending { + return None; + } + let use_fallback = self.preflight_use_fallback; self.preflight_pending = false; - pending + self.preflight_use_fallback = false; + Some(use_fallback) } pub fn take_capture_done(&mut self) -> bool { @@ -97,6 +132,84 @@ impl FrozenState { done } + pub fn activate_pending_image( + &mut self, + phys_width: u32, + phys_height: u32, + input_state: &mut InputState, + ) -> Result { + let Some(pending) = self.pending_image.take() else { + return Ok(false); + }; + let mut image = pending.image; + + if pending.needs_output_transform { + let output_transform = pending + .source_geometry + .as_ref() + .or(self.active_geometry.as_ref()) + .map(|geo| geo.transform) + .unwrap_or(wl_output::Transform::Normal); + image = image.with_output_transform(output_transform); + } + + if image.width != phys_width || image.height != phys_height { + let Some(cropped) = self.crop_pending_image( + image, + pending.source_geometry.as_ref(), + phys_width, + phys_height, + ) else { + self.capture_done = true; + input_state.set_frozen_active(false); + input_state.needs_redraw = true; + return Err("Freeze failed after the display changed size".to_string()); + }; + image = cropped; + } + + self.set_image(image); + input_state.set_frozen_active(true); + input_state.dirty_tracker.mark_full(); + input_state.needs_redraw = true; + self.capture_done = true; + Ok(true) + } + + fn crop_pending_image( + &self, + image: FrozenImage, + source_geometry: Option<&OutputGeometry>, + target_width: u32, + target_height: u32, + ) -> Option { + if target_width == 0 || target_height == 0 { + return None; + } + let (origin_x, origin_y) = source_geometry + .or(self.active_geometry.as_ref()) + .map(|geo| geo.physical_origin()) + .unwrap_or((0, 0)); + let (width, height, data) = crop_argb( + &image.data, + image.width, + image.height, + origin_x.max(0) as u32, + origin_y.max(0) as u32, + target_width, + target_height, + )?; + if width != target_width || height != target_height { + return None; + } + Some(FrozenImage { + width: target_width, + height: target_height, + stride: (target_width * 4) as i32, + data, + }) + } + /// Drop frozen image if the surface size no longer matches. pub fn handle_resize( &mut self, @@ -126,6 +239,8 @@ impl FrozenState { capture.frame.destroy(); } self.preflight_pending = false; + self.preflight_use_fallback = false; + self.pending_image = None; self.capture_done = true; input_state.set_frozen_active(false); input_state.needs_redraw = true; diff --git a/src/backend/wayland/handlers/compositor.rs b/src/backend/wayland/handlers/compositor.rs index 0a380cbe..d27f738a 100644 --- a/src/backend/wayland/handlers/compositor.rs +++ b/src/backend/wayland/handlers/compositor.rs @@ -68,8 +68,10 @@ impl CompositorHandler for WaylandState { ); self.surface.set_frame_callback_pending(false); - if self.frozen.take_preflight_pending() - && let Err(err) = self.frozen.begin_screencopy(&self.shm, qh) + if let Some(use_fallback) = self.frozen.take_preflight_pending() + && let Err(err) = + self.frozen + .begin_preflight_capture(use_fallback, &self.shm, qh, &self.tokio_handle) { warn!("Frozen preflight capture failed: {}", err); self.frozen.cancel(&mut self.input_state); diff --git a/src/backend/wayland/handlers/xdg.rs b/src/backend/wayland/handlers/xdg.rs index 6fad76b6..4b82b1a5 100644 --- a/src/backend/wayland/handlers/xdg.rs +++ b/src/backend/wayland/handlers/xdg.rs @@ -68,7 +68,13 @@ impl WindowHandler for WaylandState { .map(|h| h.get()) .unwrap_or(fallback_dimensions.1); - if self.xdg_fullscreen() { + if self.xdg_frozen_fullscreen_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() { 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. @@ -132,6 +138,18 @@ impl WindowHandler for WaylandState { self.frozen.set_active_geometry(Some(geo.clone())); self.zoom.set_active_geometry(Some(geo)); } + if self.xdg_frozen_fullscreen_requested() && self.frozen.has_pending_image() { + if self.xdg_frozen_fullscreen_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() + && !self.input_state.frozen_active() + && !self.frozen.has_pending_image() + { + self.restore_xdg_after_frozen(); + } self.input_state.needs_redraw = true; // Surface is now sized; re-apply toolbar offsets so margins reflect configured bounds. diff --git a/src/backend/wayland/portal_capture.rs b/src/backend/wayland/portal_capture.rs index b3cf38e4..81b30dbf 100644 --- a/src/backend/wayland/portal_capture.rs +++ b/src/backend/wayland/portal_capture.rs @@ -1,3 +1,26 @@ +#[cfg(feature = "portal")] +use std::time::Duration; + +#[cfg(feature = "portal")] +const PORTAL_STARTUP_PROBE_TIMEOUT: Duration = Duration::from_millis(500); + +#[cfg(feature = "portal")] +pub(crate) fn screenshot_portal_available(runtime: &tokio::runtime::Runtime) -> bool { + runtime.block_on(async { + tokio::time::timeout( + PORTAL_STARTUP_PROBE_TIMEOUT, + crate::capture::portal::is_portal_available(), + ) + .await + .unwrap_or(false) + }) +} + +#[cfg(not(feature = "portal"))] +pub(crate) fn screenshot_portal_available(_runtime: &tokio::runtime::Runtime) -> bool { + false +} + #[cfg(feature = "portal")] pub(crate) async fn capture_via_portal_fullscreen_bytes() -> Result, String> { use crate::capture::sources::portal::capture_via_portal_bytes; @@ -30,7 +53,7 @@ pub(crate) fn crop_argb( y: u32, crop_w: u32, crop_h: u32, -) -> Option> { +) -> Option<(u32, u32, Vec)> { if x >= width || y >= height { return None; } @@ -38,6 +61,9 @@ pub(crate) fn crop_argb( let max_h = height.saturating_sub(y); let cw = crop_w.min(max_w); let ch = crop_h.min(max_h); + if cw == 0 || ch == 0 { + return None; + } let mut out = vec![0u8; (cw * ch * 4) as usize]; let src_stride = (width * 4) as usize; @@ -52,7 +78,7 @@ pub(crate) fn crop_argb( out[dst_offset..dst_offset + dst_stride] .copy_from_slice(&data[src_offset..src_offset + dst_stride]); } - Some(out) + Some((cw, ch, out)) } #[cfg(test)] @@ -66,7 +92,8 @@ mod tests { 1, 2, 3, 4, 5, 6, 7, 8, // 9, 10, 11, 12, 13, 14, 15, 16, ]; - let cropped = crop_argb(&data, 2, 2, 1, 0, 1, 2).expect("crop"); + let (width, height, cropped) = crop_argb(&data, 2, 2, 1, 0, 1, 2).expect("crop"); + assert_eq!((width, height), (1, 2)); assert_eq!(cropped, vec![5, 6, 7, 8, 13, 14, 15, 16]); } diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index a1632ca4..b9487cf5 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -63,7 +63,7 @@ use crate::{ }; use self::data::{MoveDrag, StateData}; -pub use self::data::{MoveDragKind, OverlaySuppression}; +pub use self::data::{MoveDragKind, OverlaySuppression, XdgFrozenFullscreenState}; use super::{ capture::{CapturePreflightRequest, CaptureState, PendingPdfExport}, clipboard::{ClipboardPasteCompletion, ClipboardPublishCompletion}, diff --git a/src/backend/wayland/state/core/accessors.rs b/src/backend/wayland/state/core/accessors.rs index 6eed0ffa..dd1b6775 100644 --- a/src/backend/wayland/state/core/accessors.rs +++ b/src/backend/wayland/state/core/accessors.rs @@ -1,8 +1,10 @@ -use smithay_client_toolkit::shell::wlr_layer::Layer; +use smithay_client_toolkit::shell::{WaylandSurface, wlr_layer::Layer}; use super::super::*; use std::time::{Duration, Instant}; +const XDG_FROZEN_FULLSCREEN_TIMEOUT: Duration = Duration::from_millis(1500); + impl WaylandState { pub(in crate::backend::wayland) fn current_mouse(&self) -> (i32, i32) { (self.data.current_mouse_x, self.data.current_mouse_y) @@ -213,6 +215,92 @@ impl WaylandState { 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_timed_out(&self) -> bool { + self.xdg_frozen_fullscreen_pending_configure() + && self + .data + .xdg_frozen_fullscreen_requested_at + .is_some_and(|requested_at| requested_at.elapsed() >= XDG_FROZEN_FULLSCREEN_TIMEOUT) + } + + 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()); + if let Some(output) = self.preferred_fullscreen_output() { + window.set_fullscreen(Some(&output)); + } else { + window.set_fullscreen(None); + } + window.commit(); + true + } + + pub(in crate::backend::wayland) fn restore_xdg_after_frozen(&mut self) { + if !self.xdg_frozen_fullscreen_requested() { + return; + } + if let Some(window) = self.surface.xdg_window().cloned() { + if self.xdg_fullscreen() { + if let Some(output) = self.preferred_fullscreen_output() { + window.set_fullscreen(Some(&output)); + } else { + window.set_fullscreen(None); + } + } else { + window.unset_fullscreen(); + window.set_maximized(); + } + window.commit(); + } + self.data.xdg_frozen_fullscreen_state = + crate::backend::wayland::state::XdgFrozenFullscreenState::Inactive; + self.data.xdg_frozen_fullscreen_requested_at = None; + } + + 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 (phys_width, phys_height) = self.surface.physical_dimensions(); + match self + .frozen + .activate_pending_image(phys_width, phys_height, &mut self.input_state) + { + 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; + } + } + Ok(false) => {} + Err(err) => { + log::warn!("Frozen pending image activation failed: {}", err); + self.input_state + .set_ui_toast(crate::input::state::UiToastKind::Error, err); + self.restore_xdg_after_frozen(); + } + } + } + pub(in crate::backend::wayland) fn main_surface_layer(&self) -> Layer { if self.data.main_surface_uses_overlay_layer { Layer::Overlay diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index 23beb5a0..af7e6342 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -19,6 +19,14 @@ pub enum OverlaySuppression { Zoom, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum XdgFrozenFullscreenState { + #[default] + Inactive, + PendingConfigure, + Active, +} + impl OverlaySuppression { pub(in crate::backend::wayland) fn effective_for_board( self, @@ -107,6 +115,8 @@ pub struct StateData { 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_clickthrough: bool, @@ -181,6 +191,8 @@ impl StateData { 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_clickthrough: false, diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index 3bd7b543..f36c36ed 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -51,7 +51,7 @@ impl ZoomState { && origin_y >= 0 && phys_w > 0 && phys_h > 0 - && let Some(cropped) = crop_argb( + && let Some((cropped_w, cropped_h, cropped)) = crop_argb( &data, width, height, @@ -62,8 +62,8 @@ impl ZoomState { ) { data = cropped; - width = phys_w; - height = phys_h; + width = cropped_w; + height = cropped_h; } } diff --git a/src/input/state/core/base/mod.rs b/src/input/state/core/base/mod.rs index 009cb802..b23c014f 100644 --- a/src/input/state/core/base/mod.rs +++ b/src/input/state/core/base/mod.rs @@ -5,11 +5,12 @@ pub use state::InputState; pub(crate) use state::{LightModeRestore, PresenterRestore}; pub use types::{ BLOCKED_ACTION_DURATION_MS, BOARD_DELETE_CONFIRM_MS, BOARD_UNDO_EXPIRE_MS, - CompositorCapabilities, DrawingState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, - OutputFocusAction, PAGE_DELETE_CONFIRM_MS, PAGE_UNDO_EXPIRE_MS, PRESET_FEEDBACK_DURATION_MS, - PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, - PressureThicknessEntryMode, SelectionAxis, SelectionHandle, TEXT_EDIT_ENTRY_DURATION_MS, - TextInputMode, ToolbarDrawerTab, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, + CompositorCapabilities, DesktopEnvironment, DrawingState, MAX_STROKE_THICKNESS, + MIN_STROKE_THICKNESS, OutputFocusAction, PAGE_DELETE_CONFIRM_MS, PAGE_UNDO_EXPIRE_MS, + PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, + PressureThicknessEditMode, PressureThicknessEntryMode, SelectionAxis, SelectionHandle, + ShellMode, TEXT_EDIT_ENTRY_DURATION_MS, TextInputMode, ToolbarDrawerTab, UI_TOAST_DURATION_MS, + UiToastKind, ZoomAction, }; pub(crate) use types::{ BlockedActionFeedback, BoardPickerClickState, ClipboardFingerprint, ClipboardPasteRequest, diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 6f715c4f..8ca6e822 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -289,7 +289,25 @@ pub(crate) enum HistoryMode { pub struct CompositorCapabilities { pub layer_shell: bool, pub screencopy: bool, + pub freeze_capture: bool, pub pointer_constraints: bool, + pub desktop_environment: DesktopEnvironment, + pub shell_mode: ShellMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DesktopEnvironment { + Gnome, + #[default] + Unknown, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ShellMode { + LayerShell, + XdgFallback, + #[default] + Unknown, } impl CompositorCapabilities { @@ -300,10 +318,12 @@ impl CompositorCapabilities { pub fn limitations_summary(&self) -> Option { let mut issues = Vec::new(); if !self.layer_shell { - issues.push("Toolbars limited"); + issues.push("Toolbars limited, light passthrough unavailable"); } - if !self.screencopy { + if !self.freeze_capture { issues.push("Freeze unavailable"); + } else if !self.screencopy { + issues.push("Freeze uses portal capture"); } if !self.pointer_constraints { issues.push("Pointer lock unavailable"); @@ -462,7 +482,10 @@ mod tests { CompositorCapabilities { layer_shell: true, screencopy: true, + freeze_capture: true, pointer_constraints: true, + desktop_environment: Default::default(), + shell_mode: Default::default(), } .limitations_summary(), None @@ -475,10 +498,34 @@ mod tests { CompositorCapabilities { layer_shell: false, screencopy: true, + freeze_capture: true, pointer_constraints: false, + desktop_environment: Default::default(), + shell_mode: Default::default(), } .limitations_summary(), - Some("Toolbars limited, Pointer lock unavailable".to_string()) + Some( + "Toolbars limited, light passthrough unavailable, Pointer lock unavailable" + .to_string() + ) + ); + } + + #[test] + fn compositor_capabilities_reports_portal_freeze_without_hiding_limitations() { + let caps = CompositorCapabilities { + layer_shell: true, + screencopy: false, + freeze_capture: true, + pointer_constraints: true, + desktop_environment: Default::default(), + shell_mode: Default::default(), + }; + + assert!(!caps.all_available()); + assert_eq!( + caps.limitations_summary(), + Some("Freeze uses portal capture".to_string()) ); } } diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 773d4263..42948f05 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -20,11 +20,11 @@ mod tour; mod utility; pub use base::{ - BLOCKED_ACTION_DURATION_MS, CompositorCapabilities, DrawingState, InputState, - MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, - PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, - PressureThicknessEntryMode, SelectionAxis, SelectionHandle, TextInputMode, ToolbarDrawerTab, - UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, + BLOCKED_ACTION_DURATION_MS, CompositorCapabilities, DesktopEnvironment, DrawingState, + InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, + PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, + PressureThicknessEditMode, PressureThicknessEntryMode, SelectionAxis, SelectionHandle, + ShellMode, TextInputMode, ToolbarDrawerTab, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, }; pub(crate) use base::{BoardPickerClickState, PolygonClickState, TextClickState}; pub(crate) use base::{ diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index 2073c974..b234e3f5 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -1,4 +1,6 @@ -use super::super::base::{InputState, LightModeRestore, UiToastKind}; +use super::super::base::{ + DesktopEnvironment, InputState, LightModeRestore, ShellMode, UiToastKind, +}; use crate::config::keybindings::Action; use crate::input::tool::Tool; @@ -7,6 +9,26 @@ impl InputState { self.compositor_capabilities.layer_shell } + fn light_mode_unsupported_message(&self) -> &'static str { + let caps = self.compositor_capabilities; + match ( + caps.desktop_environment, + caps.shell_mode, + caps.freeze_capture, + ) { + (DesktopEnvironment::Gnome, ShellMode::XdgFallback, true) => { + "Light passthrough is unavailable on stock GNOME Wayland. Use Freeze for a still-frame workflow." + } + (DesktopEnvironment::Gnome, ShellMode::XdgFallback, false) => { + "Light passthrough is unavailable on stock GNOME Wayland. No freeze capture backend is available in this session." + } + (_, ShellMode::XdgFallback, _) => { + "Light passthrough requires compositor overlay support; this session is using the xdg-shell fallback." + } + _ => "Light passthrough requires compositor overlay support.", + } + } + pub fn light_mode_passthrough(&self) -> bool { self.light_mode_supported() && self.light_mode && !self.light_mode_drawing } @@ -31,10 +53,7 @@ impl InputState { self.exit_light_mode(); } else { if !self.light_mode_supported() { - self.set_ui_toast( - UiToastKind::Warning, - "Light Mode requires layer-shell support", - ); + self.set_ui_toast(UiToastKind::Warning, self.light_mode_unsupported_message()); self.needs_redraw = true; return false; } @@ -56,10 +75,7 @@ impl InputState { if !self.light_mode { if drawing { if !self.light_mode_supported() { - self.set_ui_toast( - UiToastKind::Warning, - "Light Mode requires layer-shell support", - ); + self.set_ui_toast(UiToastKind::Warning, self.light_mode_unsupported_message()); self.needs_redraw = true; return false; } diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index bb2210bb..84dcc7c4 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -19,13 +19,14 @@ pub use core::{ COLOR_PICKER_PREVIEW_SIZE, COMMAND_PALETTE_MAX_VISIBLE, ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupState, CommandPaletteCursorHint, CompositorCapabilities, ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, - ContextMenuState, DrawingState, HelpOverlayCursorHint, InputState, MAX_STROKE_THICKNESS, - MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, - PresetAction, PresetFeedbackKind, PressureThicknessEditMode, PressureThicknessEntryMode, - RADIAL_COLOR_SEGMENT_COUNT, RADIAL_TOOL_LABELS, RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, - RadialMenuState, RadialSegmentId, SelectionAxis, SelectionHandle, SelectionState, - TextInputMode, ToolbarDrawerTab, TourStep, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, - color_picker_rgb_to_hsv, radial_color_for_index, sub_ring_child_count, sub_ring_child_label, + ContextMenuState, DesktopEnvironment, DrawingState, HelpOverlayCursorHint, InputState, + MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, + PRESET_TOAST_DURATION_MS, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, + PressureThicknessEntryMode, RADIAL_COLOR_SEGMENT_COUNT, RADIAL_TOOL_LABELS, + RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialSegmentId, SelectionAxis, + SelectionHandle, SelectionState, ShellMode, TextInputMode, ToolbarDrawerTab, TourStep, + UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, color_picker_rgb_to_hsv, radial_color_for_index, + sub_ring_child_count, sub_ring_child_label, }; pub(crate) use core::{ COMMAND_PALETTE_INPUT_HEIGHT, COMMAND_PALETTE_ITEM_HEIGHT, COMMAND_PALETTE_LIST_GAP, From e33463ad07f50884488ba221515c271f579a36d4 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com.> Date: Wed, 10 Jun 2026 21:34:48 +0200 Subject: [PATCH 2/2] Improve fallback user-facing messages --- README.md | 6 +++--- docs/CONFIG.md | 2 +- src/backend/wayland/backend/event_loop/capture.rs | 2 +- src/backend/wayland/backend/event_loop/mod.rs | 2 +- src/backend/wayland/backend/helpers.rs | 4 ++-- src/backend/wayland/frozen/portal.rs | 12 +++++------- src/backend/wayland/handlers/keyboard/mod.rs | 3 ++- src/backend/wayland/state/core/output.rs | 2 +- src/backend/wayland/zoom/portal.rs | 2 +- src/input/state/core/utility/light_mode.rs | 8 ++++---- 10 files changed, 21 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 22990843..64d6812b 100644 --- a/README.md +++ b/README.md @@ -178,7 +178,7 @@ For distro-specific package details, see [Installation](#installation). For keyb | Platform | Status | Notes | |----------|--------|-------| | Wayland (layer-shell) | ✅ Supported | Hyprland, Sway, River, Wayfire, Niri/Cosmic, Plasma/KWin | -| GNOME | ⚠️ Partial | Freeze via portal when available; stock GNOME light passthrough unavailable | +| GNOME | ⚠️ Partial | Normal overlay and Freeze via portal when available; Light Mode passthrough unavailable | | X11 | ❌ | Not supported |
@@ -510,7 +510,7 @@ Light passthrough controls: - Ctrl+Shift+L is a Wayscriber in-overlay shortcut, not an OS/global shortcut. It works while the overlay is focused. - Once light passthrough is active, normal keyboard and pointer input goes to the app underneath. Bind compositor/global shortcuts to `wayscriber --light-toggle` and `wayscriber --light-draw-toggle` for reliable control. - Use `wayscriber --light-draw-on` on press and `wayscriber --light-draw-off` on release for draw-while-held shortcuts. -- Stock GNOME Wayland does not support this regular-app passthrough mode. Use Freeze for still-frame annotation workflows, or a GNOME Shell extension approach for true shell-level passthrough. +- Stock GNOME Wayland does not support this regular-app passthrough mode. Freeze may still work for still-image capture, but it is not a live passthrough replacement. A GNOME Shell extension approach would be needed for true shell-level passthrough. Use `--no-tray` or `WAYSCRIBER_NO_TRAY=1` if you don't have a system tray; otherwise right-click the tray icon for options: - Toggle overlay visibility @@ -736,7 +736,7 @@ The polygon tools are available from the toolbar picker; their default keybindin
-For light passthrough, Ctrl+Shift+L is the default Wayscriber-level binding only while the overlay has focus. Use compositor/global shortcuts that run `wayscriber --light-toggle` and related light-draw commands once passthrough is active. On stock GNOME Wayland, regular app windows cannot provide the required click-through shell overlay; Freeze remains the supported GNOME fallback when portal capture is available. +For light passthrough, Ctrl+Shift+L is the default Wayscriber-level binding only while the overlay has focus. Use compositor/global shortcuts that run `wayscriber --light-toggle` and related light-draw commands once passthrough is active. On stock GNOME Wayland, regular app windows cannot provide the required click-through shell overlay. Freeze may still work for still-image capture, but it is not a live passthrough replacement. Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R. Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`). Preset slots can be saved/cleared from the toolbar; edit names and advanced fields in `config.toml`. Blur has no default keyboard shortcut; bind `select_blur_tool` in `config.toml` if you want direct keyboard access. diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 5be0b655..91f83512 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -431,7 +431,7 @@ show_toast = true Light mode hides UI chrome and sets the overlay to click-through passthrough until drawing is explicitly enabled. `toggle_light_mode` defaults to Ctrl+Shift+L, but that is a Wayscriber in-overlay shortcut: it works while the overlay still has focus. Once passthrough is active, normal keyboard and pointer input goes to the app underneath, so compositor/global shortcuts should call the daemon commands below for reliable control. -This mode requires compositor overlay support through layer-shell. It is disabled on the xdg fallback because regular app windows cannot reliably stay visible as click-through shell overlays while keyboard and pointer input go to apps underneath. On stock GNOME Wayland, use Freeze for still-frame annotation workflows when portal capture is available; true passthrough would require a GNOME Shell extension companion. +This mode requires compositor overlay support through layer-shell. It is disabled on the xdg fallback because regular app windows cannot reliably stay visible as click-through shell overlays while keyboard and pointer input go to apps underneath. On stock GNOME Wayland, Freeze may still work for still-image capture when portal capture is available, but it is not a live passthrough replacement. True passthrough would require a GNOME Shell extension companion. For compositor/global shortcuts while passthrough is active, run: diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index a24f3292..422303ee 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -97,7 +97,7 @@ fn handle_frozen_toggle(state: &mut WaylandState) { ); state.input_state.set_ui_toast( UiToastKind::Warning, - "Freeze unavailable: no screenshot backend is available.", + "Freeze is unavailable because screen capture is not available.", ); } else if state.frozen.is_in_progress() { warn!("Frozen capture already in progress; ignoring toggle"); diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index ae5b7a71..ee6a836b 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -167,7 +167,7 @@ pub(super) fn run_event_loop( notification::send_notification_async( &state.tokio_handle, "Wayscriber lost focus".to_string(), - "GNOME could not keep the overlay focused; closing fallback window." + "The desktop could not keep the overlay focused, so Wayscriber closed it." .to_string(), Some("dialog-warning".to_string()), ); diff --git a/src/backend/wayland/backend/helpers.rs b/src/backend/wayland/backend/helpers.rs index 7cdeebea..c340d215 100644 --- a/src/backend/wayland/backend/helpers.rs +++ b/src/backend/wayland/backend/helpers.rs @@ -26,7 +26,7 @@ pub(super) fn friendly_capture_error(error: &str) -> String { } else if lower.contains("permission") { "Permission denied. Enable screen sharing in system settings.".to_string() } else if lower.contains("portal returned error code") { - "Portal screenshot failed. If you use wlroots/Hyprland/Niri, install grim + slurp. Otherwise check xdg-desktop-portal." + "Screen capture failed. If you use Hyprland, Niri, or another wlroots desktop, install grim + slurp. Otherwise check the desktop screen capture service." .to_string() } else if lower.contains("busy") { "Screen capture in progress. Try again in a moment.".to_string() @@ -207,7 +207,7 @@ mod tests { ); assert_eq!( friendly_capture_error("portal returned error code 2"), - "Portal screenshot failed. If you use wlroots/Hyprland/Niri, install grim + slurp. Otherwise check xdg-desktop-portal." + "Screen capture failed. If you use Hyprland, Niri, or another wlroots desktop, install grim + slurp. Otherwise check the desktop screen capture service." ); assert_eq!( friendly_capture_error("resource busy"), diff --git a/src/backend/wayland/frozen/portal.rs b/src/backend/wayland/frozen/portal.rs index ba442025..302cf272 100644 --- a/src/backend/wayland/frozen/portal.rs +++ b/src/backend/wayland/frozen/portal.rs @@ -36,7 +36,7 @@ impl FrozenState { crate::notification::send_notification_async( tokio_handle, "Freezing screen".to_string(), - "Taking screenshot via portal…".to_string(), + "Requesting screen capture...".to_string(), Some("camera-photo".to_string()), ); tokio_handle.spawn(async move { @@ -78,7 +78,7 @@ impl FrozenState { warn!("Portal frozen capture timed out; restoring overlay"); input_state.set_ui_toast( UiToastKind::Error, - "Freeze timed out waiting for the screenshot portal", + "Freeze timed out while waiting for screen capture.", ); input_state.set_frozen_active(false); self.portal_in_progress = false; @@ -110,10 +110,8 @@ impl FrozenState { } Ok(Err(err)) => { warn!("Portal frozen capture failed: {}", err); - input_state.set_ui_toast( - UiToastKind::Error, - "Freeze failed through the screenshot portal", - ); + input_state + .set_ui_toast(UiToastKind::Error, "Freeze could not capture the screen."); input_state.set_frozen_active(false); self.portal_in_progress = false; self.portal_rx = None; @@ -126,7 +124,7 @@ impl FrozenState { warn!("Portal frozen capture channel disconnected"); input_state.set_ui_toast( UiToastKind::Error, - "Freeze failed because the screenshot portal stopped responding", + "Freeze could not capture the screen because the system capture service stopped responding.", ); input_state.set_frozen_active(false); self.portal_in_progress = false; diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index cca0c951..efd19223 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -84,7 +84,8 @@ impl KeyboardHandler for WaylandState { notification::send_notification_async( &self.tokio_handle, "Wayscriber lost focus".to_string(), - "GNOME could not keep the overlay focused; closing fallback window.".to_string(), + "The desktop could not keep the overlay focused, so Wayscriber closed it." + .to_string(), Some("dialog-warning".to_string()), ); self.input_state.should_exit = true; diff --git a/src/backend/wayland/state/core/output.rs b/src/backend/wayland/state/core/output.rs index e3332cae..4f390ef5 100644 --- a/src/backend/wayland/state/core/output.rs +++ b/src/backend/wayland/state/core/output.rs @@ -380,7 +380,7 @@ impl WaylandState { if !self.xdg_fullscreen() { self.input_state.set_ui_toast( UiToastKind::Info, - "Enable ui.xdg_fullscreen to switch outputs on xdg fallback", + "Enable fullscreen mode before switching outputs in this session.", ); self.input_state.trigger_blocked_feedback(); return; diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index f36c36ed..5dbcd653 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -34,7 +34,7 @@ impl ZoomState { crate::notification::send_notification_async( tokio_handle, "Zoom capture".to_string(), - "Taking screenshot via portal...".to_string(), + "Requesting screen capture...".to_string(), Some("camera-photo".to_string()), ); tokio_handle.spawn(async move { diff --git a/src/input/state/core/utility/light_mode.rs b/src/input/state/core/utility/light_mode.rs index b234e3f5..be61ca19 100644 --- a/src/input/state/core/utility/light_mode.rs +++ b/src/input/state/core/utility/light_mode.rs @@ -17,15 +17,15 @@ impl InputState { caps.freeze_capture, ) { (DesktopEnvironment::Gnome, ShellMode::XdgFallback, true) => { - "Light passthrough is unavailable on stock GNOME Wayland. Use Freeze for a still-frame workflow." + "Light Mode passthrough is not supported in this GNOME Wayland session." } (DesktopEnvironment::Gnome, ShellMode::XdgFallback, false) => { - "Light passthrough is unavailable on stock GNOME Wayland. No freeze capture backend is available in this session." + "Light Mode passthrough is not supported in this GNOME Wayland session. Screen capture is also unavailable." } (_, ShellMode::XdgFallback, _) => { - "Light passthrough requires compositor overlay support; this session is using the xdg-shell fallback." + "Light Mode passthrough is not supported in this desktop session." } - _ => "Light passthrough requires compositor overlay support.", + _ => "Light Mode passthrough is not supported by this compositor.", } }