diff --git a/dist/linux/app.desktop b/dist/linux/app.desktop index e6e736da7..c87e73cee 100644 --- a/dist/linux/app.desktop +++ b/dist/linux/app.desktop @@ -9,6 +9,7 @@ Keywords=terminal;tty;pty; StartupNotify=true Terminal=false Actions=new-window; +X-GNOME-UsesNotifications=true [Desktop Action new-window] Name=New Window diff --git a/include/ghostty.h b/include/ghostty.h index 0546115a2..f66f92c47 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -362,6 +362,7 @@ typedef void (*ghostty_runtime_toggle_fullscreen_cb)(void *, ghostty_non_native_ typedef void (*ghostty_runtime_set_initial_window_size_cb)(void *, uint32_t, uint32_t); typedef void (*ghostty_runtime_render_inspector_cb)(void *); typedef void (*ghostty_runtime_set_cell_size_cb)(void *, uint32_t, uint32_t); +typedef void (*ghostty_runtime_show_desktop_notification_cb)(void *, const char *, const char *); typedef struct { void *userdata; @@ -388,6 +389,7 @@ typedef struct { ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb; ghostty_runtime_render_inspector_cb render_inspector_cb; ghostty_runtime_set_cell_size_cb set_cell_size_cb; + ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/AppDelegate.swift b/macos/Sources/AppDelegate.swift index 00cfd8479..600038963 100644 --- a/macos/Sources/AppDelegate.swift +++ b/macos/Sources/AppDelegate.swift @@ -1,8 +1,9 @@ import AppKit +import UserNotifications import OSLog import GhosttyKit -class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyAppStateDelegate { +class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, UNUserNotificationCenterDelegate, GhosttyAppStateDelegate { // The application logger. We should probably move this at some point to a dedicated // class/struct but for now it lives here! 🤷‍♂️ static let logger = Logger( @@ -87,6 +88,22 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp // Register our service provider. This must happen after everything // else is initialized. NSApp.servicesProvider = ServiceProvider() + + // Configure user notifications + let actions = [ + UNNotificationAction(identifier: Ghostty.userNotificationActionShow, title: "Show") + ] + + let center = UNUserNotificationCenter.current() + center.setNotificationCategories([ + UNNotificationCategory( + identifier: Ghostty.userNotificationCategory, + actions: actions, + intentIdentifiers: [], + options: [.customDismissAction] + ) + ]) + center.delegate = self } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -248,7 +265,28 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp private func focusedSurface() -> ghostty_surface_t? { return terminalManager.focusedSurface?.surface } - + + //MARK: - UNUserNotificationCenterDelegate + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive: UNNotificationResponse, + withCompletionHandler: () -> Void + ) { + ghostty.handleUserNotification(response: didReceive) + withCompletionHandler() + } + + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent: UNNotification, + withCompletionHandler: (UNNotificationPresentationOptions) -> Void + ) { + let shouldPresent = ghostty.shouldPresentNotification(notification: willPresent) + let options: UNNotificationPresentationOptions = shouldPresent ? [.banner, .sound] : [] + withCompletionHandler(options) + } + //MARK: - GhosttyAppStateDelegate func configDidReload(_ state: Ghostty.AppState) { diff --git a/macos/Sources/Ghostty/AppState.swift b/macos/Sources/Ghostty/AppState.swift index 0b61fbcb6..f9f4da8b8 100644 --- a/macos/Sources/Ghostty/AppState.swift +++ b/macos/Sources/Ghostty/AppState.swift @@ -1,4 +1,5 @@ import SwiftUI +import UserNotifications import GhosttyKit protocol GhosttyAppStateDelegate: AnyObject { @@ -167,7 +168,10 @@ extension Ghostty { toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) }, set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) }, render_inspector_cb: { userdata in AppState.renderInspector(userdata) }, - set_cell_size_cb: { userdata, width, height in AppState.setCellSize(userdata, width: width, height: height) } + set_cell_size_cb: { userdata, width, height in AppState.setCellSize(userdata, width: width, height: height) }, + show_desktop_notification_cb: { userdata, title, body in + AppState.showUserNotification(userdata, title: title, body: body) + } ) // Create the ghostty app. @@ -350,7 +354,7 @@ extension Ghostty { // MARK: Ghostty Callbacks static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [ "direction": direction, Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), @@ -358,14 +362,14 @@ extension Ghostty { } static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [ "process_alive": processAlive, ]) } static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return } NotificationCenter.default.post( name: Notification.ghosttyFocusSplit, @@ -377,7 +381,7 @@ extension Ghostty { } static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return } NotificationCenter.default.post( name: Notification.didResizeSplit, @@ -390,12 +394,12 @@ extension Ghostty { } static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface) } static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.didToggleSplitZoom, @@ -404,7 +408,7 @@ extension Ghostty { } static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyGotoTab, object: surface, @@ -417,7 +421,7 @@ extension Ghostty { static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { // If we don't even have a surface, something went terrible wrong so we have // to leak "state". - guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let surfaceView = self.surfaceUserdata(from: userdata) guard let surface = surfaceView.surface else { return } // We only support the standard clipboard @@ -436,7 +440,7 @@ extension Ghostty { state: UnsafeMutableRawPointer?, request: ghostty_clipboard_request_e ) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let valueStr = String(cString: string!, encoding: .utf8) else { return } guard let request = Ghostty.ClipboardRequest.from(request: request) else { return } NotificationCenter.default.post( @@ -462,7 +466,7 @@ extension Ghostty { } static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer?, location: ghostty_clipboard_e, confirm: Bool) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) // We only support the standard clipboard if (location != GHOSTTY_CLIPBOARD_STANDARD) { return } @@ -515,7 +519,7 @@ extension Ghostty { } static func renderInspector(_ userdata: UnsafeMutableRawPointer?) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.inspectorNeedsDisplay, object: surface @@ -523,7 +527,7 @@ extension Ghostty { } static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = self.surfaceUserdata(from: userdata) guard let titleStr = String(cString: title!, encoding: .utf8) else { return } DispatchQueue.main.async { surfaceView.title = titleStr @@ -531,17 +535,17 @@ extension Ghostty { } static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.setCursorShape(shape) } static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) { - let surfaceView = Unmanaged.fromOpaque(userdata!).takeUnretainedValue() + let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.setCursorVisibility(visible) } static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyToggleFullscreen, object: surface, @@ -553,18 +557,66 @@ extension Ghostty { static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { // We need a window to set the frame - guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let surfaceView = self.surfaceUserdata(from: userdata) surfaceView.initialSize = NSMakeSize(Double(width), Double(height)) } static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { - guard let surfaceView = self.surfaceUserdata(from: userdata) else { return } + let surfaceView = self.surfaceUserdata(from: userdata) let backingSize = NSSize(width: Double(width), height: Double(height)) surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) } + static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) { + let surfaceView = self.surfaceUserdata(from: userdata) + guard let title = String(cString: title!, encoding: .utf8) else { return } + guard let body = String(cString: body!, encoding: .utf8) else { return } + + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { _, error in + if let error = error { + AppDelegate.logger.error("Error while requesting notification authorization: \(error)") + } + } + + center.getNotificationSettings() { settings in + guard settings.authorizationStatus == .authorized else { return } + surfaceView.showUserNotification(title: title, body: body) + } + } + + /// Handle a received user notification. This is called when a user notification is clicked or dismissed by the user + func handleUserNotification(response: UNNotificationResponse) { + let userInfo = response.notification.request.content.userInfo + guard let address = userInfo["address"] as? Int else { return } + guard let userdata = UnsafeMutableRawPointer(bitPattern: address) else { return } + let surface = Ghostty.AppState.surfaceUserdata(from: userdata) + + switch (response.actionIdentifier) { + case UNNotificationDefaultActionIdentifier, Ghostty.userNotificationActionShow: + // The user clicked on a notification + surface.handleUserNotification(notification: response.notification, focus: true) + case UNNotificationDismissActionIdentifier: + // The user dismissed the notification + surface.handleUserNotification(notification: response.notification, focus: false) + default: + break + } + } + + /// Determine if a given notification should be presented to the user when Ghostty is running in the foreground. + func shouldPresentNotification(notification: UNNotification) -> Bool { + let userInfo = notification.request.content.userInfo + guard let address = userInfo["address"] as? Int else { return false } + guard let userdata = UnsafeMutableRawPointer(bitPattern: address) else { return false } + let surface = Ghostty.AppState.surfaceUserdata(from: userdata) + + guard let window = surface.window else { return false } + return !window.isKeyWindow || !surface.focused + } + static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) guard let appState = self.appState(fromView: surface) else { return } guard appState.windowDecorations else { @@ -587,7 +639,7 @@ extension Ghostty { } static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post( name: Notification.ghosttyNewWindow, @@ -599,7 +651,7 @@ extension Ghostty { } static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) { - guard let surface = self.surfaceUserdata(from: userdata) else { return } + let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [ "mode": mode, ]) @@ -614,7 +666,7 @@ extension Ghostty { } /// Returns the surface view from the userdata. - static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView? { + static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView { return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() } } diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 49ab84ab6..e1f3f5e99 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -4,6 +4,12 @@ import GhosttyKit struct Ghostty { // All the notifications that will be emitted will be put here. struct Notification {} + + // The user notification category identifier + static let userNotificationCategory = "com.mitchellh.ghostty.userNotification" + + // The user notification "Show" action + static let userNotificationActionShow = "com.mitchellh.ghostty.userNotification.Show" } // MARK: Surface Notifications diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index b1a8a69ab..51e9e5eec 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -1,4 +1,5 @@ import SwiftUI +import UserNotifications import GhosttyKit extension Ghostty { @@ -275,13 +276,16 @@ extension Ghostty { } } } + + // Notification identifiers associated with this surface + var notificationIdentifiers: Set = [] private(set) var surface: ghostty_surface_t? var error: Error? = nil private var markedText: NSMutableAttributedString private var mouseEntered: Bool = false - private var focused: Bool = true + private(set) var focused: Bool = true private var cursor: NSCursor = .iBeam private var cursorVisible: CursorVisibility = .visible @@ -348,6 +352,10 @@ extension Ghostty { /// Ghostty resources while references may still be held to this view. I've found that SwiftUI /// tends to hold this view longer than it should so we free the expensive stuff explicitly. func close() { + // Remove any notifications associated with this surface + let identifiers = Array(self.notificationIdentifiers) + UNUserNotificationCenter.current().removeDeliveredNotifications(withIdentifiers: identifiers) + guard let surface = self.surface else { return } ghostty_surface_free(surface) self.surface = nil @@ -1001,6 +1009,52 @@ extension Ghostty { print("SEL: \(selector)") } + + /// Show a user notification and associate it with this surface + func showUserNotification(title: String, body: String) { + let content = UNMutableNotificationContent() + content.title = title + content.subtitle = self.title + content.body = body + content.sound = UNNotificationSound.default + content.categoryIdentifier = Ghostty.userNotificationCategory + + // The userInfo must conform to NSSecureCoding, which SurfaceView + // does not. So instead we pass an integer representation of the + // SurfaceView's address, which is reconstructed back into a + // SurfaceView if the notification is clicked. This is safe to do + // so long as the SurfaceView removes all of its notifications when + // it closes so that there are no dangling pointers. + content.userInfo = [ + "address": Int(bitPattern: Unmanaged.passUnretained(self).toOpaque()), + ] + + let uuid = UUID().uuidString + let request = UNNotificationRequest( + identifier: uuid, + content: content, + trigger: nil + ) + + UNUserNotificationCenter.current().add(request) { error in + if let error = error { + AppDelegate.logger.error("Error scheduling user notification: \(error)") + return + } + + self.notificationIdentifiers.insert(uuid) + } + } + + /// Handle a user notification click + func handleUserNotification(notification: UNNotification, focus: Bool) { + let id = notification.request.identifier + guard self.notificationIdentifiers.remove(id) != nil else { return } + if focus { + self.window?.makeKeyAndOrderFront(self) + Ghostty.moveFocus(to: self) + } + } } } diff --git a/src/Surface.zig b/src/Surface.zig index 70b276658..308e6e878 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,17 @@ 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 = std.mem.sliceTo(¬ification.title, 0); + const body = std.mem.sliceTo(¬ification.body, 0); + try self.showDesktopNotification(title, body); + }, } } @@ -2755,6 +2768,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 5ac6cef43..da74de9bb 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. @@ -932,6 +935,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/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 5a7b2da0b..06293e8e5 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -596,6 +596,50 @@ pub fn getCursorPos(self: *const Surface) !apprt.CursorPos { return self.cursor_pos; } +pub fn showDesktopNotification( + self: *Surface, + title: []const u8, + body: []const u8, +) !void { + // Set a default title if we don't already have one + const t = switch (title.len) { + 0 => "Ghostty", + else => title, + }; + const notif = c.g_notification_new(t.ptr); + defer c.g_object_unref(notif); + c.g_notification_set_body(notif, body.ptr); + + // Find our icon in the current icon theme. Not pretty, but the builtin GIO + // method "g_themed_icon_new" doesn't search XDG_DATA_DIRS, so any install + // not in /usr/share will be unable to find an icon + const display = c.gdk_display_get_default(); + const theme = c.gtk_icon_theme_get_for_display(display); + const icon = c.gtk_icon_theme_lookup_icon( + theme, + "com.mitchellh.ghostty", + null, + 48, + 1, // Window scale + c.GTK_TEXT_DIR_LTR, + 0, + ); + defer c.g_object_unref(icon); + // Get the filepath of the icon we found + const file = c.gtk_icon_paintable_get_file(icon); + defer c.g_object_unref(file); + // Create a GIO icon + const gicon = c.g_file_icon_new(file); + defer c.g_object_unref(gicon); + c.g_notification_set_icon(notif, gicon); + + const g_app: *c.GApplication = @ptrCast(self.app.app); + + // We set the notification ID to the body content. If the content is the + // same, this notification may replace a previous notification + c.g_application_send_notification(g_app, body.ptr, notif); +} + fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void { log.debug("gl surface realized", .{}); diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index abb062503..aaf4738fe 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -51,3 +51,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..8e8fe01e0 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: [63:0]u8, + + /// Desktop notification body. + body: [255:0]u8, + }, }; /// A surface mailbox. diff --git a/src/config/Config.zig b/src/config/Config.zig index 11890d5b5..af0d1d4af 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -658,6 +658,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 2b8f5eee9..79da8eef8 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -2389,4 +2389,22 @@ const StreamHandler = struct { }, } } + + pub fn showDesktopNotification( + self: *StreamHandler, + title: []const u8, + body: []const u8, + ) !void { + var message = apprt.surface.Message{ .desktop_notification = undefined }; + + const title_len = @min(title.len, message.desktop_notification.title.len); + @memcpy(message.desktop_notification.title[0..title_len], title[0..title_len]); + message.desktop_notification.title[title_len] = 0; + + const body_len = @min(body.len, message.desktop_notification.body.len); + @memcpy(message.desktop_notification.body[0..body_len], body[0..body_len]); + message.desktop_notification.body[body_len] = 0; + + _ = self.ev.surface_mailbox.push(message, .{ .forever = {} }); + } };