diff --git a/src/config.rs b/src/config.rs index 64ab13d..4f335ee 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ pub struct Config { pub mouse: Mouse, pub shell_integration: ShellIntegration, pub url: Url, + pub notify: Notify, /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults; /// a value of `"none"` unbinds. pub key_bindings: std::collections::HashMap, @@ -42,6 +43,27 @@ pub struct Cursor { pub struct Bell { /// Briefly flash the screen. pub visual: bool, + /// Command (argv) to run on the bell, e.g. `["paplay", "/usr/share/.../bell.oga"]`. + pub command: Vec, + /// Request the compositor's attention (xdg-activation) when the bell rings + /// while the window is unfocused. + pub urgent: bool, +} + +/// `[notify]`: how desktop notifications (OSC 9/777/99) are delivered. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Notify { + /// Notifier argv; the title and body are appended as the last two arguments. + pub command: Vec, +} + +impl Default for Notify { + fn default() -> Self { + Self { + command: vec!["notify-send".to_string()], + } + } } /// `[mouse]`: pointer and wheel behaviour. diff --git a/src/vt.rs b/src/vt.rs index 4e32815..dbb16c1 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -34,6 +34,14 @@ pub enum ClipboardOp { Query { primary: bool }, } +/// A desktop notification an application requested (OSC 9 / 777 / 99), for the +/// front-end to deliver via the configured notifier. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Notification { + pub title: Option, + pub body: String, +} + /// Which dynamic colour an OSC 10/11/17/19 escape targets. #[derive(Clone, Copy, Debug)] enum Dynamic { @@ -106,6 +114,8 @@ pub struct Term { bell: bool, /// Working directory reported by the shell via OSC 7, for new windows. cwd: Option, + /// Desktop notifications requested via OSC 9/777/99, drained by the front-end. + notifications: Vec, } impl Term { @@ -123,6 +133,7 @@ impl Term { theme: Theme::default(), bell: false, cwd: None, + notifications: Vec::new(), } } @@ -131,6 +142,11 @@ impl Term { self.cwd.as_deref() } + /// Drain the desktop notifications requested since the last call. + pub fn take_notifications(&mut self) -> Vec { + std::mem::take(&mut self.notifications) + } + /// Take and clear the pending bell flag. pub fn take_bell(&mut self) -> bool { std::mem::take(&mut self.bell) @@ -691,6 +707,27 @@ impl Perform for Term { let uri = std::str::from_utf8(&uri_bytes).unwrap_or(""); self.grid.set_link((!uri.is_empty()).then_some(uri)); } + // OSC 9: iTerm2-style notification (`OSC 9 ; body`). + Some(&n) if n == b"9" => { + if let Some(body) = osc_text(params.get(1)) { + self.notifications.push(Notification { title: None, body }); + } + } + // OSC 777: `OSC 777 ; notify ; title ; body`. + Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => { + let title = osc_text(params.get(2)); + if let Some(body) = osc_text(params.get(3)) { + self.notifications.push(Notification { title, body }); + } + } + // OSC 99: kitty desktop-notification protocol. We honour the common + // single-chunk form, taking the payload as the body and ignoring the + // metadata key=value field. + Some(&n) if n == b"99" => { + if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) { + self.notifications.push(Notification { title: None, body }); + } + } // OSC 4: set/query palette entries (pairs of index;spec). Some(&n) if n == b"4" => self.osc_palette(params, bell), // OSC 104: reset palette (all, or the listed indices). @@ -834,6 +871,11 @@ fn base64_decode(data: &[u8]) -> Option> { Some(out) } +/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent. +fn osc_text(field: Option<&&[u8]>) -> Option { + field.map(|b| String::from_utf8_lossy(b).into_owned()) +} + /// Map an OSC 133 mark letter to a [`PromptKind`]. fn prompt_kind(b: u8) -> Option { match b { @@ -1158,6 +1200,28 @@ mod tests { assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n")); } + #[test] + fn osc_notifications_collected() { + let mut t = Term::new(20, 2); + feed(&mut t, b"\x1b]9;hello\x07"); + feed(&mut t, b"\x1b]777;notify;Title;Body\x07"); + let n = t.take_notifications(); + assert_eq!( + n, + vec![ + Notification { + title: None, + body: "hello".into() + }, + Notification { + title: Some("Title".into()), + body: "Body".into() + }, + ] + ); + assert!(t.take_notifications().is_empty()); + } + #[test] fn osc7_tracks_cwd_and_decodes_percent() { let mut t = Term::new(20, 1); diff --git a/src/wayland.rs b/src/wayland.rs index 90610f4..1b1a5c4 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -32,6 +32,7 @@ use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::cl zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1, }; use smithay_client_toolkit::{ + activation::{ActivationHandler, ActivationState, RequestData}, compositor::{CompositorHandler, CompositorState}, data_device_manager::{ DataDeviceManagerState, WritePipe, @@ -39,7 +40,8 @@ use smithay_client_toolkit::{ data_offer::{DataOfferHandler, DragOffer}, data_source::{CopyPasteSource, DataSourceHandler}, }, - delegate_compositor, delegate_data_device, delegate_keyboard, delegate_output, + delegate_activation, delegate_compositor, delegate_data_device, delegate_keyboard, + delegate_output, delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm, delegate_xdg_shell, delegate_xdg_window, output::{OutputHandler, OutputState}, @@ -224,6 +226,7 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ())) }); let text_input_manager = bind_global::(&globals, &qh); + let activation = ActivationState::bind(&globals, &qh).ok(); // First commit with no buffer kicks off the initial configure. window.commit(); @@ -261,6 +264,7 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R primary_manager, cursor_shape_manager, text_input_manager, + activation, preedit: String::new(), ime_preedit_pending: String::new(), ime_commit_pending: String::new(), @@ -435,6 +439,8 @@ struct App { cursor_shape_manager: Option, /// IME manager (text-input-v3); per-seat handles live in `seats`. text_input_manager: Option, + /// xdg-activation, used to request attention on an urgent bell. + activation: Option, /// Committed IME preedit string shown inline at the cursor while composing. preedit: String, /// Preedit/commit accumulated since the last text-input `done`. @@ -1569,15 +1575,78 @@ impl App { } let rang = session.term.take_bell(); let ops = session.term.take_clipboard_ops(); + let notifications = session.term.take_notifications(); if !ops.is_empty() { self.handle_clipboard_ops(ops); } - if rang && self.config.bell.visual { - self.start_flash(); + for note in notifications { + self.send_notification(¬e); + } + if rang { + self.ring_bell(); } self.needs_draw = true; } + /// React to a `BEL`: optionally flash, run the configured bell command, and + /// request the compositor's attention when unfocused. + fn ring_bell(&mut self) { + if self.config.bell.visual { + self.start_flash(); + } + if let Some((program, args)) = self.config.bell.command.split_first() { + let _ = std::process::Command::new(program) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .inspect_err(|err| tracing::warn!("bell command: {err}")); + } + if self.config.bell.urgent && !self.focused { + self.request_attention(); + } + } + + /// Deliver a desktop notification through the configured notifier (default + /// `notify-send`), appending the title and body as the final arguments. + fn send_notification(&self, note: &crate::vt::Notification) { + let Some((program, args)) = self.config.notify.command.split_first() else { + return; + }; + let title = note + .title + .clone() + .or_else(|| self.title.clone()) + .unwrap_or_else(|| "beer".to_string()); + let _ = std::process::Command::new(program) + .args(args) + .arg(title) + .arg(¬e.body) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .inspect_err(|err| tracing::warn!("notify command: {err}")); + } + + /// Ask the compositor to draw attention to the window (xdg-activation). + fn request_attention(&mut self) { + let Some(activation) = self.activation.as_ref() else { + return; + }; + let seat_and_serial = self + .seats + .get(self.active_seat) + .map(|s| (s.seat.clone(), self.serial)); + let data = smithay_client_toolkit::activation::RequestData { + app_id: Some("dev.notashelf.beer".to_string()), + seat_and_serial, + surface: Some(self.window.wl_surface().clone()), + }; + activation.request_token::(&self.qh, data); + } + /// Begin a visual-bell flash: invert the screen for a moment. Clearing the /// buffer ring forces a full repaint with the inverted theme. fn start_flash(&mut self) { @@ -2478,6 +2547,17 @@ impl Dispatch for App { } } +impl ActivationHandler for App { + type RequestData = RequestData; + + fn new_token(&mut self, token: String, _: &RequestData) { + // The compositor granted an activation token; use it to draw attention. + if let Some(activation) = self.activation.as_ref() { + activation.activate::(self.window.wl_surface(), token); + } + } +} + impl Dispatch for App { fn event( _: &mut Self, @@ -2542,3 +2622,4 @@ delegate_xdg_window!(App); delegate_data_device!(App); delegate_primary_selection!(App); delegate_registry!(App); +delegate_activation!(App);