mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
Merge pull request #876 from gpanders/notifications
Add support for desktop notifications
This commit is contained in:
1
dist/linux/app.desktop
vendored
1
dist/linux/app.desktop
vendored
@ -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
|
||||
|
@ -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;
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
|
@ -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 {
|
||||
@ -249,6 +266,27 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
|
||||
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) {
|
||||
|
@ -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<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
|
||||
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<CChar>?) {
|
||||
let surfaceView = Unmanaged<SurfaceView>.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<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
let surfaceView = self.surfaceUserdata(from: userdata)
|
||||
surfaceView.setCursorShape(shape)
|
||||
}
|
||||
|
||||
static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {
|
||||
let surfaceView = Unmanaged<SurfaceView>.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<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) {
|
||||
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<SurfaceView>.fromOpaque(userdata!).takeUnretainedValue()
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import UserNotifications
|
||||
import GhosttyKit
|
||||
|
||||
extension Ghostty {
|
||||
@ -276,12 +277,15 @@ extension Ghostty {
|
||||
}
|
||||
}
|
||||
|
||||
// Notification identifiers associated with this surface
|
||||
var notificationIdentifiers: Set<String> = []
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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");
|
||||
|
@ -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
|
||||
|
@ -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", .{});
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -121,6 +121,12 @@ pub const Command = union(enum) {
|
||||
value: []const u8,
|
||||
},
|
||||
|
||||
/// Show a desktop notification (OSC 9 or OSC 777)
|
||||
show_desktop_notification: struct {
|
||||
title: []const u8,
|
||||
body: []const u8,
|
||||
},
|
||||
|
||||
pub const ColorKind = union(enum) {
|
||||
palette: u8,
|
||||
foreground,
|
||||
@ -225,6 +231,9 @@ pub const Parser = struct {
|
||||
@"5",
|
||||
@"52",
|
||||
@"7",
|
||||
@"77",
|
||||
@"777",
|
||||
@"9",
|
||||
|
||||
// OSC 10 is used to query or set the current foreground color.
|
||||
query_fg_color,
|
||||
@ -255,6 +264,13 @@ pub const Parser = struct {
|
||||
// Reset color palette index
|
||||
reset_color_palette_index,
|
||||
|
||||
// rxvt extension. Only used for OSC 777 and only the value "notify" is
|
||||
// supported
|
||||
rxvt_extension,
|
||||
|
||||
// Title of a desktop notification
|
||||
notification_title,
|
||||
|
||||
// Expect a string parameter. param_str must be set as well as
|
||||
// buf_start.
|
||||
string,
|
||||
@ -311,6 +327,7 @@ pub const Parser = struct {
|
||||
'4' => self.state = .@"4",
|
||||
'5' => self.state = .@"5",
|
||||
'7' => self.state = .@"7",
|
||||
'9' => self.state = .@"9",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
@ -465,17 +482,6 @@ pub const Parser = struct {
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"7" => switch (c) {
|
||||
';' => {
|
||||
self.command = .{ .report_pwd = .{ .value = "" } };
|
||||
|
||||
self.state = .string;
|
||||
self.temp_state = .{ .str = &self.command.report_pwd.value };
|
||||
self.buf_start = self.buf_idx;
|
||||
},
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"52" => switch (c) {
|
||||
';' => {
|
||||
self.command = .{ .clipboard_contents = undefined };
|
||||
@ -506,6 +512,72 @@ pub const Parser = struct {
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"7" => switch (c) {
|
||||
';' => {
|
||||
self.command = .{ .report_pwd = .{ .value = "" } };
|
||||
|
||||
self.state = .string;
|
||||
self.temp_state = .{ .str = &self.command.report_pwd.value };
|
||||
self.buf_start = self.buf_idx;
|
||||
},
|
||||
'7' => self.state = .@"77",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"77" => switch (c) {
|
||||
'7' => self.state = .@"777",
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.@"777" => switch (c) {
|
||||
';' => {
|
||||
self.state = .rxvt_extension;
|
||||
self.buf_start = self.buf_idx;
|
||||
},
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.rxvt_extension => switch (c) {
|
||||
'a'...'z' => {},
|
||||
';' => {
|
||||
const ext = self.buf[self.buf_start .. self.buf_idx - 1];
|
||||
if (!std.mem.eql(u8, ext, "notify")) {
|
||||
log.warn("unknown rxvt extension: {s}", .{ext});
|
||||
self.state = .invalid;
|
||||
return;
|
||||
}
|
||||
|
||||
self.command = .{ .show_desktop_notification = undefined };
|
||||
self.buf_start = self.buf_idx;
|
||||
self.state = .notification_title;
|
||||
},
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.notification_title => switch (c) {
|
||||
';' => {
|
||||
self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1];
|
||||
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
|
||||
self.buf_start = self.buf_idx;
|
||||
self.state = .string;
|
||||
},
|
||||
else => {},
|
||||
},
|
||||
|
||||
.@"9" => switch (c) {
|
||||
';' => {
|
||||
self.command = .{ .show_desktop_notification = .{
|
||||
.title = "",
|
||||
.body = undefined,
|
||||
} };
|
||||
|
||||
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
|
||||
self.buf_start = self.buf_idx;
|
||||
self.state = .string;
|
||||
},
|
||||
else => self.state = .invalid,
|
||||
},
|
||||
|
||||
.query_fg_color => switch (c) {
|
||||
'?' => {
|
||||
self.command = .{ .report_color = .{ .kind = .foreground } };
|
||||
@ -1128,3 +1200,31 @@ test "OSC: set palette color" {
|
||||
try testing.expectEqual(cmd.set_color.kind, .{ .palette = 17 });
|
||||
try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc");
|
||||
}
|
||||
|
||||
test "OSC: show desktop notification" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .{};
|
||||
|
||||
const input = "9;Hello world";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?;
|
||||
try testing.expect(cmd == .show_desktop_notification);
|
||||
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
|
||||
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world");
|
||||
}
|
||||
|
||||
test "OSC: show desktop notification with title" {
|
||||
const testing = std.testing;
|
||||
|
||||
var p: Parser = .{};
|
||||
|
||||
const input = "777;notify;Title;Body";
|
||||
for (input) |ch| p.next(ch);
|
||||
|
||||
const cmd = p.end('\x1b').?;
|
||||
try testing.expect(cmd == .show_desktop_notification);
|
||||
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title");
|
||||
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body");
|
||||
}
|
||||
|
@ -1067,6 +1067,13 @@ pub fn Stream(comptime Handler: type) type {
|
||||
return;
|
||||
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
||||
},
|
||||
|
||||
.show_desktop_notification => |v| {
|
||||
if (@hasDecl(T, "showDesktopNotification")) {
|
||||
try self.handler.showDesktopNotification(v.title, v.body);
|
||||
return;
|
||||
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
||||
},
|
||||
}
|
||||
|
||||
// Fall through for when we don't have a handler.
|
||||
|
@ -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 = {} });
|
||||
}
|
||||
};
|
||||
|
Reference in New Issue
Block a user