core: support OSC 9 and OSC 777 for showing desktop notifications

This commit is contained in:
Gregory Anders
2023-11-12 09:39:03 -05:00
parent 1deafe34fb
commit 3f4ea2f763
8 changed files with 217 additions and 11 deletions

View File

@ -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");

View File

@ -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

View File

@ -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,
};

View File

@ -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.

View File

@ -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

View File

@ -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");
}

View File

@ -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.

View File

@ -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 = {} });
}
};