mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 08:46:08 +03:00
core: support OSC 9 and OSC 777 for showing desktop notifications
This commit is contained in:
@ -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");
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -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");
|
||||
}
|
||||
|
@ -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.
|
||||
|
@ -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 = {} });
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user