diff --git a/docs/wiki/Configuration:-Key-Bindings.md b/docs/wiki/Configuration:-Key-Bindings.md index f60df35bb9..12e83fa81e 100644 --- a/docs/wiki/Configuration:-Key-Bindings.md +++ b/docs/wiki/Configuration:-Key-Bindings.md @@ -356,7 +356,7 @@ Actions for taking screenshots. - `screenshot`: opens the built-in interactive screenshot UI. - `screenshot-screen`, `screenshot-window`: takes a screenshot of the focused screen or window respectively. -The screenshot is both stored to the clipboard and saved to disk, according to the [`screenshot-path` option](./Configuration:-Miscellaneous.md#screenshot-path). +The screenshot is both stored to the clipboard and saved to disk, according to the [`screenshot { path ""; }` option](./Configuration:-Miscellaneous.md#path). Since: 25.02 You can disable saving to disk for a specific bind with the `write-to-disk=false` property: diff --git a/docs/wiki/Configuration:-Miscellaneous.md b/docs/wiki/Configuration:-Miscellaneous.md index 7a2f42dd26..d0d7018a38 100644 --- a/docs/wiki/Configuration:-Miscellaneous.md +++ b/docs/wiki/Configuration:-Miscellaneous.md @@ -9,7 +9,13 @@ spawn-sh-at-startup "qs -c ~/source/qs/MyAwesomeShell" prefer-no-csd -screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" +screenshot { + path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" + notification { + action "Open" "xdg-open" "{path}" + action "Edit" "swappy" "-f" "{path}" + } +} environment { QT_QPA_PLATFORM "wayland" @@ -116,7 +122,13 @@ With `prefer-no-csd` set, applications that negotiate server-side decorations th prefer-no-csd ``` -### `screenshot-path` +### `screenshot` + +Since: next release + +Settings for screenshots taken with the built-in niri screenshot tool. + +#### `path` Set the path where screenshots are saved. A `~` at the front will be expanded to the home directory. @@ -126,15 +138,49 @@ The path is formatted with `strftime(3)` to give you the screenshot date and tim Niri will create the last folder of the path if it doesn't exist. ```kdl -screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" +screenshot { + path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" +} ``` You can also set this option to `null` to disable saving screenshots to disk. ```kdl -screenshot-path null +screenshot { + path null +} +``` + +Until: 26.04 For backwards compatibility, the old top-level `screenshot-path` option is still supported. It is used if `screenshot { path ""; }` is not set. + +```kdl +// Deprecated +screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" ``` + +#### `notification` + +Since: next release + +Add action buttons to the screenshot notification. Actions are shown only for screenshots that were saved to disk. + +The `{path}` placeholder in command arguments is replaced with the saved screenshot path. + +```kdl +screenshot { + notification { + action "Open" "xdg-open" "{path}" + action "Edit" "swappy" "-f" "{path}" + } +} +``` + +The notification block accepts any number of `action` entries, which have the following format: + +`action `. + + ### `environment` Override environment variables for processes spawned by niri. diff --git a/docs/wiki/Development:-Design-Principles.md b/docs/wiki/Development:-Design-Principles.md index 7a3892a841..66b3a14059 100644 --- a/docs/wiki/Development:-Design-Principles.md +++ b/docs/wiki/Development:-Design-Principles.md @@ -98,7 +98,7 @@ The default input settings like touchpad tap and natural-scroll are how I believ Shadows default to off because they are a fairly performance-intensive shader, and because many clientside-decorated windows already draw their own shadows. -The default screenshot-path matches GNOME Shell. +The default screenshot path matches GNOME Shell. Default window rules are limited to fixing known severe issues (WezTerm) and doing something the absolute majority likely wants (make Firefox Picture-in-Picture player floating—it can't do that on its own currently, maybe the pip protocol will change that). diff --git a/niri-config/src/lib.rs b/niri-config/src/lib.rs index 909aeb80a5..de526cb121 100644 --- a/niri-config/src/lib.rs +++ b/niri-config/src/lib.rs @@ -75,7 +75,7 @@ pub struct Config { pub layout: Layout, pub prefer_no_csd: bool, pub cursor: Cursor, - pub screenshot_path: ScreenshotPath, + pub screenshot: Screenshot, pub clipboard: Clipboard, pub hotkey_overlay: HotkeyOverlay, pub config_notification: ConfigNotification, @@ -193,6 +193,7 @@ where match name { "input" => m_merge!(input), "cursor" => m_merge!(cursor), + "screenshot" => m_merge!(screenshot), "clipboard" => m_merge!(clipboard), "hotkey-overlay" => m_merge!(hotkey_overlay), "config-notification" => m_merge!(config_notification), @@ -240,7 +241,7 @@ where "screenshot-path" => { let part = knuffel::Decode::decode_node(node, ctx)?; - config.borrow_mut().screenshot_path = part; + config.borrow_mut().screenshot.path = part; } "layout" => { @@ -648,6 +649,12 @@ mod tests { assert_eq!(config.input.keyboard.repeat_rate, 25); } + #[test] + fn legacy_screenshot_path() { + let config = Config::parse_mem(r#"screenshot-path "~/foo.png""#).unwrap(); + assert_eq!(config.screenshot.path.0.as_deref(), Some("~/foo.png")); + } + #[track_caller] fn do_parse(text: &str) -> Config { Config::parse_mem(text) @@ -832,7 +839,14 @@ mod tests { hide-after-inactive-ms 3000 } - screenshot-path "~/Screenshots/screenshot.png" + screenshot { + path "~/Screenshots/screenshot.png" + + notification { + action "Open" "xdg-open" "{path}" + action "Edit" "swappy" "-f" "{path}" + } + } clipboard { disable-primary @@ -1483,11 +1497,32 @@ mod tests { 3000, ), }, - screenshot_path: ScreenshotPath( - Some( - "~/Screenshots/screenshot.png", + screenshot: Screenshot { + path: ScreenshotPath( + Some( + "~/Screenshots/screenshot.png", + ), ), - ), + notification: ScreenshotNotification { + actions: [ + ScreenshotNotificationAction { + label: "Open", + command: [ + "xdg-open", + "{path}", + ], + }, + ScreenshotNotificationAction { + label: "Edit", + command: [ + "swappy", + "-f", + "{path}", + ], + }, + ], + }, + }, clipboard: Clipboard { disable_primary: true, }, diff --git a/niri-config/src/misc.rs b/niri-config/src/misc.rs index 66a200ca35..d3c3715721 100644 --- a/niri-config/src/misc.rs +++ b/niri-config/src/misc.rs @@ -53,7 +53,7 @@ impl MergeWith for Cursor { } } -#[derive(knuffel::Decode, Debug, Clone, PartialEq)] +#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)] pub struct ScreenshotPath(#[knuffel(argument)] pub Option); impl Default for ScreenshotPath { @@ -64,6 +64,63 @@ impl Default for ScreenshotPath { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Screenshot { + pub path: ScreenshotPath, + pub notification: ScreenshotNotification, +} + +impl Default for Screenshot { + fn default() -> Self { + Self { + path: ScreenshotPath::default(), + notification: ScreenshotNotification::default(), + } + } +} + +#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq, Eq)] +pub struct ScreenshotPart { + #[knuffel(child)] + pub path: Option, + #[knuffel(child)] + pub notification: Option, +} + +impl MergeWith for Screenshot { + fn merge_with(&mut self, part: &ScreenshotPart) { + merge_clone!((self, part), path); + if let Some(notification) = &part.notification { + self.notification.merge_with(notification); + } + } +} + +#[derive(Debug, Default, Clone, PartialEq, Eq)] +pub struct ScreenshotNotification { + pub actions: Vec, +} + +#[derive(knuffel::Decode, Debug, Default, Clone, PartialEq, Eq)] +pub struct ScreenshotNotificationPart { + #[knuffel(children(name = "action"))] + pub actions: Vec, +} + +impl MergeWith for ScreenshotNotification { + fn merge_with(&mut self, part: &ScreenshotNotificationPart) { + self.actions.clone_from(&part.actions); + } +} + +#[derive(knuffel::Decode, Debug, Clone, PartialEq, Eq)] +pub struct ScreenshotNotificationAction { + #[knuffel(argument)] + pub label: String, + #[knuffel(arguments)] + pub command: Vec, +} + #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub struct HotkeyOverlay { pub skip_at_startup: bool, diff --git a/niri-ipc/src/lib.rs b/niri-ipc/src/lib.rs index 0aa3cb4f48..73d6988f82 100644 --- a/niri-ipc/src/lib.rs +++ b/niri-ipc/src/lib.rs @@ -230,7 +230,8 @@ pub enum Action { /// /// The path must be absolute, otherwise an error is returned. /// - /// If `None`, the screenshot is saved according to the `screenshot-path` config setting. + /// If `None`, the screenshot is saved according to the `screenshot { path ""; }` config + /// setting. #[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set))] path: Option, }, @@ -238,7 +239,7 @@ pub enum Action { ScreenshotScreen { /// Write the screenshot to disk in addition to putting it in your clipboard. /// - /// The screenshot is saved according to the `screenshot-path` config setting. + /// The screenshot is saved according to the `screenshot { path ""; }` config setting. #[cfg_attr(feature = "clap", arg(short = 'd', long, action = clap::ArgAction::Set, default_value_t = true))] write_to_disk: bool, @@ -250,7 +251,8 @@ pub enum Action { /// /// The path must be absolute, otherwise an error is returned. /// - /// If `None`, the screenshot is saved according to the `screenshot-path` config setting. + /// If `None`, the screenshot is saved according to the `screenshot { path ""; }` config + /// setting. #[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set))] path: Option, }, @@ -264,7 +266,7 @@ pub enum Action { id: Option, /// Write the screenshot to disk in addition to putting it in your clipboard. /// - /// The screenshot is saved according to the `screenshot-path` config setting. + /// The screenshot is saved according to the `screenshot { path ""; }` config setting. #[cfg_attr(feature = "clap", arg(short = 'd', long, action = clap::ArgAction::Set, default_value_t = true))] write_to_disk: bool, @@ -279,7 +281,8 @@ pub enum Action { /// /// The path must be absolute, otherwise an error is returned. /// - /// If `None`, the screenshot is saved according to the `screenshot-path` config setting. + /// If `None`, the screenshot is saved according to the `screenshot { path ""; }` config + /// setting. #[cfg_attr(feature = "clap", arg(long, action = clap::ArgAction::Set))] path: Option, }, diff --git a/resources/default-config.kdl b/resources/default-config.kdl index ccad1ac22e..064a2b505d 100644 --- a/resources/default-config.kdl +++ b/resources/default-config.kdl @@ -285,13 +285,23 @@ hotkey-overlay { // After enabling or disabling this, you need to restart the apps for this to take effect. // prefer-no-csd -// You can change the path where screenshots are saved. -// A ~ at the front will be expanded to the home directory. -// The path is formatted with strftime(3) to give you the screenshot date and time. -screenshot-path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" - -// You can also set this to null to disable saving screenshots to disk. -// screenshot-path null +// Screenshot settings. +screenshot { + // You can change the path where screenshots are saved. + // A ~ at the front will be expanded to the home directory. + // The path is formatted with strftime(3) to give you the screenshot date and time. + path "~/Pictures/Screenshots/Screenshot from %Y-%m-%d %H-%M-%S.png" + + // You can also set this to null to disable saving screenshots to disk. + // path null + + // Add buttons to screenshot notifications. The buttons only appear for screenshots saved to disk. + // The {path} placeholder is replaced with the saved screenshot path. + // notification { + // action "Open" "xdg-open" "{path}" + // action "Edit" "swappy" "-f" "{path}" + // } +} // Animation settings. // The wiki explains how to configure individual animations: diff --git a/src/niri.rs b/src/niri.rs index 190ef09d55..a9e71ac6fc 100644 --- a/src/niri.rs +++ b/src/niri.rs @@ -5690,6 +5690,8 @@ impl Niri { }) .unwrap(); + let notification_actions = self.config.borrow().screenshot.notification.actions.clone(); + // Encode and save the image in a thread as it's slow. thread::spawn(move || { let mut buf = vec![]; @@ -5732,7 +5734,10 @@ impl Niri { } #[cfg(feature = "dbus")] - if let Err(err) = crate::utils::show_screenshot_notification(image_path.as_deref()) { + if let Err(err) = crate::utils::show_screenshot_notification( + image_path.as_deref(), + ¬ification_actions, + ) { warn!("error showing screenshot notification: {err:?}"); } diff --git a/src/protocols/foreign_toplevel.rs b/src/protocols/foreign_toplevel.rs index 11379169de..a8eb248d75 100644 --- a/src/protocols/foreign_toplevel.rs +++ b/src/protocols/foreign_toplevel.rs @@ -5,11 +5,13 @@ use std::sync::Arc; use arrayvec::ArrayVec; use smithay::output::Output; use smithay::reexports::wayland_protocols::ext::foreign_toplevel_list::v1::server::{ - ext_foreign_toplevel_handle_v1::{self, ExtForeignToplevelHandleV1}, ext_foreign_toplevel_list_v1::{self, ExtForeignToplevelListV1}, + ext_foreign_toplevel_handle_v1::{self, ExtForeignToplevelHandleV1}, + ext_foreign_toplevel_list_v1::{self, ExtForeignToplevelListV1}, }; use smithay::reexports::wayland_protocols::xdg::shell::server::xdg_toplevel; use smithay::reexports::wayland_protocols_wlr::foreign_toplevel::v1::server::{ - zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1}, zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, + zwlr_foreign_toplevel_handle_v1::{self, ZwlrForeignToplevelHandleV1}, + zwlr_foreign_toplevel_manager_v1::{self, ZwlrForeignToplevelManagerV1}, }; use smithay::reexports::wayland_server::backend::ClientId; use smithay::reexports::wayland_server::protocol::wl_output::WlOutput; @@ -18,12 +20,12 @@ use smithay::reexports::wayland_server::{ Client, DataInit, Dispatch, DisplayHandle, GlobalDispatch, New, Resource, }; use smithay::wayland::shell::xdg::{ - ToplevelState, ToplevelStateSet, XdgToplevelSurfaceRoleAttributes + ToplevelState, ToplevelStateSet, XdgToplevelSurfaceRoleAttributes, }; use crate::niri::State; -use crate::window::mapped::MappedId; use crate::utils::with_toplevel_role_and_current; +use crate::window::mapped::MappedId; const EXT_LIST_VERSION: u32 = 1; const WLR_MANAGEMENT_VERSION: u32 = 3; diff --git a/src/utils/mod.rs b/src/utils/mod.rs index bbeac5bc6a..db2155a10a 100644 --- a/src/utils/mod.rs +++ b/src/utils/mod.rs @@ -1,3 +1,4 @@ +use std::borrow::Cow; use std::cmp::{max, min}; use std::ffi::{CString, OsStr}; use std::fmt::Display; @@ -13,7 +14,7 @@ use anyhow::{ensure, Context}; use bitflags::bitflags; use directories::UserDirs; use git_version::git_version; -use niri_config::{Config, OutputName}; +use niri_config::{Config, OutputName, ScreenshotNotificationAction}; use smithay::backend::renderer::utils::{ with_renderer_surface_state, RendererSurfaceStateUserData, }; @@ -277,7 +278,7 @@ pub fn expand_home(path: &Path) -> anyhow::Result> { } pub fn make_screenshot_path(config: &Config) -> anyhow::Result> { - let Some(path) = &config.screenshot_path.0 else { + let Some(path) = &config.screenshot.path.0 else { return Ok(None); }; @@ -541,7 +542,10 @@ pub fn baba_is_float_offset(now: Duration, view_height: f64) -> f64 { } #[cfg(feature = "dbus")] -pub fn show_screenshot_notification(image_path: Option<&Path>) -> anyhow::Result<()> { +pub fn show_screenshot_notification( + image_path: Option<&Path>, + actions: &[ScreenshotNotificationAction], +) -> anyhow::Result<()> { use std::collections::HashMap; use pango::glib; @@ -567,9 +571,23 @@ pub fn show_screenshot_notification(image_path: Option<&Path>) -> anyhow::Result } } - let actions: &[&str] = &[]; + let actions = if image_path.is_some() { + actions + .iter() + .filter(|action| !action.command.is_empty()) + .cloned() + .collect() + } else { + Vec::new() + }; + + let action_strings: Vec> = actions + .iter() + .enumerate() + .flat_map(|(idx, action)| [idx.to_string().into(), action.label.as_str().into()]) + .collect(); - conn.call_method( + let reply = conn.call_method( Some("org.freedesktop.Notifications"), "/org/freedesktop/Notifications", Some("org.freedesktop.Notifications"), @@ -580,7 +598,7 @@ pub fn show_screenshot_notification(image_path: Option<&Path>) -> anyhow::Result image_url.as_ref().map(|url| url.as_str()).unwrap_or(""), "Screenshot captured", "You can paste the image from the clipboard.", - actions, + action_strings, HashMap::from([ ("transient", zvariant::Value::Bool(true)), ("urgency", zvariant::Value::U8(1)), @@ -589,9 +607,113 @@ pub fn show_screenshot_notification(image_path: Option<&Path>) -> anyhow::Result ), )?; + let notification_id: u32 = reply + .body() + .deserialize() + .context("error parsing notification id")?; + + if !actions.is_empty() { + if let Some(image_path) = image_path { + let image_path = image_path.to_owned(); + let conn = conn.inner().clone(); + let _ = std::thread::Builder::new() + .name("Screenshot Notification Actions".to_owned()) + .spawn(move || { + async_io::block_on(async move { + if let Err(err) = handle_screenshot_notification_actions( + conn, + notification_id, + image_path, + actions, + ) + .await + { + warn!("error handling screenshot notification action: {err:?}"); + } + }); + }); + } + } + Ok(()) } +#[cfg(feature = "dbus")] +async fn handle_screenshot_notification_actions( + conn: zbus::Connection, + notification_id: u32, + image_path: PathBuf, + actions: Vec, +) -> anyhow::Result<()> { + use futures_util::{select_biased, FutureExt as _, StreamExt as _}; + + let proxy = zbus::Proxy::new( + &conn, + "org.freedesktop.Notifications", + "/org/freedesktop/Notifications", + "org.freedesktop.Notifications", + ) + .await + .context("error creating notifications proxy")?; + + let mut action_invoked = proxy + .receive_signal("ActionInvoked") + .await + .context("error receiving ActionInvoked signal")?; + let mut notification_closed = proxy + .receive_signal("NotificationClosed") + .await + .context("error receiving NotificationClosed signal")?; + + loop { + select_biased! { + message = action_invoked.next().fuse() => { + let Some(message) = message else { + return Ok(()); + }; + let (id, action_key): (u32, String) = message + .body() + .deserialize() + .context("error parsing ActionInvoked signal")?; + if id != notification_id { + continue; + } + + if let Ok(idx) = action_key.parse::() { + if let Some(action) = actions.get(idx) { + let path = image_path.to_string_lossy(); + let command = action + .command + .iter() + .map(|arg| { + if arg == "{path}" { + path.to_string() + } else { + arg.clone() + } + }) + .collect(); + spawning::spawn(command, None); + } + } + return Ok(()); + } + message = notification_closed.next().fuse() => { + let Some(message) = message else { + return Ok(()); + }; + let (id, _reason): (u32, u32) = message + .body() + .deserialize() + .context("error parsing NotificationClosed signal")?; + if id == notification_id { + return Ok(()); + } + } + } + } +} + #[inline(never)] pub fn cause_panic() { let a = Duration::from_secs(1);