clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 69ba5fb30cb4
tarballunverified3 files changed+170-3
@@ -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.@@ -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");@@ -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(¬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::<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);