Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 | Normal overlay and Freeze via portal when available; Light Mode passthrough unavailable |
| X11 | ❌ | Not supported |

<details>
Expand Down Expand Up @@ -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 (<kbd>Ctrl+Shift+M</kbd>): hides UI, forces click highlights
- Light passthrough mode (layer-shell): <kbd>Ctrl+Shift+L</kbd> enters from the focused overlay; compositor/global shortcuts such as `wayscriber --light-toggle` keep control reliable while input is passed through
- Screen freeze (<kbd>Ctrl+Shift+F</kbd>): pause display while apps run
- Light passthrough mode (layer-shell): <kbd>Ctrl+Shift+L</kbd> 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 (<kbd>Ctrl+Shift+F</kbd>): pause display while apps run. On GNOME, this uses the screenshot portal when available.

### Callouts & Zoom
- **Numbered callouts:** auto-numbered arrow labels, step markers
Expand Down Expand Up @@ -510,6 +510,7 @@ Light passthrough controls:
- <kbd>Ctrl+Shift+L</kbd> 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. 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
Expand Down Expand Up @@ -735,7 +736,7 @@ The polygon tools are available from the toolbar picker; their default keybindin

</details>

For light passthrough, <kbd>Ctrl+Shift+L</kbd> 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, <kbd>Ctrl+Shift+L</kbd> 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 <kbd>Ctrl+Shift+R</kbd>. 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.

Expand Down
2 changes: 1 addition & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>Ctrl+Shift+L</kbd>, 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, 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:

Expand Down
6 changes: 4 additions & 2 deletions docs/SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
49 changes: 47 additions & 2 deletions src/backend/wayland/backend/event_loop/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 is unavailable because screen capture is not 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();
Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/backend/wayland/backend/event_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
);
Expand Down
4 changes: 2 additions & 2 deletions src/backend/wayland/backend/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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"),
Expand Down
2 changes: 1 addition & 1 deletion src/backend/wayland/backend/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ pub(super) fn setup_wayland() -> Result<WaylandSetup> {
}
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
Expand Down
43 changes: 37 additions & 6 deletions src/backend/wayland/backend/state_init/mod.rs
Original file line number Diff line number Diff line change
@@ -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},
};

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
48 changes: 26 additions & 22 deletions src/backend/wayland/frozen/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -75,22 +72,36 @@ 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");
return Ok(());
}

self.capture_done = false;
self.preflight_use_fallback = use_fallback || self.manager.is_none();
self.preflight_pending = true;
Ok(())
}

pub fn begin_preflight_capture<State>(
&mut self,
use_fallback: bool,
shm: &Shm,
qh: &QueueHandle<State>,
tokio_handle: &tokio::runtime::Handle,
) -> Result<()>
where
State:
Dispatch<ZwlrScreencopyFrameV1, ()> + Dispatch<ZwlrScreencopyManagerV1, ()> + '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<State>(&mut self, shm: &Shm, qh: &QueueHandle<State>) -> Result<()>
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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(())
Expand Down
9 changes: 8 additions & 1 deletion src/backend/wayland/frozen/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,12 @@ mod state;
pub use image::FrozenImage;
pub use state::FrozenState;

type PortalCaptureResult = Result<(Option<u32>, self::image::FrozenImage), String>;
type PortalCaptureResult = Result<
(
Option<u32>,
Option<crate::backend::wayland::frozen_geometry::OutputGeometry>,
self::image::FrozenImage,
),
String,
>;
type PortalCaptureRx = std::sync::mpsc::Receiver<PortalCaptureResult>;
Loading
Loading