mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 16:56:09 +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,
|
clipboard_paste_bracketed_safe: bool,
|
||||||
copy_on_select: configpkg.CopyOnSelect,
|
copy_on_select: configpkg.CopyOnSelect,
|
||||||
confirm_close_surface: bool,
|
confirm_close_surface: bool,
|
||||||
|
desktop_notifications: bool,
|
||||||
mouse_interval: u64,
|
mouse_interval: u64,
|
||||||
mouse_hide_while_typing: bool,
|
mouse_hide_while_typing: bool,
|
||||||
mouse_shift_capture: configpkg.MouseShiftCapture,
|
mouse_shift_capture: configpkg.MouseShiftCapture,
|
||||||
@ -173,6 +174,7 @@ const DerivedConfig = struct {
|
|||||||
.clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe",
|
.clipboard_paste_bracketed_safe = config.@"clipboard-paste-bracketed-safe",
|
||||||
.copy_on_select = config.@"copy-on-select",
|
.copy_on_select = config.@"copy-on-select",
|
||||||
.confirm_close_surface = config.@"confirm-close-surface",
|
.confirm_close_surface = config.@"confirm-close-surface",
|
||||||
|
.desktop_notifications = config.@"desktop-notifications",
|
||||||
.mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms
|
.mouse_interval = config.@"click-repeat-interval" * 1_000_000, // 500ms
|
||||||
.mouse_hide_while_typing = config.@"mouse-hide-while-typing",
|
.mouse_hide_while_typing = config.@"mouse-hide-while-typing",
|
||||||
.mouse_shift_capture = config.@"mouse-shift-capture",
|
.mouse_shift_capture = config.@"mouse-shift-capture",
|
||||||
@ -713,6 +715,27 @@ pub fn handleMessage(self: *Surface, msg: Message) !void {
|
|||||||
self.child_exited = true;
|
self.child_exited = true;
|
||||||
self.close();
|
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 {};
|
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_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
|
||||||
pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
|
pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
|
||||||
pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.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.
|
/// Called when the cell size changes.
|
||||||
set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null,
|
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.
|
/// Special values for the goto_tab callback.
|
||||||
@ -939,6 +942,20 @@ pub const Surface = struct {
|
|||||||
const scale = try self.getContentScale();
|
const scale = try self.getContentScale();
|
||||||
return .{ .x = pos.x * scale.x, .y = pos.y * scale.y };
|
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
|
/// 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.
|
/// A request to write clipboard contents via OSC 52.
|
||||||
osc_52_write: Clipboard,
|
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
|
/// The child process running in the surface has exited. This may trigger
|
||||||
/// a surface close, it may not.
|
/// a surface close, it may not.
|
||||||
child_exited: void,
|
child_exited: void,
|
||||||
|
|
||||||
|
/// Show a desktop notification.
|
||||||
|
desktop_notification: struct {
|
||||||
|
/// Desktop notification title.
|
||||||
|
title: WriteReq,
|
||||||
|
|
||||||
|
/// Desktop notification body.
|
||||||
|
body: WriteReq,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// A surface mailbox.
|
/// A surface mailbox.
|
||||||
|
@ -650,6 +650,10 @@ keybind: Keybinds = .{},
|
|||||||
/// libadwaita support.
|
/// libadwaita support.
|
||||||
@"gtk-adwaita": bool = true,
|
@"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.
|
/// 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
|
/// HACK: We set this with an "xterm" prefix because vim uses that to enable key
|
||||||
/// protocols (specifically this will enable 'modifyOtherKeys'), among other
|
/// protocols (specifically this will enable 'modifyOtherKeys'), among other
|
||||||
|
@ -121,6 +121,12 @@ pub const Command = union(enum) {
|
|||||||
value: []const u8,
|
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) {
|
pub const ColorKind = union(enum) {
|
||||||
palette: u8,
|
palette: u8,
|
||||||
foreground,
|
foreground,
|
||||||
@ -225,6 +231,9 @@ pub const Parser = struct {
|
|||||||
@"5",
|
@"5",
|
||||||
@"52",
|
@"52",
|
||||||
@"7",
|
@"7",
|
||||||
|
@"77",
|
||||||
|
@"777",
|
||||||
|
@"9",
|
||||||
|
|
||||||
// OSC 10 is used to query or set the current foreground color.
|
// OSC 10 is used to query or set the current foreground color.
|
||||||
query_fg_color,
|
query_fg_color,
|
||||||
@ -255,6 +264,13 @@ pub const Parser = struct {
|
|||||||
// Reset color palette index
|
// Reset color palette index
|
||||||
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
|
// Expect a string parameter. param_str must be set as well as
|
||||||
// buf_start.
|
// buf_start.
|
||||||
string,
|
string,
|
||||||
@ -311,6 +327,7 @@ pub const Parser = struct {
|
|||||||
'4' => self.state = .@"4",
|
'4' => self.state = .@"4",
|
||||||
'5' => self.state = .@"5",
|
'5' => self.state = .@"5",
|
||||||
'7' => self.state = .@"7",
|
'7' => self.state = .@"7",
|
||||||
|
'9' => self.state = .@"9",
|
||||||
else => self.state = .invalid,
|
else => self.state = .invalid,
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -465,17 +482,6 @@ pub const Parser = struct {
|
|||||||
else => self.state = .invalid,
|
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) {
|
.@"52" => switch (c) {
|
||||||
';' => {
|
';' => {
|
||||||
self.command = .{ .clipboard_contents = undefined };
|
self.command = .{ .clipboard_contents = undefined };
|
||||||
@ -506,6 +512,72 @@ pub const Parser = struct {
|
|||||||
else => self.state = .invalid,
|
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) {
|
.query_fg_color => switch (c) {
|
||||||
'?' => {
|
'?' => {
|
||||||
self.command = .{ .report_color = .{ .kind = .foreground } };
|
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.expectEqual(cmd.set_color.kind, .{ .palette = 17 });
|
||||||
try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc");
|
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;
|
return;
|
||||||
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
} 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.
|
// 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