From 5d6c610fec0ff7c13f7b58da4819186eb6740acb Mon Sep 17 00:00:00 2001 From: Samuel Onoja Date: Sat, 26 Sep 2026 23:22:38 +0100 Subject: [PATCH] Add area screenshots and preserve stacking on hover focus --- README.md | 8 ++++++++ compositor/src/config.rs | 21 +++++++++++++++++---- compositor/src/input.rs | 20 ++++++++++++++------ docs/configuration.md | 12 ++++++++++-- docs/installation.md | 6 ++++++ packaging/config.toml | 3 ++- packaging/ferese-screenshot | 36 ++++++++++++++++++++++++++++++++++++ packaging/ferese-session | 2 ++ scripts/install-session.sh | 5 +++-- 9 files changed, 98 insertions(+), 15 deletions(-) create mode 100755 packaging/ferese-screenshot diff --git a/README.md b/README.md index a6f6612..c5be601 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,7 @@ and native Settings app. - Built-in bar and control center - Native Settings app - Desktop widgets +- Area screenshots saved to disk and copied to the clipboard - Themes, blur and gradient borders - Multi-monitor support and fractional scaling - Live configuration reload @@ -50,6 +51,7 @@ Requirements (Fedora package names): - Default terminal: `foot` (or configure another terminal). - Optional: `zenity` for wallpaper browsing, `swayidle` for idle actions, and `swaylock` or `swaylock-effects` for locking. +- Screenshots: `grim`, `slurp`, and `wl-clipboard`. For a login-screen session alongside your existing desktop: @@ -77,8 +79,14 @@ Built-in defaults (your configuration can override them): | Super + Shift + F | True fullscreen | | Super + Tab | Open workspace and window overview | | Super + M | Switch scrolling / tree layout | +| Super + Shift + S | Select a screenshot area | | Super + Shift + E | Log out immediately—save your work first | +In an installed session, screenshots are saved to `Pictures/Screenshots` and +copied to the clipboard. Press Escape to cancel the selection. +When focus follows the mouse is enabled, hovering changes keyboard focus without +raising the window; clicking raises it within its layer. + ## Make it yours Open **Control Center → Settings**, or run `ferese-settings`. diff --git a/compositor/src/config.rs b/compositor/src/config.rs index 4f01ee0..7084c8d 100644 --- a/compositor/src/config.rs +++ b/compositor/src/config.rs @@ -895,7 +895,13 @@ impl Config { } pub fn bindings(&self, input: &InputSettings) -> Result, ConfigError> { - let mut commands = HashMap::from([("terminal".to_owned(), vec!["foot".to_owned()])]); + let mut commands = HashMap::from([ + ("terminal".to_owned(), vec!["foot".to_owned()]), + ( + "screenshot".to_owned(), + vec!["ferese-screenshot".to_owned()], + ), + ]); commands.extend(self.commands.clone()); validate_commands(&commands)?; @@ -1398,6 +1404,7 @@ fn parse_workspace(argument: &str) -> Result { fn default_bindings() -> Vec { let mut bindings = vec![ binding("Super+Enter", "spawn", Some("terminal")), + binding("Super+Shift+S", "spawn", Some("screenshot")), binding("Super+Q", "close", None), binding("Super+F", "toggle-maximized", None), binding("Super+Shift+F", "toggle-fullscreen", None), @@ -2069,7 +2076,7 @@ mod tests { let input = config.input_settings().unwrap(); let bindings = config.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 42); + assert_eq!(bindings.len(), 43); for (shift, action) in [ (false, BindingAction::ToggleMaximized), (true, BindingAction::ToggleFullscreen), @@ -2089,6 +2096,12 @@ mod tests { && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Return) && binding.action == BindingAction::Spawn(vec!["foot".to_owned()]) })); + assert!(bindings.iter().any(|binding| { + binding.modifiers.logo + && binding.modifiers.shift + && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_s) + && binding.action == BindingAction::Spawn(vec!["ferese-screenshot".to_owned()]) + })); assert!(bindings.iter().any(|binding| { binding.modifiers.logo && binding.trigger == BindingTrigger::Keysym(keysyms::KEY_Tab) @@ -2105,7 +2118,7 @@ mod tests { let input = replaced.input_settings().unwrap(); let bindings = replaced.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 42); + assert_eq!(bindings.len(), 43); assert!(bindings.iter().any(|binding| { binding.action == BindingAction::Spawn(vec![ @@ -2117,7 +2130,7 @@ mod tests { let input = unbound.input_settings().unwrap(); let bindings = unbound.bindings(&input).unwrap(); - assert_eq!(bindings.len(), 41); + assert_eq!(bindings.len(), 42); assert!( !bindings .iter() diff --git a/compositor/src/input.rs b/compositor/src/input.rs index a639c34..32a6a49 100644 --- a/compositor/src/input.rs +++ b/compositor/src/input.rs @@ -217,7 +217,7 @@ impl Ferese { crate::dimming::DimAnimation::new(f64::from(opacity)), )); pointer.unset_grab(self, serial, event.time() as u32); - self.focus_window_at(pointer.current_location(), serial); + self.focus_window_at(pointer.current_location(), serial, true); crate::backends::direct::render_all(self); } @@ -254,7 +254,7 @@ impl Ferese { } if event.state() == ButtonState::Pressed && !pointer.is_grabbed() { - self.focus_window_at(pointer.current_location(), serial); + self.focus_window_at(pointer.current_location(), serial, true); } pointer.button( @@ -320,7 +320,7 @@ impl Ferese { let Some(location) = self.absolute_event_position(&event) else { return; }; - self.focus_window_at(location, SERIAL_COUNTER.next_serial()); + self.focus_window_at(location, SERIAL_COUNTER.next_serial(), true); let touch = self.seat.get_touch().expect("seat has touch capability"); let serial = SERIAL_COUNTER.next_serial(); @@ -387,7 +387,7 @@ impl Ferese { Some(event.position_transformed(geometry.size) + geometry.loc.to_f64()) } - fn focus_window_at(&mut self, position: Point, serial: Serial) { + fn focus_window_at(&mut self, position: Point, serial: Serial, raise: bool) { if self.session_lock.active { self.focus_output_at(position); self.focus_lock_surface(); @@ -412,7 +412,15 @@ impl Ferese { return; } self.focused_window = focused; - self.raise_window(&window, true); + if raise { + self.raise_window(&window, true); + } else { + // Hover transfers keyboard focus without changing the persistent + // stack: an exposed window must not cover the floats above it. + for mapped in self.space.elements() { + mapped.set_activated(mapped == &window); + } + } let surface = window .toplevel() .expect("mapped window has a toplevel") @@ -455,7 +463,7 @@ impl Ferese { return; } - self.focus_window_at(position, SERIAL_COUNTER.next_serial()); + self.focus_window_at(position, SERIAL_COUNTER.next_serial(), false); } fn clamp_pointer_position(&self, requested: Point) -> Point { diff --git a/docs/configuration.md b/docs/configuration.md index 3b8e267..9d7ae19 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -127,7 +127,7 @@ angle = 135.0 | `[input]` key | Type | Default | Meaning | | --- | --- | --- | --- | -| `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer | +| `focus_follows_mouse` | boolean | `false` | Focus windows under the pointer without raising them; click to raise | | `xkb_layout` | nonempty string | `"us"` | XKB layout | | `xkb_variant` | string | `""` | XKB variant | | `xkb_options` | string array | `[]` | XKB options | @@ -178,7 +178,13 @@ Super+Shift+H/J/K/L move; Super+Ctrl+H/J/K/L resize; Super+1–9 workspace; Super+Shift+1–9 move to workspace; Super+R width cycle; Super+C center; Super+[/] consume/expel; Super+F maximize; Super+Shift+F fullscreen; Super+M layout; Super+Shift+Space floating; Super+Tab overview; -Super+Shift+E immediate logout. +Super+Shift+S area screenshot; Super+Shift+E immediate logout. + +The screenshot shortcut runs `ferese-screenshot`: drag to select an area, or +press Escape to cancel. Captures are saved under your Pictures directory in +`Screenshots` and copied to the clipboard as PNG images. It requires `grim`, +`slurp`, and `wl-copy` (from `wl-clipboard`). Override the `screenshot` command +to use another screenshot tool. ## Window rules @@ -273,5 +279,7 @@ ending the session from another TTY. These are launch-time environment switches, not TOML keys. Enable only for trusted clients: `FERESE_ENABLE_INPUT_METHOD=1`, `FERESE_ENABLE_SHORTCUT_INHIBIT=1`, `FERESE_ENABLE_SCREENCOPY=1`. +The installed session launcher enables screencopy for screenshots unless +`FERESE_ENABLE_SCREENCOPY=0` is explicitly set in its environment. Text input is available by default; Ctrl+Alt+Escape releases an active shortcut inhibitor. Use `feresectl --help` for runtime control commands. diff --git a/docs/installation.md b/docs/installation.md index 5850648..df0a08c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -5,6 +5,7 @@ On Fedora with SDDM, install a Rust toolchain (1.93 or newer), native build dependencies, `desktop-file-utils`, D-Bus and `foot`. Wallpaper browsing also needs `zenity`; locking needs swaylock or swaylock-effects. +Area screenshots need `grim`, `slurp`, and `wl-clipboard`. From the checkout, run as your normal user: @@ -35,6 +36,11 @@ its DRM backend inside an active graphical session. Logs are stored in `${XDG_STATE_HOME:-~/.local/state}/ferese/`. Keep an existing desktop available while testing scaling, monitor hotplug, suspend and locking. +The session launcher enables the screencopy protocol for screenshot tools. +Set `FERESE_ENABLE_SCREENCOPY=0` in the session environment to disable it. +When enabled, Wayland clients can capture the unlocked desktop. Capture remains +blocked while the session is locked. Launcher changes take effect at next login. + ## Logout and recovery Save your work before **Super+Shift+E** or `feresectl exit`: logout is immediate. diff --git a/packaging/config.toml b/packaging/config.toml index 2f327b7..073a5b4 100644 --- a/packaging/config.toml +++ b/packaging/config.toml @@ -2,6 +2,7 @@ # Unspecified monitors use automatic placement and preferred modes. [commands] terminal = ["foot"] +screenshot = ["ferese-screenshot"] [input] xkb_layout = "us" @@ -17,7 +18,7 @@ opacity = 0.95 text_primary = "#F0F3FA" text_muted = "#AAB4C7" -# Super+Enter opens Foot. Super+Shift+E logs out. +# Super+Enter opens Foot. Super+Shift+S selects a screenshot. Super+Shift+E logs out. # See README.md for monitor profiles and additional bindings. [[window_rules]] diff --git a/packaging/ferese-screenshot b/packaging/ferese-screenshot new file mode 100755 index 0000000..e2647df --- /dev/null +++ b/packaging/ferese-screenshot @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +# Select an area, save it in Pictures/Screenshots, and copy it to the clipboard. +set -euo pipefail +umask 077 + +fail() { + echo "ferese-screenshot: $*" >&2 + if command -v notify-send >/dev/null; then + notify-send --app-name=Ferese --urgency=critical 'Screenshot failed' "$*" || true + fi + exit 1 +} + +for tool in slurp grim wl-copy flock; do + command -v "$tool" >/dev/null || fail "Required command not found: $tool" +done +: "${XDG_RUNTIME_DIR:?A Wayland login session is required}" +exec 9>"$XDG_RUNTIME_DIR/ferese-screenshot.lock" +flock -n 9 || exit 0 + +# Escape cancels without creating a file or changing the clipboard. +geometry=$(slurp) || exit 0 +[[ -n $geometry ]] || exit 0 +pictures=$(xdg-user-dir PICTURES 2>/dev/null) || pictures="$HOME/Pictures" +directory=${pictures:-$HOME/Pictures}/Screenshots +mkdir -p -- "$directory" || fail 'Could not create the screenshot directory.' +file=$(mktemp "$directory/Screenshot-$(date +%Y-%m-%d_%H-%M-%S)-XXXXXX.png") || fail 'Could not create the screenshot file.' +trap 'rm -f -- "$file"' EXIT +grim -g "$geometry" "$file" || fail 'Could not capture the screen. Log back into Ferese after enabling screen capture.' +trap - EXIT +# Do not pass the selector lock to wl-copy's background clipboard process. +wl-copy --type image/png <"$file" 9>&- || fail "Saved to $file, but could not copy to the clipboard." +if command -v notify-send >/dev/null; then + notify-send --app-name=Ferese 'Screenshot saved and copied' "$file" || true +fi +printf '%s\n' "$file" diff --git a/packaging/ferese-session b/packaging/ferese-session index 4b7bd8a..f7e8840 100644 --- a/packaging/ferese-session +++ b/packaging/ferese-session @@ -26,6 +26,8 @@ export XDG_SESSION_DESKTOP=ferese export XDG_SESSION_TYPE=wayland export MOZ_ENABLE_WAYLAND=1 export QT_QPA_PLATFORM=wayland +# Used by the area screenshot shortcut; explicitly set 0 to disable capture. +export FERESE_ENABLE_SCREENCOPY=${FERESE_ENABLE_SCREENCOPY:-1} export PATH="$release_dir:$PATH" unset WAYLAND_SOCKET if [[ $backend == drm ]]; then diff --git a/scripts/install-session.sh b/scripts/install-session.sh index 3037df1..cfb5852 100644 --- a/scripts/install-session.sh +++ b/scripts/install-session.sh @@ -19,7 +19,7 @@ session_entry=/usr/share/wayland-sessions/ferese.desktop [[ ! -e $release_dir ]] || { echo "Release already exists: $release_dir" >&2; exit 1; } # Refuse to overwrite unrelated installations. -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock; do +for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do link=/usr/local/bin/$name if [[ -e $link || -L $link ]]; then [[ -L $link && $(readlink -- "$link") == "$install_root/current/$name" ]] || { @@ -54,6 +54,7 @@ for name in ferese ferese-shell ferese-settings feresectl; do done install -m 0755 -- "$repo_dir/packaging/ferese-session" "$release_dir/ferese-session" install -m 0755 -- "$repo_dir/packaging/ferese-lock" "$release_dir/ferese-lock" +install -m 0755 -- "$repo_dir/packaging/ferese-screenshot" "$release_dir/ferese-screenshot" install -m 0644 -- "$repo_dir/packaging/config.toml" "$release_dir/config.example.toml" if [[ -L $install_root/current ]]; then @@ -61,7 +62,7 @@ if [[ -L $install_root/current ]]; then fi ln -s -- "releases/$release_id" "$install_root/current-$release_id" mv -Tf -- "$install_root/current-$release_id" "$install_root/current" -for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock; do +for name in ferese ferese-shell ferese-settings feresectl ferese-session ferese-lock ferese-screenshot; do ln -sfn -- "$install_root/current/$name" "/usr/local/bin/$name" done install -m 0644 -- "$repo_dir/packaging/ferese.desktop" "$session_entry"