forked from NotAShelf/beer
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:
parent
2161d7250f
commit
69ba5fb30c
3 changed files with 170 additions and 3 deletions
|
|
@ -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.
|
||||
|
|
|
|||
64
src/vt.rs
64
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 {
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue