vt: desktop notifications (OSC 9/777/99) and a configurable bell

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I43ad1f9892ecec1f32c03c67e863b1746a6a6964
This commit is contained in:
raf 2026-06-25 14:02:53 +03:00
commit 69ba5fb30c
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
3 changed files with 170 additions and 3 deletions

View file

@ -19,6 +19,7 @@ pub struct Config {
pub mouse: Mouse, pub mouse: Mouse,
pub shell_integration: ShellIntegration, pub shell_integration: ShellIntegration,
pub url: Url, pub url: Url,
pub notify: Notify,
/// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults; /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;
/// a value of `"none"` unbinds. /// a value of `"none"` unbinds.
pub key_bindings: std::collections::HashMap<String, String>, pub key_bindings: std::collections::HashMap<String, String>,
@ -42,6 +43,27 @@ pub struct Cursor {
pub struct Bell { pub struct Bell {
/// Briefly flash the screen. /// Briefly flash the screen.
pub visual: bool, 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. /// `[mouse]`: pointer and wheel behaviour.

View file

@ -34,6 +34,14 @@ pub enum ClipboardOp {
Query { primary: bool }, 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. /// Which dynamic colour an OSC 10/11/17/19 escape targets.
#[derive(Clone, Copy, Debug)] #[derive(Clone, Copy, Debug)]
enum Dynamic { enum Dynamic {
@ -106,6 +114,8 @@ pub struct Term {
bell: bool, bell: bool,
/// Working directory reported by the shell via OSC 7, for new windows. /// Working directory reported by the shell via OSC 7, for new windows.
cwd: Option<String>, cwd: Option<String>,
/// Desktop notifications requested via OSC 9/777/99, drained by the front-end.
notifications: Vec<Notification>,
} }
impl Term { impl Term {
@ -123,6 +133,7 @@ impl Term {
theme: Theme::default(), theme: Theme::default(),
bell: false, bell: false,
cwd: None, cwd: None,
notifications: Vec::new(),
} }
} }
@ -131,6 +142,11 @@ impl Term {
self.cwd.as_deref() 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. /// Take and clear the pending bell flag.
pub fn take_bell(&mut self) -> bool { pub fn take_bell(&mut self) -> bool {
std::mem::take(&mut self.bell) std::mem::take(&mut self.bell)
@ -691,6 +707,27 @@ impl Perform for Term {
let uri = std::str::from_utf8(&uri_bytes).unwrap_or(""); let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");
self.grid.set_link((!uri.is_empty()).then_some(uri)); 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). // OSC 4: set/query palette entries (pairs of index;spec).
Some(&n) if n == b"4" => self.osc_palette(params, bell), Some(&n) if n == b"4" => self.osc_palette(params, bell),
// OSC 104: reset palette (all, or the listed indices). // OSC 104: reset palette (all, or the listed indices).
@ -834,6 +871,11 @@ fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
Some(out) 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`]. /// Map an OSC 133 mark letter to a [`PromptKind`].
fn prompt_kind(b: u8) -> Option<PromptKind> { fn prompt_kind(b: u8) -> Option<PromptKind> {
match b { match b {
@ -1158,6 +1200,28 @@ mod tests {
assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n")); 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] #[test]
fn osc7_tracks_cwd_and_decodes_percent() { fn osc7_tracks_cwd_and_decodes_percent() {
let mut t = Term::new(20, 1); let mut t = Term::new(20, 1);

View file

@ -32,6 +32,7 @@ use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::cl
zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1, zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,
}; };
use smithay_client_toolkit::{ use smithay_client_toolkit::{
activation::{ActivationHandler, ActivationState, RequestData},
compositor::{CompositorHandler, CompositorState}, compositor::{CompositorHandler, CompositorState},
data_device_manager::{ data_device_manager::{
DataDeviceManagerState, WritePipe, DataDeviceManagerState, WritePipe,
@ -39,7 +40,8 @@ use smithay_client_toolkit::{
data_offer::{DataOfferHandler, DragOffer}, data_offer::{DataOfferHandler, DragOffer},
data_source::{CopyPasteSource, DataSourceHandler}, 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_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,
delegate_xdg_shell, delegate_xdg_window, delegate_xdg_shell, delegate_xdg_window,
output::{OutputHandler, OutputState}, 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, ())) .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))
}); });
let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&globals, &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. // First commit with no buffer kicks off the initial configure.
window.commit(); window.commit();
@ -261,6 +264,7 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R
primary_manager, primary_manager,
cursor_shape_manager, cursor_shape_manager,
text_input_manager, text_input_manager,
activation,
preedit: String::new(), preedit: String::new(),
ime_preedit_pending: String::new(), ime_preedit_pending: String::new(),
ime_commit_pending: String::new(), ime_commit_pending: String::new(),
@ -435,6 +439,8 @@ struct App {
cursor_shape_manager: Option<CursorShapeManager>, cursor_shape_manager: Option<CursorShapeManager>,
/// IME manager (text-input-v3); per-seat handles live in `seats`. /// IME manager (text-input-v3); per-seat handles live in `seats`.
text_input_manager: Option<ZwpTextInputManagerV3>, 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. /// Committed IME preedit string shown inline at the cursor while composing.
preedit: String, preedit: String,
/// Preedit/commit accumulated since the last text-input `done`. /// Preedit/commit accumulated since the last text-input `done`.
@ -1569,15 +1575,78 @@ impl App {
} }
let rang = session.term.take_bell(); let rang = session.term.take_bell();
let ops = session.term.take_clipboard_ops(); let ops = session.term.take_clipboard_ops();
let notifications = session.term.take_notifications();
if !ops.is_empty() { if !ops.is_empty() {
self.handle_clipboard_ops(ops); self.handle_clipboard_ops(ops);
} }
if rang && self.config.bell.visual { for note in notifications {
self.start_flash(); self.send_notification(&note);
}
if rang {
self.ring_bell();
} }
self.needs_draw = true; 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 /// Begin a visual-bell flash: invert the screen for a moment. Clearing the
/// buffer ring forces a full repaint with the inverted theme. /// buffer ring forces a full repaint with the inverted theme.
fn start_flash(&mut self) { 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 { impl Dispatch<ZwpTextInputManagerV3, ()> for App {
fn event( fn event(
_: &mut Self, _: &mut Self,
@ -2542,3 +2622,4 @@ delegate_xdg_window!(App);
delegate_data_device!(App); delegate_data_device!(App);
delegate_primary_selection!(App); delegate_primary_selection!(App);
delegate_registry!(App); delegate_registry!(App);
delegate_activation!(App);