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
23 changes: 21 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ Press <kbd>F1</kbd> or <kbd>F10</kbd> for help; <kbd>Shift+F1</kbd> for quick re

**3. Daemon mode (preferred for daily use, optional)**
Daemon mode is faster and keeps session state between toggles.
For setup steps, see [Usage](#daemon-mode-preferred).
For setup steps, see [Usage](#daemon-mode-preferred). Ubuntu GNOME users: <kbd>Super+G</kbd> is a good default because <kbd>Super+D</kbd> is often reserved.

Alternative (one-shot mode):
```bash
Expand Down Expand Up @@ -359,7 +359,9 @@ Run wayscriber in the background and toggle with a keybind:
systemctl --user enable --now wayscriber.service
```

Add keybinding (Hyprland):
Add keybinding:

Hyprland:
```conf
bind = SUPER, D, exec, pkill -SIGUSR1 wayscriber
```
Expand All @@ -369,6 +371,23 @@ Reload your config:
hyprctl reload
```

GNOME (Ubuntu/Debian/Fedora):
1. Open `Settings -> Keyboard -> Keyboard Shortcuts`.
2. Scroll down to `Custom Shortcuts`, click `+`.
3. Name: `Wayscriber Toggle`.
4. Command: `pkill -SIGUSR1 wayscriber`.
5. Set a shortcut key (recommended on Ubuntu GNOME: <kbd>Super+G</kbd>; <kbd>Super+D</kbd> is often already in use).

KDE Plasma:
1. Open `Settings -> Keyboard -> Shortcuts`.
2. Add new `Command or Script`.
3. Name: `Wayscriber Toggle`.
4. Command: `pkill -SIGUSR1 wayscriber`.
5. Assign a key (for example <kbd>Meta+Shift+D</kbd>).

Other desktops/window managers:
- Bind `pkill -SIGUSR1 wayscriber` to any global shortcut key you prefer.

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
- Freeze/unfreeze the current overlay
Expand Down
4 changes: 4 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ active_output_badge = true
# Request fullscreen for the GNOME fallback overlay. Disable if fullscreen appears opaque.
#xdg_fullscreen = false

# Behavior when GNOME fallback (xdg-shell) loses keyboard focus.
# "stay" (default on Ubuntu/GNOME) keeps it open. "exit" closes the overlay.
#xdg_focus_loss_behavior = "exit"

# Mouse button that toggles radial menu
# Options: "middle", "right", "disabled"
radial_menu_mouse_binding = "middle"
Expand Down
9 changes: 8 additions & 1 deletion configurator/src/app/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,17 @@ use iced::{Application, Settings, Size};
use super::state::ConfiguratorApp;

pub fn run() -> iced::Result {
let mut settings = Settings::default();
let mut settings = Settings {
id: Some("wayscriber-configurator".to_string()),
..Settings::default()
};
settings.window.size = Size::new(960.0, 640.0);
settings.window.resizable = true;
settings.window.decorations = true;
#[cfg(target_os = "linux")]
{
settings.window.platform_specific.application_id = "wayscriber-configurator".to_string();
}
if std::env::var_os("ICED_BACKEND").is_none() && should_force_tiny_skia() {
// GNOME Wayland + wgpu can crash on dma-buf/present mode selection; tiny-skia avoids this.
// SAFETY: setting a process-local env var before initializing iced is safe here.
Expand Down
6 changes: 6 additions & 0 deletions configurator/src/app/view/ui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,12 @@ impl ConfiguratorApp {
self.defaults.ui_xdg_fullscreen,
ToggleField::UiXdgFullscreen,
),
toggle_row(
"Keep open on xdg focus loss",
self.draft.ui_xdg_keep_on_focus_loss,
self.defaults.ui_xdg_keep_on_focus_loss,
ToggleField::UiXdgKeepOnFocusLoss,
),
toggle_row(
"Enable context menu",
self.draft.ui_context_menu_enabled,
Expand Down
6 changes: 5 additions & 1 deletion configurator/src/models/config/draft/from_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use super::super::boards::BoardsDraft;
use super::super::presets::PresetsDraft;
use super::super::toolbar_overrides::ToolbarModeOverridesDraft;
use super::ConfigDraft;
use wayscriber::config::Config;
use wayscriber::config::{Config, XdgFocusLossBehavior};

impl ConfigDraft {
pub fn from_config(config: &Config) -> Self {
Expand Down Expand Up @@ -73,6 +73,10 @@ impl ConfigDraft {
ui_context_menu_enabled: config.ui.context_menu.enabled,
ui_preferred_output: config.ui.preferred_output.clone().unwrap_or_default(),
ui_xdg_fullscreen: config.ui.xdg_fullscreen,
ui_xdg_keep_on_focus_loss: matches!(
config.ui.xdg_focus_loss_behavior,
XdgFocusLossBehavior::Stay
),
ui_command_palette_toast_duration_ms: config
.ui
.command_palette_toast_duration_ms
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/draft/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ pub struct ConfigDraft {
pub ui_context_menu_enabled: bool,
pub ui_preferred_output: String,
pub ui_xdg_fullscreen: bool,
pub ui_xdg_keep_on_focus_loss: bool,
pub ui_command_palette_toast_duration_ms: String,
pub ui_toolbar_top_pinned: bool,
pub ui_toolbar_side_pinned: bool,
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/config/setters.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ impl ConfigDraft {
ToggleField::UiHelpOverlayContextFilter => self.help_context_filter = value,
ToggleField::UiContextMenuEnabled => self.ui_context_menu_enabled = value,
ToggleField::UiXdgFullscreen => self.ui_xdg_fullscreen = value,
ToggleField::UiXdgKeepOnFocusLoss => self.ui_xdg_keep_on_focus_loss = value,
ToggleField::UiToolbarTopPinned => self.ui_toolbar_top_pinned = value,
ToggleField::UiToolbarSidePinned => self.ui_toolbar_side_pinned = value,
ToggleField::UiToolbarUseIcons => self.ui_toolbar_use_icons = value,
Expand Down
19 changes: 18 additions & 1 deletion configurator/src/models/config/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use super::super::fields::{
};
use super::super::{ColorMode, NamedColorOption};
use super::ConfigDraft;
use wayscriber::config::{ColorSpec, Config, ToolPresetConfig};
use wayscriber::config::{ColorSpec, Config, ToolPresetConfig, XdgFocusLossBehavior};
use wayscriber::input::Tool;

#[test]
Expand Down Expand Up @@ -170,3 +170,20 @@ fn config_draft_round_trips_drag_tool_mapping() {
config.drawing.tab_drag_tool
);
}

#[test]
fn config_draft_round_trips_xdg_focus_loss_behavior() {
let mut config = Config::default();
config.ui.xdg_focus_loss_behavior = XdgFocusLossBehavior::Stay;

let draft = ConfigDraft::from_config(&config);
assert!(draft.ui_xdg_keep_on_focus_loss);

let round_trip = draft
.to_config(&config)
.expect("expected config to round trip");
assert_eq!(
round_trip.ui.xdg_focus_loss_behavior,
XdgFocusLossBehavior::Stay
);
}
7 changes: 6 additions & 1 deletion configurator/src/models/config/to_config/ui.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use super::super::draft::ConfigDraft;
use super::super::parse::{parse_field, parse_u64_field};
use crate::models::error::FormError;
use wayscriber::config::Config;
use wayscriber::config::{Config, XdgFocusLossBehavior};

impl ConfigDraft {
pub(super) fn apply_ui(&self, config: &mut Config, errors: &mut Vec<FormError>) {
Expand All @@ -18,6 +18,11 @@ impl ConfigDraft {
Some(preferred_output.to_string())
};
config.ui.xdg_fullscreen = self.ui_xdg_fullscreen;
config.ui.xdg_focus_loss_behavior = if self.ui_xdg_keep_on_focus_loss {
XdgFocusLossBehavior::Stay
} else {
XdgFocusLossBehavior::Exit
};
parse_u64_field(
&self.ui_command_palette_toast_duration_ms,
"ui.command_palette_toast_duration_ms",
Expand Down
1 change: 1 addition & 0 deletions configurator/src/models/fields/toggles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pub enum ToggleField {
UiHelpOverlayContextFilter,
UiContextMenuEnabled,
UiXdgFullscreen,
UiXdgKeepOnFocusLoss,
UiToolbarTopPinned,
UiToolbarSidePinned,
UiToolbarUseIcons,
Expand Down
6 changes: 5 additions & 1 deletion docs/CONFIG.md
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,10 @@ active_output_badge = true
# Request fullscreen for the GNOME fallback overlay (disable if opaque)
#xdg_fullscreen = false

# Behavior when GNOME fallback (xdg-shell) loses keyboard focus
# Options: "exit", "stay" (default on Ubuntu/GNOME)
#xdg_focus_loss_behavior = "exit"

# Mouse button that toggles radial menu
# Options: "middle", "right", "disabled"
radial_menu_mouse_binding = "middle"
Expand Down Expand Up @@ -320,7 +324,7 @@ enabled = true
- **Highlight tool ring**: `show_on_highlight_tool = true` keeps a persistent halo visible while the highlight tool is active
- **Context menu**: `ui.context_menu.enabled` toggles right-click / keyboard menus
- **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar
- **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized
- **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized; `xdg_focus_loss_behavior` controls whether losing focus closes (`exit`) or keeps (`stay`) the overlay
- **Radial menu trigger**: `radial_menu_mouse_binding` selects which mouse button opens radial menu (`middle` default, `right`, or `disabled`)

**Multi-monitor behavior:**
Expand Down
2 changes: 1 addition & 1 deletion packaging/wayscriber.desktop
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,4 @@ Icon=wayscriber
Terminal=false
Categories=Graphics;
Keywords=draw;annotate;screenshot;whiteboard;wayland;
StartupNotify=false
StartupNotify=true
5 changes: 4 additions & 1 deletion src/about_window/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ use smithay_client_toolkit::shm::{Shm, slot::SlotPool};
use wayland_client::Connection;
use wayland_client::globals::registry_queue_init;

use crate::app_id::runtime_app_id;

mod clipboard;
mod handlers;
mod render;
Expand Down Expand Up @@ -37,7 +39,8 @@ pub fn run_about_window() -> Result<()> {
let wl_surface = compositor_state.create_surface(&qh);
let window = xdg_shell.create_window(wl_surface, WindowDecorations::None, &qh);
window.set_title("Wayscriber About");
window.set_app_id("com.devmobasa.wayscriber");
let app_id = runtime_app_id();
window.set_app_id(&app_id);
window.set_min_size(Some((ABOUT_WIDTH, ABOUT_HEIGHT)));
window.set_max_size(Some((ABOUT_WIDTH, ABOUT_HEIGHT)));
window.commit();
Expand Down
31 changes: 31 additions & 0 deletions src/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,39 @@ mod usage;

use crate::backend::ExitAfterCaptureMode;
use crate::cli::Cli;
use crate::paths::overlay_lock_file;
use crate::session::try_lock_exclusive;
use crate::session_override::set_runtime_session_override;
use env::env_flag_enabled;
use session::run_session_cli_commands;
use std::fs::{File, OpenOptions};
use std::io::ErrorKind;
use std::process::{Command, Stdio};
use usage::{log_overlay_controls, print_usage};

fn acquire_overlay_lock() -> anyhow::Result<Option<File>> {
let lock_path = overlay_lock_file();
if let Some(parent) = lock_path.parent() {
std::fs::create_dir_all(parent)?;
}

let lock_file = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)?;

match try_lock_exclusive(&lock_file) {
Ok(()) => Ok(Some(lock_file)),
Err(err) if err.kind() == ErrorKind::WouldBlock => {
log::warn!("Overlay already running; skipping duplicate --active launch");
Ok(None)
}
Err(err) => Err(err.into()),
}
}

fn maybe_detach_active(cli: &Cli) -> anyhow::Result<bool> {
if !(cli.active || cli.freeze) {
return Ok(false);
Expand Down Expand Up @@ -89,6 +116,10 @@ pub fn run(cli: Cli) -> anyhow::Result<()> {
if maybe_detach_active(&cli)? {
return Ok(());
}
let _overlay_lock = match acquire_overlay_lock()? {
Some(lock) => lock,
None => return Ok(()),
};
// One-shot mode: show overlay immediately and exit when done
log_overlay_controls(cli.freeze);

Expand Down
4 changes: 3 additions & 1 deletion src/app/usage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,9 @@ pub(crate) fn print_usage() {
println!(" 1. Run: wayscriber --daemon");
println!(" 2. Add to Hyprland config:");
println!(" exec-once = wayscriber --daemon");
println!(" bind = SUPER, D, exec, pkill -SIGUSR1 wayscriber");
println!(
" bind = SUPER, D, exec, bash -lc \"kill -USR1 $(pgrep -fo 'wayscriber --daemon')\""
);
println!(" 3. Press your bound shortcut (e.g. Super+D) to toggle overlay on/off");
println!();
println!("Requirements:");
Expand Down
24 changes: 24 additions & 0 deletions src/app_id.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
const DEFAULT_APP_ID: &str = "wayscriber";
const APP_ID_ENV: &str = "WAYSCRIBER_APP_ID";
const PORTAL_APP_ID_ENV: &str = "WAYSCRIBER_PORTAL_APP_ID";

pub(crate) fn runtime_app_id() -> String {
std::env::var(APP_ID_ENV)
.ok()
.and_then(non_empty_trimmed)
.or_else(|| {
std::env::var(PORTAL_APP_ID_ENV)
.ok()
.and_then(non_empty_trimmed)
})
.unwrap_or_else(|| DEFAULT_APP_ID.to_string())
}

fn non_empty_trimmed(value: String) -> Option<String> {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
66 changes: 55 additions & 11 deletions src/backend/wayland/backend/event_loop/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -116,22 +116,43 @@ pub(super) fn run_event_loop(

// Check immediately after dispatch returns.
if state.input_state.should_exit {
info!("Exit requested after dispatch, breaking event loop");
break;
let explicit_xdg_close_requested = state.take_xdg_explicit_close_requested();
if should_defer_xdg_unfocused_exit(
state.surface.is_xdg_window(),
!state.xdg_focus_loss_exits_overlay(),
state.has_keyboard_focus(),
explicit_xdg_close_requested,
) {
warn!("Exit requested while unfocused in xdg stay mode; keeping overlay open");
state.input_state.should_exit = false;
} else {
info!("Exit requested after dispatch, breaking event loop");
break;
}
}
if state.surface.is_xdg_window()
&& !state.has_keyboard_focus()
&& state.focus_exit_suppression_expired(Instant::now())
{
warn!("Keyboard focus not restored after clipboard action; exiting overlay");
state.clear_focus_exit_suppression();
notification::send_notification_async(
&state.tokio_handle,
"Wayscriber lost focus".to_string(),
"GNOME could not keep the overlay focused; closing fallback window.".to_string(),
Some("dialog-warning".to_string()),
);
state.input_state.should_exit = true;
if state.xdg_focus_loss_exits_overlay() {
warn!("Keyboard focus not restored after clipboard action; exiting overlay");
state.clear_focus_exit_suppression();
notification::send_notification_async(
&state.tokio_handle,
"Wayscriber lost focus".to_string(),
"GNOME could not keep the overlay focused; closing fallback window."
.to_string(),
Some("dialog-warning".to_string()),
);
state.input_state.should_exit = true;
} else {
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.request_xdg_activation(qh);
}
}
// Adjust keyboard interactivity if toolbar visibility changed.
state.sync_toolbar_visibility(qh);
Expand Down Expand Up @@ -180,3 +201,26 @@ pub(super) fn run_event_loop(

EventLoopOutcome { loop_error }
}

fn should_defer_xdg_unfocused_exit(
is_xdg_window: bool,
stay_mode: bool,
has_keyboard_focus: bool,
explicit_xdg_close_requested: bool,
) -> bool {
is_xdg_window && stay_mode && !has_keyboard_focus && !explicit_xdg_close_requested
}

#[cfg(test)]
mod tests {
use super::should_defer_xdg_unfocused_exit;

#[test]
fn defers_exit_only_for_unfocused_xdg_stay_without_explicit_close() {
assert!(should_defer_xdg_unfocused_exit(true, true, false, false));
assert!(!should_defer_xdg_unfocused_exit(true, true, true, false));
assert!(!should_defer_xdg_unfocused_exit(true, false, false, false));
assert!(!should_defer_xdg_unfocused_exit(false, true, false, false));
assert!(!should_defer_xdg_unfocused_exit(true, true, false, true));
}
}
Loading