diff --git a/src/Surface.zig b/src/Surface.zig index 16ceb93ae..ce3fd8394 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -147,6 +147,7 @@ const DerivedConfig = struct { clipboard_paste_bracketed_safe: bool, copy_on_select: configpkg.CopyOnSelect, confirm_close_surface: bool, + desktop_notifications: bool, mouse_interval: u64, mouse_hide_while_typing: bool, mouse_shift_capture: configpkg.MouseShiftCapture, @@ -173,6 +174,7 @@ const DerivedConfig = struct { .clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe", .copy_on_select = config.@"copy-on-select", .confirm_close_surface = config.@"confirm-close-surface", + .desktop_notifications = config.@"desktop-notifications", .mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms .mouse_hide_while_typing = config.@"mouse-hide-while-typing", .mouse_shift_capture = config.@"mouse-shift-capture", @@ -713,6 +715,27 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { self.child_exited = true; self.close(); }, + + .desktop_notification => |notification| { + if (!self.config.desktop_notifications) { + log.info("application attempted to display a desktop notification, but 'desktop-notifications' is disabled", .{}); + return; + } + + const title: [:0]const u8 = switch (notification.title) { + .small => |v| v.data[0..v.len :0], + // Stream handler only sends small messages + else => unreachable, + }; + + const body: [:0]const u8 = switch (notification.body) { + .small => |v| v.data[0..v.len :0], + // Stream handler only sends small messages + else => unreachable, + }; + + try self.showDesktopNotification(title, body); + }, } } @@ -2721,6 +2744,12 @@ fn completeClipboardReadOSC52( self.io_thread.wakeup.notify() catch {}; } +fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { + if (@hasDecl(apprt.Surface, "showDesktopNotification")) { + try self.rt_surface.showDesktopNotification(title, body); + } else log.warn("runtime doesn't support desktop notifications", .{}); +} + pub const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf"); pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf"); pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf"); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 9d6dc5d00..43d693385 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -117,6 +117,9 @@ pub const App = struct { /// Called when the cell size changes. set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, + + /// Show a desktop notification to the user. + show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null, }; /// Special values for the goto_tab callback. @@ -939,6 +942,20 @@ pub const Surface = struct { const scale = try self.getContentScale(); return .{ .x = pos.x * scale.x, .y = pos.y * scale.y }; } + + /// Show a desktop notification. + pub fn showDesktopNotification( + self: *const Surface, + title: [:0]const u8, + body: [:0]const u8, + ) !void { + const func = self.app.opts.show_desktop_notification orelse { + log.info("runtime embedder does not support show_desktop_notification", .{}); + return; + }; + + func(self.opts.userdata, title, body); + } }; /// Inspector is the state required for the terminal inspector. A terminal diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index d452bc8eb..9cdbe599a 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -50,3 +50,13 @@ pub const ClipboardRequest = union(ClipboardRequestType) { /// A request to write clipboard contents via OSC 52. osc_52_write: Clipboard, }; + +/// A desktop notification. +pub const DesktopNotification = struct { + /// The title of the notification. May be an empty string to not show a + /// title. + title: []const u8, + + /// The body of a notification. This will always be shown. + body: []const u8, +}; diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index b86fbf54b..cf54a7bb3 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -45,6 +45,15 @@ pub const Message = union(enum) { /// The child process running in the surface has exited. This may trigger /// a surface close, it may not. child_exited: void, + + /// Show a desktop notification. + desktop_notification: struct { + /// Desktop notification title. + title: WriteReq, + + /// Desktop notification body. + body: WriteReq, + }, }; /// A surface mailbox. diff --git a/src/config/Config.zig b/src/config/Config.zig index c13a76bef..56bf73ad9 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -650,6 +650,10 @@ keybind: Keybinds = .{}, /// libadwaita support. @"gtk-adwaita": bool = true, +/// If true (default), applications running in the terminal can show desktop +/// notifications using certain escape sequences such as OSC 9 or OSC 777. +@"desktop-notifications": bool = true, + /// This will be used to set the TERM environment variable. /// HACK: We set this with an "xterm" prefix because vim uses that to enable key /// protocols (specifically this will enable 'modifyOtherKeys'), among other diff --git a/src/terminal/osc.zig b/src/terminal/osc.zig index 2e59eb279..1ab7aa651 100644 --- a/src/terminal/osc.zig +++ b/src/terminal/osc.zig @@ -121,6 +121,12 @@ pub const Command = union(enum) { value: []const u8, }, + /// Show a desktop notification (OSC 9 or OSC 777) + show_desktop_notification: struct { + title: []const u8, + body: []const u8, + }, + pub const ColorKind = union(enum) { palette: u8, foreground, @@ -225,6 +231,9 @@ pub const Parser = struct { @"5", @"52", @"7", + @"77", + @"777", + @"9", // OSC 10 is used to query or set the current foreground color. query_fg_color, @@ -255,6 +264,13 @@ pub const Parser = struct { // Reset color palette index reset_color_palette_index, + // rxvt extension. Only used for OSC 777 and only the value "notify" is + // supported + rxvt_extension, + + // Title of a desktop notification + notification_title, + // Expect a string parameter. param_str must be set as well as // buf_start. string, @@ -311,6 +327,7 @@ pub const Parser = struct { '4' => self.state = .@"4", '5' => self.state = .@"5", '7' => self.state = .@"7", + '9' => self.state = .@"9", else => self.state = .invalid, }, @@ -465,17 +482,6 @@ pub const Parser = struct { else => self.state = .invalid, }, - .@"7" => switch (c) { - ';' => { - self.command = .{ .report_pwd = .{ .value = "" } }; - - self.state = .string; - self.temp_state = .{ .str = &self.command.report_pwd.value }; - self.buf_start = self.buf_idx; - }, - else => self.state = .invalid, - }, - .@"52" => switch (c) { ';' => { self.command = .{ .clipboard_contents = undefined }; @@ -506,6 +512,72 @@ pub const Parser = struct { else => self.state = .invalid, }, + .@"7" => switch (c) { + ';' => { + self.command = .{ .report_pwd = .{ .value = "" } }; + + self.state = .string; + self.temp_state = .{ .str = &self.command.report_pwd.value }; + self.buf_start = self.buf_idx; + }, + '7' => self.state = .@"77", + else => self.state = .invalid, + }, + + .@"77" => switch (c) { + '7' => self.state = .@"777", + else => self.state = .invalid, + }, + + .@"777" => switch (c) { + ';' => { + self.state = .rxvt_extension; + self.buf_start = self.buf_idx; + }, + else => self.state = .invalid, + }, + + .rxvt_extension => switch (c) { + 'a'...'z' => {}, + ';' => { + const ext = self.buf[self.buf_start .. self.buf_idx - 1]; + if (!std.mem.eql(u8, ext, "notify")) { + log.warn("unknown rxvt extension: {s}", .{ext}); + self.state = .invalid; + return; + } + + self.command = .{ .show_desktop_notification = undefined }; + self.buf_start = self.buf_idx; + self.state = .notification_title; + }, + else => self.state = .invalid, + }, + + .notification_title => switch (c) { + ';' => { + self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1]; + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => {}, + }, + + .@"9" => switch (c) { + ';' => { + self.command = .{ .show_desktop_notification = .{ + .title = "", + .body = undefined, + } }; + + self.temp_state = .{ .str = &self.command.show_desktop_notification.body }; + self.buf_start = self.buf_idx; + self.state = .string; + }, + else => self.state = .invalid, + }, + .query_fg_color => switch (c) { '?' => { self.command = .{ .report_color = .{ .kind = .foreground } }; @@ -1128,3 +1200,31 @@ test "OSC: set palette color" { try testing.expectEqual(cmd.set_color.kind, .{ .palette = 17 }); try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc"); } + +test "OSC: show desktop notification" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "9;Hello world"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, ""); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world"); +} + +test "OSC: show desktop notification with title" { + const testing = std.testing; + + var p: Parser = .{}; + + const input = "777;notify;Title;Body"; + for (input) |ch| p.next(ch); + + const cmd = p.end('\x1b').?; + try testing.expect(cmd == .show_desktop_notification); + try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title"); + try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body"); +} diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index c438776dd..be0381f23 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1067,6 +1067,13 @@ pub fn Stream(comptime Handler: type) type { return; } else log.warn("unimplemented OSC callback: {}", .{cmd}); }, + + .show_desktop_notification => |v| { + if (@hasDecl(T, "showDesktopNotification")) { + try self.handler.showDesktopNotification(v.title, v.body); + return; + } else log.warn("unimplemented OSC callback: {}", .{cmd}); + }, } // Fall through for when we don't have a handler. diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index fa85c53d4..85f2ce8ce 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -2388,4 +2388,34 @@ const StreamHandler = struct { }, } } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + // Subtract one to leave room for the sentinel + const max_length = apprt.surface.Message.WriteReq.Small.Max - 1; + if (title.len >= max_length or body.len >= max_length) { + log.warn("requested notification is too long", .{}); + return; + } + + var req_title: apprt.surface.Message.WriteReq.Small = .{}; + @memcpy(req_title.data[0..title.len], title); + req_title.data[title.len] = 0; + req_title.len = @intCast(title.len); + + var req_body: apprt.surface.Message.WriteReq.Small = .{}; + @memcpy(req_body.data[0..body.len], body); + req_body.data[body.len] = 0; + req_body.len = @intCast(body.len); + + _ = self.ev.surface_mailbox.push(.{ + .desktop_notification = .{ + .title = .{ .small = req_title }, + .body = .{ .small = req_body }, + }, + }, .{ .forever = {} }); + } };