Merge pull request #876 from gpanders/notifications

Add support for desktop notifications
This commit is contained in:
Mitchell Hashimoto
2023-11-17 21:57:09 -08:00
committed by GitHub
15 changed files with 417 additions and 36 deletions

View File

@ -9,6 +9,7 @@ Keywords=terminal;tty;pty;
StartupNotify=true StartupNotify=true
Terminal=false Terminal=false
Actions=new-window; Actions=new-window;
X-GNOME-UsesNotifications=true
[Desktop Action new-window] [Desktop Action new-window]
Name=New Window Name=New Window

View File

@ -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_set_initial_window_size_cb)(void *, uint32_t, uint32_t);
typedef void (*ghostty_runtime_render_inspector_cb)(void *); 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_set_cell_size_cb)(void *, uint32_t, uint32_t);
typedef void (*ghostty_runtime_show_desktop_notification_cb)(void *, const char *, const char *);
typedef struct { typedef struct {
void *userdata; void *userdata;
@ -388,6 +389,7 @@ typedef struct {
ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb; ghostty_runtime_set_initial_window_size_cb set_initial_window_size_cb;
ghostty_runtime_render_inspector_cb render_inspector_cb; ghostty_runtime_render_inspector_cb render_inspector_cb;
ghostty_runtime_set_cell_size_cb set_cell_size_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; } ghostty_runtime_config_s;
//------------------------------------------------------------------- //-------------------------------------------------------------------

View File

@ -1,8 +1,9 @@
import AppKit import AppKit
import UserNotifications
import OSLog import OSLog
import GhosttyKit 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 // The application logger. We should probably move this at some point to a dedicated
// class/struct but for now it lives here! 🤷 // class/struct but for now it lives here! 🤷
static let logger = Logger( static let logger = Logger(
@ -87,6 +88,22 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
// Register our service provider. This must happen after everything // Register our service provider. This must happen after everything
// else is initialized. // else is initialized.
NSApp.servicesProvider = ServiceProvider() 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 { func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool {
@ -248,7 +265,28 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
private func focusedSurface() -> ghostty_surface_t? { private func focusedSurface() -> ghostty_surface_t? {
return terminalManager.focusedSurface?.surface 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 //MARK: - GhosttyAppStateDelegate
func configDidReload(_ state: Ghostty.AppState) { func configDidReload(_ state: Ghostty.AppState) {

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UserNotifications
import GhosttyKit import GhosttyKit
protocol GhosttyAppStateDelegate: AnyObject { protocol GhosttyAppStateDelegate: AnyObject {
@ -167,7 +168,10 @@ extension Ghostty {
toggle_fullscreen_cb: { userdata, nonNativeFullscreen in AppState.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) }, 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) }, set_initial_window_size_cb: { userdata, width, height in AppState.setInitialWindowSize(userdata, width: width, height: height) },
render_inspector_cb: { userdata in AppState.renderInspector(userdata) }, 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. // Create the ghostty app.
@ -350,7 +354,7 @@ extension Ghostty {
// MARK: Ghostty Callbacks // MARK: Ghostty Callbacks
static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) { 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: [ NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [
"direction": direction, "direction": direction,
Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config),
@ -358,14 +362,14 @@ extension Ghostty {
} }
static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) { 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: [ NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [
"process_alive": processAlive, "process_alive": processAlive,
]) ])
} }
static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) { 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 } guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return }
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.ghosttyFocusSplit, name: Notification.ghosttyFocusSplit,
@ -377,7 +381,7 @@ extension Ghostty {
} }
static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) { 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 } guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return }
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.didResizeSplit, name: Notification.didResizeSplit,
@ -390,12 +394,12 @@ extension Ghostty {
} }
static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) { 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) NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface)
} }
static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) { static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {
guard let surface = self.surfaceUserdata(from: userdata) else { return } let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.didToggleSplitZoom, name: Notification.didToggleSplitZoom,
@ -404,7 +408,7 @@ extension Ghostty {
} }
static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) { 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( NotificationCenter.default.post(
name: Notification.ghosttyGotoTab, name: Notification.ghosttyGotoTab,
object: surface, object: surface,
@ -417,7 +421,7 @@ extension Ghostty {
static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { 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 // If we don't even have a surface, something went terrible wrong so we have
// to leak "state". // 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 } guard let surface = surfaceView.surface else { return }
// We only support the standard clipboard // We only support the standard clipboard
@ -436,7 +440,7 @@ extension Ghostty {
state: UnsafeMutableRawPointer?, state: UnsafeMutableRawPointer?,
request: ghostty_clipboard_request_e 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 valueStr = String(cString: string!, encoding: .utf8) else { return }
guard let request = Ghostty.ClipboardRequest.from(request: request) else { return } guard let request = Ghostty.ClipboardRequest.from(request: request) else { return }
NotificationCenter.default.post( NotificationCenter.default.post(
@ -462,7 +466,7 @@ extension Ghostty {
} }
static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, location: ghostty_clipboard_e, confirm: Bool) { static func writeClipboard(_ userdata: UnsafeMutableRawPointer?, string: UnsafePointer<CChar>?, 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 // We only support the standard clipboard
if (location != GHOSTTY_CLIPBOARD_STANDARD) { return } if (location != GHOSTTY_CLIPBOARD_STANDARD) { return }
@ -515,7 +519,7 @@ extension Ghostty {
} }
static func renderInspector(_ userdata: UnsafeMutableRawPointer?) { static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {
guard let surface = self.surfaceUserdata(from: userdata) else { return } let surface = self.surfaceUserdata(from: userdata)
NotificationCenter.default.post( NotificationCenter.default.post(
name: Notification.inspectorNeedsDisplay, name: Notification.inspectorNeedsDisplay,
object: surface object: surface
@ -523,7 +527,7 @@ extension Ghostty {
} }
static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) { static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?) {
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue() let surfaceView = self.surfaceUserdata(from: userdata)
guard let titleStr = String(cString: title!, encoding: .utf8) else { return } guard let titleStr = String(cString: title!, encoding: .utf8) else { return }
DispatchQueue.main.async { DispatchQueue.main.async {
surfaceView.title = titleStr surfaceView.title = titleStr
@ -531,17 +535,17 @@ extension Ghostty {
} }
static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue() let surfaceView = self.surfaceUserdata(from: userdata)
surfaceView.setCursorShape(shape) surfaceView.setCursorShape(shape)
} }
static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) { static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {
let surfaceView = Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue() let surfaceView = self.surfaceUserdata(from: userdata)
surfaceView.setCursorVisibility(visible) surfaceView.setCursorVisibility(visible)
} }
static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { 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( NotificationCenter.default.post(
name: Notification.ghosttyToggleFullscreen, name: Notification.ghosttyToggleFullscreen,
object: surface, object: surface,
@ -553,18 +557,66 @@ extension Ghostty {
static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {
// We need a window to set the frame // 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)) surfaceView.initialSize = NSMakeSize(Double(width), Double(height))
} }
static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { 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)) let backingSize = NSSize(width: Double(width), height: Double(height))
surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) surfaceView.cellSize = surfaceView.convertFromBacking(backingSize)
} }
static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer<CChar>?, body: UnsafePointer<CChar>?) {
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) { 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 let appState = self.appState(fromView: surface) else { return }
guard appState.windowDecorations else { guard appState.windowDecorations else {
@ -587,7 +639,7 @@ extension Ghostty {
} }
static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { 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( NotificationCenter.default.post(
name: Notification.ghosttyNewWindow, name: Notification.ghosttyNewWindow,
@ -599,7 +651,7 @@ extension Ghostty {
} }
static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) { 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: [ NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [
"mode": mode, "mode": mode,
]) ])
@ -614,7 +666,7 @@ extension Ghostty {
} }
/// Returns the surface view from the userdata. /// 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<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue() return Unmanaged<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
} }
} }

View File

@ -4,6 +4,12 @@ import GhosttyKit
struct Ghostty { struct Ghostty {
// All the notifications that will be emitted will be put here. // All the notifications that will be emitted will be put here.
struct Notification {} 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 // MARK: Surface Notifications

View File

@ -1,4 +1,5 @@
import SwiftUI import SwiftUI
import UserNotifications
import GhosttyKit import GhosttyKit
extension Ghostty { extension Ghostty {
@ -275,13 +276,16 @@ extension Ghostty {
} }
} }
} }
// Notification identifiers associated with this surface
var notificationIdentifiers: Set<String> = []
private(set) var surface: ghostty_surface_t? private(set) var surface: ghostty_surface_t?
var error: Error? = nil var error: Error? = nil
private var markedText: NSMutableAttributedString private var markedText: NSMutableAttributedString
private var mouseEntered: Bool = false private var mouseEntered: Bool = false
private var focused: Bool = true private(set) var focused: Bool = true
private var cursor: NSCursor = .iBeam private var cursor: NSCursor = .iBeam
private var cursorVisible: CursorVisibility = .visible 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 /// 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. /// tends to hold this view longer than it should so we free the expensive stuff explicitly.
func close() { 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 } guard let surface = self.surface else { return }
ghostty_surface_free(surface) ghostty_surface_free(surface)
self.surface = nil self.surface = nil
@ -1001,6 +1009,52 @@ extension Ghostty {
print("SEL: \(selector)") 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)
}
}
} }
} }

View File

@ -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,17 @@ 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 = std.mem.sliceTo(&notification.title, 0);
const body = std.mem.sliceTo(&notification.body, 0);
try self.showDesktopNotification(title, body);
},
} }
} }
@ -2755,6 +2768,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");

View File

@ -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.
@ -932,6 +935,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

View File

@ -596,6 +596,50 @@ pub fn getCursorPos(self: *const Surface) !apprt.CursorPos {
return self.cursor_pos; 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 { fn gtkRealize(area: *c.GtkGLArea, ud: ?*anyopaque) callconv(.C) void {
log.debug("gl surface realized", .{}); log.debug("gl surface realized", .{});

View File

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

View File

@ -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: [63:0]u8,
/// Desktop notification body.
body: [255:0]u8,
},
}; };
/// A surface mailbox. /// A surface mailbox.

View File

@ -658,6 +658,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

View File

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

View File

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

View File

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