brewery
notashelf /
69ba5fb30cb408a31874450f5a53d03d39413337

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 69ba5fb30cb4

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified3 files changed+170-3
vt: desktop notifications (OSC 9/777/99) and a configurable bell

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I43ad1f9892ecec1f32c03c67e863b1746a6a6964
diff --git a/src/config.rs b/src/config.rsindex 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<String, String>,@@ -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<String>,+    /// 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<String>,+}++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.rsindex 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<String>,+    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<String>,+    /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.+    notifications: Vec<Notification>, }  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<Notification> {+        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<Vec<u8>> {     Some(out) } +/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.+fn osc_text(field: Option<&&[u8]>) -> Option<String> {+    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<PromptKind> {     match b {@@ -1159,6 +1201,28 @@ mod tests {     }      #[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);         feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");diff --git a/src/wayland.rs b/src/wayland.rsindex 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<std::path::PathBuf>) -> anyhow::R             .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))     });     let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&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<std::path::PathBuf>) -> 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<CursorShapeManager>,     /// IME manager (text-input-v3); per-seat handles live in `seats`.     text_input_manager: Option<ZwpTextInputManagerV3>,+    /// xdg-activation, used to request attention on an urgent bell.+    activation: Option<ActivationState>,     /// 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(&note);+        }+        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(&note.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::<App>(&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<WpViewport, ()> 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::<App>(self.window.wl_surface(), token);+        }+    }+}+ impl Dispatch<ZwpTextInputManagerV3, ()> 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);