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
|
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
|
||||||
|
@ -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;
|
||||||
|
|
||||||
//-------------------------------------------------------------------
|
//-------------------------------------------------------------------
|
||||||
|
@ -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 {
|
||||||
@ -249,6 +266,27 @@ class AppDelegate: NSObject, ObservableObject, NSApplicationDelegate, GhosttyApp
|
|||||||
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) {
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import UserNotifications
|
||||||
import GhosttyKit
|
import GhosttyKit
|
||||||
|
|
||||||
extension Ghostty {
|
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?
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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(¬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 {};
|
self.io_thread.wakeup.notify() catch {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void {
|
||||||
|
if (@hasDecl(apprt.Surface, "showDesktopNotification")) {
|
||||||
|
try self.rt_surface.showDesktopNotification(title, body);
|
||||||
|
} else log.warn("runtime doesn't support desktop notifications", .{});
|
||||||
|
}
|
||||||
|
|
||||||
pub const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
|
pub const face_ttf = @embedFile("font/res/FiraCode-Regular.ttf");
|
||||||
pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
|
pub const face_bold_ttf = @embedFile("font/res/FiraCode-Bold.ttf");
|
||||||
pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");
|
pub const face_emoji_ttf = @embedFile("font/res/NotoColorEmoji.ttf");
|
||||||
|
@ -117,6 +117,9 @@ pub const App = struct {
|
|||||||
|
|
||||||
/// Called when the cell size changes.
|
/// Called when the cell size changes.
|
||||||
set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null,
|
set_cell_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null,
|
||||||
|
|
||||||
|
/// Show a desktop notification to the user.
|
||||||
|
show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Special values for the goto_tab callback.
|
/// Special values for the goto_tab callback.
|
||||||
@ -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
|
||||||
|
@ -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", .{});
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
};
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -121,6 +121,12 @@ pub const Command = union(enum) {
|
|||||||
value: []const u8,
|
value: []const u8,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/// Show a desktop notification (OSC 9 or OSC 777)
|
||||||
|
show_desktop_notification: struct {
|
||||||
|
title: []const u8,
|
||||||
|
body: []const u8,
|
||||||
|
},
|
||||||
|
|
||||||
pub const ColorKind = union(enum) {
|
pub const ColorKind = union(enum) {
|
||||||
palette: u8,
|
palette: u8,
|
||||||
foreground,
|
foreground,
|
||||||
@ -225,6 +231,9 @@ pub const Parser = struct {
|
|||||||
@"5",
|
@"5",
|
||||||
@"52",
|
@"52",
|
||||||
@"7",
|
@"7",
|
||||||
|
@"77",
|
||||||
|
@"777",
|
||||||
|
@"9",
|
||||||
|
|
||||||
// OSC 10 is used to query or set the current foreground color.
|
// OSC 10 is used to query or set the current foreground color.
|
||||||
query_fg_color,
|
query_fg_color,
|
||||||
@ -255,6 +264,13 @@ pub const Parser = struct {
|
|||||||
// Reset color palette index
|
// Reset color palette index
|
||||||
reset_color_palette_index,
|
reset_color_palette_index,
|
||||||
|
|
||||||
|
// rxvt extension. Only used for OSC 777 and only the value "notify" is
|
||||||
|
// supported
|
||||||
|
rxvt_extension,
|
||||||
|
|
||||||
|
// Title of a desktop notification
|
||||||
|
notification_title,
|
||||||
|
|
||||||
// Expect a string parameter. param_str must be set as well as
|
// Expect a string parameter. param_str must be set as well as
|
||||||
// buf_start.
|
// buf_start.
|
||||||
string,
|
string,
|
||||||
@ -311,6 +327,7 @@ pub const Parser = struct {
|
|||||||
'4' => self.state = .@"4",
|
'4' => self.state = .@"4",
|
||||||
'5' => self.state = .@"5",
|
'5' => self.state = .@"5",
|
||||||
'7' => self.state = .@"7",
|
'7' => self.state = .@"7",
|
||||||
|
'9' => self.state = .@"9",
|
||||||
else => self.state = .invalid,
|
else => self.state = .invalid,
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -465,17 +482,6 @@ pub const Parser = struct {
|
|||||||
else => self.state = .invalid,
|
else => self.state = .invalid,
|
||||||
},
|
},
|
||||||
|
|
||||||
.@"7" => switch (c) {
|
|
||||||
';' => {
|
|
||||||
self.command = .{ .report_pwd = .{ .value = "" } };
|
|
||||||
|
|
||||||
self.state = .string;
|
|
||||||
self.temp_state = .{ .str = &self.command.report_pwd.value };
|
|
||||||
self.buf_start = self.buf_idx;
|
|
||||||
},
|
|
||||||
else => self.state = .invalid,
|
|
||||||
},
|
|
||||||
|
|
||||||
.@"52" => switch (c) {
|
.@"52" => switch (c) {
|
||||||
';' => {
|
';' => {
|
||||||
self.command = .{ .clipboard_contents = undefined };
|
self.command = .{ .clipboard_contents = undefined };
|
||||||
@ -506,6 +512,72 @@ pub const Parser = struct {
|
|||||||
else => self.state = .invalid,
|
else => self.state = .invalid,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
.@"7" => switch (c) {
|
||||||
|
';' => {
|
||||||
|
self.command = .{ .report_pwd = .{ .value = "" } };
|
||||||
|
|
||||||
|
self.state = .string;
|
||||||
|
self.temp_state = .{ .str = &self.command.report_pwd.value };
|
||||||
|
self.buf_start = self.buf_idx;
|
||||||
|
},
|
||||||
|
'7' => self.state = .@"77",
|
||||||
|
else => self.state = .invalid,
|
||||||
|
},
|
||||||
|
|
||||||
|
.@"77" => switch (c) {
|
||||||
|
'7' => self.state = .@"777",
|
||||||
|
else => self.state = .invalid,
|
||||||
|
},
|
||||||
|
|
||||||
|
.@"777" => switch (c) {
|
||||||
|
';' => {
|
||||||
|
self.state = .rxvt_extension;
|
||||||
|
self.buf_start = self.buf_idx;
|
||||||
|
},
|
||||||
|
else => self.state = .invalid,
|
||||||
|
},
|
||||||
|
|
||||||
|
.rxvt_extension => switch (c) {
|
||||||
|
'a'...'z' => {},
|
||||||
|
';' => {
|
||||||
|
const ext = self.buf[self.buf_start .. self.buf_idx - 1];
|
||||||
|
if (!std.mem.eql(u8, ext, "notify")) {
|
||||||
|
log.warn("unknown rxvt extension: {s}", .{ext});
|
||||||
|
self.state = .invalid;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.command = .{ .show_desktop_notification = undefined };
|
||||||
|
self.buf_start = self.buf_idx;
|
||||||
|
self.state = .notification_title;
|
||||||
|
},
|
||||||
|
else => self.state = .invalid,
|
||||||
|
},
|
||||||
|
|
||||||
|
.notification_title => switch (c) {
|
||||||
|
';' => {
|
||||||
|
self.command.show_desktop_notification.title = self.buf[self.buf_start .. self.buf_idx - 1];
|
||||||
|
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
|
||||||
|
self.buf_start = self.buf_idx;
|
||||||
|
self.state = .string;
|
||||||
|
},
|
||||||
|
else => {},
|
||||||
|
},
|
||||||
|
|
||||||
|
.@"9" => switch (c) {
|
||||||
|
';' => {
|
||||||
|
self.command = .{ .show_desktop_notification = .{
|
||||||
|
.title = "",
|
||||||
|
.body = undefined,
|
||||||
|
} };
|
||||||
|
|
||||||
|
self.temp_state = .{ .str = &self.command.show_desktop_notification.body };
|
||||||
|
self.buf_start = self.buf_idx;
|
||||||
|
self.state = .string;
|
||||||
|
},
|
||||||
|
else => self.state = .invalid,
|
||||||
|
},
|
||||||
|
|
||||||
.query_fg_color => switch (c) {
|
.query_fg_color => switch (c) {
|
||||||
'?' => {
|
'?' => {
|
||||||
self.command = .{ .report_color = .{ .kind = .foreground } };
|
self.command = .{ .report_color = .{ .kind = .foreground } };
|
||||||
@ -1128,3 +1200,31 @@ test "OSC: set palette color" {
|
|||||||
try testing.expectEqual(cmd.set_color.kind, .{ .palette = 17 });
|
try testing.expectEqual(cmd.set_color.kind, .{ .palette = 17 });
|
||||||
try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc");
|
try testing.expectEqualStrings(cmd.set_color.value, "rgb:aa/bb/cc");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "OSC: show desktop notification" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
var p: Parser = .{};
|
||||||
|
|
||||||
|
const input = "9;Hello world";
|
||||||
|
for (input) |ch| p.next(ch);
|
||||||
|
|
||||||
|
const cmd = p.end('\x1b').?;
|
||||||
|
try testing.expect(cmd == .show_desktop_notification);
|
||||||
|
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "");
|
||||||
|
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Hello world");
|
||||||
|
}
|
||||||
|
|
||||||
|
test "OSC: show desktop notification with title" {
|
||||||
|
const testing = std.testing;
|
||||||
|
|
||||||
|
var p: Parser = .{};
|
||||||
|
|
||||||
|
const input = "777;notify;Title;Body";
|
||||||
|
for (input) |ch| p.next(ch);
|
||||||
|
|
||||||
|
const cmd = p.end('\x1b').?;
|
||||||
|
try testing.expect(cmd == .show_desktop_notification);
|
||||||
|
try testing.expectEqualStrings(cmd.show_desktop_notification.title, "Title");
|
||||||
|
try testing.expectEqualStrings(cmd.show_desktop_notification.body, "Body");
|
||||||
|
}
|
||||||
|
@ -1067,6 +1067,13 @@ pub fn Stream(comptime Handler: type) type {
|
|||||||
return;
|
return;
|
||||||
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
.show_desktop_notification => |v| {
|
||||||
|
if (@hasDecl(T, "showDesktopNotification")) {
|
||||||
|
try self.handler.showDesktopNotification(v.title, v.body);
|
||||||
|
return;
|
||||||
|
} else log.warn("unimplemented OSC callback: {}", .{cmd});
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall through for when we don't have a handler.
|
// Fall through for when we don't have a handler.
|
||||||
|
@ -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