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 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.
|
||||||
|
|
|
||||||
64
src/vt.rs
64
src/vt.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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(¬e);
|
||||||
|
}
|
||||||
|
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(¬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
|
/// 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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue