mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-07-31 12:49:03 +00:00
terminal: expose desktop notification effect
This commit is contained in:
@@ -94,6 +94,7 @@ extern "C" {
|
||||
* | `GHOSTTY_TERMINAL_OPT_COLOR_SCHEME` | `GhosttyTerminalColorSchemeFn` | Color scheme query (CSI ? 996 n) |
|
||||
* | `GHOSTTY_TERMINAL_OPT_DEVICE_ATTRIBUTES`| `GhosttyTerminalDeviceAttributesFn`| Device attributes query (CSI c / > c / = c)|
|
||||
* | `GHOSTTY_TERMINAL_OPT_CLIPBOARD_WRITE` | `GhosttyTerminalClipboardWriteFn` | Clipboard write via OSC 52 / OSC 1337 |
|
||||
* | `GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION`| `GhosttyTerminalDesktopNotificationFn` | Desktop notification via OSC 9 / OSC 777 |
|
||||
*
|
||||
* ### Defining a write_pty callback
|
||||
* @snippet c-vt-effects/src/main.c effects-write-pty
|
||||
@@ -447,6 +448,42 @@ typedef GhosttyClipboardWriteResult (*GhosttyTerminalClipboardWriteFn)(
|
||||
void* userdata,
|
||||
const GhosttyClipboardWrite* write);
|
||||
|
||||
/**
|
||||
* A request to show a desktop notification.
|
||||
*
|
||||
* This is a sized struct. The callback must only access fields present in the
|
||||
* size reported by `size`. Both strings are borrowed and valid only for the
|
||||
* duration of the callback.
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef struct {
|
||||
/** Size of this struct in bytes. */
|
||||
size_t size;
|
||||
|
||||
/** Notification title, or an empty string when the protocol omits it. */
|
||||
GhosttyString title;
|
||||
|
||||
/** Notification body. */
|
||||
GhosttyString body;
|
||||
} GhosttyTerminalDesktopNotification;
|
||||
|
||||
/**
|
||||
* Callback function type for desktop notifications.
|
||||
*
|
||||
* Called synchronously when the terminal receives OSC 9 or OSC 777.
|
||||
*
|
||||
* @param terminal The terminal handle
|
||||
* @param userdata The userdata pointer set via GHOSTTY_TERMINAL_OPT_USERDATA
|
||||
* @param notification Borrowed desktop notification request
|
||||
*
|
||||
* @ingroup terminal
|
||||
*/
|
||||
typedef void (*GhosttyTerminalDesktopNotificationFn)(
|
||||
GhosttyTerminal terminal,
|
||||
void* userdata,
|
||||
const GhosttyTerminalDesktopNotification* notification);
|
||||
|
||||
/**
|
||||
* Callback function type for color scheme queries (CSI ? 996 n).
|
||||
*
|
||||
@@ -914,6 +951,15 @@ typedef enum GHOSTTY_ENUM_TYPED {
|
||||
* Input type: size_t*
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_SCROLLBACK_MAX_LINES = 28,
|
||||
|
||||
/**
|
||||
* Callback invoked when the running program requests a desktop
|
||||
* notification via OSC 9 or OSC 777. Set to NULL to ignore desktop
|
||||
* notification requests.
|
||||
*
|
||||
* Input type: GhosttyTerminalDesktopNotificationFn
|
||||
*/
|
||||
GHOSTTY_TERMINAL_OPT_DESKTOP_NOTIFICATION = 29,
|
||||
GHOSTTY_TERMINAL_OPT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE,
|
||||
} GhosttyTerminalOption;
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ const build_options = @import("terminal_options");
|
||||
const lib = @import("../lib.zig");
|
||||
const CAllocator = lib.alloc.Allocator;
|
||||
pub const ZigTerminal = @import("../Terminal.zig");
|
||||
const Action = @import("../stream.zig").Action;
|
||||
const Stream = @import("../stream_terminal.zig").Stream;
|
||||
const Screen = @import("../Screen.zig");
|
||||
const ScreenSet = @import("../ScreenSet.zig");
|
||||
@@ -81,6 +82,15 @@ pub const ClipboardWrite = extern struct {
|
||||
contents_len: usize,
|
||||
};
|
||||
|
||||
/// A request to show a desktop notification.
|
||||
///
|
||||
/// C: GhosttyTerminalDesktopNotification
|
||||
pub const DesktopNotification = extern struct {
|
||||
size: usize,
|
||||
title: lib.String,
|
||||
body: lib.String,
|
||||
};
|
||||
|
||||
/// C callback state for terminal effects. Trampolines are always
|
||||
/// installed on the stream handler; they check these fields and
|
||||
/// no-op when the corresponding callback is null.
|
||||
@@ -89,6 +99,7 @@ const Effects = struct {
|
||||
write_pty: ?WritePtyFn = null,
|
||||
bell: ?BellFn = null,
|
||||
color_scheme: ?ColorSchemeFn = null,
|
||||
desktop_notification: ?DesktopNotificationFn = null,
|
||||
device_attributes_cb: ?DeviceAttributesFn = null,
|
||||
enquiry: ?EnquiryFn = null,
|
||||
xtversion: ?XtversionFn = null,
|
||||
@@ -130,6 +141,10 @@ const Effects = struct {
|
||||
/// and its contents are borrowed and only valid for the callback duration.
|
||||
pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
|
||||
|
||||
/// C function pointer type for the desktop_notification callback. The
|
||||
/// request and its strings are borrowed for the callback duration.
|
||||
pub const DesktopNotificationFn = *const fn (Terminal, ?*anyopaque, *const DesktopNotification) callconv(lib.calling_conv) void;
|
||||
|
||||
/// C function pointer type for the title_changed callback.
|
||||
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
|
||||
|
||||
@@ -219,6 +234,26 @@ const Effects = struct {
|
||||
return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
|
||||
}
|
||||
|
||||
fn desktopNotificationTrampoline(
|
||||
handler: *Handler,
|
||||
notification: Action.ShowDesktopNotification,
|
||||
) void {
|
||||
const wrapper = TerminalWrapper.fromHandler(handler);
|
||||
const func = wrapper.effects.desktop_notification orelse return;
|
||||
const request: DesktopNotification = .{
|
||||
.size = @sizeOf(DesktopNotification),
|
||||
.title = .{
|
||||
.ptr = notification.title.ptr,
|
||||
.len = notification.title.len,
|
||||
},
|
||||
.body = .{
|
||||
.ptr = notification.body.ptr,
|
||||
.len = notification.body.len,
|
||||
},
|
||||
};
|
||||
func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
|
||||
}
|
||||
|
||||
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
|
||||
const wrapper = TerminalWrapper.fromHandler(handler);
|
||||
const func = wrapper.effects.color_scheme orelse return null;
|
||||
@@ -376,6 +411,7 @@ fn new_(
|
||||
.write_pty = &Effects.writePtyTrampoline,
|
||||
.bell = &Effects.bellTrampoline,
|
||||
.color_scheme = &Effects.colorSchemeTrampoline,
|
||||
.desktop_notification = &Effects.desktopNotificationTrampoline,
|
||||
.device_attributes = &Effects.deviceAttributesTrampoline,
|
||||
.enquiry = &Effects.enquiryTrampoline,
|
||||
.xtversion = &Effects.xtversionTrampoline,
|
||||
@@ -457,6 +493,7 @@ pub const Option = enum(c_int) {
|
||||
clipboard_write = 26,
|
||||
scrollback_max_bytes = 27,
|
||||
scrollback_max_lines = 28,
|
||||
desktop_notification = 29,
|
||||
|
||||
/// Input type expected for setting the option.
|
||||
pub fn InType(comptime self: Option) type {
|
||||
@@ -465,6 +502,7 @@ pub const Option = enum(c_int) {
|
||||
.write_pty => ?Effects.WritePtyFn,
|
||||
.bell => ?Effects.BellFn,
|
||||
.color_scheme => ?Effects.ColorSchemeFn,
|
||||
.desktop_notification => ?Effects.DesktopNotificationFn,
|
||||
.device_attributes => ?Effects.DeviceAttributesFn,
|
||||
.enquiry => ?Effects.EnquiryFn,
|
||||
.xtversion => ?Effects.XtversionFn,
|
||||
@@ -526,6 +564,7 @@ fn setTyped(
|
||||
.write_pty => wrapper.effects.write_pty = value,
|
||||
.bell => wrapper.effects.bell = value,
|
||||
.color_scheme => wrapper.effects.color_scheme = value,
|
||||
.desktop_notification => wrapper.effects.desktop_notification = value,
|
||||
.device_attributes => wrapper.effects.device_attributes_cb = value,
|
||||
.enquiry => wrapper.effects.enquiry = value,
|
||||
.xtversion => wrapper.effects.xtversion = value,
|
||||
@@ -2843,6 +2882,81 @@ test "title_changed without callback is silent" {
|
||||
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
|
||||
}
|
||||
|
||||
test "set desktop_notification callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
&lib.alloc.test_allocator,
|
||||
&t,
|
||||
.{
|
||||
.cols = 80,
|
||||
.rows = 24,
|
||||
.max_scrollback = 0,
|
||||
},
|
||||
));
|
||||
defer free(t);
|
||||
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
var last_userdata: ?*anyopaque = null;
|
||||
var last_size: usize = 0;
|
||||
var title: [64]u8 = undefined;
|
||||
var title_len: usize = 0;
|
||||
var body: [64]u8 = undefined;
|
||||
var body_len: usize = 0;
|
||||
|
||||
fn desktopNotification(
|
||||
_: Terminal,
|
||||
ud: ?*anyopaque,
|
||||
notification: *const DesktopNotification,
|
||||
) callconv(lib.calling_conv) void {
|
||||
count += 1;
|
||||
last_userdata = ud;
|
||||
last_size = notification.size;
|
||||
title_len = notification.title.len;
|
||||
body_len = notification.body.len;
|
||||
@memcpy(title[0..title_len], notification.title.ptr[0..title_len]);
|
||||
@memcpy(body[0..body_len], notification.body.ptr[0..body_len]);
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
S.last_userdata = null;
|
||||
S.last_size = 0;
|
||||
S.title_len = 0;
|
||||
S.body_len = 0;
|
||||
|
||||
var sentinel: u8 = 99;
|
||||
try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
|
||||
try testing.expectEqual(Result.success, set(
|
||||
t,
|
||||
.desktop_notification,
|
||||
@ptrCast(&S.desktopNotification),
|
||||
));
|
||||
|
||||
// Split OSC 777 across writes to exercise the persistent VT parser.
|
||||
const seq_a = "\x1B]777;notify;Codex;";
|
||||
const seq_b = "Needs attention\x1B\\";
|
||||
vt_write(t, seq_a, seq_a.len);
|
||||
try testing.expectEqual(@as(usize, 0), S.count);
|
||||
vt_write(t, seq_b, seq_b.len);
|
||||
try testing.expectEqual(@as(usize, 1), S.count);
|
||||
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
|
||||
try testing.expectEqual(@sizeOf(DesktopNotification), S.last_size);
|
||||
try testing.expectEqualStrings("Codex", S.title[0..S.title_len]);
|
||||
try testing.expectEqualStrings("Needs attention", S.body[0..S.body_len]);
|
||||
|
||||
// OSC 9 has no title and preserves its body.
|
||||
const seq_c = "\x1B]9;Build complete\x07";
|
||||
vt_write(t, seq_c, seq_c.len);
|
||||
try testing.expectEqual(@as(usize, 2), S.count);
|
||||
try testing.expectEqualStrings("", S.title[0..S.title_len]);
|
||||
try testing.expectEqualStrings("Build complete", S.body[0..S.body_len]);
|
||||
|
||||
// Removing the callback takes effect immediately.
|
||||
try testing.expectEqual(Result.success, set(t, .desktop_notification, null));
|
||||
vt_write(t, seq_c, seq_c.len);
|
||||
try testing.expectEqual(@as(usize, 2), S.count);
|
||||
}
|
||||
|
||||
test "set pwd_changed callback" {
|
||||
var t: Terminal = null;
|
||||
try testing.expectEqual(Result.success, new(
|
||||
|
||||
@@ -68,6 +68,7 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
|
||||
.{ "GhosttySurfacePosition", StructInfo.init(SurfacePosition) },
|
||||
.{ "GhosttyStyle", StructInfo.init(style_c.Style) },
|
||||
.{ "GhosttyStyleColor", StructInfo.init(style_c.Color) },
|
||||
.{ "GhosttyTerminalDesktopNotification", StructInfo.init(terminal.DesktopNotification) },
|
||||
.{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) },
|
||||
.{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) },
|
||||
});
|
||||
|
||||
@@ -74,6 +74,11 @@ pub const Handler = struct {
|
||||
/// Called when the bell is rung (BEL).
|
||||
bell: ?*const fn (*Handler) void,
|
||||
|
||||
/// Called when the running program requests a desktop notification
|
||||
/// via OSC 9 or OSC 777. The title and body are borrowed and only
|
||||
/// valid for the duration of the callback.
|
||||
desktop_notification: ?*const fn (*Handler, Action.ShowDesktopNotification) void,
|
||||
|
||||
/// Called in response to a color scheme DSR query (CSI ? 996 n).
|
||||
/// Returns the current color scheme. Return null to silently
|
||||
/// ignore the query.
|
||||
@@ -131,6 +136,7 @@ pub const Handler = struct {
|
||||
.bell = null,
|
||||
.clipboard_write = null,
|
||||
.color_scheme = null,
|
||||
.desktop_notification = null,
|
||||
.device_attributes = null,
|
||||
.enquiry = null,
|
||||
.size = null,
|
||||
@@ -314,6 +320,7 @@ pub const Handler = struct {
|
||||
|
||||
// Effect-based handlers
|
||||
.bell => self.bell(),
|
||||
.show_desktop_notification => self.desktopNotification(value),
|
||||
.device_attributes => self.reportDeviceAttributes(value),
|
||||
.device_status => self.deviceStatus(value.request),
|
||||
.enquiry => self.reportEnquiry(),
|
||||
@@ -337,7 +344,6 @@ pub const Handler = struct {
|
||||
.dcs_unhook => try self.dcsUnhook(),
|
||||
|
||||
// Have no terminal-modifying effect
|
||||
.show_desktop_notification,
|
||||
.progress_report,
|
||||
.title_push,
|
||||
.title_pop,
|
||||
@@ -396,6 +402,14 @@ pub const Handler = struct {
|
||||
func(self);
|
||||
}
|
||||
|
||||
fn desktopNotification(
|
||||
self: *Handler,
|
||||
notification: Action.ShowDesktopNotification,
|
||||
) void {
|
||||
const func = self.effects.desktop_notification orelse return;
|
||||
func(self, notification);
|
||||
}
|
||||
|
||||
fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
|
||||
const func = self.effects.clipboard_write orelse return;
|
||||
|
||||
@@ -2035,6 +2049,63 @@ test "bell effect callback" {
|
||||
}
|
||||
}
|
||||
|
||||
test "desktop_notification effect callback" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
// A null callback (the default readonly effects) silently ignores
|
||||
// notifications and leaves the terminal usable.
|
||||
{
|
||||
var s: Stream = .initAlloc(testing.allocator, .init(&t));
|
||||
defer s.deinit();
|
||||
|
||||
s.nextSlice("\x1B]9;Ignored\x1B\\AfterNotification");
|
||||
const str = try t.plainString(testing.allocator);
|
||||
defer testing.allocator.free(str);
|
||||
try testing.expectEqualStrings("AfterNotification", str);
|
||||
}
|
||||
|
||||
t.fullReset();
|
||||
|
||||
const S = struct {
|
||||
var count: usize = 0;
|
||||
var last_title: []const u8 = "";
|
||||
var last_body: []const u8 = "";
|
||||
|
||||
fn desktopNotification(
|
||||
_: *Handler,
|
||||
notification: Action.ShowDesktopNotification,
|
||||
) void {
|
||||
count += 1;
|
||||
last_title = notification.title;
|
||||
last_body = notification.body;
|
||||
}
|
||||
};
|
||||
S.count = 0;
|
||||
S.last_title = "";
|
||||
S.last_body = "";
|
||||
|
||||
var handler: Handler = .init(&t);
|
||||
handler.effects.desktop_notification = &S.desktopNotification;
|
||||
|
||||
var s: Stream = .initAlloc(testing.allocator, handler);
|
||||
defer s.deinit();
|
||||
|
||||
// OSC 9 is split across writes and carries only a body.
|
||||
s.nextSlice("\x1B]9;Build ");
|
||||
try testing.expectEqual(@as(usize, 0), S.count);
|
||||
s.nextSlice("complete\x1B\\");
|
||||
try testing.expectEqual(@as(usize, 1), S.count);
|
||||
try testing.expectEqualStrings("", S.last_title);
|
||||
try testing.expectEqualStrings("Build complete", S.last_body);
|
||||
|
||||
// OSC 777 preserves its separate title and body fields.
|
||||
s.nextSlice("\x1B]777;notify;Codex;Needs attention\x07");
|
||||
try testing.expectEqual(@as(usize, 2), S.count);
|
||||
try testing.expectEqualStrings("Codex", S.last_title);
|
||||
try testing.expectEqualStrings("Needs attention", S.last_body);
|
||||
}
|
||||
|
||||
test "clipboard_write effect callback" {
|
||||
var t: Terminal = try .init(testing.io, testing.allocator, .{ .cols = 80, .rows = 24 });
|
||||
defer t.deinit(testing.allocator);
|
||||
|
||||
Reference in New Issue
Block a user