From c26da4ea06b30e8a74dea7b7b41c5040d14d3a0e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 09:19:48 -0700 Subject: [PATCH 001/139] pkg/macos: expose carbon API --- pkg/macos/carbon.zig | 5 +++++ pkg/macos/carbon/c.zig | 3 +++ pkg/macos/main.zig | 1 + 3 files changed, 9 insertions(+) create mode 100644 pkg/macos/carbon.zig create mode 100644 pkg/macos/carbon/c.zig diff --git a/pkg/macos/carbon.zig b/pkg/macos/carbon.zig new file mode 100644 index 000000000..8eafaffe6 --- /dev/null +++ b/pkg/macos/carbon.zig @@ -0,0 +1,5 @@ +pub const c = @import("carbon/c.zig").c; + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/pkg/macos/carbon/c.zig b/pkg/macos/carbon/c.zig new file mode 100644 index 000000000..248af3c90 --- /dev/null +++ b/pkg/macos/carbon/c.zig @@ -0,0 +1,3 @@ +pub const c = @cImport({ + @cInclude("Carbon/Carbon.h"); +}); diff --git a/pkg/macos/main.zig b/pkg/macos/main.zig index 20274e9c0..ef244fc78 100644 --- a/pkg/macos/main.zig +++ b/pkg/macos/main.zig @@ -1,3 +1,4 @@ +pub const carbon = @import("carbon.zig"); pub const foundation = @import("foundation.zig"); pub const animation = @import("animation.zig"); pub const dispatch = @import("dispatch.zig"); From 0c38f40f0a4e24b00c993a7b2942cccdaec0962c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 10:11:10 -0700 Subject: [PATCH 002/139] macos: secure input manager, global option in app --- macos/Ghostty.xcodeproj/project.pbxproj | 12 ++ macos/Sources/App/macOS/AppDelegate.swift | 9 ++ macos/Sources/App/macOS/MainMenu.xib | 13 +- .../Features/Secure Input/SecureInput.swift | 135 ++++++++++++++++++ 4 files changed, 166 insertions(+), 3 deletions(-) create mode 100644 macos/Sources/Features/Secure Input/SecureInput.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index b758411cf..88c90ba33 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -41,6 +41,7 @@ A56D58862ACDDB4100508D2C /* Ghostty.Shell.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */; }; A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */; }; A571AB1D2A206FCF00248498 /* GhosttyKit.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5D495A1299BEC7E00DD1313 /* GhosttyKit.xcframework */; }; + A57D79272C9C879B001D522E /* SecureInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = A57D79262C9C8798001D522E /* SecureInput.swift */; }; A586167C2B7703CC009BDB1D /* fish in Resources */ = {isa = PBXBuildFile; fileRef = A586167B2B7703CC009BDB1D /* fish */; }; A59444F729A2ED5200725BBA /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59444F629A2ED5200725BBA /* SettingsView.swift */; }; A59630972AEE163600D64628 /* HostingWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59630962AEE163600D64628 /* HostingWindow.swift */; }; @@ -105,6 +106,7 @@ A56D58852ACDDB4100508D2C /* Ghostty.Shell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Shell.swift; sourceTree = ""; }; A56D58882ACDE6CA00508D2C /* ServiceProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServiceProvider.swift; sourceTree = ""; }; A571AB1C2A206FC600248498 /* Ghostty-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = "Ghostty-Info.plist"; sourceTree = ""; }; + A57D79262C9C8798001D522E /* SecureInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInput.swift; sourceTree = ""; }; A586167B2B7703CC009BDB1D /* fish */ = {isa = PBXFileReference; lastKnownFileType = folder; name = fish; path = "../zig-out/share/fish"; sourceTree = ""; }; A59444F629A2ED5200725BBA /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; A59630962AEE163600D64628 /* HostingWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HostingWindow.swift; sourceTree = ""; }; @@ -191,6 +193,7 @@ A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, + A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, A51BFC1C2B2FB5AB00E92F16 /* About */, A51BFC292B30F69F00E92F16 /* Update */, @@ -295,6 +298,14 @@ path = Services; sourceTree = ""; }; + A57D79252C9C8782001D522E /* Secure Input */ = { + isa = PBXGroup; + children = ( + A57D79262C9C8798001D522E /* SecureInput.swift */, + ); + path = "Secure Input"; + sourceTree = ""; + }; A59630982AEE1C4400D64628 /* Terminal */ = { isa = PBXGroup; children = ( @@ -516,6 +527,7 @@ A5FEB3002ABB69450068369E /* main.swift in Sources */, A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */, A51B78472AF4B58B00F3EDB9 /* TerminalWindow.swift in Sources */, + A57D79272C9C879B001D522E /* SecureInput.swift in Sources */, A5CEAFDC29B8009000646FDA /* SplitView.swift in Sources */, A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 01031c9a5..60750517c 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -22,6 +22,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuCheckForUpdates: NSMenuItem? @IBOutlet private var menuOpenConfig: NSMenuItem? @IBOutlet private var menuReloadConfig: NSMenuItem? + @IBOutlet private var menuSecureInput: NSMenuItem? @IBOutlet private var menuQuit: NSMenuItem? @IBOutlet private var menuNewWindow: NSMenuItem? @@ -294,6 +295,8 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) + // TODO: sync secure keyboard entry toggle + // This menu item is NOT synced with the configuration because it disables macOS // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue // to work but it won't be reflected in the menu item. @@ -484,4 +487,10 @@ class AppDelegate: NSObject, guard let url = URL(string: "https://github.com/ghostty-org/ghostty") else { return } NSWorkspace.shared.open(url) } + + @IBAction func toggleSecureInput(_ sender: Any) { + let input = SecureInput.shared + input.global.toggle() + self.menuSecureInput?.state = if (input.global) { .on } else { .off } + } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index bbfd59eae..beb411987 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -1,8 +1,8 @@ - + - + @@ -35,14 +35,15 @@ + - + @@ -76,6 +77,12 @@ + + + + + + diff --git a/macos/Sources/Features/Secure Input/SecureInput.swift b/macos/Sources/Features/Secure Input/SecureInput.swift new file mode 100644 index 000000000..231306f5d --- /dev/null +++ b/macos/Sources/Features/Secure Input/SecureInput.swift @@ -0,0 +1,135 @@ +import Carbon +import Cocoa +import OSLog + +// Manages the secure keyboard input state. Secure keyboard input is an old Carbon +// API still in use by applications such as Webkit. From the old Carbon docs: +// "When secure event input mode is enabled, keyboard input goes only to the +// application with keyboard focus and is not echoed to other applications that +// might be using the event monitor target to watch keyboard input." +// +// Secure input is global and stateful so you need a singleton class to manage +// it. You have to yield secure input on application deactivation (because +// it'll affect other apps) and reacquire on reactivation, and every enable +// needs to be balanced with a disable. +class SecureInput { + static let shared = SecureInput() + + private static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: SecureInput.self) + ) + + // True if you want to enable secure input globally. + var global: Bool = false { + didSet { + apply() + } + } + + // The scoped objects and whether they're currently in focus. + private var scoped: [ObjectIdentifier: Bool] = [:] + + // This is set to true when we've successfully called EnableSecureInput. + private var enabled: Bool = false + + // This is true if we want to enable secure input. We want to enable + // secure input if its enabled globally or any of the scoped objects are + // in focus. + private var desired: Bool { + global || scoped.contains(where: { $0.value }) + } + + private init() { + // Add notifications for application active/resign so we can disable + // secure input. This is only useful for global enabling of secure + // input. + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(onDidResignActive(notification:)), + name: NSApplication.didResignActiveNotification, + object: nil) + center.addObserver( + self, + selector: #selector(onDidBecomeActive(notification:)), + name: NSApplication.didBecomeActiveNotification, + object: nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + + // Reset our state so that we can ensure we set the proper secure input + // system state + scoped.removeAll() + global = false + apply() + } + + // Add a scoped object that has secure input enabled. The focused value will + // determine if the object currently has focus. This is used so that secure + // input is only enabled while the object is focused. + func setScoped(_ object: ObjectIdentifier, focused: Bool) { + scoped[object] = focused + apply() + } + + // Remove a scoped object completely. + func removeScoped(_ object: ObjectIdentifier) { + scoped[object] = nil + apply() + } + + private func apply() { + // If we aren't active then we don't do anything. The become/resign + // active notifications will handle applying for us. + guard NSApp.isActive else { return } + + // We only need to apply if we're not in our desired state + guard enabled != desired else { return } + + let err: OSStatus + if (enabled) { + err = DisableSecureEventInput() + } else { + err = EnableSecureEventInput() + } + if (err == noErr) { + enabled = desired + Self.logger.debug("secure input state=\(self.enabled)") + return + } + + Self.logger.warning("secure input apply failed err=\(err)") + } + + // MARK: Notifications + + @objc private func onDidBecomeActive(notification: NSNotification) { + // We only want to re-enable if we're not already enabled and we + // desire to be enabled. + guard !enabled && desired else { return } + let err = EnableSecureEventInput() + if (err == noErr) { + enabled = true + Self.logger.debug("secure input enabled on activation") + return + } + + Self.logger.warning("secure input apply failed err=\(err)") + } + + @objc private func onDidResignActive(notification: NSNotification) { + // We only want to disable if we're enabled. + guard enabled else { return } + let err = DisableSecureEventInput() + if (err == noErr) { + enabled = false + Self.logger.debug("secure input disabled on deactivation") + return + } + + Self.logger.warning("secure input apply failed err=\(err)") + } +} From c0e0eff46829fcfdf153c1a6b3dd84fed93fd5c9 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 10:20:20 -0700 Subject: [PATCH 003/139] core: add toggle_secure_input keybinding --- include/ghostty.h | 2 ++ macos/Sources/App/macOS/AppDelegate.swift | 2 +- macos/Sources/Ghostty/Ghostty.App.swift | 9 ++++++++- src/Surface.zig | 6 ++++++ src/apprt/embedded.zig | 12 ++++++++++++ src/input/Binding.zig | 10 ++++++++++ 6 files changed, 39 insertions(+), 2 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b413dec41..b4f6a89a2 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -464,6 +464,7 @@ typedef void (*ghostty_runtime_show_desktop_notification_cb)(void*, typedef void ( *ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e); typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t); +typedef void (*ghostty_runtime_toggle_secure_input_cb)(); typedef struct { void* userdata; @@ -494,6 +495,7 @@ typedef struct { ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; ghostty_runtime_update_renderer_health update_renderer_health_cb; ghostty_runtime_mouse_over_link_cb mouse_over_link_cb; + ghostty_runtime_toggle_secure_input_cb toggle_secure_input_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 60750517c..d686474f1 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -295,7 +295,7 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) - // TODO: sync secure keyboard entry toggle + syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) // This menu item is NOT synced with the configuration because it disables macOS // global fullscreen keyboard shortcut. The shortcut in the Ghostty config will continue diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 69cbfbfc6..190bb3224 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -94,7 +94,8 @@ extension Ghostty { show_desktop_notification_cb: { userdata, title, body in App.showUserNotification(userdata, title: title, body: body) }, update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) }, - mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) } + mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }, + toggle_secure_input_cb: { App.toggleSecureInput() } ) // Create the ghostty app. @@ -299,6 +300,7 @@ extension Ghostty { static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) {} + static func toggleSecureInput() {} #endif #if os(macOS) @@ -544,6 +546,11 @@ extension Ghostty { surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) } + static func toggleSecureInput() { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.toggleSecureInput(self) + } + static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) { let surfaceView = self.surfaceUserdata(from: userdata) guard let title = String(cString: title!, encoding: .utf8) else { return } diff --git a/src/Surface.zig b/src/Surface.zig index cb7f8a9ae..b0c3d67f4 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3717,6 +3717,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } else log.warn("runtime doesn't implement toggleWindowDecorations", .{}); }, + .toggle_secure_input => { + if (@hasDecl(apprt.Surface, "toggleSecureInput")) { + self.rt_surface.toggleSecureInput(); + } else log.warn("runtime doesn't implement toggleSecureInput", .{}); + }, + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 9127bb5bd..846805f77 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -133,6 +133,9 @@ pub const App = struct { /// parameter. The link target will be null if the mouse is no longer /// over a link. mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null, + + /// Toggle secure input for the application. + toggle_secure_input: ?*const fn () callconv(.C) void = null, }; core_app: *CoreApp, @@ -1005,6 +1008,15 @@ pub const Surface = struct { func(self.userdata, nonNativeFullscreen); } + pub fn toggleSecureInput(self: *Surface) void { + const func = self.app.opts.toggle_secure_input orelse { + log.info("runtime embedder does not toggle_secure_input", .{}); + return; + }; + + func(); + } + pub fn newTab(self: *const Surface) !void { const func = self.app.opts.new_tab orelse { log.info("runtime embedder does not support new_tab", .{}); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 37b18f581..b347d263b 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -297,6 +297,16 @@ pub const Action = union(enum) { /// Toggle window decorations on and off. This only works on Linux. toggle_window_decorations: void, + /// Toggle secure input mode on or off. This is used to prevent apps + /// that monitor input from seeing what you type. This is useful for + /// entering passwords or other sensitive information. + /// + /// This applies to the entire application, not just the focused + /// terminal. You must toggle it off to disable it, or quit Ghostty. + /// + /// This only works on macOS, since this is a system API on macOS. + toggle_secure_input: void, + /// Quit ghostty. quit: void, From c3d6356a8773f843244a3dfd7ea576fb5c15a1bf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 16:24:19 -0700 Subject: [PATCH 004/139] macos: show secure input overlay when it is enabled --- macos/Ghostty.xcodeproj/project.pbxproj | 14 +++- .../Features/Secure Input/SecureInput.swift | 4 +- .../Secure Input/SecureInputOverlay.swift | 67 +++++++++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 11 +++ macos/Sources/Helpers/View+Extension.swift | 18 +++++ 5 files changed, 109 insertions(+), 5 deletions(-) create mode 100644 macos/Sources/Features/Secure Input/SecureInputOverlay.swift create mode 100644 macos/Sources/Helpers/View+Extension.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 88c90ba33..713058e19 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; + A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */; }; A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */; }; @@ -124,6 +126,8 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; + A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; A5CDF1922AAF9E0800513312 /* ConfigurationErrorsController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsController.swift; sourceTree = ""; }; A5CDF1942AAFA19600513312 /* ConfigurationErrorsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfigurationErrorsView.swift; sourceTree = ""; }; @@ -214,6 +218,7 @@ C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, A5985CD62C320C4500C57AD3 /* String+Extension.swift */, + A5CC36142C9CDA03004D6760 /* View+Extension.swift */, C1F26EE72B76CBFC00404083 /* VibrantLayer.h */, C1F26EE82B76CBFC00404083 /* VibrantLayer.m */, A5CEAFDA29B8005900646FDA /* SplitView */, @@ -302,6 +307,7 @@ isa = PBXGroup; children = ( A57D79262C9C8798001D522E /* SecureInput.swift */, + A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */, ); path = "Secure Input"; sourceTree = ""; @@ -507,6 +513,7 @@ A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, + A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, @@ -532,6 +539,7 @@ A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, + A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, @@ -646,7 +654,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; @@ -797,7 +805,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; @@ -836,7 +844,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.0; + MACOSX_DEPLOYMENT_TARGET = 12.4; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; diff --git a/macos/Sources/Features/Secure Input/SecureInput.swift b/macos/Sources/Features/Secure Input/SecureInput.swift index 231306f5d..f999ce5ca 100644 --- a/macos/Sources/Features/Secure Input/SecureInput.swift +++ b/macos/Sources/Features/Secure Input/SecureInput.swift @@ -12,7 +12,7 @@ import OSLog // it. You have to yield secure input on application deactivation (because // it'll affect other apps) and reacquire on reactivation, and every enable // needs to be balanced with a disable. -class SecureInput { +class SecureInput : ObservableObject { static let shared = SecureInput() private static let logger = Logger( @@ -31,7 +31,7 @@ class SecureInput { private var scoped: [ObjectIdentifier: Bool] = [:] // This is set to true when we've successfully called EnableSecureInput. - private var enabled: Bool = false + @Published private(set) var enabled: Bool = false // This is true if we want to enable secure input. We want to enable // secure input if its enabled globally or any of the scoped objects are diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift new file mode 100644 index 000000000..50b94fdf8 --- /dev/null +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -0,0 +1,67 @@ +import SwiftUI + +struct SecureInputOverlay: View { + // Animations + @State private var shadowAngle: Angle = .degrees(0) + @State private var shadowWidth: CGFloat = 6 + + // Popover explainer text + @State private var isPopover = false + + var body: some View { + VStack { + HStack { + Spacer() + + Image(systemName: "lock.shield.fill") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: 25, height: 25) + .foregroundColor(.primary) + .padding(5) + .background( + RoundedRectangle(cornerRadius: 12) + .fill(.background) + .innerShadow( + using: RoundedRectangle(cornerRadius: 12), + stroke: AngularGradient( + gradient: Gradient(colors: [.cyan, .blue, .yellow, .blue, .cyan]), + center: .center, + angle: shadowAngle + ), + width: shadowWidth + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke(Color.gray, lineWidth: 1) + ) + .onTapGesture { + isPopover = true + } + .padding(.top, 10) + .padding(.trailing, 10) + .popover(isPresented: $isPopover, arrowEdge: .bottom) { + Text(""" + Secure Input is active. Secure Input is a macOS security feature that + prevents applications from reading keyboard events. Ghostty turns + this on manually if `Ghostty > Secure Keyboard Entry` is enabled or + automatically when at a password prompt. + """) + .padding(.all) + } + } + + Spacer() + } + .onAppear { + withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: false)) { + shadowAngle = .degrees(360) + } + + withAnimation(Animation.linear(duration: 2).repeatForever(autoreverses: true)) { + shadowWidth = 12 + } + } + } +} diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index cd3967052..5cfd3b732 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -52,6 +52,9 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false + // Observe SecureInput to detect when its enabled + @ObservedObject private var secureInput = SecureInput.shared + @EnvironmentObject private var ghostty: Ghostty.App var body: some View { @@ -197,6 +200,14 @@ extension Ghostty { } } + // If we have secure input enabled and we're the focused surface and window + // then we want to show the secure input overlay. + if (secureInput.enabled && + surfaceFocus && + windowFocus) { + SecureInputOverlay() + } + // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { Rectangle().fill(ghostty.config.backgroundColor) diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/View+Extension.swift new file mode 100644 index 000000000..db17b441f --- /dev/null +++ b/macos/Sources/Helpers/View+Extension.swift @@ -0,0 +1,18 @@ +import SwiftUI + +extension View { + func innerShadow( + using shape: S = Rectangle(), + stroke: ST = Color.black, + width: CGFloat = 6, + blur: CGFloat = 6 + ) -> some View { + return self + .overlay( + shape + .stroke(stroke, lineWidth: width) + .blur(radius: blur) + .mask(shape) + ) + } +} From 1ed1c73c1a1a9d64a52a5e62a379ba0100a953b5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 16:37:25 -0700 Subject: [PATCH 005/139] macos: enable secure input on password input --- include/ghostty.h | 2 ++ macos/Sources/Ghostty/Ghostty.App.swift | 7 ++++++ .../Sources/Ghostty/SurfaceView_AppKit.swift | 24 +++++++++++++++++++ src/Surface.zig | 5 ++++ src/apprt/embedded.zig | 14 +++++++++++ 5 files changed, 52 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index b4f6a89a2..c82411820 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -464,6 +464,7 @@ typedef void (*ghostty_runtime_show_desktop_notification_cb)(void*, typedef void ( *ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e); typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t); +typedef void (*ghostty_runtime_set_password_input_cb)(void*, bool); typedef void (*ghostty_runtime_toggle_secure_input_cb)(); typedef struct { @@ -495,6 +496,7 @@ typedef struct { ghostty_runtime_show_desktop_notification_cb show_desktop_notification_cb; ghostty_runtime_update_renderer_health update_renderer_health_cb; ghostty_runtime_mouse_over_link_cb mouse_over_link_cb; + ghostty_runtime_set_password_input_cb set_password_input_cb; ghostty_runtime_toggle_secure_input_cb toggle_secure_input_cb; } ghostty_runtime_config_s; diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 190bb3224..366f83711 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -95,6 +95,7 @@ extension Ghostty { App.showUserNotification(userdata, title: title, body: body) }, update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) }, mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }, + set_password_input_cb: { userdata, value in App.setPasswordInput(userdata, value: value) }, toggle_secure_input_cb: { App.toggleSecureInput() } ) @@ -300,6 +301,7 @@ extension Ghostty { static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) {} + static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) {} static func toggleSecureInput() {} #endif @@ -546,6 +548,11 @@ extension Ghostty { surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) } + static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) { + let surfaceView = self.surfaceUserdata(from: userdata) + surfaceView.passwordInput = value + } + static func toggleSecureInput() { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } appDelegate.toggleSecureInput(self) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8a617fdd6..c3ae03138 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -42,6 +42,21 @@ extension Ghostty { // then the view is moved to a new window. var initialSize: NSSize? = nil + // Set whether the surface is currently on a password input or not. This is + // detected with the set_password_input_cb on the Ghostty state. + var passwordInput: Bool = false { + didSet { + // We need to update our state within the SecureInput manager. + let input = SecureInput.shared + let id = ObjectIdentifier(self) + if (passwordInput) { + input.setScoped(id, focused: focused) + } else { + input.removeScoped(id) + } + } + } + // Returns true if quit confirmation is required for this surface to // exit safely. var needsConfirmQuit: Bool { @@ -59,6 +74,7 @@ extension Ghostty { if (v.count == 0) { return nil } return v } + // Returns the inspector instance for this surface, or nil if the // surface has been closed. var inspector: ghostty_inspector_t? { @@ -185,6 +201,9 @@ extension Ghostty { mouseExited(with: NSEvent()) } + // Remove ourselves from secure input if we have to + SecureInput.shared.removeScoped(ObjectIdentifier(self)) + guard let surface = self.surface else { return } ghostty_surface_free(surface) } @@ -209,6 +228,11 @@ extension Ghostty { self.focused = focused ghostty_surface_set_focus(surface, focused) + // Update our secure input state if we are a password input + if (passwordInput) { + SecureInput.shared.setScoped(ObjectIdentifier(self), focused: focused) + } + // On macOS 13+ we can store our continuous clock... if #available(macOS 13, iOS 16, *) { if (focused) { diff --git a/src/Surface.zig b/src/Surface.zig index b0c3d67f4..3700df7d9 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -837,6 +837,11 @@ fn passwordInput(self: *Surface, v: bool) !void { self.io.terminal.flags.password_input = v; } + // Notify our apprt so it can do whatever it wants. + if (@hasDecl(apprt.Surface, "setPasswordInput")) { + self.rt_surface.setPasswordInput(v); + } + try self.queueRender(); } diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 846805f77..f57b16272 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -134,6 +134,11 @@ pub const App = struct { /// over a link. mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null, + /// Notifies that a password input has been started for the given + /// surface. The apprt can use this to modify UI, enable features + /// such as macOS secure input, etc. + set_password_input: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, + /// Toggle secure input for the application. toggle_secure_input: ?*const fn () callconv(.C) void = null, }; @@ -1017,6 +1022,15 @@ pub const Surface = struct { func(); } + pub fn setPasswordInput(self: *Surface, v: bool) void { + const func = self.app.opts.set_password_input orelse { + log.info("runtime embedder does not set_password_input", .{}); + return; + }; + + func(self.userdata, v); + } + pub fn newTab(self: *const Surface) !void { const func = self.app.opts.new_tab orelse { log.info("runtime embedder does not support new_tab", .{}); From 6b85a152d87c2603b6580392556f4c4947c0f8d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 16:43:38 -0700 Subject: [PATCH 006/139] macos: fix deployment target back to 12.0 --- macos/Ghostty.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 713058e19..0c0b95418 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -654,7 +654,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.4; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; @@ -805,7 +805,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.4; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; @@ -844,7 +844,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 12.4; + MACOSX_DEPLOYMENT_TARGET = 12.0; MARKETING_VERSION = 0.1; "OTHER_LDFLAGS[arch=*]" = "-lstdc++"; PRODUCT_BUNDLE_IDENTIFIER = com.mitchellh.ghostty; From 9f03aae764eb4efc3b5adb988f58f5782ccf77b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 16:56:37 -0700 Subject: [PATCH 007/139] ios: disable secure input --- macos/Sources/Ghostty/SurfaceView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 5cfd3b732..a00ff7ce9 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -52,8 +52,10 @@ extension Ghostty { // True if we're hovering over the left URL view, so we can show it on the right. @State private var isHoveringURLLeft: Bool = false + #if canImport(AppKit) // Observe SecureInput to detect when its enabled @ObservedObject private var secureInput = SecureInput.shared + #endif @EnvironmentObject private var ghostty: Ghostty.App @@ -200,6 +202,7 @@ extension Ghostty { } } + #if canImport(AppKit) // If we have secure input enabled and we're the focused surface and window // then we want to show the secure input overlay. if (secureInput.enabled && @@ -207,6 +210,7 @@ extension Ghostty { windowFocus) { SecureInputOverlay() } + #endif // If our surface is not healthy, then we render an error view over it. if (!surfaceView.healthy) { From ced8395c77b4e6e241b5bb89307a9eca7543cc93 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 17:06:43 -0700 Subject: [PATCH 008/139] macos: copy changes --- macos/Sources/Features/Secure Input/SecureInputOverlay.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift index 50b94fdf8..9f14dfad4 100644 --- a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -44,8 +44,8 @@ struct SecureInputOverlay: View { .popover(isPresented: $isPopover, arrowEdge: .bottom) { Text(""" Secure Input is active. Secure Input is a macOS security feature that - prevents applications from reading keyboard events. Ghostty turns - this on manually if `Ghostty > Secure Keyboard Entry` is enabled or + prevents applications from reading keyboard events. This is enabled + when `Ghostty > Secure Keyboard Entry` is active, as well as automatically when at a password prompt. """) .padding(.all) From a513a02328dc2ab853724e76793532121045e81a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 17:20:42 -0700 Subject: [PATCH 009/139] config: config to disable auto secure input and secure input overlay --- macos/Sources/Ghostty/Ghostty.App.swift | 2 ++ macos/Sources/Ghostty/Ghostty.Config.swift | 16 +++++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 3 ++- src/build/fish_completions.zig | 2 +- src/config/Config.zig | 28 ++++++++++++++++++++++ 5 files changed, 49 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 366f83711..7b8c5688f 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -550,6 +550,8 @@ extension Ghostty { static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) { let surfaceView = self.surfaceUserdata(from: userdata) + guard let appState = self.appState(fromView: surfaceView) else { return } + guard appState.config.autoSecureInput else { return } surfaceView.passwordInput = value } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 8ea9371fe..6288e54f7 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -371,6 +371,22 @@ extension Ghostty { let str = String(cString: ptr) return AutoUpdate(rawValue: str) ?? defaultValue } + + var autoSecureInput: Bool { + guard let config = self.config else { return true } + var v = false; + let key = "macos-auto-secure-input" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return v + } + + var secureInputOverlay: Bool { + guard let config = self.config else { return true } + var v = false; + let key = "macos-secure-input-overlay" + _ = ghostty_config_get(config, &v, key, UInt(key.count)) + return v + } } } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index a00ff7ce9..75d821cee 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -205,7 +205,8 @@ extension Ghostty { #if canImport(AppKit) // If we have secure input enabled and we're the focused surface and window // then we want to show the secure input overlay. - if (secureInput.enabled && + if (ghostty.config.secureInputOverlay && + secureInput.enabled && surfaceFocus && windowFocus) { SecureInputOverlay() diff --git a/src/build/fish_completions.zig b/src/build/fish_completions.zig index b6fe9b0dc..0ff0a2163 100644 --- a/src/build/fish_completions.zig +++ b/src/build/fish_completions.zig @@ -12,7 +12,7 @@ pub const fish_completions = comptimeGenerateFishCompletions(); fn comptimeGenerateFishCompletions() []const u8 { comptime { - @setEvalBranchQuota(16000); + @setEvalBranchQuota(17000); var counter = std.io.countingWriter(std.io.null_writer); try writeFishCompletions(&counter.writer()); diff --git a/src/config/Config.zig b/src/config/Config.zig index 9739b36b8..d2ab120a8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1348,6 +1348,34 @@ keybind: Keybinds = .{}, /// find false more visually appealing. @"macos-window-shadow": bool = true, +/// If true, Ghostty on macOS will automatically enable the "Secure Input" +/// feature when it detects that a password prompt is being displayed. +/// +/// "Secure Input" is a macOS security feature that prevents applications from +/// reading keyboard events. This can always be enabled manually using the +/// `Ghostty > Secure Keyboard Entry` menu item. +/// +/// Note that automatic password prompt detection is based on heuristics +/// and may not always work as expected. Specifically, it does not work +/// over SSH connections, but there may be other cases where it also +/// doesn't work. +/// +/// A reason to disable this feature is if you find that it is interfering +/// with legitimate accessibility software (or software that uses the +/// accessibility APIs), since secure input prevents any application from +/// reading keyboard events. +@"macos-auto-secure-input": bool = true, + +/// If true, Ghostty will show a graphical overlay when secure input is +/// enabled. This overlay is generally recommended to know when secure input +/// is enabled. +/// +/// Normally, secure input is only active when a password prompt is displayed +/// or it is manually (and typically temporarily) enabled. However, if you +/// always have secure input enabled, this overlay can be distracting and +/// you may want to disable it. +@"macos-secure-input-overlay": bool = true, + /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// /// This makes it so that resource management can be done on a per-surface From fa9df4f6f0521b3acd64b9be36cdf55dd008e17e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 19:54:42 -0700 Subject: [PATCH 010/139] macos: persist secure input state across restarts --- macos/Sources/App/macOS/AppDelegate.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index d686474f1..41815631d 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -106,6 +106,11 @@ class AppDelegate: NSObject, "ApplePressAndHoldEnabled": false, ]) + // Check if secure input was enabled when we last quit. + if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { + toggleSecureInput(self) + } + // Hook up updater menu menuCheckForUpdates?.target = updaterController menuCheckForUpdates?.action = #selector(SPUStandardUpdaterController.checkForUpdates(_:)) @@ -492,5 +497,6 @@ class AppDelegate: NSObject, let input = SecureInput.shared input.global.toggle() self.menuSecureInput?.state = if (input.global) { .on } else { .off } + UserDefaults.standard.set(input.global, forKey: "SecureInput") } } From 08a2a71ab83a01a67d8a0c91bea2f462cdeea483 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 19:57:01 -0700 Subject: [PATCH 011/139] macos: copy --- .../Sources/Features/Secure Input/SecureInputOverlay.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift index 9f14dfad4..717eeb90c 100644 --- a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -44,9 +44,9 @@ struct SecureInputOverlay: View { .popover(isPresented: $isPopover, arrowEdge: .bottom) { Text(""" Secure Input is active. Secure Input is a macOS security feature that - prevents applications from reading keyboard events. This is enabled - when `Ghostty > Secure Keyboard Entry` is active, as well as - automatically when at a password prompt. + prevents applications from reading keyboard events. This is enabled + automatically whenever Ghostty detects a password prompt in the terminal, + or at all times if `Ghostty > Secure Keyboard Entry` is active. """) .padding(.all) } From df5cd719d6cfa4de7ba350efa53f07bfbc62aa42 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 20:00:41 -0700 Subject: [PATCH 012/139] macos: rename overlay config to indication --- macos/Sources/Ghostty/Ghostty.Config.swift | 4 ++-- macos/Sources/Ghostty/SurfaceView.swift | 2 +- src/config/Config.zig | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 6288e54f7..7ecd45cc4 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -380,10 +380,10 @@ extension Ghostty { return v } - var secureInputOverlay: Bool { + var secureInputIndication: Bool { guard let config = self.config else { return true } var v = false; - let key = "macos-secure-input-overlay" + let key = "macos-secure-input-indication" _ = ghostty_config_get(config, &v, key, UInt(key.count)) return v } diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 75d821cee..6d20e1e82 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -205,7 +205,7 @@ extension Ghostty { #if canImport(AppKit) // If we have secure input enabled and we're the focused surface and window // then we want to show the secure input overlay. - if (ghostty.config.secureInputOverlay && + if (ghostty.config.secureInputIndication && secureInput.enabled && surfaceFocus && windowFocus) { diff --git a/src/config/Config.zig b/src/config/Config.zig index d2ab120a8..fd7ce996f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1366,15 +1366,15 @@ keybind: Keybinds = .{}, /// reading keyboard events. @"macos-auto-secure-input": bool = true, -/// If true, Ghostty will show a graphical overlay when secure input is -/// enabled. This overlay is generally recommended to know when secure input +/// If true, Ghostty will show a graphical indication when secure input is +/// enabled. This indication is generally recommended to know when secure input /// is enabled. /// /// Normally, secure input is only active when a password prompt is displayed /// or it is manually (and typically temporarily) enabled. However, if you -/// always have secure input enabled, this overlay can be distracting and +/// always have secure input enabled, the indication can be distracting and /// you may want to disable it. -@"macos-secure-input-overlay": bool = true, +@"macos-secure-input-indication": bool = true, /// Put every surface (tab, split, window) into a dedicated Linux cgroup. /// From a503e0250ef89a00f704541e77db72a86637af25 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 19 Sep 2024 20:12:28 -0700 Subject: [PATCH 013/139] apprt/gtk: add version helpers This adds version helpers similar to the adwaita version helpers so that build time and runtime version checks can be done. --- src/apprt/gtk/App.zig | 10 ++++++++++ src/apprt/gtk/version.zig | 40 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 src/apprt/gtk/version.zig diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index c8434a024..035e4d347 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -85,6 +85,16 @@ quit_timer: union(enum) { pub fn init(core_app: *CoreApp, opts: Options) !App { _ = opts; + // Log our GTK version + log.info("GTK version build={d}.{d}.{d} runtime={d}.{d}.{d}", .{ + c.GTK_MAJOR_VERSION, + c.GTK_MINOR_VERSION, + c.GTK_MICRO_VERSION, + c.gtk_get_major_version(), + c.gtk_get_minor_version(), + c.gtk_get_micro_version(), + }); + // We need to export GDK_DEBUG to run on Wayland after GTK 4.14. // Older versions of GTK do not support these values so it is safe // to always set this. Forwards versions are uncertain so we'll have to diff --git a/src/apprt/gtk/version.zig b/src/apprt/gtk/version.zig new file mode 100644 index 000000000..c61e940fb --- /dev/null +++ b/src/apprt/gtk/version.zig @@ -0,0 +1,40 @@ +const c = @import("c.zig").c; + +/// Verifies that the GTK version is at least the given version. +/// +/// This can be run in both a comptime and runtime context. If it +/// is run in a comptime context, it will only check the version +/// in the headers. If it is run in a runtime context, it will +/// check the actual version of the library we are linked against. +/// +/// This is inlined so that the comptime checks will disable the +/// runtime checks if the comptime checks fail. +pub inline fn atLeast( + comptime major: u16, + comptime minor: u16, + comptime micro: u16, +) bool { + // If our header has lower versions than the given version, + // we can return false immediately. This prevents us from + // compiling against unknown symbols and makes runtime checks + // very slightly faster. + if (comptime c.GTK_MAJOR_VERSION < major or + c.GTK_MINOR_VERSION < minor or + c.GTK_MICRO_VERSION < micro) return false; + + // If we're in comptime then we can't check the runtime version. + if (@inComptime()) return true; + + // We use the functions instead of the constants such as + // c.GTK_MINOR_VERSION because the function gets the actual + // runtime version. + if (c.gtk_get_major_version() >= major) { + if (c.gtk_get_major_version() > major) return true; + if (c.gtk_get_minor_version() >= minor) { + if (c.gtk_get_minor_version() > minor) return true; + return c.gtk_get_micro_version() >= micro; + } + } + + return false; +} From 6f3db36251c5fd9e3097419969f84df8da72227c Mon Sep 17 00:00:00 2001 From: FineFindus Date: Fri, 20 Sep 2024 17:42:08 +0200 Subject: [PATCH 014/139] termio: correct comment about windows support The comment has conflicting information about supporting windows. This removes the incorrect information that only windows is supported. --- src/termio/Exec.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 87e7e4b2f..1f7ac5c5e 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -154,7 +154,7 @@ pub fn threadEnter( processExit, ); - // Start our termios timer. We only support this on Windows. + // Start our termios timer. We don't support this on Windows. // Fundamentally, we could support this on Windows so we're just // waiting for someone to implement it. if (comptime builtin.os.tag != .windows) { From df24d8e1a687d4feee7f3c33967e18de72869857 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 09:56:07 -0700 Subject: [PATCH 015/139] macos: show clickable mouse pointer when hovering over secure input This only works on macOS 15 because it uses the new `pointerStyle` API. I don't have the interest to backport this to older macOS versions but I'm happy to accept a PR if someone else wants to do it. --- .../Secure Input/SecureInputOverlay.swift | 1 + macos/Sources/Helpers/Backport.swift | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift index 717eeb90c..96f309de5 100644 --- a/macos/Sources/Features/Secure Input/SecureInputOverlay.swift +++ b/macos/Sources/Features/Secure Input/SecureInputOverlay.swift @@ -39,6 +39,7 @@ struct SecureInputOverlay: View { .onTapGesture { isPopover = true } + .backport.pointerStyle(.link) .padding(.top, 10) .padding(.trailing, 10) .popover(isPresented: $isPopover, arrowEdge: .bottom) { diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 1af5e6fe1..000251e49 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -23,3 +23,28 @@ extension Backport where Content: Scene { } } } + +extension Backport where Content: View { + func pointerStyle(_ style: BackportPointerStyle) -> some View { + if #available(macOS 15, *) { + return content.pointerStyle(style.official) + } else { + return content + } + } + + enum BackportPointerStyle { + case grabIdle + case grabActive + case link + + @available(macOS 15, *) + var official: PointerStyle { + switch self { + case .grabIdle: return .grabIdle + case .grabActive: return .grabActive + case .link: return .link + } + } + } +} From 08ee0c10389f04fda9dabc99558ca138b41e59fb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 13:47:36 -0700 Subject: [PATCH 016/139] ci: use xcode 16 --- .github/workflows/release-pr.yml | 10 ++++++++-- .github/workflows/release-tip.yml | 15 ++++++++++++--- .github/workflows/test.yml | 3 +++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index 1ab280761..d1a3b321c 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -92,7 +92,10 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty -configuration Release + run: | + cd macos + sudo xcode-select -s /Applications/Xcode_16.0.app + xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -240,7 +243,10 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty -configuration Release + run: | + cd macos + sudo xcode-select -s /Applications/Xcode_16.0.app + xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 74466c7b8..fb1bb84b4 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -140,7 +140,10 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty -configuration Release + run: | + cd macos + sudo xcode-select -s /Applications/Xcode_16.0.app + xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -321,7 +324,10 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty -configuration Release + run: | + cd macos + sudo xcode-select -s /Applications/Xcode_16.0.app + xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -490,7 +496,10 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty -configuration Release + run: | + cd macos + sudo xcode-select -s /Applications/Xcode_16.0.app + xcodebuild -target Ghostty -configuration Release # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 366cd05bd..d887699a9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -160,6 +160,9 @@ jobs: name: ghostty authToken: "${{ secrets.CACHIX_AUTH_TOKEN }}" + - name: XCode Select + run: sudo xcode-select -s /Applications/Xcode_16.0.app + # GhosttyKit is the framework that is built from Zig for our native # Mac app to access. - name: Build GhosttyKit From 16919488da29842cfc5c3f6aa8229c5c1c2026ec Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 20 Sep 2024 16:11:51 -0600 Subject: [PATCH 017/139] macOS: add `macos-titlebar-style = hidden` Hides titlebar without removing the other typical window frame elements --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../Terminal/TerminalController.swift | 40 ++++++++++++++++++- .../Features/Terminal/TerminalManager.swift | 29 ++++++++------ .../Features/Terminal/TerminalView.swift | 2 + .../Helpers/EventSinkHostingView.swift | 33 +++++++++++++++ src/config/Config.zig | 8 +++- 6 files changed, 101 insertions(+), 15 deletions(-) create mode 100644 macos/Sources/Helpers/EventSinkHostingView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0c0b95418..0c38f12fd 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -72,6 +72,7 @@ A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; + AEA619802C9E1DF7004B3751 /* EventSinkHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -142,6 +143,7 @@ A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSinkHostingView.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -214,6 +216,7 @@ 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, + AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, @@ -541,6 +544,7 @@ A55685E029A03A9F004303CE /* AppError.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, + AEA619802C9E1DF7004B3751 /* EventSinkHostingView.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index bc69255fc..aaabd3c8c 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -328,12 +328,48 @@ class TerminalController: NSWindowController, NSWindowDelegate, } // Initialize our content view to the SwiftUI root - window.contentView = NSHostingView(rootView: TerminalView( + window.contentView = EventSinkHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self )) + // If our titlebar style is "hidden" we adjust the style appropriately + if (ghostty.config.macosTitlebarStyle == "hidden") { + window.styleMask = [ + // We need `titled` in the mask to get the normal window frame + .titled, + + // Full size content view so we can extend + // content in to the hidden titlebar's area + .fullSizeContentView, + + .resizable, + .closable, + .miniaturizable, + ] + + // Hide the title + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + + // Hide the traffic lights (window control buttons) + window.standardWindowButton(.closeButton)?.isHidden = true + window.standardWindowButton(.miniaturizeButton)?.isHidden = true + window.standardWindowButton(.zoomButton)?.isHidden = true + + // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. + window.tabbingMode = .disallowed + + // Nuke it from orbit -- hide the titlebar container entirely, just in case. + if let themeFrame = window.contentView?.superview { + // Hide the titlebar container + if let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { + titleBarContainer.isHidden = true + } + } + } + // In various situations, macOS automatically tabs new windows. Ghostty handles // its own tabbing so we DONT want this behavior. This detects this scenario and undoes // it. @@ -634,7 +670,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // Custom toolbar-based title used when titlebar tabs are enabled. if let toolbar = window.toolbar as? TerminalToolbar { - if (window.titlebarTabs) { + if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") { // Updating the title text as above automatically reveals the // native title view in macOS 15.0 and above. Since we're using // a custom view instead, we need to re-hide it. diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 2559e1ec8..8b9ed3cad 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -142,19 +142,24 @@ class TerminalManager { // the macOS APIs only work on a visible window. controller.showWindow(self) - // Add the window to the tab group and show it. - switch ghostty.config.windowNewTabPosition { - case "end": - // If we already have a tab group and we want the new tab to open at the end, - // then we use the last window in the tab group as the parent. - if let last = parent.tabGroup?.windows.last { - last.addTabbedWindow(window, ordered: .above) - } else { - fallthrough + // If we have the "hidden" titlebar style we want to create new + // tabs as windows instead, so just skip adding it to the parent. + if (ghostty.config.macosTitlebarStyle != "hidden") { + // Add the window to the tab group and show it. + switch ghostty.config.windowNewTabPosition { + case "end": + // If we already have a tab group and we want the new tab to open at the end, + // then we use the last window in the tab group as the parent. + if let last = parent.tabGroup?.windows.last { + last.addTabbedWindow(window, ordered: .above) + } else { + fallthrough + } + case "current": fallthrough + default: + parent.addTabbedWindow(window, ordered: .above) + } - case "current": fallthrough - default: - parent.addTabbedWindow(window, ordered: .above) } window.makeKeyAndOrderFront(self) diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 33193fb0e..196cd2f47 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -108,6 +108,8 @@ struct TerminalView: View { self.delegate?.zoomStateDidChange(to: newValue ?? false) } } + // Ignore safe area to extend up in to the titlebar region if we have the "hidden" titlebar style + .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == "hidden" ? .top : []) } } } diff --git a/macos/Sources/Helpers/EventSinkHostingView.swift b/macos/Sources/Helpers/EventSinkHostingView.swift new file mode 100644 index 000000000..f5b3cbe1c --- /dev/null +++ b/macos/Sources/Helpers/EventSinkHostingView.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// Custom subclass of NSHostingView which sinks events so that we can +/// stop the window from receiving events originating from within this view. +class EventSinkHostingView: NSHostingView { + override var acceptsFirstResponder: Bool { + return true + } + + override func becomeFirstResponder() -> Bool { + return true + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + return true + } + + override func mouseDown(with event: NSEvent) { + // Do nothing + } + + override func mouseDragged(with event: NSEvent) { + // Do nothing + } + + override func mouseUp(with event: NSEvent) { + // Do nothing + } + + override var mouseDownCanMoveWindow: Bool { + return false + } +} diff --git a/src/config/Config.zig b/src/config/Config.zig index fd7ce996f..bdddaf4f2 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1304,7 +1304,7 @@ keybind: Keybinds = .{}, @"macos-non-native-fullscreen": NonNativeFullscreen = .false, /// The style of the macOS titlebar. Available values are: "native", -/// "transparent", and "tabs". +/// "transparent", "tabs", and "hidden". /// /// The "native" style uses the native macOS titlebar with zero customization. /// The titlebar will match your window theme (see `window-theme`). @@ -1321,6 +1321,11 @@ keybind: Keybinds = .{}, /// macOS 14 does not have this issue and any other macOS version has not /// been tested. /// +/// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, +/// however, it does not remove the frame from the window or cause it to have +/// squared corners. Changing to or from this option at run-time may affect +/// existing windows in buggy ways. +/// /// The default value is "transparent". This is an opinionated choice /// but its one I think is the most aesthetically pleasing and works in /// most cases. @@ -4269,6 +4274,7 @@ pub const MacTitlebarStyle = enum { native, transparent, tabs, + hidden, }; /// See gtk-single-instance From ae46ff6854e275dd61793260c9a377e065abd001 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Fri, 20 Sep 2024 16:18:42 -0600 Subject: [PATCH 018/139] mention `macos-titlebar-style = hidden` in `window-decoration` docs --- src/config/Config.zig | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index bdddaf4f2..b47c57abd 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -223,7 +223,7 @@ const c = @cImport({ @"font-codepoint-map": RepeatableCodepointMap = .{}, /// Draw fonts with a thicker stroke, if supported. This is only supported -/// currently on MacOS. +/// currently on macOS. @"font-thicken": bool = false, /// All of the configurations behavior adjust various metrics determined by the @@ -845,13 +845,16 @@ keybind: Keybinds = .{}, /// /// * `true` /// * `false` - windows won't have native decorations, i.e. titlebar and -/// borders. On MacOS this also disables tabs and tab overview. +/// borders. On macOS this also disables tabs and tab overview. /// /// The "toggle_window_decoration" keybind action can be used to create /// a keybinding to toggle this setting at runtime. /// /// Changing this configuration in your configuration and reloading will /// only affect new windows. Existing windows will not be affected. +/// +/// macOS: To hide the titlebar without removing the native window borders +/// or rounded corners, use `macos-titlebar-style = hidden` instead. @"window-decoration": bool = true, /// The font that will be used for the application's window and tab titles. From f8bdd2b1bb909e3cd6b83ff0389e48f51bcd257c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 15:21:37 -0700 Subject: [PATCH 019/139] termio: killpg expected to fail on darwin, still go into waitpid loop Fixes #2273 On macOS, killpg is expected to fail with EPERM because of the way we launch a login process around it. Before this commit, this caused us to never call waitpid and reap the child process, which caused the child process to stick around as a zombie. This commit allows killpg to fail with EPERM on macOS and fall through to waitpid. --- src/termio/Exec.zig | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 1f7ac5c5e..5018ced33 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1257,9 +1257,19 @@ const Subprocess = struct { // descendents are well and truly dead. We will not rest // until the entire family tree is obliterated. while (true) { - if (c.killpg(pgid, c.SIGHUP) < 0) { - log.warn("error killing process group pgid={}", .{pgid}); - return error.KillFailed; + switch (posix.errno(c.killpg(pgid, c.SIGHUP))) { + .SUCCESS => log.debug("process group killed pgid={}", .{pgid}), + else => |err| killpg: { + if ((comptime builtin.target.isDarwin()) and + err == .PERM) + { + log.debug("killpg failed with EPERM, expected on Darwin and ignoring", .{}); + break :killpg; + } + + log.warn("error killing process group pgid={} err={}", .{ pgid, err }); + return error.KillFailed; + }, } // See Command.zig wait for why we specify WNOHANG. @@ -1267,6 +1277,7 @@ const Subprocess = struct { // are still alive without blocking so that we can // kill them again. const res = posix.waitpid(pid, std.c.W.NOHANG); + log.debug("waitpid result={}", .{res.pid}); if (res.pid != 0) break; std.time.sleep(10 * std.time.ns_per_ms); } From 962aa49a454d86e4e0c94c354b31d7084a36bac6 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 19:17:50 -0700 Subject: [PATCH 020/139] macos: remove nstitlecontainerview hidden --- macos/Sources/Features/Terminal/TerminalController.swift | 8 -------- 1 file changed, 8 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index aaabd3c8c..2dd016ebe 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -360,14 +360,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, // Disallow tabbing if the titlebar is hidden, since that will (should) also hide the tab bar. window.tabbingMode = .disallowed - - // Nuke it from orbit -- hide the titlebar container entirely, just in case. - if let themeFrame = window.contentView?.superview { - // Hide the titlebar container - if let titleBarContainer = themeFrame.firstDescendant(withClassName: "NSTitlebarContainerView") { - titleBarContainer.isHidden = true - } - } } // In various situations, macOS automatically tabs new windows. Ghostty handles From 578aaa1ba63d31649781386dfde88076a7bfdcde Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 20:44:48 -0700 Subject: [PATCH 021/139] macos: DraggableWindowView helper (unused currently) --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++++ .../Features/Terminal/TerminalView.swift | 2 +- .../Sources/Helpers/DraggableWindowView.swift | 19 +++++++++++++++++++ 3 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Helpers/DraggableWindowView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 0c38f12fd..a214279e9 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -58,6 +58,7 @@ A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59FB5D02AE0DEA7009128F3 /* MetalView.swift */; }; A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; + A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -127,6 +128,7 @@ A5B30531299BEAAA0047F10C /* Ghostty.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Ghostty.app; sourceTree = BUILT_PRODUCTS_DIR; }; A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; + A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -217,6 +219,7 @@ A59630962AEE163600D64628 /* HostingWindow.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */, + A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */, @@ -520,6 +523,7 @@ A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, + A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, C1F26EE92B76CBFC00404083 /* VibrantLayer.m in Sources */, A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 196cd2f47..248c09056 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -83,7 +83,7 @@ struct TerminalView: View { if (Ghostty.info.mode == GHOSTTY_BUILD_MODE_DEBUG || Ghostty.info.mode == GHOSTTY_BUILD_MODE_RELEASE_SAFE) { DebugBuildWarningView() } - + Ghostty.TerminalSplit(node: $viewModel.surfaceTree) .environmentObject(ghostty) .focused($focused) diff --git a/macos/Sources/Helpers/DraggableWindowView.swift b/macos/Sources/Helpers/DraggableWindowView.swift new file mode 100644 index 000000000..8d88e2f66 --- /dev/null +++ b/macos/Sources/Helpers/DraggableWindowView.swift @@ -0,0 +1,19 @@ +import Cocoa +import SwiftUI + +struct DraggableWindowView: NSViewRepresentable { + func makeNSView(context: Context) -> DraggableWindowNSView { + return DraggableWindowNSView() + } + + func updateNSView(_ nsView: DraggableWindowNSView, context: Context) { + // No need to update anything here + } +} + +class DraggableWindowNSView: NSView { + override func mouseDown(with event: NSEvent) { + guard let window = self.window else { return } + window.performDrag(with: event) + } +} From 2c44e208604dfb4792dbb6752d01d52fa816ba9e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 21:40:03 -0700 Subject: [PATCH 022/139] macos: EventSinkHostingView must not override mouse events This breaks split resizing. Removing this doesn't seem to have negative effects for hidden titlebars (which it was originally made for). --- macos/Sources/Helpers/EventSinkHostingView.swift | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/macos/Sources/Helpers/EventSinkHostingView.swift b/macos/Sources/Helpers/EventSinkHostingView.swift index f5b3cbe1c..8b27b8a7d 100644 --- a/macos/Sources/Helpers/EventSinkHostingView.swift +++ b/macos/Sources/Helpers/EventSinkHostingView.swift @@ -15,18 +15,6 @@ class EventSinkHostingView: NSHostingView { return true } - override func mouseDown(with event: NSEvent) { - // Do nothing - } - - override func mouseDragged(with event: NSEvent) { - // Do nothing - } - - override func mouseUp(with event: NSEvent) { - // Do nothing - } - override var mouseDownCanMoveWindow: Bool { return false } From c6bbdfb7bfec3d5f08eef999fa86510b01e18c07 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 21:59:07 -0700 Subject: [PATCH 023/139] macos: remove EventSinkHostingView This was breaking various other features: - Popovers stopped working - Split divider drag gestures stopped working For now we document the top part of the window is draggable... we can look into removing that limitation later. --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ---- .../Terminal/TerminalController.swift | 2 +- .../Helpers/EventSinkHostingView.swift | 21 ------------------- src/config/Config.zig | 4 +++- 4 files changed, 4 insertions(+), 27 deletions(-) delete mode 100644 macos/Sources/Helpers/EventSinkHostingView.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index a214279e9..111c9aeef 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -73,7 +73,6 @@ A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */; }; A5E112972AF7401B00C6E0C2 /* ClipboardConfirmationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */; }; A5FEB3002ABB69450068369E /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5FEB2FF2ABB69450068369E /* main.swift */; }; - AEA619802C9E1DF7004B3751 /* EventSinkHostingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */; }; AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */; }; AEF9CE242B6AD07A0017E195 /* TerminalToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */; }; C159E81D2B66A06B00FDFE9C /* OSColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */; }; @@ -145,7 +144,6 @@ A5E112942AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationController.swift; sourceTree = ""; }; A5E112962AF7401B00C6E0C2 /* ClipboardConfirmationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardConfirmationView.swift; sourceTree = ""; }; A5FEB2FF2ABB69450068369E /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; - AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventSinkHostingView.swift; sourceTree = ""; }; AEE8B3442B9AA39600260C5E /* NSPasteboard+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPasteboard+Extension.swift"; sourceTree = ""; }; AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TerminalToolbar.swift; sourceTree = ""; }; C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OSColor+Extension.swift"; sourceTree = ""; }; @@ -218,7 +216,6 @@ 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, A59FB5D02AE0DEA7009128F3 /* MetalView.swift */, - AEA6197F2C9E1DE5004B3751 /* EventSinkHostingView.swift */, A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */, C159E81C2B66A06B00FDFE9C /* OSColor+Extension.swift */, C1F26EA62B738B9900404083 /* NSView+Extension.swift */, @@ -548,7 +545,6 @@ A55685E029A03A9F004303CE /* AppError.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, - AEA619802C9E1DF7004B3751 /* EventSinkHostingView.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, A5CEAFFF29C2410700646FDA /* Backport.swift in Sources */, A5E112952AF73E8A00C6E0C2 /* ClipboardConfirmationController.swift in Sources */, diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 2dd016ebe..74702c621 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -328,7 +328,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, } // Initialize our content view to the SwiftUI root - window.contentView = EventSinkHostingView(rootView: TerminalView( + window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, viewModel: self, delegate: self diff --git a/macos/Sources/Helpers/EventSinkHostingView.swift b/macos/Sources/Helpers/EventSinkHostingView.swift deleted file mode 100644 index 8b27b8a7d..000000000 --- a/macos/Sources/Helpers/EventSinkHostingView.swift +++ /dev/null @@ -1,21 +0,0 @@ -import SwiftUI - -/// Custom subclass of NSHostingView which sinks events so that we can -/// stop the window from receiving events originating from within this view. -class EventSinkHostingView: NSHostingView { - override var acceptsFirstResponder: Bool { - return true - } - - override func becomeFirstResponder() -> Bool { - return true - } - - override func acceptsFirstMouse(for event: NSEvent?) -> Bool { - return true - } - - override var mouseDownCanMoveWindow: Bool { - return false - } -} diff --git a/src/config/Config.zig b/src/config/Config.zig index b47c57abd..35ca981f8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1327,7 +1327,9 @@ keybind: Keybinds = .{}, /// The "hidden" style hides the titlebar. Unlike `window-decoration = false`, /// however, it does not remove the frame from the window or cause it to have /// squared corners. Changing to or from this option at run-time may affect -/// existing windows in buggy ways. +/// existing windows in buggy ways. The top titlebar area of the window will +/// continue to drag the window around and you will not be able to use +/// the mouse for terminal events in this space. /// /// The default value is "transparent". This is an opinionated choice /// but its one I think is the most aesthetically pleasing and works in From e89a4f74089b66c00043096589dc75fff5ab6674 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 21:21:05 -0700 Subject: [PATCH 024/139] macos: use macOS 15 pointerVisibility to show/hide cursor --- macos/Sources/Features/Terminal/Terminal.xib | 4 +- macos/Sources/Ghostty/SurfaceView.swift | 1 + .../Sources/Ghostty/SurfaceView_AppKit.swift | 117 +----------------- macos/Sources/Helpers/Backport.swift | 63 ++++++++-- 4 files changed, 59 insertions(+), 126 deletions(-) diff --git a/macos/Sources/Features/Terminal/Terminal.xib b/macos/Sources/Features/Terminal/Terminal.xib index 4078fa2c6..65b03b6eb 100644 --- a/macos/Sources/Features/Terminal/Terminal.xib +++ b/macos/Sources/Features/Terminal/Terminal.xib @@ -1,8 +1,8 @@ - + - + diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 6d20e1e82..309a97b09 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -82,6 +82,7 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) + .backport.pointerVisibility(surfaceView.pointerVisible ? .visible : .hidden) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index c3ae03138..8d12c6193 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -38,6 +38,9 @@ extension Ghostty { // structure because I'm lazy. @Published var surfaceSize: ghostty_surface_size_s? = nil + // Whether the pointer should be visible or not + @Published var pointerVisible: Bool = true + // An initial size to request for a window. This will only affect // then the view is moved to a new window. var initialSize: NSSize? = nil @@ -97,11 +100,9 @@ extension Ghostty { private(set) var surface: ghostty_surface_t? private var markedText: NSMutableAttributedString - private var mouseEntered: Bool = false private(set) var focused: Bool = true private var prevPressureStage: Int = 0 private var cursor: NSCursor = .iBeam - private var cursorVisible: CursorVisibility = .visible private var appearanceObserver: NSKeyValueObservation? = nil // This is set to non-null during keyDown to accumulate insertText contents @@ -114,15 +115,6 @@ extension Ghostty { // so we'll use that to tell ghostty to refresh. override var wantsUpdateLayer: Bool { return true } - // State machine for mouse cursor visibility because every call to - // NSCursor.hide/unhide must be balanced. - enum CursorVisibility { - case visible - case hidden - case pendingVisible - case pendingHidden - } - init(_ app: ghostty_app_t, baseConfig: SurfaceConfiguration? = nil, uuid: UUID? = nil) { self.markedText = NSMutableAttributedString() self.uuid = uuid ?? .init() @@ -194,13 +186,6 @@ extension Ghostty { trackingAreas.forEach { removeTrackingArea($0) } - // mouseExited is not called by AppKit one last time when the view - // closes so we do it manually to ensure our NSCursor state remains - // accurate. - if (mouseEntered) { - mouseExited(with: NSEvent()) - } - // Remove ourselves from secure input if we have to SecureInput.shared.removeScoped(ObjectIdentifier(self)) @@ -242,8 +227,6 @@ extension Ghostty { } func sizeDidChange(_ size: CGSize) { - guard let surface = self.surface else { return } - // Ghostty wants to know the actual framebuffer size... It is very important // here that we use "size" and NOT the view frame. If we're in the middle of // an animation (i.e. a fullscreen animation), the frame will not yet be updated. @@ -332,44 +315,10 @@ extension Ghostty { // We ignore unknown shapes. return } - - // Set our cursor immediately if our mouse is over our window - if (mouseEntered) { cursorUpdate(with: NSEvent()) } - if let window = self.window { - window.invalidateCursorRects(for: self) - } } func setCursorVisibility(_ visible: Bool) { - switch (cursorVisible) { - case .visible: - // If we want to be visible, do nothing. If we want to be hidden - // enter the pending state. - if (visible) { return } - cursorVisible = .pendingHidden - - case .hidden: - // If we want to be hidden, do nothing. If we want to be visible - // enter the pending state. - if (!visible) { return } - cursorVisible = .pendingVisible - - case .pendingVisible: - // If we want to be visible, do nothing because we're already pending. - // If we want to be hidden, we're already hidden so reset state. - if (visible) { return } - cursorVisible = .hidden - - case .pendingHidden: - // If we want to be hidden, do nothing because we're pending that switch. - // If we want to be visible, we're already visible so reset state. - if (!visible) { return } - cursorVisible = .visible - } - - if (mouseEntered) { - cursorUpdate(with: NSEvent()) - } + pointerVisible = visible } // MARK: - Notifications @@ -419,7 +368,6 @@ extension Ghostty { addTrackingArea(NSTrackingArea( rect: frame, options: [ - .mouseEnteredAndExited, .mouseMoved, // Only send mouse events that happen in our visible (not obscured) rect @@ -433,11 +381,6 @@ extension Ghostty { userInfo: nil)) } - override func resetCursorRects() { - discardCursorRects() - addCursorRect(frame, cursor: self.cursor) - } - override func viewDidChangeBackingProperties() { super.viewDidChangeBackingProperties() @@ -578,40 +521,6 @@ extension Ghostty { self.mouseMoved(with: event) } - override func mouseEntered(with event: NSEvent) { - // For reasons unknown (Cocoaaaaaaaaa), mouseEntered is called - // multiple times in an unbalanced way with mouseExited when a new - // tab is created. In this scenario, we only want to process our - // callback once since this is stateful and we expect balancing. - if (mouseEntered) { return } - - mouseEntered = true - - // Update our cursor when we enter so we fully process our - // cursorVisible state. - cursorUpdate(with: NSEvent()) - } - - override func mouseExited(with event: NSEvent) { - // See mouseEntered - if (!mouseEntered) { return } - - mouseEntered = false - - // If the mouse is currently hidden, we want to show it when we exit - // this view. We go through the cursorVisible dance so that only - // cursorUpdate manages cursor state. - if (cursorVisible == .hidden) { - cursorVisible = .pendingVisible - cursorUpdate(with: NSEvent()) - assert(cursorVisible == .visible) - - // We set the state to pending hidden again for the next time - // we enter. - cursorVisible = .pendingHidden - } - } - override func scrollWheel(with event: NSEvent) { guard let surface = self.surface else { return } @@ -675,24 +584,6 @@ extension Ghostty { quickLook(with: event) } - override func cursorUpdate(with event: NSEvent) { - switch (cursorVisible) { - case .visible, .hidden: - // Do nothing, stable state - break - - case .pendingHidden: - NSCursor.hide() - cursorVisible = .hidden - - case .pendingVisible: - NSCursor.unhide() - cursorVisible = .visible - } - - cursor.set() - } - override func keyDown(with event: NSEvent) { guard let surface = self.surface else { self.interpretKeyEvents([event]) diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 000251e49..2d4eef5ca 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -25,6 +25,14 @@ extension Backport where Content: Scene { } extension Backport where Content: View { + func pointerVisibility(_ v: BackportVisibility) -> some View { + if #available(macOS 15, *) { + return content.pointerVisibility(v.official) + } else { + return content + } + } + func pointerStyle(_ style: BackportPointerStyle) -> some View { if #available(macOS 15, *) { return content.pointerStyle(style.official) @@ -32,19 +40,52 @@ extension Backport where Content: View { return content } } +} - enum BackportPointerStyle { - case grabIdle - case grabActive - case link +enum BackportVisibility { + case automatic + case visible + case hidden - @available(macOS 15, *) - var official: PointerStyle { - switch self { - case .grabIdle: return .grabIdle - case .grabActive: return .grabActive - case .link: return .link - } + @available(macOS 15, *) + var official: Visibility { + switch self { + case .automatic: return .automatic + case .visible: return .visible + case .hidden: return .hidden + } + } +} + +enum BackportPointerStyle { + case `default` + case grabIdle + case grabActive + case horizontalText + case verticalText + case link + case resizeLeft + case resizeRight + case resizeUp + case resizeDown + case resizeUpDown + case resizeLeftRight + + @available(macOS 15, *) + var official: PointerStyle { + switch self { + case .default: return .default + case .grabIdle: return .grabIdle + case .grabActive: return .grabActive + case .horizontalText: return .horizontalText + case .verticalText: return .verticalText + case .link: return .link + case .resizeLeft: return .frameResize(position: .trailing, directions: [.inward]) + case .resizeRight: return .frameResize(position: .leading, directions: [.inward]) + case .resizeUp: return .frameResize(position: .bottom, directions: [.inward]) + case .resizeDown: return .frameResize(position: .top, directions: [.inward]) + case .resizeUpDown: return .frameResize(position: .top) + case .resizeLeftRight: return .frameResize(position: .trailing) } } } From 0e1258b7fefa32c961a724585b803128d8dd622e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 21:35:49 -0700 Subject: [PATCH 025/139] macos: pointer style uses macOS 15 helpers --- macos/Sources/Ghostty/SurfaceView.swift | 1 + .../Sources/Ghostty/SurfaceView_AppKit.swift | 42 +++++++++---------- macos/Sources/Helpers/View+Extension.swift | 13 ++++++ 3 files changed, 35 insertions(+), 21 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 309a97b09..e4839e707 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -83,6 +83,7 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) .backport.pointerVisibility(surfaceView.pointerVisible ? .visible : .hidden) + .backport.pointerStyle(surfaceView.pointerStyle) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } guard let surfaceWindow = surfaceView.window else { return } diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 8d12c6193..90d259d22 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -39,7 +39,8 @@ extension Ghostty { @Published var surfaceSize: ghostty_surface_size_s? = nil // Whether the pointer should be visible or not - @Published var pointerVisible: Bool = true + @Published private(set) var pointerVisible: Bool = true + @Published private(set) var pointerStyle: BackportPointerStyle = .default // An initial size to request for a window. This will only affect // then the view is moved to a new window. @@ -102,7 +103,6 @@ extension Ghostty { private var markedText: NSMutableAttributedString private(set) var focused: Bool = true private var prevPressureStage: Int = 0 - private var cursor: NSCursor = .iBeam private var appearanceObserver: NSKeyValueObservation? = nil // This is set to non-null during keyDown to accumulate insertText contents @@ -267,49 +267,49 @@ extension Ghostty { func setCursorShape(_ shape: ghostty_mouse_shape_e) { switch (shape) { case GHOSTTY_MOUSE_SHAPE_DEFAULT: - cursor = .arrow - - case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: - cursor = .contextualMenu + pointerStyle = .default case GHOSTTY_MOUSE_SHAPE_TEXT: - cursor = .iBeam - - case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: - cursor = .crosshair + pointerStyle = .horizontalText case GHOSTTY_MOUSE_SHAPE_GRAB: - cursor = .openHand + pointerStyle = .grabIdle case GHOSTTY_MOUSE_SHAPE_GRABBING: - cursor = .closedHand + pointerStyle = .grabActive case GHOSTTY_MOUSE_SHAPE_POINTER: - cursor = .pointingHand + pointerStyle = .link case GHOSTTY_MOUSE_SHAPE_W_RESIZE: - cursor = .resizeLeft + pointerStyle = .resizeLeft case GHOSTTY_MOUSE_SHAPE_E_RESIZE: - cursor = .resizeRight + pointerStyle = .resizeRight case GHOSTTY_MOUSE_SHAPE_N_RESIZE: - cursor = .resizeUp + pointerStyle = .resizeUp case GHOSTTY_MOUSE_SHAPE_S_RESIZE: - cursor = .resizeDown + pointerStyle = .resizeDown case GHOSTTY_MOUSE_SHAPE_NS_RESIZE: - cursor = .resizeUpDown + pointerStyle = .resizeUpDown case GHOSTTY_MOUSE_SHAPE_EW_RESIZE: - cursor = .resizeLeftRight + pointerStyle = .resizeLeftRight case GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT: - cursor = .iBeamCursorForVerticalLayout + pointerStyle = .default + // These are not yet supported. We should support them by constructing a + // PointerStyle from an NSCursor. + case GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU: + fallthrough + case GHOSTTY_MOUSE_SHAPE_CROSSHAIR: + fallthrough case GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED: - cursor = .operationNotAllowed + pointerStyle = .default default: // We ignore unknown shapes. diff --git a/macos/Sources/Helpers/View+Extension.swift b/macos/Sources/Helpers/View+Extension.swift index db17b441f..fb6e0c20f 100644 --- a/macos/Sources/Helpers/View+Extension.swift +++ b/macos/Sources/Helpers/View+Extension.swift @@ -16,3 +16,16 @@ extension View { ) } } + +extension View { + func pointerStyleFromCursor(_ cursor: NSCursor) -> some View { + if #available(macOS 15.0, *) { + return self.pointerStyle(.image( + Image(nsImage: cursor.image), + hotSpot: .init(x: cursor.hotSpot.x, y: cursor.hotSpot.y) + )) + } else { + return self + } + } +} From c01bdc6d7c71746cecec8b0a04879e1569dfb41a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 20 Sep 2024 21:46:39 -0700 Subject: [PATCH 026/139] macos: use pointerStyle for SplitView Divider --- macos/Sources/Helpers/Backport.swift | 4 ++-- .../Helpers/SplitView/SplitView.Divider.swift | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index 2d4eef5ca..c8c6de309 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -33,9 +33,9 @@ extension Backport where Content: View { } } - func pointerStyle(_ style: BackportPointerStyle) -> some View { + func pointerStyle(_ style: BackportPointerStyle?) -> some View { if #available(macOS 15, *) { - return content.pointerStyle(style.official) + return content.pointerStyle(style?.official) } else { return content } diff --git a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift index f1a7f666d..83847ff0c 100644 --- a/macos/Sources/Helpers/SplitView/SplitView.Divider.swift +++ b/macos/Sources/Helpers/SplitView/SplitView.Divider.swift @@ -44,15 +44,30 @@ extension SplitView { } } + private var pointerStyle: BackportPointerStyle { + return switch (direction) { + case .horizontal: .resizeLeftRight + case .vertical: .resizeUpDown + } + } + var body: some View { ZStack { Color.clear .frame(width: invisibleWidth, height: invisibleHeight) + .contentShape(Rectangle()) // Makes it hit testable for pointerStyle Rectangle() .fill(color) .frame(width: visibleWidth, height: visibleHeight) } + .backport.pointerStyle(pointerStyle) .onHover { isHovered in + // macOS 15+ we use the pointerStyle helper which is much less + // error-prone versus manual NSCursor push/pop + if #available(macOS 15, *) { + return + } + if (isHovered) { switch (direction) { case .horizontal: From d5ab772b666678aeb6aa609d571eee6949cac538 Mon Sep 17 00:00:00 2001 From: Khang Nguyen Duy Date: Sat, 21 Sep 2024 21:46:25 +0700 Subject: [PATCH 027/139] fix(terminal/PageList): ensure enough pages before first page reuse Running alacritty/vtebench on some machines causes Ghostty to fail on `assert(first != last)` when trying to grow scrollback. We now make sure we have enough pages before trying to reuse pages. --- src/terminal/PageList.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 705d6ba2d..64580dd9a 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1694,7 +1694,7 @@ pub fn grow(self: *PageList) !?*List.Node { // If allocation would exceed our max size, we prune the first page. // We don't need to reallocate because we can simply reuse that first // page. - if (self.page_size + PagePool.item_size > self.maxSize()) prune: { + if (self.pages.len > 1 and self.page_size + PagePool.item_size > self.maxSize()) prune: { // If we need to add more memory to ensure our active area is // satisfied then we do not prune. if (self.growRequiredForActive()) break :prune; From f9bd009ce5bb16dee2fc9c04ff0523a9520cf3b2 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Sep 2024 09:59:19 -0700 Subject: [PATCH 028/139] macos: unhide cursor on clipboard confirmation --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../ClipboardConfirmationView.swift | 22 +++++++++++ macos/Sources/Ghostty/SurfaceView.swift | 17 ++++++++- macos/Sources/Helpers/Cursor.swift | 38 +++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Helpers/Cursor.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 111c9aeef..ea866e430 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -59,6 +59,7 @@ A5A1F8852A489D6800D1E8BC /* terminfo in Resources */ = {isa = PBXBuildFile; fileRef = A5A1F8842A489D6800D1E8BC /* terminfo */; }; A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; + A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -128,6 +129,7 @@ A5B30538299BEAAB0047F10C /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; + A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -212,6 +214,7 @@ children = ( A5CEAFFE29C2410700646FDA /* Backport.swift */, A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */, + A5CBD0572C9F30860017A1AE /* Cursor.swift */, A5D0AF3C2B37804400D21823 /* CodableBridge.swift */, 8503D7C62A549C66006CFF3D /* FullScreenHandler.swift */, A59630962AEE163600D64628 /* HostingWindow.swift */, @@ -527,6 +530,7 @@ A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, + A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, A5333E222B5A2128008AEFF7 /* SurfaceView_AppKit.swift in Sources */, A5CDF1952AAFA19600513312 /* ConfigurationErrorsView.swift in Sources */, A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */, diff --git a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift index 8a4f24678..1a7272e16 100644 --- a/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift +++ b/macos/Sources/Features/ClipboardConfirmation/ClipboardConfirmationView.swift @@ -34,6 +34,9 @@ struct ClipboardConfirmationView: View { /// Optional delegate to get results. If this is nil, then this view will never close on its own. weak var delegate: ClipboardConfirmationViewDelegate? = nil + /// Used to track if we should rehide on disappear + @State private var cursorHiddenCount: UInt = 0 + var body: some View { VStack { HStack { @@ -65,6 +68,25 @@ struct ClipboardConfirmationView: View { } .padding(.bottom) } + .onAppear { + // I can't find a better way to handle this. There is no API to detect + // if the cursor is hidden and OTHER THINGS do unhide the cursor. So we + // try to unhide it completely here and hope for the best. Issue #1516. + cursorHiddenCount = Cursor.unhideCompletely() + + // If we didn't unhide anything, we just send an unhide to be safe. + // I don't think the count can go negative on NSCursor so this handles + // scenarios cursor is hidden outside of our own NSCursor usage. + if (cursorHiddenCount == 0) { + _ = Cursor.unhide() + } + } + .onDisappear { + // Rehide if we unhid + for _ in 0.. Bool { + // Its always safe to call unhide when the counter is zero because it + // won't go negative. + NSCursor.unhide() + + if (counter > 0) { + counter -= 1 + return true + } + + return false + } + + static func unhideCompletely() -> UInt { + let counter = self.counter + for _ in 0.. Date: Sat, 21 Sep 2024 10:00:30 -0700 Subject: [PATCH 029/139] typos --- macos/Sources/Ghostty/SurfaceView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index e4c9de1fd..8aa4e18ad 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -60,7 +60,7 @@ extension Ghostty { @EnvironmentObject private var ghostty: Ghostty.App // The visibility state of the mouse pointer - private var pointerVisibililty: BackportVisibility { + private var pointerVisibility: BackportVisibility { // If our window or surface loses focus we always bring it back if (!windowFocus || !surfaceFocus) { return .visible @@ -97,7 +97,7 @@ extension Ghostty { .focusedValue(\.ghosttySurfaceView, surfaceView) .focusedValue(\.ghosttySurfaceCellSize, surfaceView.cellSize) #if canImport(AppKit) - .backport.pointerVisibility(pointerVisibililty) + .backport.pointerVisibility(pointerVisibility) .backport.pointerStyle(surfaceView.pointerStyle) .onReceive(pubBecomeKey) { notification in guard let window = notification.object as? NSWindow else { return } From 3769c83bdfcdbc5568eafc26c041673097e16755 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Sep 2024 10:03:49 -0700 Subject: [PATCH 030/139] config: note that mouse hide while typing on macos requires 15+ --- src/config/Config.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 35ca981f8..9bc518326 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -429,6 +429,8 @@ palette: Palette = .{}, /// Hide the mouse immediately when typing. The mouse becomes visible again when /// the mouse is used. The mouse is only hidden if the mouse cursor is over the /// active terminal surface. +/// +/// macOS: This feature requires macOS 15.0 (Sequoia) or later. @"mouse-hide-while-typing": bool = false, /// Determines whether running programs can detect the shift key pressed with a From d0903846007f1b3b8cde0727c8c01e344e139ebb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Sep 2024 10:22:04 -0700 Subject: [PATCH 031/139] macos: fix non-AppKit builds --- macos/Ghostty.xcodeproj/project.pbxproj | 2 ++ macos/Sources/Ghostty/SurfaceView.swift | 2 ++ macos/Sources/Helpers/Backport.swift | 10 ++++++++++ 3 files changed, 14 insertions(+) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ea866e430..ee4b52779 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -60,6 +60,7 @@ A5B30539299BEAAB0047F10C /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; + A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -566,6 +567,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */, A53D0C942B53B43700305CE6 /* iOSApp.swift in Sources */, A514C8D72B54A16400493A16 /* Ghostty.Config.swift in Sources */, A5333E232B5A219A008AEFF7 /* SurfaceView.swift in Sources */, diff --git a/macos/Sources/Ghostty/SurfaceView.swift b/macos/Sources/Ghostty/SurfaceView.swift index 8aa4e18ad..5eb277ba1 100644 --- a/macos/Sources/Ghostty/SurfaceView.swift +++ b/macos/Sources/Ghostty/SurfaceView.swift @@ -59,6 +59,7 @@ extension Ghostty { @EnvironmentObject private var ghostty: Ghostty.App + #if canImport(AppKit) // The visibility state of the mouse pointer private var pointerVisibility: BackportVisibility { // If our window or surface loses focus we always bring it back @@ -73,6 +74,7 @@ extension Ghostty { return .hidden } } + #endif var body: some View { let center = NotificationCenter.default diff --git a/macos/Sources/Helpers/Backport.swift b/macos/Sources/Helpers/Backport.swift index c8c6de309..8c3c10502 100644 --- a/macos/Sources/Helpers/Backport.swift +++ b/macos/Sources/Helpers/Backport.swift @@ -26,19 +26,27 @@ extension Backport where Content: Scene { extension Backport where Content: View { func pointerVisibility(_ v: BackportVisibility) -> some View { + #if canImport(AppKit) if #available(macOS 15, *) { return content.pointerVisibility(v.official) } else { return content } + #else + return content + #endif } func pointerStyle(_ style: BackportPointerStyle?) -> some View { + #if canImport(AppKit) if #available(macOS 15, *) { return content.pointerStyle(style?.official) } else { return content } + #else + return content + #endif } } @@ -71,6 +79,7 @@ enum BackportPointerStyle { case resizeUpDown case resizeLeftRight + #if canImport(AppKit) @available(macOS 15, *) var official: PointerStyle { switch self { @@ -88,4 +97,5 @@ enum BackportPointerStyle { case .resizeLeftRight: return .frameResize(position: .trailing) } } + #endif } From 261ce00552d3275d9f2790883ecbe9cff0be6187 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Sep 2024 15:11:28 -0700 Subject: [PATCH 032/139] apprt/macos,gtk: unfocused splits now highlight hovered links Fixes #1547 The core change to make this work is to make the cursor position callback support taking updated modifiers. On both macOS and GTK, cursor position events also provide the pressed modifiers so we can pass those in. --- include/ghostty.h | 5 +++- .../Sources/Ghostty/SurfaceView_AppKit.swift | 3 ++- src/Surface.zig | 16 ++++++++++-- src/apprt/embedded.zig | 25 ++++++++++++++++--- src/apprt/glfw.zig | 2 +- src/apprt/gtk/Surface.zig | 9 +++++-- 6 files changed, 49 insertions(+), 11 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index c82411820..072a8536a 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -556,7 +556,10 @@ bool ghostty_surface_mouse_button(ghostty_surface_t, ghostty_input_mouse_state_e, ghostty_input_mouse_button_e, ghostty_input_mods_e); -void ghostty_surface_mouse_pos(ghostty_surface_t, double, double); +void ghostty_surface_mouse_pos(ghostty_surface_t, + double, + double, + ghostty_input_mods_e); void ghostty_surface_mouse_scroll(ghostty_surface_t, double, double, diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 90d259d22..82bc547de 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -505,7 +505,8 @@ extension Ghostty { // Convert window position to view position. Note (0, 0) is bottom left. let pos = self.convert(event.locationInWindow, from: nil) - ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y) + let mods = Ghostty.ghosttyMods(event.modifierFlags) + ghostty_surface_mouse_pos(surface, pos.x, frame.height - pos.y, mods) // If focus follows mouse is enabled then move focus to this surface. if let window = self.window as? TerminalWindow, diff --git a/src/Surface.zig b/src/Surface.zig index 3700df7d9..a9b2c17d6 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1459,7 +1459,7 @@ pub fn keyCallback( // mod changes can affect link highlighting. self.mouse.link_point = null; const pos = self.rt_surface.getCursorPos() catch break :mouse_mods; - self.cursorPosCallback(pos) catch {}; + self.cursorPosCallback(pos, null) catch {}; if (rehide) self.mouse.hidden = true; } @@ -2421,7 +2421,7 @@ pub fn mouseButtonCallback( // expensive because it could block all our threads. if (self.hasSelection()) { const pos = try self.rt_surface.getCursorPos(); - try self.cursorPosCallback(pos); + try self.cursorPosCallback(pos, null); return true; } } @@ -2887,9 +2887,18 @@ pub fn mousePressureCallback( } } +/// Cursor position callback. +/// +/// The mods parameter is optional because some apprts do not provide +/// modifier information on cursor position events. If mods is null then +/// we'll use the last known mods. This is usually accurate since mod events +/// will trigger key press events but on some platforms we don't get them. +/// For example, on macOS, unfocused surfaces don't receive key events but +/// do receive mouse events so we have to rely on updated mods. pub fn cursorPosCallback( self: *Surface, pos: apprt.CursorPos, + mods: ?input.Mods, ) !void { // Crash metadata in case we crash in here crash.sentry.thread_state = self.crashThreadState(); @@ -2898,6 +2907,9 @@ pub fn cursorPosCallback( // Always show the mouse again if it is hidden if (self.mouse.hidden) self.showMouse(); + // Update our modifiers if they changed + if (mods) |v| self.modsChanged(v); + // The mouse position in the viewport const pos_vp = self.posToViewport(pos.x, pos.y); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index f57b16272..b59ab1c9d 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -763,7 +763,12 @@ pub const Surface = struct { }; } - pub fn cursorPosCallback(self: *Surface, x: f64, y: f64) void { + pub fn cursorPosCallback( + self: *Surface, + x: f64, + y: f64, + mods: input.Mods, + ) void { // Convert our unscaled x/y to scaled. self.cursor_pos = self.cursorPosToPixels(.{ .x = @floatCast(x), @@ -776,7 +781,7 @@ pub const Surface = struct { return; }; - self.core_surface.cursorPosCallback(self.cursor_pos) catch |err| { + self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { log.err("error in cursor pos callback err={}", .{err}); return; }; @@ -1716,8 +1721,20 @@ pub const CAPI = struct { } /// Update the mouse position within the view. - export fn ghostty_surface_mouse_pos(surface: *Surface, x: f64, y: f64) void { - surface.cursorPosCallback(x, y); + export fn ghostty_surface_mouse_pos( + surface: *Surface, + x: f64, + y: f64, + mods: c_int, + ) void { + surface.cursorPosCallback( + x, + y, + @bitCast(@as( + input.Mods.Backing, + @truncate(@as(c_uint, @bitCast(mods))), + )), + ); } export fn ghostty_surface_mouse_scroll( diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index f38214e32..57cb257b4 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -1040,7 +1040,7 @@ pub const Surface = struct { core_win.cursorPosCallback(.{ .x = @floatCast(pos.xpos), .y = @floatCast(pos.ypos), - }) catch |err| { + }, null) catch |err| { log.err("error in cursor pos callback err={}", .{err}); return; }; diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 7d337fbe0..c1146d348 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -1386,7 +1386,7 @@ fn gtkMouseUp( } fn gtkMouseMotion( - _: *c.GtkEventControllerMotion, + ec: *c.GtkEventControllerMotion, x: c.gdouble, y: c.gdouble, ud: ?*anyopaque, @@ -1415,7 +1415,12 @@ fn gtkMouseMotion( self.grabFocus(); } - self.core_surface.cursorPosCallback(self.cursor_pos) catch |err| { + // Get our modifiers + const event = c.gtk_event_controller_get_current_event(@ptrCast(ec)); + const gtk_mods = c.gdk_event_get_modifier_state(event); + const mods = translateMods(gtk_mods); + + self.core_surface.cursorPosCallback(self.cursor_pos, mods) catch |err| { log.err("error in cursor pos callback err={}", .{err}); return; }; From f0eb5d0d43833c1b7537f3ab0422e7a69c51003c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Sep 2024 20:54:32 -0700 Subject: [PATCH 033/139] terminal: test for scenario where grow() has to prune with a single page See comments in the test, also this: > The scenario is if you adjustCapacity a page beyond a std_size then our > precondition no longer holds. PageList.adjustCapacity makes no guarantee > that the resulting capacity fits within a standard page size. It is in fact > documented this way and that it is slow since we have to mmap out of our > memory pool. --- src/terminal/PageList.zig | 45 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 64580dd9a..9ea15944c 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -3772,6 +3772,51 @@ test "PageList grow allows exceeding max size for active area" { try testing.expectEqual(start_pages + 1, s.totalPages()); } +test "PageList grow prune required with a single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // This block is all test setup. There is nothing required about this + // behavior during a refactor. This is setting up a scenario that is + // possible to trigger a bug (#2280). + { + // Adjust our capacity until our page is larger than the standard size. + // This is important because it triggers a scenario where our calculated + // minSize() which is supposed to accommodate 2 pages is no longer true. + var cap = std_capacity; + while (true) { + cap.grapheme_bytes *= 2; + const layout = Page.layout(cap); + if (layout.total_size > std_size) break; + } + + // Adjust to that capacity. After we should still have one page. + _ = try s.adjustCapacity( + s.pages.first.?, + .{ .grapheme_bytes = cap.grapheme_bytes }, + ); + try testing.expect(s.pages.first != null); + try testing.expect(s.pages.first == s.pages.last); + } + + // Figure out the remaining number of rows. This is the amount that + // can be added to the current page before we need to allocate a new + // page. + const rem = rem: { + const page = s.pages.first.?; + break :rem page.data.capacity.rows - page.data.size.rows; + }; + for (0..rem) |_| try testing.expect(try s.grow() == null); + + // The next one we add will trigger a new page. + const new = try s.grow(); + try testing.expect(new != null); + try testing.expect(new != s.pages.first); +} + test "PageList scroll top" { const testing = std.testing; const alloc = testing.allocator; From f858ae13ae3152cb0d1e0e3add6f8b5e19ef76e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 21 Sep 2024 20:59:00 -0700 Subject: [PATCH 034/139] terminal: clarify comment --- src/terminal/PageList.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 9ea15944c..8d9641bfa 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1694,7 +1694,14 @@ pub fn grow(self: *PageList) !?*List.Node { // If allocation would exceed our max size, we prune the first page. // We don't need to reallocate because we can simply reuse that first // page. - if (self.pages.len > 1 and self.page_size + PagePool.item_size > self.maxSize()) prune: { + // + // We only take this path if we have more than one page since pruning + // reuses the popped page. It is possible to have a single page and + // exceed the max size if that page was adjusted to be larger after + // initial allocation. + if (self.pages.len > 1 and + self.page_size + PagePool.item_size > self.maxSize()) + prune: { // If we need to add more memory to ensure our active area is // satisfied then we do not prune. if (self.growRequiredForActive()) break :prune; From c63af5efaae45c2ee7a39a261be703c1fd907a39 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 10:16:33 -0700 Subject: [PATCH 035/139] macos: cannot call mouseEntered/Exited with blank NSEvent Fixes #2283 macOS <= 14 crash with an invalid NSEvent error. macOS 15 seems to just ignore the blank event. We just got lucky with this. Since we don't override mouseEntered/Exited anymore we can remove this completely. The regression was from: e89a4f74089b66c00043096589dc75fff5ab6674 --- .../Sources/Ghostty/SurfaceView_AppKit.swift | 27 +++++-------------- 1 file changed, 7 insertions(+), 20 deletions(-) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 82bc547de..2aee1cd5a 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -233,25 +233,6 @@ extension Ghostty { // The size represents our final size we're going for. let scaledSize = self.convertToBacking(size) setSurfaceSize(width: UInt32(scaledSize.width), height: UInt32(scaledSize.height)) - - // Frame changes do not always call mouseEntered/mouseExited, so we do some - // calculations ourself to call those events. - if let window = self.window { - let mouseScreen = NSEvent.mouseLocation - let mouseWindow = window.convertPoint(fromScreen: mouseScreen) - let mouseView = self.convert(mouseWindow, from: nil) - let isEntered = self.isMousePoint(mouseView, in: bounds) - if (isEntered) { - mouseEntered(with: NSEvent()) - } else { - mouseExited(with: NSEvent()) - } - } else { - // If we don't have a window, then our mouse can NOT be in our view. - // When the window comes back, I believe this event fires again so - // we'll get a mouseEntered. - mouseExited(with: NSEvent()) - } } private func setSurfaceSize(width: UInt32, height: UInt32) { @@ -261,7 +242,13 @@ extension Ghostty { ghostty_surface_set_size(surface, width, height) // Update our cached size metrics - self.surfaceSize = ghostty_surface_size(surface) + let size = ghostty_surface_size(surface) + DispatchQueue.main.async { + // DispatchQueue required since this may be called by SwiftUI off + // the main thread and Published changes need to be on the main + // thread. This caused a crash on macOS <= 14. + self.surfaceSize = size + } } func setCursorShape(_ shape: ghostty_mouse_shape_e) { From 554dd6ff95da82468b89bf7bd645494bd8c38f88 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 10:20:00 -0700 Subject: [PATCH 036/139] macos: enable dead code stripping This is a recommended Xcode setting, the binary _seems_ to run fine. It eliminates a warning from builds and reduces the binary size. --- macos/Ghostty.xcodeproj/project.pbxproj | 8 +++++++- .../xcshareddata/xcschemes/Ghostty.xcscheme | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ee4b52779..d2b3cff83 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -446,7 +446,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1520; - LastUpgradeCheck = 1420; + LastUpgradeCheck = 1600; TargetAttributes = { A5B30530299BEAAA0047F10C = { CreatedOnToolsVersion = 14.2; @@ -617,6 +617,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -649,6 +650,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; @@ -708,6 +710,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; @@ -768,6 +771,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; @@ -800,6 +804,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; @@ -839,6 +844,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; diff --git a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme index eec27fad9..d9b7183aa 100644 --- a/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme +++ b/macos/Ghostty.xcodeproj/xcshareddata/xcschemes/Ghostty.xcscheme @@ -1,6 +1,6 @@ Date: Fri, 6 Sep 2024 21:09:14 -0700 Subject: [PATCH 037/139] crash/minidump: header parsing --- src/crash/main.zig | 1 + src/crash/minidump.zig | 75 +++++++++++++++++++++++++++++++++++ src/crash/testdata/macos.dmp | Bin 0 -> 447456 bytes 3 files changed, 76 insertions(+) create mode 100644 src/crash/minidump.zig create mode 100644 src/crash/testdata/macos.dmp diff --git a/src/crash/main.zig b/src/crash/main.zig index 1ac971851..5f9aa96c5 100644 --- a/src/crash/main.zig +++ b/src/crash/main.zig @@ -5,6 +5,7 @@ const dir = @import("dir.zig"); const sentry_envelope = @import("sentry_envelope.zig"); +pub const minidump = @import("minidump.zig"); pub const sentry = @import("sentry.zig"); pub const Envelope = sentry_envelope.Envelope; pub const defaultDir = dir.defaultDir; diff --git a/src/crash/minidump.zig b/src/crash/minidump.zig new file mode 100644 index 000000000..caec0f1ea --- /dev/null +++ b/src/crash/minidump.zig @@ -0,0 +1,75 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +const log = std.log.scoped(.minidump); + +/// Minidump parser. +pub const Minidump = struct { + header: Header, + + /// Read the minidump file for the given source. + /// + /// The source must have a reader() and seekableStream() method. + /// For example, both File and std.io.FixedBufferStream implement these. + pub fn read(alloc: Allocator, source: anytype) !Minidump { + _ = alloc; + + // Read the header which also determines the endianness of the file. + const header, const endian = try readHeader(source); + log.warn("header={} endian={}", .{ header, endian }); + + return .{ + .header = header, + }; + } + + /// Reads the header for the minidump file and returns endianness of + /// the file. + fn readHeader(source: anytype) !struct { Header, std.builtin.Endian } { + // Start by trying LE. + var endian: std.builtin.Endian = .little; + var header = try source.reader().readStructEndian(Header, endian); + + // If the signature doesn't match, we assume its BE. + if (header.signature != signature) { + // Seek back to the start of the file so we can reread. + try source.seekableStream().seekTo(0); + + // Try BE, if the signature doesn't match, return an error. + endian = .big; + header = try source.reader().readStructEndian(Header, endian); + if (header.signature != signature) return error.InvalidHeader; + } + + // "The low-order word is MINIDUMP_VERSION. The high-order word is an + // internal value that is implementation specific." + if (header.version.low != version) return error.InvalidVersion; + + return .{ header, endian }; + } +}; + +/// "MDMP" in little-endian. +pub const signature = 0x504D444D; + +/// The version of the minidump format. +pub const version = 0xA793; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_header +pub const Header = extern struct { + signature: u32, + version: packed struct(u32) { low: u16, high: u16 }, + stream_count: u32, + stream_directory_rva: u32, + checksum: u32, + time_date_stamp: u32, + flags: u64, +}; + +test "Minidump read" { + const testing = std.testing; + const alloc = testing.allocator; + var fbs = std.io.fixedBufferStream(@embedFile("testdata/macos.dmp")); + _ = try Minidump.read(alloc, &fbs); +} diff --git a/src/crash/testdata/macos.dmp b/src/crash/testdata/macos.dmp new file mode 100644 index 0000000000000000000000000000000000000000..5931c13a06284043c286fcda9a94aa93f2762909 GIT binary patch literal 447456 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
<8}G+das4`uDlUM z{_*q9Tf1JCfOE4h|6Z76(qzFHtc5K#N+HYXoy_K}v9()dQ4FuCSzcBtN%p=YmF_-Y zjK64n#FB;Ls}O2YXZdNc zA;IQ^y^w*Nt8}uxVEhh1RJuDhNzf*J@Y+iXVcMKTp6qYELMxR_U7~0ppWt<6NBZB z#k=R;${7U%4*k#-%mCdY;|qD1fGc1)}$?vcYm$kA9dK2jby-sR^i7>6ii zAPPyyfelHp{PB6!cr+7D=Io>@!QRV9*~M97A6S~Pvq(E#yl<*Ty%7$hJCaTtYcjarcNm#9Z9k&2IIWmWJjz?+;`)&sQ# zRZ}43PLvy$MKbKEyWAAh-f%ucX~fl}R!exOPb!hH;yF8&EH?%KykQCTN;3HzhH*Ap zWbJ&nQz|)R507V}ShaGS;q{>U`UzVo2T?I{Gi?4yLKKGCDp*-NFY*&lC38_bX=TQu z$>^{pCoqBjCDao~I@j0tfemr?t)BX2tgMyUXU8q2W9uDngKX~G&sxu27sTRnHW#V!=&I`Dw!`&9{Yf!_({abzQVkPWhJ7y>BoNNdMS7SnuiPQdjm!A4&7jJ0@w z4O!9Lc*Yt^M28i*_OAa{_7|?>Lp5}W#p_`whss)nA3dFN z?*4HNt*imZ;{1m4p;q$%blKo%3Io>7QsBNx?)_>v*Y0Hkurar6z*V2-CyZq_8n^vDS+PmSFSAYBd>95`L4s5T4E!TrlE0eU!s&b^xeoC{MHpk{T z)Op-3g6;90Uw>fZ>wbH|hL8WY=kMOQ`79Q=!Kp_&5zP&yGL_R#-Ti~MjuEy7M61?F zDKDv4Jo4IP$Coa?>w_P^^@GXh{aIzZq;n$~D=JsYdfNqO;W1%(8LMj@%1(}@bE&Zk zsfD`rh8)wCQP->Np~dAjmx;!m9i{jE`Zy$oKIgU#$bMk|a3NvG#*_Q)q-=kT2JcND zpMZ3YwT9qtB3)ix0N%;UQ7wh*pqptqxFbj1Zyj{7e~*DxUQf{8-2o}aD{79;USz0> zb{*^y^O)RCt%EjAdi}iCks419vMmd5xa;e#cN<$1ttHjJmGOYOaqWEce6*!9mdeXP z4}3%*12*hq?TqDN&$xR-a@7jsOl$9kC`_^*!r9teuc%zr$_Js7E99$nJeTT@PNv3l z0H5~2S8YyISwyaMyI$`+dCQSQ#~(L_x2bz)mVi1pxVnaIUzdNvj$Q3o^?|hO4?&cz zgSZPx!l-LzqxneKv7zXwm9Uee75Xm5sIH!G_&E-qV&R<5_s`jy8YICN+O;!Qwo+%r zMR{FC&v>18+Mok=RA{&Uh)H zq|*N0#l}0|`JsKq-;7_&qVLX8Lf0Q{jy1n4nX@v7W^=f#Sd2XHbjPV{cn386VGNtYo}wGe`Vo z9rfvDdl){m;o6R89;l<^4w27x?oqq=xZT^E%h<``NG27>En{+6beGQlrk5MsLsU!b zV(pW1*&Wf{eX)J2SG@*z4Q_3EDJ!3xsTa!=q2pWCx66y5)(?vB({T5Ny4WyG)?;6_ zl2>veLXt`bowh%Pj06fBhNDj;#&J(O%oWp`IDanQs&(AW^lW(6wK}j*U(zeqHx@ zkeAwaFI{i?(fVrV+yT9?mCc-}ap9=&z87$(mC&_ENAK2sRx-!-ZixGj!}e%nD>|O^ zp{FRf;`(WmTInTp?a~&_#8OEdZJhFWloPbIpeGNv=tq6Ei94-?u049JTy!v+i%PoV zQBHP3Z`IuDM^0*`m(aD#&R*5}0qhy)#nzwNqh3n3dv)-`LG7F|M`;_}?%M)Vof@HQ zk6sJSh};%}xc4}mPg-HCuU18R2_4_9g5=TTkY7Ao!aX*?-5!PxJ3_^#v z)qrQycsAf?7Nhc!(6v|ZNHk*&cGwB4+s@_yuC04KV$b9G0iNY@-$CzMm^&>sU&hBX zc5YJgReRsW_4p{HnIDS>K4z!R;C7 z|BR>K3D^STFvPrV@fMEvkxS)+(D4iIeQ@vL*iC(T6j|VvTKR*_r`}ttVG)7 z4~lW7bn`UcsYB1k>eFYv+$QW;IyzQ9ekSvuV5__D`>zwxjN>U&`(7JwN4$xR=S#Ss zO~3{0ZdJ18ZCjF`5z8JMj3zFKW|QS>x1^j@`d(M|6(J|W!Mb9)CeOdo?+@4TTAm0* zIq7&bU3NmC+K=D4*t_VR8ETv*?ATy3ySM7XOrzEzXC6?!ywIx3Zy(e%qtwbtLf5Wr zKH*McUCE(T!_LF;^hKTDojpvf&K1d9juJY4yLR&Bq9YR>vtnYA0$74y7uKtn>Bs2b z#yxhu_g-~s4t@Q5$1_9GxG=|&<17JJvwIzD;X>DFCg+XB)nn1M7mDFr&v_4?;iK2n z1Vq?O$?vXto=@2M-`-u;ur(2hX0nwx=_vg?23NB=ALA0ZZSK1EOxUs5)f?8XcP#E4 zRD6OqHbdiGQ{3Ivx^+ReZ`+6!AMLQ^jSG%9dDx$MIj`x1Hg?MAT>^Ou30*t&wY4#l zcQvS0QYk8g!Io5grE2=tLhpF!{PR4cuHJhaB+R>I0eb7DfH^g7&GqUM0H zXWTnWUHc^L*q9ZM#-w&akLz7b%O7XGiCnu&(esh8V{N-SddKZtg*yr9m&tD$NB`w*m7^=QL zdw(r?@gr}JW+874;w_JT(8XF7+t^QTYC43j9rksNMTado4*AA0!f(R(Vt z?buSzcb=^NcIxkkZ@%N&2R+O_TYdLc*Z&DSwy(#|mTx5e^thA%rL{*p^s;r?Jp)qh z(cT-8)4{W>J+?qMv_h}Hd2&m4Yj3$de*UbI4{VJHv_czfceM#2AKk5y*0$}UMer%> z<7>TG%6P%~7Of|ER@Mp$w(7*#eK+^YO0|!jw4#~T#IP01M5W;T=2_KFz05X;aR)8U zm?GMzH;uc&ictm6vi5l$izzk8Bqt|gV^m5I(LS#mj}B&7EDR|;@a&t1cfbzc^YD%x z&(?WZ9Z#O+gpVU3BaN~<^gtJM`_jC43JIND_jF;z1=?W8l_c}7z|$S{*pN|=yzka) z@1F3kYGd-JbJFjx+B$Zotz^55!UWen&F!Rb4Gx{0RdT^!Jy}fe@nYvg{R>Yd{p@vQ z8icOCt&t8E=iyUzx3~LDz4pBr?-J@IKRrM4fZe%M!}|;4b?eR#F%kiuN>|RP9J+SH zI2bv*9G(UK*+ctz>1R9y@-2RRK~pUBMts?meBFvmBi!sgf-Zf zO6II&uG@}fqM6Bt*Bv9UG+TI zEvbG+y4O6ZcI8dV4~kU}%tqdymJw4>9`P0`m#t0~!;pKB_~9C7jKy=5(8&{zoD#{T zFivhK^Fv`tKGvZ5UCwK*EcR%E-AqD#*55gjJ{Ji+e;KPa8BI*)>^Pns9cU0=&Y13H zUe|ul#cQQU==k7%4AF6UFpf_gHA2rqUvIn__E; z(DBu6Cx^#v3^vowZaFpTyuX|IgUFq_KTgn-M5dI`*58`IeSO39o-NF$XOw9+GHzTS z)7M6!hs_OqUU2j_?^5SCZ>rz6MB}5w84Nbp7tN0Lun^SfMJ?Nl(tG-#o81Ib&w6z2 zo3LYj-CG?4Pw{(vDAbhEFCB9;CN%;xWcHfe820b|8;pFbnU_1 zG}@wR3=xiWOS!MxNcorc#9sFPjLllazecIgMMBpuXqW7bXY6#2E0bRKakVA9vGXKa z5->8P_c6j#Na*T|I!ZH$zDHO+P%p}jsrZwve@ax z2=g1plN^ks$o(mC2k);{7RyaS+fHfAijVBF;;CT_L!J`m;w8j|l{;C~V4aA=OR?v8 z%aKFJC+?T+I15aynsXWucX1m#y-u_EoAuf;+xmvBD<+Xmm2$S`{*-oE!!*Xq=C zecMurRHk<_85eXvu6w@sHp=|)QhWVug+AZsc$k82ht@t$@$hlaKdyXBnGI{D*N?x> zu8Qdn9yspXho8>iiJzQh{K#B&YFch089T{>A_}@69QW<>I%xORB1o^7kJsUk0I;&| zxExQ_yiq+fqK$2s*{d+Ro_7tJ&WS^(N0jH=ZfiIipL_+ruMeJ*H9T zM(8bqJ>qRe;OFWxjsCWwo{xkb>qsSYRTmAt*vY)~$1Zl#kNX)sE!qMpwtE`%$Y1pf zBp65EYo1j9?HW%e?d0&5R5UZFu857|yZT;h^}P?&3H`{Y*E}h{yX5`Z^2M_hJJ4;^ z`OW2QkB6RugKQ^T8Ta4&oJ{3I@pn1C3_O?`=1&9wyvM`5Qd=*T+6I5MAF@kZa_^*Gyxu+5pe+LfHp)K$Wxg>Z zr*x*)} z#~!p&g6=DIk5i|GBFr}q<44HXY|-n2M-fAx1@v6@!;`<9X?|J%M63+H92ZT-t=^I7 zpq0rs%s;RP##!Vbv>W0%Esyax&Z75Vd-#jIws>w5x^dGpp2*p`)OdWPcRZa=Wu&51 z|GjK}Hv~Dkx-4yedb3t~y}v7P>qTI)1o+!cQv-Qt@-p&kAFw)$$nB<)5%))r!g{@K>$uydU8gQK%n^g)uJ%Od3{v_Sgn$(TH5iypBJ zBCwn78!&z;zggkk`nC}^KnOO#M%QbnJmQs{W$}jEJ{W@wU`V_&uEzSqy{9U%97KC0 z68pw37)nHQJWQW**#=+<_Ybs_vM*rayWs(kpUW}FQc6h`9`gW-wdtCBVC`1gpRMSoso={wURmEbl#|Vdl-jk zD@?GRPe$C4s9fUilhEk-bf z!t_JM%geHrBng8s&Q_Jm8IMC(4#ve#+sVQ4v2?|Hw{-h0z5tK*{`%~r(Pz-N7*9a_`OZ73 zBy{5_9vx4h2Yu~#dAT2k(>`deRw(i(*-J5Rb9RbJJ#EyJk?tNdp~kyV0@-_ zpSKd+Y4z=ZW1y$2UED^{y)W#RtI9DfcNj9_gu1&V+DRRXJZ!S^qHLq|R*VUc5hE+3 zmDKAEvUqM1I=*-Iwn{f2=^Da7#k-R8H{Z`bxm~bAm5pH&i9qdXxDeDJ#g28Z`D-RVX^YF_Sm3W^3wNxj(ysXWXToFNzI|F zKdxIM^fyDJ<}+L?wlYnx`ZaRzUP$^}By@bW_HK3FE^nYc19hY^cJmq6a`gtBpI*}E zBBA4}cXBL-$`-rn2H@%brx$%Y0wa)OzDC^MhNA#yx_v?$!914IllA0}(6vKkB$Z2L zaZ8`G_u0A0ip~aDLL7&B?dfVdeZBFX^0sbh*t<*4%|i4k!l-K)S8YSo<7HZAU+2N`Ib#CnLMP&*$5^cRMqA{pWE{1|J>JU-61+ zsVF~~~e;o1E z2791~y`#W&db|VTwi2p))TcM|l{;&dLk@yZRO$Bg@Lh4aNgL&lDDNgMW%r}Y1EJ#!FEy!K zoxAHE7rWW*CFxb(2b0V$(8WB;@urr|A~pK=F87nKp3mJ$8(qr#Cev2epq0$oLpHjV zH_WcOliju6zut>}FKg&++;ThR2<(F)@9WpdzM=AT(=fYBJ@xK~J|&&B+{deW z$B9Ge?j`;BOxUshJ~kPlK|e103xf+f%)?Ud;XC%2M&Ey_y^i%jM7#uz-WGl|?#h%L zI{EI2^s-~3`r|h|J?*0Eao+b*yk(rUdU@)J^s43;IHT@)^XX}y9bmD@XSnOcf9J}t z*7=tteO_ey?Xbo((Zmibw?CEPL9qbf@w)Ycr>7n9{!NZW8^`xo{YsjedeL#%6U}98 z8Bg!|&oFsIkJQO>e0KhJiF_%Bw7oTx5!aw$*)vS;I>ZYH^7IGK|NV~A>O9Ey>KISP@q~vR?L5=g zg)Pi$6Hi+4J=hG4LN~iDnXaSV^m&o(x5Y}vN5-O=(e6}=#ZW~wDz1k!TICtOUx~oA z5?4(pJwmHL;eBxQw-E<@jXKw?ypNAuVN74#yI>qj+$H;w^Tl(M(D5nm=l57+smx?o za!C3kHp;JPwCn}ohc73Ugs%Rh=q1p=^Cs?NQV_@3u@dJF?5d2nH2Crj4?Xwu8lP2@O|Bw!gJOmj}gHyA>NB<^s%Ch(_Hj;htJa zCcTqP`|%qCwRR2?I{Dn$tG?m_u)>4B=w%%0A}{b*TTyo2jXplkIV$)2yqB#=6B62 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 literal 0 HcmV?d00001 From ae8859bc7bcf0267c00dfb0c136c40be4c1fc0f4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 6 Sep 2024 21:50:47 -0700 Subject: [PATCH 038/139] crash/minidump: read the streams from the minidump file --- src/crash/minidump.zig | 118 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 6 deletions(-) diff --git a/src/crash/minidump.zig b/src/crash/minidump.zig index caec0f1ea..fbd4ec809 100644 --- a/src/crash/minidump.zig +++ b/src/crash/minidump.zig @@ -4,23 +4,108 @@ const Allocator = std.mem.Allocator; const log = std.log.scoped(.minidump); -/// Minidump parser. +/// Minidump file format. pub const Minidump = struct { + /// The arena that all streams are allocated within when reading the + /// minidump file. This is freed on deinit. + arena: std.heap.ArenaAllocator, + + /// The header of the minidump file. On serialization, the stream count + /// and rva will be updated to match the streams. On deserialization, + /// this is read directly from the file. header: Header, + /// The streams within the minidump file in the order they're serialized. + streams: std.ArrayListUnmanaged(Stream), + + pub const Stream = struct { + type: u32, + data: []const u8, + }; + /// Read the minidump file for the given source. /// /// The source must have a reader() and seekableStream() method. /// For example, both File and std.io.FixedBufferStream implement these. - pub fn read(alloc: Allocator, source: anytype) !Minidump { - _ = alloc; + /// + /// The reader will read the full minidump data into memory. This makes + /// it easy to serialize the data back out. This is acceptable for our + /// use case which doesn't rely too much on being memory efficient or + /// high load. We also expect the minidump files to be relatively small + /// (dozens of MB at most, hundreds of KB typically). + /// + /// NOTE(mitchellh): If we ever want to make this more memory efficient, + /// I would create a new type that is a "lazy reader" that stores the + /// source type and reads the data as needed. Then this type should use + /// that type. + pub fn read(alloc_gpa: Allocator, source: anytype) !Minidump { + var arena = std.heap.ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); // Read the header which also determines the endianness of the file. const header, const endian = try readHeader(source); - log.warn("header={} endian={}", .{ header, endian }); + + var streams = try std.ArrayListUnmanaged(Stream).initCapacity( + alloc, + header.stream_count, + ); + errdefer streams.deinit(alloc); + + // Read the streams. All the streams are first described in a + // "directory" structure which tells us the type of stream and + // where it is located in the file. The directory structures are + // stored in a contiguous block at the stream_directory_rva. + // + // Due to how we use this structure, we read directories one by one, + // then read all the data for that directory, then move on to the + // next directory. This is because we copy all the minidump data + // into memory. + const seeker = source.seekableStream(); + try seeker.seekTo(header.stream_directory_rva); + for (0..header.stream_count) |_| { + // Read the current directory + const directory = try source.reader().readStructEndian(Directory, endian); + + // Seek to the location of the data. We have to store our current + // position because we need to seek back to it after reading the + // data in order to read the next directory. + const pos = try seeker.getPos(); + try seeker.seekTo(directory.location.rva); + + // Read the data. The data length is defined by the directory. + // If we can't read exactly that amount of data, we return an error. + var data = std.ArrayList(u8).init(alloc); + defer data.deinit(); + source.reader().readAllArrayList( + &data, + directory.location.data_size, + ) catch |err| switch (err) { + // This means there was more data in the reader than what + // we asked for this. This is okay and expected because + // all streams except the last one will have this error. + error.StreamTooLong => {}, + else => return err, + }; + + // Basic check. + if (data.items.len != directory.location.data_size) return error.DataSizeMismatch; + + // Store our stream + try streams.append(alloc, .{ + .type = directory.stream_type, + .data = try data.toOwnedSlice(), + }); + + // Seek back to where we were after reading this directory + // entry so we can read the next one. + try seeker.seekTo(pos); + } return .{ + .arena = arena, .header = header, + .streams = streams, }; } @@ -48,8 +133,16 @@ pub const Minidump = struct { return .{ header, endian }; } -}; + pub fn deinit(self: *Minidump) void { + self.arena.deinit(); + } + + /// The arena allocator associated with this envelope + pub fn allocator(self: *Minidump) Allocator { + return self.arena.allocator(); + } +}; /// "MDMP" in little-endian. pub const signature = 0x504D444D; @@ -67,9 +160,22 @@ pub const Header = extern struct { flags: u64, }; +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_directory +pub const Directory = extern struct { + stream_type: u32, + location: LocationDescriptor, +}; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_location_descriptor +pub const LocationDescriptor = extern struct { + data_size: u32, + rva: u32, +}; + test "Minidump read" { const testing = std.testing; const alloc = testing.allocator; var fbs = std.io.fixedBufferStream(@embedFile("testdata/macos.dmp")); - _ = try Minidump.read(alloc, &fbs); + var md = try Minidump.read(alloc, &fbs); + defer md.deinit(); } From 3cc18b62e7ae7b82009c7dd80bc6de94cc4df8e1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 7 Sep 2024 09:35:43 -0700 Subject: [PATCH 039/139] crash/minidump: split out into multiple files --- src/crash/minidump.zig | 183 +------------------------------- src/crash/minidump/external.zig | 36 +++++++ src/crash/minidump/minidump.zig | 154 +++++++++++++++++++++++++++ src/crash/minidump/stream.zig | 21 ++++ 4 files changed, 216 insertions(+), 178 deletions(-) create mode 100644 src/crash/minidump/external.zig create mode 100644 src/crash/minidump/minidump.zig create mode 100644 src/crash/minidump/stream.zig diff --git a/src/crash/minidump.zig b/src/crash/minidump.zig index fbd4ec809..0cf641114 100644 --- a/src/crash/minidump.zig +++ b/src/crash/minidump.zig @@ -1,181 +1,8 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; +const minidump = @import("minidump/minidump.zig"); -const log = std.log.scoped(.minidump); +pub const stream = @import("minidump/stream.zig"); +pub const Minidump = minidump.Minidump; -/// Minidump file format. -pub const Minidump = struct { - /// The arena that all streams are allocated within when reading the - /// minidump file. This is freed on deinit. - arena: std.heap.ArenaAllocator, - - /// The header of the minidump file. On serialization, the stream count - /// and rva will be updated to match the streams. On deserialization, - /// this is read directly from the file. - header: Header, - - /// The streams within the minidump file in the order they're serialized. - streams: std.ArrayListUnmanaged(Stream), - - pub const Stream = struct { - type: u32, - data: []const u8, - }; - - /// Read the minidump file for the given source. - /// - /// The source must have a reader() and seekableStream() method. - /// For example, both File and std.io.FixedBufferStream implement these. - /// - /// The reader will read the full minidump data into memory. This makes - /// it easy to serialize the data back out. This is acceptable for our - /// use case which doesn't rely too much on being memory efficient or - /// high load. We also expect the minidump files to be relatively small - /// (dozens of MB at most, hundreds of KB typically). - /// - /// NOTE(mitchellh): If we ever want to make this more memory efficient, - /// I would create a new type that is a "lazy reader" that stores the - /// source type and reads the data as needed. Then this type should use - /// that type. - pub fn read(alloc_gpa: Allocator, source: anytype) !Minidump { - var arena = std.heap.ArenaAllocator.init(alloc_gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Read the header which also determines the endianness of the file. - const header, const endian = try readHeader(source); - - var streams = try std.ArrayListUnmanaged(Stream).initCapacity( - alloc, - header.stream_count, - ); - errdefer streams.deinit(alloc); - - // Read the streams. All the streams are first described in a - // "directory" structure which tells us the type of stream and - // where it is located in the file. The directory structures are - // stored in a contiguous block at the stream_directory_rva. - // - // Due to how we use this structure, we read directories one by one, - // then read all the data for that directory, then move on to the - // next directory. This is because we copy all the minidump data - // into memory. - const seeker = source.seekableStream(); - try seeker.seekTo(header.stream_directory_rva); - for (0..header.stream_count) |_| { - // Read the current directory - const directory = try source.reader().readStructEndian(Directory, endian); - - // Seek to the location of the data. We have to store our current - // position because we need to seek back to it after reading the - // data in order to read the next directory. - const pos = try seeker.getPos(); - try seeker.seekTo(directory.location.rva); - - // Read the data. The data length is defined by the directory. - // If we can't read exactly that amount of data, we return an error. - var data = std.ArrayList(u8).init(alloc); - defer data.deinit(); - source.reader().readAllArrayList( - &data, - directory.location.data_size, - ) catch |err| switch (err) { - // This means there was more data in the reader than what - // we asked for this. This is okay and expected because - // all streams except the last one will have this error. - error.StreamTooLong => {}, - else => return err, - }; - - // Basic check. - if (data.items.len != directory.location.data_size) return error.DataSizeMismatch; - - // Store our stream - try streams.append(alloc, .{ - .type = directory.stream_type, - .data = try data.toOwnedSlice(), - }); - - // Seek back to where we were after reading this directory - // entry so we can read the next one. - try seeker.seekTo(pos); - } - - return .{ - .arena = arena, - .header = header, - .streams = streams, - }; - } - - /// Reads the header for the minidump file and returns endianness of - /// the file. - fn readHeader(source: anytype) !struct { Header, std.builtin.Endian } { - // Start by trying LE. - var endian: std.builtin.Endian = .little; - var header = try source.reader().readStructEndian(Header, endian); - - // If the signature doesn't match, we assume its BE. - if (header.signature != signature) { - // Seek back to the start of the file so we can reread. - try source.seekableStream().seekTo(0); - - // Try BE, if the signature doesn't match, return an error. - endian = .big; - header = try source.reader().readStructEndian(Header, endian); - if (header.signature != signature) return error.InvalidHeader; - } - - // "The low-order word is MINIDUMP_VERSION. The high-order word is an - // internal value that is implementation specific." - if (header.version.low != version) return error.InvalidVersion; - - return .{ header, endian }; - } - - pub fn deinit(self: *Minidump) void { - self.arena.deinit(); - } - - /// The arena allocator associated with this envelope - pub fn allocator(self: *Minidump) Allocator { - return self.arena.allocator(); - } -}; -/// "MDMP" in little-endian. -pub const signature = 0x504D444D; - -/// The version of the minidump format. -pub const version = 0xA793; - -/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_header -pub const Header = extern struct { - signature: u32, - version: packed struct(u32) { low: u16, high: u16 }, - stream_count: u32, - stream_directory_rva: u32, - checksum: u32, - time_date_stamp: u32, - flags: u64, -}; - -/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_directory -pub const Directory = extern struct { - stream_type: u32, - location: LocationDescriptor, -}; - -/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_location_descriptor -pub const LocationDescriptor = extern struct { - data_size: u32, - rva: u32, -}; - -test "Minidump read" { - const testing = std.testing; - const alloc = testing.allocator; - var fbs = std.io.fixedBufferStream(@embedFile("testdata/macos.dmp")); - var md = try Minidump.read(alloc, &fbs); - defer md.deinit(); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/crash/minidump/external.zig b/src/crash/minidump/external.zig new file mode 100644 index 000000000..9356a6cb3 --- /dev/null +++ b/src/crash/minidump/external.zig @@ -0,0 +1,36 @@ +//! This file contains the external structs and constants for the minidump +//! format. Most are from the Microsoft documentation on the minidump format: +//! https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ +//! +//! Wherever possible, we also compare our definitions to other projects +//! such as rust-minidump, libmdmp, breakpad, etc. to ensure we're doing +//! the right thing. + +/// "MDMP" in little-endian. +pub const signature = 0x504D444D; + +/// The version of the minidump format. +pub const version = 0xA793; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_header +pub const Header = extern struct { + signature: u32, + version: packed struct(u32) { low: u16, high: u16 }, + stream_count: u32, + stream_directory_rva: u32, + checksum: u32, + time_date_stamp: u32, + flags: u64, +}; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_directory +pub const Directory = extern struct { + stream_type: u32, + location: LocationDescriptor, +}; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_location_descriptor +pub const LocationDescriptor = extern struct { + data_size: u32, + rva: u32, +}; diff --git a/src/crash/minidump/minidump.zig b/src/crash/minidump/minidump.zig new file mode 100644 index 000000000..2056212a7 --- /dev/null +++ b/src/crash/minidump/minidump.zig @@ -0,0 +1,154 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const external = @import("external.zig"); +const stream = @import("stream.zig"); +const Stream = stream.Stream; + +const log = std.log.scoped(.minidump); + +/// Minidump file format. +pub const Minidump = struct { + /// The arena that all streams are allocated within when reading the + /// minidump file. This is freed on deinit. + arena: std.heap.ArenaAllocator, + + /// The header of the minidump file. On serialization, the stream count + /// and rva will be updated to match the streams. On deserialization, + /// this is read directly from the file. + header: external.Header, + + /// The streams within the minidump file in the order they're serialized. + streams: std.ArrayListUnmanaged(Stream), + + /// Read the minidump file for the given source. + /// + /// The source must have a reader() and seekableStream() method. + /// For example, both File and std.io.FixedBufferStream implement these. + /// + /// The reader will read the full minidump data into memory. This makes + /// it easy to serialize the data back out. This is acceptable for our + /// use case which doesn't rely too much on being memory efficient or + /// high load. We also expect the minidump files to be relatively small + /// (dozens of MB at most, hundreds of KB typically). + /// + /// NOTE(mitchellh): If we ever want to make this more memory efficient, + /// I would create a new type that is a "lazy reader" that stores the + /// source type and reads the data as needed. Then this type should use + /// that type. + pub fn read(alloc_gpa: Allocator, source: anytype) !Minidump { + var arena = std.heap.ArenaAllocator.init(alloc_gpa); + errdefer arena.deinit(); + const alloc = arena.allocator(); + + // Read the header which also determines the endianness of the file. + const header, const endian = try readHeader(source); + //log.warn("header={} endian={}", .{ header, endian }); + + var streams = try std.ArrayListUnmanaged(Stream).initCapacity( + alloc, + header.stream_count, + ); + errdefer streams.deinit(alloc); + + // Read the streams. All the streams are first described in a + // "directory" structure which tells us the type of stream and + // where it is located in the file. The directory structures are + // stored in a contiguous block at the stream_directory_rva. + // + // Due to how we use this structure, we read directories one by one, + // then read all the data for that directory, then move on to the + // next directory. This is because we copy all the minidump data + // into memory. + const seeker = source.seekableStream(); + try seeker.seekTo(header.stream_directory_rva); + for (0..header.stream_count) |_| { + // Read the current directory + const directory = try source.reader().readStructEndian(external.Directory, endian); + log.warn("directory={}", .{directory}); + + // Seek to the location of the data. We have to store our current + // position because we need to seek back to it after reading the + // data in order to read the next directory. + const pos = try seeker.getPos(); + + try seeker.seekTo(directory.location.rva); + + // Read the data. The data length is defined by the directory. + // If we can't read exactly that amount of data, we return an error. + var data = std.ArrayList(u8).init(alloc); + defer data.deinit(); + source.reader().readAllArrayList( + &data, + directory.location.data_size, + ) catch |err| switch (err) { + // This means there was more data in the reader than what + // we asked for this. This is okay and expected because + // all streams except the last one will have this error. + error.StreamTooLong => {}, + else => return err, + }; + + // Basic check. + if (data.items.len != directory.location.data_size) return error.DataSizeMismatch; + + // Store our stream + try streams.append(alloc, .{ .encoded = .{ + .type = directory.stream_type, + .data = try data.toOwnedSlice(), + } }); + + // Seek back to where we were after reading this directory + // entry so we can read the next one. + try seeker.seekTo(pos); + } + + return .{ + .arena = arena, + .header = header, + .streams = streams, + }; + } + + /// Reads the header for the minidump file and returns endianness of + /// the file. + fn readHeader(source: anytype) !struct { external.Header, std.builtin.Endian } { + // Start by trying LE. + var endian: std.builtin.Endian = .little; + var header = try source.reader().readStructEndian(external.Header, endian); + + // If the signature doesn't match, we assume its BE. + if (header.signature != external.signature) { + // Seek back to the start of the file so we can reread. + try source.seekableStream().seekTo(0); + + // Try BE, if the signature doesn't match, return an error. + endian = .big; + header = try source.reader().readStructEndian(external.Header, endian); + if (header.signature != external.signature) return error.InvalidHeader; + } + + // "The low-order word is MINIDUMP_VERSION. The high-order word is an + // internal value that is implementation specific." + if (header.version.low != external.version) return error.InvalidVersion; + + return .{ header, endian }; + } + + pub fn deinit(self: *Minidump) void { + self.arena.deinit(); + } + + /// The arena allocator associated with this envelope + pub fn allocator(self: *Minidump) Allocator { + return self.arena.allocator(); + } +}; + +test "Minidump read" { + const testing = std.testing; + const alloc = testing.allocator; + var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); + var md = try Minidump.read(alloc, &fbs); + defer md.deinit(); +} diff --git a/src/crash/minidump/stream.zig b/src/crash/minidump/stream.zig new file mode 100644 index 000000000..d607ed82b --- /dev/null +++ b/src/crash/minidump/stream.zig @@ -0,0 +1,21 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; + +/// A stream within the minidump file. A stream can be either in an encoded +/// form or decoded form. The encoded form are raw bytes and aren't validated +/// until they're decoded. The decoded form is a structured form of the stream. +/// +/// The decoded form is more ergonomic to work with but the encoded form is +/// more efficient to read/write. +pub const Stream = union(enum) { + encoded: EncodedStream, +}; + +/// An encoded stream value. It is "encoded" in the sense that it is raw bytes +/// with a type associated. The raw bytes are not validated to be correct for +/// the type. +pub const EncodedStream = struct { + type: u32, + data: []const u8, +}; From b8ec91242f96cf74f7545de7f70e2c80deb37829 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Sep 2024 10:07:24 -0700 Subject: [PATCH 040/139] crash/minidump: reader that streams data from a source --- src/crash/minidump.zig | 4 +- src/crash/minidump/minidump.zig | 154 ----------------------------- src/crash/minidump/reader.zig | 167 ++++++++++++++++++++++++++++++++ 3 files changed, 169 insertions(+), 156 deletions(-) delete mode 100644 src/crash/minidump/minidump.zig create mode 100644 src/crash/minidump/reader.zig diff --git a/src/crash/minidump.zig b/src/crash/minidump.zig index 0cf641114..1e103283f 100644 --- a/src/crash/minidump.zig +++ b/src/crash/minidump.zig @@ -1,7 +1,7 @@ -const minidump = @import("minidump/minidump.zig"); +const reader = @import("minidump/reader.zig"); pub const stream = @import("minidump/stream.zig"); -pub const Minidump = minidump.Minidump; +pub const Reader = reader.Reader; test { @import("std").testing.refAllDecls(@This()); diff --git a/src/crash/minidump/minidump.zig b/src/crash/minidump/minidump.zig deleted file mode 100644 index 2056212a7..000000000 --- a/src/crash/minidump/minidump.zig +++ /dev/null @@ -1,154 +0,0 @@ -const std = @import("std"); -const assert = std.debug.assert; -const Allocator = std.mem.Allocator; -const external = @import("external.zig"); -const stream = @import("stream.zig"); -const Stream = stream.Stream; - -const log = std.log.scoped(.minidump); - -/// Minidump file format. -pub const Minidump = struct { - /// The arena that all streams are allocated within when reading the - /// minidump file. This is freed on deinit. - arena: std.heap.ArenaAllocator, - - /// The header of the minidump file. On serialization, the stream count - /// and rva will be updated to match the streams. On deserialization, - /// this is read directly from the file. - header: external.Header, - - /// The streams within the minidump file in the order they're serialized. - streams: std.ArrayListUnmanaged(Stream), - - /// Read the minidump file for the given source. - /// - /// The source must have a reader() and seekableStream() method. - /// For example, both File and std.io.FixedBufferStream implement these. - /// - /// The reader will read the full minidump data into memory. This makes - /// it easy to serialize the data back out. This is acceptable for our - /// use case which doesn't rely too much on being memory efficient or - /// high load. We also expect the minidump files to be relatively small - /// (dozens of MB at most, hundreds of KB typically). - /// - /// NOTE(mitchellh): If we ever want to make this more memory efficient, - /// I would create a new type that is a "lazy reader" that stores the - /// source type and reads the data as needed. Then this type should use - /// that type. - pub fn read(alloc_gpa: Allocator, source: anytype) !Minidump { - var arena = std.heap.ArenaAllocator.init(alloc_gpa); - errdefer arena.deinit(); - const alloc = arena.allocator(); - - // Read the header which also determines the endianness of the file. - const header, const endian = try readHeader(source); - //log.warn("header={} endian={}", .{ header, endian }); - - var streams = try std.ArrayListUnmanaged(Stream).initCapacity( - alloc, - header.stream_count, - ); - errdefer streams.deinit(alloc); - - // Read the streams. All the streams are first described in a - // "directory" structure which tells us the type of stream and - // where it is located in the file. The directory structures are - // stored in a contiguous block at the stream_directory_rva. - // - // Due to how we use this structure, we read directories one by one, - // then read all the data for that directory, then move on to the - // next directory. This is because we copy all the minidump data - // into memory. - const seeker = source.seekableStream(); - try seeker.seekTo(header.stream_directory_rva); - for (0..header.stream_count) |_| { - // Read the current directory - const directory = try source.reader().readStructEndian(external.Directory, endian); - log.warn("directory={}", .{directory}); - - // Seek to the location of the data. We have to store our current - // position because we need to seek back to it after reading the - // data in order to read the next directory. - const pos = try seeker.getPos(); - - try seeker.seekTo(directory.location.rva); - - // Read the data. The data length is defined by the directory. - // If we can't read exactly that amount of data, we return an error. - var data = std.ArrayList(u8).init(alloc); - defer data.deinit(); - source.reader().readAllArrayList( - &data, - directory.location.data_size, - ) catch |err| switch (err) { - // This means there was more data in the reader than what - // we asked for this. This is okay and expected because - // all streams except the last one will have this error. - error.StreamTooLong => {}, - else => return err, - }; - - // Basic check. - if (data.items.len != directory.location.data_size) return error.DataSizeMismatch; - - // Store our stream - try streams.append(alloc, .{ .encoded = .{ - .type = directory.stream_type, - .data = try data.toOwnedSlice(), - } }); - - // Seek back to where we were after reading this directory - // entry so we can read the next one. - try seeker.seekTo(pos); - } - - return .{ - .arena = arena, - .header = header, - .streams = streams, - }; - } - - /// Reads the header for the minidump file and returns endianness of - /// the file. - fn readHeader(source: anytype) !struct { external.Header, std.builtin.Endian } { - // Start by trying LE. - var endian: std.builtin.Endian = .little; - var header = try source.reader().readStructEndian(external.Header, endian); - - // If the signature doesn't match, we assume its BE. - if (header.signature != external.signature) { - // Seek back to the start of the file so we can reread. - try source.seekableStream().seekTo(0); - - // Try BE, if the signature doesn't match, return an error. - endian = .big; - header = try source.reader().readStructEndian(external.Header, endian); - if (header.signature != external.signature) return error.InvalidHeader; - } - - // "The low-order word is MINIDUMP_VERSION. The high-order word is an - // internal value that is implementation specific." - if (header.version.low != external.version) return error.InvalidVersion; - - return .{ header, endian }; - } - - pub fn deinit(self: *Minidump) void { - self.arena.deinit(); - } - - /// The arena allocator associated with this envelope - pub fn allocator(self: *Minidump) Allocator { - return self.arena.allocator(); - } -}; - -test "Minidump read" { - const testing = std.testing; - const alloc = testing.allocator; - var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); - var md = try Minidump.read(alloc, &fbs); - defer md.deinit(); -} diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig new file mode 100644 index 000000000..0735de048 --- /dev/null +++ b/src/crash/minidump/reader.zig @@ -0,0 +1,167 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const external = @import("external.zig"); +const stream = @import("stream.zig"); +const EncodedStream = stream.EncodedStream; + +const log = std.log.scoped(.minidump_reader); + +/// Possible minidump-specific errors that can occur when reading a minidump. +/// This isn't the full error set since IO errors can also occur depending +/// on the Source type. +pub const ReadError = error{ + InvalidHeader, + InvalidVersion, +}; + +/// Reader creates a new minidump reader for the given source type. The +/// source must have both a "reader()" and "seekableStream()" function. +/// +/// Given the format of a minidump file, we must keep the source open and +/// continually access it because the format of the minidump is full of +/// pointers and offsets that we must follow depending on the stream types. +/// Also, since we're not aware of all stream types (in fact its impossible +/// to be aware since custom stream types are allowed), its possible any stream +/// type can define their own pointers and offsets. So, the source must always +/// be available so callers can decode the streams as needed. +pub fn Reader(comptime Source: type) type { + return struct { + const Self = @This(); + + /// The source data. + source: Source, + + /// The endianness of the minidump file. This is detected by reading + /// the byte order of the header. + endian: std.builtin.Endian, + + /// The number of streams within the minidump file. This is read from + /// the header and stored here so we can quickly access them. Note + /// the stream types require reading the source; this is an optimization + /// to avoid any allocations on the reader and the caller can choose + /// to store them if they want. + stream_count: u32, + stream_directory_rva: u32, + + const SourceCallable = switch (@typeInfo(Source)) { + .Pointer => |v| v.child, + .Struct => Source, + else => @compileError("Source type must be a pointer or struct"), + }; + + const SourceReader = @typeInfo(@TypeOf(SourceCallable.reader)).Fn.return_type.?; + const SourceSeeker = @typeInfo(@TypeOf(SourceCallable.seekableStream)).Fn.return_type.?; + + /// The reader type for stream reading. This is a LimitedReader so + /// you must still call reader() on the result to get the actual + /// reader to read the data. + pub const StreamReader = std.io.LimitedReader(SourceReader); + + /// Initialize a reader. The source must remain available for the entire + /// lifetime of the reader. The reader does not take ownership of the + /// source so if it has resources that need to be cleaned up, the caller + /// must do so once the reader is no longer needed. + pub fn init(source: Source) !Self { + const header, const endian = try readHeader(Source, source); + return .{ + .source = source, + .endian = endian, + .stream_count = header.stream_count, + .stream_directory_rva = header.stream_directory_rva, + }; + } + + /// Return a StreamReader for the given directory type. This streams + /// from the underlying source so the returned reader is only valid + /// as long as the source is unmodified (i.e. the source is not + /// closed, the source is not seeked, etc.). + pub fn streamReader( + self: *const Self, + dir: external.Directory, + ) SourceSeeker.SeekError!StreamReader { + try self.source.seekableStream().seekTo(dir.location.rva); + return .{ + .inner_reader = self.source.reader(), + .bytes_left = dir.location.data_size, + }; + } + + /// Get the directory entry with the given index. + /// + /// Asserts the index is valid (idx < stream_count). + pub fn directory(self: *const Self, idx: usize) !external.Directory { + assert(idx < self.stream_count); + + // Seek to the directory. + const offset: u32 = @intCast(@sizeOf(external.Directory) * idx); + const rva: u32 = self.stream_directory_rva + offset; + try self.source.seekableStream().seekTo(rva); + + // Read the directory. + return try self.source.reader().readStructEndian( + external.Directory, + self.endian, + ); + } + }; +} + +/// Reads the header for the minidump file and returns endianness of +/// the file. +fn readHeader(comptime T: type, source: T) !struct { + external.Header, + std.builtin.Endian, +} { + // Start by trying LE. + var endian: std.builtin.Endian = .little; + var header = try source.reader().readStructEndian(external.Header, endian); + + // If the signature doesn't match, we assume its BE. + if (header.signature != external.signature) { + // Seek back to the start of the file so we can reread. + try source.seekableStream().seekTo(0); + + // Try BE, if the signature doesn't match, return an error. + endian = .big; + header = try source.reader().readStructEndian(external.Header, endian); + if (header.signature != external.signature) return ReadError.InvalidHeader; + } + + // "The low-order word is MINIDUMP_VERSION. The high-order word is an + // internal value that is implementation specific." + if (header.version.low != external.version) return ReadError.InvalidVersion; + + return .{ header, endian }; +} + +// Uncomment to dump some debug information for a minidump file. +test "Minidump debug" { + var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); + const r = try Reader(*@TypeOf(fbs)).init(&fbs); + for (0..r.stream_count) |i| { + const dir = try r.directory(i); + log.warn("directory i={} dir={}", .{ i, dir }); + } +} + +test "Minidump read" { + const testing = std.testing; + const alloc = testing.allocator; + + var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); + const r = try Reader(*@TypeOf(fbs)).init(&fbs); + try testing.expectEqual(std.builtin.Endian.little, r.endian); + try testing.expectEqual(7, r.stream_count); + { + const dir = try r.directory(0); + try testing.expectEqual(3, dir.stream_type); + try testing.expectEqual(584, dir.location.data_size); + + var bytes = std.ArrayList(u8).init(alloc); + defer bytes.deinit(); + var sr = try r.streamReader(dir); + try sr.reader().readAllArrayList(&bytes, std.math.maxInt(usize)); + try testing.expectEqual(584, bytes.items.len); + } +} From facbabfd2c2eb3af182328c7034f17a8e6971ab8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Sep 2024 10:15:36 -0700 Subject: [PATCH 041/139] crash/minidump: StreamReader --- src/crash/minidump/reader.zig | 35 ++++++++++++++++++++++++++++++----- 1 file changed, 30 insertions(+), 5 deletions(-) diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig index 0735de048..8f795960f 100644 --- a/src/crash/minidump/reader.zig +++ b/src/crash/minidump/reader.zig @@ -53,10 +53,36 @@ pub fn Reader(comptime Source: type) type { const SourceReader = @typeInfo(@TypeOf(SourceCallable.reader)).Fn.return_type.?; const SourceSeeker = @typeInfo(@TypeOf(SourceCallable.seekableStream)).Fn.return_type.?; - /// The reader type for stream reading. This is a LimitedReader so + /// The reader type for stream reading. This has some other methods so /// you must still call reader() on the result to get the actual /// reader to read the data. - pub const StreamReader = std.io.LimitedReader(SourceReader); + pub const StreamReader = struct { + source: Source, + directory: external.Directory, + + /// Should not be accessed directly. This is setup whenever + /// reader() is called. + limit_reader: LimitedReader = undefined, + + const LimitedReader = std.io.LimitedReader(SourceReader); + pub const Reader = LimitedReader.Reader; + + /// Returns a Reader implementation that reads the bytes of the + /// stream. + /// + /// The reader is dependent on the state of Source so any + /// state-changing operations on Source will invalidate the + /// reader. For example, making another reader, reading another + /// stream directory, closing the source, etc. + pub fn reader(self: *StreamReader) LimitedReader.Reader { + try self.source.seekableStream().seekTo(self.directory.location.rva); + self.limit_reader = .{ + .inner_reader = self.source.reader(), + .bytes_left = self.directory.location.data_size, + }; + return self.limit_reader.reader(); + } + }; /// Initialize a reader. The source must remain available for the entire /// lifetime of the reader. The reader does not take ownership of the @@ -80,10 +106,9 @@ pub fn Reader(comptime Source: type) type { self: *const Self, dir: external.Directory, ) SourceSeeker.SeekError!StreamReader { - try self.source.seekableStream().seekTo(dir.location.rva); return .{ - .inner_reader = self.source.reader(), - .bytes_left = dir.location.data_size, + .source = self.source, + .directory = dir, }; } From ca1ab7bcdc61b0ecb213e1940aa196818a683527 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Sep 2024 10:32:31 -0700 Subject: [PATCH 042/139] crash/minidump: streamIterator --- src/crash/minidump/reader.zig | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig index 8f795960f..efdd76a6a 100644 --- a/src/crash/minidump/reader.zig +++ b/src/crash/minidump/reader.zig @@ -84,6 +84,19 @@ pub fn Reader(comptime Source: type) type { } }; + /// Iterator type to read over the streams in the minidump file. + pub const StreamIterator = struct { + reader: *const Self, + i: u32 = 0, + + pub fn next(self: *StreamIterator) !?StreamReader { + if (self.i >= self.reader.stream_count) return null; + const dir = try self.reader.directory(self.i); + self.i += 1; + return try self.reader.streamReader(dir); + } + }; + /// Initialize a reader. The source must remain available for the entire /// lifetime of the reader. The reader does not take ownership of the /// source so if it has resources that need to be cleaned up, the caller @@ -98,6 +111,14 @@ pub fn Reader(comptime Source: type) type { }; } + /// Return an interator to read over the streams in the minidump file. + /// This is very similar to using a simple for loop to stream_count + /// and calling directory() on each index, but is more idiomatic + /// Zig. + pub fn streamIterator(self: *const Self) StreamIterator { + return .{ .reader = self }; + } + /// Return a StreamReader for the given directory type. This streams /// from the underlying source so the returned reader is only valid /// as long as the source is unmodified (i.e. the source is not @@ -164,9 +185,9 @@ fn readHeader(comptime T: type, source: T) !struct { test "Minidump debug" { var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); const r = try Reader(*@TypeOf(fbs)).init(&fbs); - for (0..r.stream_count) |i| { - const dir = try r.directory(i); - log.warn("directory i={} dir={}", .{ i, dir }); + var it = r.streamIterator(); + while (try it.next()) |s| { + log.warn("directory i={} dir={}", .{ it.i - 1, s.directory }); } } From c0719fceef05870b6992fe54c24dd0db4a919cd7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Sep 2024 10:37:59 -0700 Subject: [PATCH 043/139] crash/minidump: typos --- src/crash/minidump/reader.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig index efdd76a6a..a88e6ed08 100644 --- a/src/crash/minidump/reader.zig +++ b/src/crash/minidump/reader.zig @@ -111,7 +111,7 @@ pub fn Reader(comptime Source: type) type { }; } - /// Return an interator to read over the streams in the minidump file. + /// Return an iterator to read over the streams in the minidump file. /// This is very similar to using a simple for loop to stream_count /// and calling directory() on each index, but is more idiomatic /// Zig. @@ -122,7 +122,7 @@ pub fn Reader(comptime Source: type) type { /// Return a StreamReader for the given directory type. This streams /// from the underlying source so the returned reader is only valid /// as long as the source is unmodified (i.e. the source is not - /// closed, the source is not seeked, etc.). + /// closed, the source seek position is not moved, etc.). pub fn streamReader( self: *const Self, dir: external.Directory, From df629044fad2648e13d686bb1245bd9d5d4a4e8b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Sep 2024 10:58:38 -0700 Subject: [PATCH 044/139] crash/minidump: working on rich stream type decoding, ThreadList --- src/crash/minidump/external.zig | 24 ++++++++++++++ src/crash/minidump/reader.zig | 16 ++++++++-- src/crash/minidump/stream.zig | 56 +++++++++++++++++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/src/crash/minidump/external.zig b/src/crash/minidump/external.zig index 9356a6cb3..a6f89d3e9 100644 --- a/src/crash/minidump/external.zig +++ b/src/crash/minidump/external.zig @@ -34,3 +34,27 @@ pub const LocationDescriptor = extern struct { data_size: u32, rva: u32, }; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_memory_descriptor +pub const MemoryDescriptor = extern struct { + start_of_memory_range: u64, + memory: LocationDescriptor, +}; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread_list +pub const ThreadList = extern struct { + number_of_threads: u32, + + // This struct has a trailing array of `Thread` structs. +}; + +/// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread +pub const Thread = extern struct { + thread_id: u32, + suspend_count: u32, + priority_class: u32, + priority: u32, + teb: u64, + stack: MemoryDescriptor, + thread_context: LocationDescriptor, +}; diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig index a88e6ed08..582044879 100644 --- a/src/crash/minidump/reader.zig +++ b/src/crash/minidump/reader.zig @@ -25,7 +25,7 @@ pub const ReadError = error{ /// to be aware since custom stream types are allowed), its possible any stream /// type can define their own pointers and offsets. So, the source must always /// be available so callers can decode the streams as needed. -pub fn Reader(comptime Source: type) type { +pub fn Reader(comptime S: type) type { return struct { const Self = @This(); @@ -53,11 +53,15 @@ pub fn Reader(comptime Source: type) type { const SourceReader = @typeInfo(@TypeOf(SourceCallable.reader)).Fn.return_type.?; const SourceSeeker = @typeInfo(@TypeOf(SourceCallable.seekableStream)).Fn.return_type.?; + /// The source type for the reader. + pub const Source = S; + /// The reader type for stream reading. This has some other methods so /// you must still call reader() on the result to get the actual /// reader to read the data. pub const StreamReader = struct { source: Source, + endian: std.builtin.Endian, directory: external.Directory, /// Should not be accessed directly. This is setup whenever @@ -82,6 +86,11 @@ pub fn Reader(comptime Source: type) type { }; return self.limit_reader.reader(); } + + /// Seeks the source to the location of the directory. + pub fn seekToPayload(self: *StreamReader) !void { + try self.source.seekableStream().seekTo(self.directory.location.rva); + } }; /// Iterator type to read over the streams in the minidump file. @@ -129,6 +138,7 @@ pub fn Reader(comptime Source: type) type { ) SourceSeeker.SeekError!StreamReader { return .{ .source = self.source, + .endian = self.endian, .directory = dir, }; } @@ -182,7 +192,7 @@ fn readHeader(comptime T: type, source: T) !struct { } // Uncomment to dump some debug information for a minidump file. -test "Minidump debug" { +test "minidump debug" { var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); const r = try Reader(*@TypeOf(fbs)).init(&fbs); var it = r.streamIterator(); @@ -191,7 +201,7 @@ test "Minidump debug" { } } -test "Minidump read" { +test "minidump read" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/crash/minidump/stream.zig b/src/crash/minidump/stream.zig index d607ed82b..bb383cce0 100644 --- a/src/crash/minidump/stream.zig +++ b/src/crash/minidump/stream.zig @@ -1,6 +1,9 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; +const Reader = @import("reader.zig").Reader; + +const log = std.log.scoped(.minidump_stream); /// A stream within the minidump file. A stream can be either in an encoded /// form or decoded form. The encoded form are raw bytes and aren't validated @@ -19,3 +22,56 @@ pub const EncodedStream = struct { type: u32, data: []const u8, }; + +/// This is the list of threads from the process. +/// +/// ThreadList is stream type 0x3. +/// StreamReader is the Reader(T).StreamReader type. +pub fn ThreadList(comptime R: type) type { + return struct { + const Self = @This(); + + /// The number of threads in the list. + count: u32, + + /// The rva to the first thread in the list. + rva: u32, + + /// The source data and endianness so we can continue reading. + source: R.Source, + endian: std.builtin.Endian, + + pub fn init(r: *R.StreamReader) !Self { + assert(r.directory.stream_type == 0x3); + try r.seekToPayload(); + + const reader = r.source.reader(); + const count = try reader.readInt(u32, r.endian); + const rva = r.directory.location.rva + @as(u32, @intCast(@sizeOf(u32))); + + return .{ + .count = count, + .rva = rva, + .source = r.source, + .endian = r.endian, + }; + } + }; +} + +test "minidump: threadlist" { + const testing = std.testing; + + var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); + const R = Reader(*@TypeOf(fbs)); + const r = try R.init(&fbs); + + // Get our thread list stream + const dir = try r.directory(0); + try testing.expectEqual(3, dir.stream_type); + var sr = try r.streamReader(dir); + + // Get our rich structure + const v = try ThreadList(R).init(&sr); + log.warn("threadlist count={} rva={}", .{ v.count, v.rva }); +} From 5b1d729748f10f1793401a0d8a68f877ceef4bcd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 8 Sep 2024 15:26:21 -0700 Subject: [PATCH 045/139] crash/minidump: handle padding in the ThreadList stream --- src/crash/minidump.zig | 3 +- src/crash/minidump/external.zig | 3 +- src/crash/minidump/reader.zig | 4 + src/crash/minidump/stream.zig | 57 +----------- src/crash/minidump/stream_threadlist.zig | 111 +++++++++++++++++++++++ src/crash/testdata/macos.dmp | Bin 447456 -> 447584 bytes 6 files changed, 122 insertions(+), 56 deletions(-) create mode 100644 src/crash/minidump/stream_threadlist.zig diff --git a/src/crash/minidump.zig b/src/crash/minidump.zig index 1e103283f..0abd67eae 100644 --- a/src/crash/minidump.zig +++ b/src/crash/minidump.zig @@ -1,5 +1,4 @@ -const reader = @import("minidump/reader.zig"); - +pub const reader = @import("minidump/reader.zig"); pub const stream = @import("minidump/stream.zig"); pub const Reader = reader.Reader; diff --git a/src/crash/minidump/external.zig b/src/crash/minidump/external.zig index a6f89d3e9..451810883 100644 --- a/src/crash/minidump/external.zig +++ b/src/crash/minidump/external.zig @@ -44,8 +44,7 @@ pub const MemoryDescriptor = extern struct { /// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread_list pub const ThreadList = extern struct { number_of_threads: u32, - - // This struct has a trailing array of `Thread` structs. + threads: [1]Thread, }; /// https://learn.microsoft.com/en-us/windows/win32/api/minidumpapiset/ns-minidumpapiset-minidump_thread diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig index 582044879..f316e63b0 100644 --- a/src/crash/minidump/reader.zig +++ b/src/crash/minidump/reader.zig @@ -13,6 +13,7 @@ const log = std.log.scoped(.minidump_reader); pub const ReadError = error{ InvalidHeader, InvalidVersion, + StreamSizeMismatch, }; /// Reader creates a new minidump reader for the given source type. The @@ -56,6 +57,9 @@ pub fn Reader(comptime S: type) type { /// The source type for the reader. pub const Source = S; + /// The stream types for reading + pub const ThreadList = stream.thread_list.ThreadListReader(Self); + /// The reader type for stream reading. This has some other methods so /// you must still call reader() on the result to get the actual /// reader to read the data. diff --git a/src/crash/minidump/stream.zig b/src/crash/minidump/stream.zig index bb383cce0..00ec6b042 100644 --- a/src/crash/minidump/stream.zig +++ b/src/crash/minidump/stream.zig @@ -1,10 +1,12 @@ const std = @import("std"); const assert = std.debug.assert; const Allocator = std.mem.Allocator; -const Reader = @import("reader.zig").Reader; const log = std.log.scoped(.minidump_stream); +/// The known stream types. +pub const thread_list = @import("stream_threadlist.zig"); + /// A stream within the minidump file. A stream can be either in an encoded /// form or decoded form. The encoded form are raw bytes and aren't validated /// until they're decoded. The decoded form is a structured form of the stream. @@ -23,55 +25,6 @@ pub const EncodedStream = struct { data: []const u8, }; -/// This is the list of threads from the process. -/// -/// ThreadList is stream type 0x3. -/// StreamReader is the Reader(T).StreamReader type. -pub fn ThreadList(comptime R: type) type { - return struct { - const Self = @This(); - - /// The number of threads in the list. - count: u32, - - /// The rva to the first thread in the list. - rva: u32, - - /// The source data and endianness so we can continue reading. - source: R.Source, - endian: std.builtin.Endian, - - pub fn init(r: *R.StreamReader) !Self { - assert(r.directory.stream_type == 0x3); - try r.seekToPayload(); - - const reader = r.source.reader(); - const count = try reader.readInt(u32, r.endian); - const rva = r.directory.location.rva + @as(u32, @intCast(@sizeOf(u32))); - - return .{ - .count = count, - .rva = rva, - .source = r.source, - .endian = r.endian, - }; - } - }; -} - -test "minidump: threadlist" { - const testing = std.testing; - - var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); - const R = Reader(*@TypeOf(fbs)); - const r = try R.init(&fbs); - - // Get our thread list stream - const dir = try r.directory(0); - try testing.expectEqual(3, dir.stream_type); - var sr = try r.streamReader(dir); - - // Get our rich structure - const v = try ThreadList(R).init(&sr); - log.warn("threadlist count={} rva={}", .{ v.count, v.rva }); +test { + @import("std").testing.refAllDecls(@This()); } diff --git a/src/crash/minidump/stream_threadlist.zig b/src/crash/minidump/stream_threadlist.zig new file mode 100644 index 000000000..e74d11e3e --- /dev/null +++ b/src/crash/minidump/stream_threadlist.zig @@ -0,0 +1,111 @@ +const std = @import("std"); +const assert = std.debug.assert; +const external = @import("external.zig"); +const readerpkg = @import("reader.zig"); +const Reader = readerpkg.Reader; +const ReadError = readerpkg.ReadError; + +const log = std.log.scoped(.minidump_stream); + +/// This is the list of threads from the process. +/// +/// This is the Reader implementation. You usually do not use this directly. +/// Instead, use Reader(T).ThreadList which will get you the same thing. +/// +/// ThreadList is stream type 0x3. +/// StreamReader is the Reader(T).StreamReader type. +pub fn ThreadListReader(comptime R: type) type { + return struct { + const Self = @This(); + + /// The number of threads in the list. + count: u32, + + /// The rva to the first thread in the list. + rva: u32, + + /// Source data and endianness so we can read. + source: R.Source, + endian: std.builtin.Endian, + + pub fn init(r: *R.StreamReader) !Self { + assert(r.directory.stream_type == 0x3); + try r.seekToPayload(); + const reader = r.source.reader(); + + // Our count is always a u32 in the header. + const count = try reader.readInt(u32, r.endian); + + // Determine if we have padding in our header. It is possible + // for there to be padding if the list header was written by + // a 32-bit process but is being read on a 64-bit process. + const padding = padding: { + const maybe_size = @sizeOf(u32) + (@sizeOf(external.Thread) * count); + switch (std.math.order(maybe_size, r.directory.location.data_size)) { + // It should never be larger than what the directory says. + .gt => return ReadError.StreamSizeMismatch, + + // If the sizes match exactly we're good. + .eq => break :padding 0, + + .lt => { + const padding = r.directory.location.data_size - maybe_size; + if (padding != 4) return ReadError.StreamSizeMismatch; + break :padding padding; + }, + } + }; + + // Rva is the location of the first thread in the list. + const rva = r.directory.location.rva + @as(u32, @sizeOf(u32)) + padding; + + return .{ + .count = count, + .rva = rva, + .source = r.source, + .endian = r.endian, + }; + } + + /// Get the thread entry for the given index. + /// + /// Index is asserted to be less than count. + pub fn thread(self: *const Self, i: usize) !external.Thread { + assert(i < self.count); + + // Seek to the thread + const offset: u32 = @intCast(@sizeOf(external.Thread) * i); + const rva: u32 = self.rva + offset; + try self.source.seekableStream().seekTo(rva); + + // Read the thread + return try self.source.reader().readStructEndian( + external.Thread, + self.endian, + ); + } + }; +} + +test "minidump: threadlist" { + const testing = std.testing; + + var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); + const R = Reader(*@TypeOf(fbs)); + const r = try R.init(&fbs); + + // Get our thread list stream + const dir = try r.directory(0); + try testing.expectEqual(3, dir.stream_type); + var sr = try r.streamReader(dir); + + // Get our rich structure + const v = try R.ThreadList.init(&sr); + log.warn("threadlist count={} rva={}", .{ v.count, v.rva }); + + try testing.expectEqual(12, v.count); + for (0..v.count) |i| { + const t = try v.thread(i); + log.warn("thread i={} thread={}", .{ i, t }); + } +} diff --git a/src/crash/testdata/macos.dmp b/src/crash/testdata/macos.dmp index 5931c13a06284043c286fcda9a94aa93f2762909..212cc7e624a1a670687dc20eca7f81e4db53ba5f 100644 GIT binary patch delta 31642 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 delta 31643 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 Date: Sun, 8 Sep 2024 21:25:34 -0700 Subject: [PATCH 046/139] crash/minidump: locationReader to read locations in a minidump --- src/crash/minidump/reader.zig | 17 ++++++++++++++++- src/crash/minidump/stream_threadlist.zig | 6 ++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/crash/minidump/reader.zig b/src/crash/minidump/reader.zig index f316e63b0..f792d6670 100644 --- a/src/crash/minidump/reader.zig +++ b/src/crash/minidump/reader.zig @@ -54,6 +54,9 @@ pub fn Reader(comptime S: type) type { const SourceReader = @typeInfo(@TypeOf(SourceCallable.reader)).Fn.return_type.?; const SourceSeeker = @typeInfo(@TypeOf(SourceCallable.seekableStream)).Fn.return_type.?; + /// A limited reader for reading data from the source. + pub const LimitedReader = std.io.LimitedReader(SourceReader); + /// The source type for the reader. pub const Source = S; @@ -72,7 +75,6 @@ pub fn Reader(comptime S: type) type { /// reader() is called. limit_reader: LimitedReader = undefined, - const LimitedReader = std.io.LimitedReader(SourceReader); pub const Reader = LimitedReader.Reader; /// Returns a Reader implementation that reads the bytes of the @@ -164,6 +166,19 @@ pub fn Reader(comptime S: type) type { self.endian, ); } + + /// Return a reader for the given location descriptor. This is only + /// valid until the reader source is modified in some way. + pub fn locationReader( + self: *const Self, + loc: external.LocationDescriptor, + ) !LimitedReader { + try self.source.seekableStream().seekTo(loc.rva); + return .{ + .inner_reader = self.source.reader(), + .bytes_left = loc.data_size, + }; + } }; } diff --git a/src/crash/minidump/stream_threadlist.zig b/src/crash/minidump/stream_threadlist.zig index e74d11e3e..51f3f9d4c 100644 --- a/src/crash/minidump/stream_threadlist.zig +++ b/src/crash/minidump/stream_threadlist.zig @@ -89,6 +89,7 @@ pub fn ThreadListReader(comptime R: type) type { test "minidump: threadlist" { const testing = std.testing; + const alloc = testing.allocator; var fbs = std.io.fixedBufferStream(@embedFile("../testdata/macos.dmp")); const R = Reader(*@TypeOf(fbs)); @@ -107,5 +108,10 @@ test "minidump: threadlist" { for (0..v.count) |i| { const t = try v.thread(i); log.warn("thread i={} thread={}", .{ i, t }); + + // Read our stack memory + var stack_reader = try r.locationReader(t.stack.memory); + const bytes = try stack_reader.reader().readAllAlloc(alloc, t.stack.memory.data_size); + defer alloc.free(bytes); } } From 2033e59240432aa3d63528e896d629cdccefc700 Mon Sep 17 00:00:00 2001 From: Paul Berg Date: Mon, 23 Sep 2024 10:13:33 +0200 Subject: [PATCH 047/139] apprt/gtk: set tabs reorderable/detachable on GtkNotebook --- src/apprt/gtk/notebook.zig | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index dad42e730..f75827c8b 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -261,6 +261,10 @@ pub const Notebook = union(enum) { _ = c.g_signal_connect_data(label_close, "clicked", c.G_CALLBACK(&Tab.gtkTabCloseClick), tab, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(gesture_tab_click, "pressed", c.G_CALLBACK(&Tab.gtkTabClick), tab, null, c.G_CONNECT_DEFAULT); + // Tab settings + c.gtk_notebook_set_tab_reorderable(notebook, box_widget, 1); + c.gtk_notebook_set_tab_detachable(notebook, box_widget, 1); + if (self.nPages() > 1) { c.gtk_notebook_set_show_tabs(notebook, 1); } From 2805657213b2511ab1f3331af3fc87137c924ed2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 23 Sep 2024 10:31:01 -0500 Subject: [PATCH 048/139] gtk/adw: conditional tab keybindings adw_tab_view_remove_shortcuts (and related APIs) was added in libadwaita 1.2.0. --- src/apprt/gtk/notebook.zig | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index dad42e730..13db7a69e 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -66,9 +66,11 @@ pub const Notebook = union(enum) { const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; - // Adwaita enables all of the shortcuts by default. - // We want to manage keybindings ourselves. - c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); + if (comptime adwaita.versionAtLeast(1, 2, 0)) { + // Adwaita enables all of the shortcuts by default. + // We want to manage keybindings ourselves. + c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); + } _ = c.g_signal_connect_data(tab_view, "page-attached", c.G_CALLBACK(&adwPageAttached), window, null, c.G_CONNECT_DEFAULT); _ = c.g_signal_connect_data(tab_view, "create-window", c.G_CALLBACK(&adwTabViewCreateWindow), window, null, c.G_CONNECT_DEFAULT); From c91c5164ffce06b4b3b9a3c661858a0fbdbbb0ca Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 09:28:56 -0700 Subject: [PATCH 049/139] update libxev This fixes the issue where a write could be interrupted and not retried. --- build.zig.zon | 4 ++-- nix/zigCacheHash.nix | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index bd86b3427..fbbd52131 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .dependencies = .{ // Zig libs .libxev = .{ - .url = "https://github.com/mitchellh/libxev/archive/43c7e4b3308f359e5b758db2d824d7c447f4ed3f.tar.gz", - .hash = "1220aec83b6367c6bc64ca781828e0ad817fb38e7fca7331bd6d736b6896910f6637", + .url = "https://github.com/mitchellh/libxev/archive/b8d1d93e5c899b27abbaa7df23b496c3e6a178c7.tar.gz", + .hash = "1220612bc023c21d75234882ec9a8c6a1cbd9d642da3dfb899297f14bb5bd7b6cd78", }, .mach_glfw = .{ .url = "https://github.com/mitchellh/mach-glfw/archive/37c2995f31abcf7e8378fba68ddcf4a3faa02de0.tar.gz", diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 842f39762..62c7f4767 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-YLopoyRgXV6GYiTiaKt64mH6lWjlKJbi61ck0fO4WvQ=" +"sha256-MocGI5dxh+WO79p01HbdFuc+wR+sXSxBnoFAmrX4p0s=" From 8186e95902caf51c8e4276788d9ff7de77080fb0 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 23 Sep 2024 11:41:39 -0500 Subject: [PATCH 050/139] gtk/adw: perform version check at both comptime and runtime --- src/apprt/gtk/notebook.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/apprt/gtk/notebook.zig b/src/apprt/gtk/notebook.zig index 13db7a69e..a42c58ce0 100644 --- a/src/apprt/gtk/notebook.zig +++ b/src/apprt/gtk/notebook.zig @@ -66,7 +66,7 @@ pub const Notebook = union(enum) { const tab_view: *c.AdwTabView = c.adw_tab_view_new().?; - if (comptime adwaita.versionAtLeast(1, 2, 0)) { + if (comptime adwaita.versionAtLeast(1, 2, 0) and adwaita.versionAtLeast(1, 2, 0)) { // Adwaita enables all of the shortcuts by default. // We want to manage keybindings ourselves. c.adw_tab_view_remove_shortcuts(tab_view, c.ADW_TAB_VIEW_SHORTCUT_ALL_SHORTCUTS); From 0394c8e2dfe05d2d0135130041db166b8abc0671 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 09:50:32 -0700 Subject: [PATCH 051/139] input: parse global keys, document them --- src/config/Config.zig | 43 ++++++++++++++++--- src/input/Binding.zig | 97 +++++++++++++++++++++++++++++++++++++++---- 2 files changed, 126 insertions(+), 14 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 9bc518326..628afa4ad 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -651,7 +651,8 @@ class: ?[:0]const u8 = null, @"working-directory": ?[]const u8 = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will -/// overwrite previously set values. +/// overwrite previously set values. The list of actions is available in +/// the documentation or using the `ghostty +list-actions` command. /// /// Trigger: `+`-separated list of keys and modifiers. Example: `ctrl+a`, /// `ctrl+shift+b`, `up`. Some notes: @@ -722,6 +723,9 @@ class: ?[:0]const u8 = null, /// * `text:text` - Send a string. Uses Zig string literal syntax. /// i.e. `text:\x15` sends Ctrl-U. /// +/// * All other actions can be found in the documentation or by using the +/// `ghostty +list-actions` command. +/// /// Some notes for the action: /// /// * The parameter is taken as-is after the `:`. Double quotes or @@ -736,11 +740,38 @@ class: ?[:0]const u8 = null, /// removes ALL keybindings up to this point, including the default /// keybindings. /// -/// A keybind by default causes the input to be consumed. This means that the -/// associated encoding (if any) will not be sent to the running program -/// in the terminal. If you wish to send the encoded value to the program, -/// specify the "unconsumed:" prefix before the entire keybind. For example: -/// "unconsumed:ctrl+a=reload_config" +/// The keybind trigger can be prefixed with some special values to change +/// the behavior of the keybind. These are: +/// +/// * `unconsumed:` - Do not consume the input. By default, a keybind +/// will consume the input, meaning that the associated encoding (if +/// any) will not be sent to the running program in the terminal. If +/// you wish to send the encoded value to the program, specify the +/// `unconsumed:` prefix before the entire keybind. For example: +/// `unconsumed:ctrl+a=reload_config` +/// +/// * `global:` - Make the keybind global. By default, keybinds only work +/// within Ghostty and under the right conditions (application focused, +/// sometimes terminal focused, etc.). If you want a keybind to work +/// globally across your system (i.e. even when Ghostty is not focused), +/// specify this prefix. Note: this does not work in all environments; +/// see the additional notes below for more information. +/// +/// Multiple prefixes can be specified. For example, +/// `global:unconsumed:ctrl+a=reload_config` will make the keybind global +/// and not consume the input to reload the config. +/// +/// A note on `global:`: this feature is only supported on macOS. On macOS, +/// this feature requires accessibility permissions to be granted to Ghostty. +/// When a `global:` keybind is specified and Ghostty is launched or reloaded, +/// Ghostty will attempt to request these permissions. If the permissions are +/// not granted, the keybind will not work. On macOS, you can find these +/// permissions in System Preferences -> Privacy & Security -> Accessibility. +/// +/// Additionally, `global:` keybinds associated with actions that affect +/// a specific terminal surface will target the last focused terminal surface +/// within Ghostty. There is not a way to target a specific terminal surface +/// with a `global:` keybind. keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b347d263b..b491756c8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -17,17 +17,34 @@ action: Action, /// action is triggered. consumed: bool = true, +/// True if this binding is global. Global bindings should work system-wide +/// and not just while Ghostty is focused. This may not work on all platforms. +/// See the keybind config documentation for more information. +global: bool = false, + pub const Error = error{ InvalidFormat, InvalidAction, }; +/// Flags the full binding-scoped flags that can be set per binding. +pub const Flags = packed struct { + /// True if this binding should consume the input when the + /// action is triggered. + consumed: bool = true, + + /// True if this binding is global. Global bindings should work system-wide + /// and not just while Ghostty is focused. This may not work on all platforms. + /// See the keybind config documentation for more information. + global: bool = false, +}; + /// Full binding parser. The binding parser is implemented as an iterator /// which yields elements to support multi-key sequences without allocation. pub const Parser = struct { - unconsumed: bool = false, trigger_it: SequenceIterator, action: Action, + flags: Flags = .{}, pub const Elem = union(enum) { /// A leader trigger in a sequence. @@ -38,11 +55,7 @@ pub const Parser = struct { }; pub fn init(raw_input: []const u8) Error!Parser { - // If our entire input is prefixed with "unconsumed:" then we are - // not consuming this keybind when the action is triggered. - const unconsumed_prefix = "unconsumed:"; - const unconsumed = std.mem.startsWith(u8, raw_input, unconsumed_prefix); - const start_idx = if (unconsumed) unconsumed_prefix.len else 0; + const flags, const start_idx = try parseFlags(raw_input); const input = raw_input[start_idx..]; // Find the first = which splits are mapping into the trigger @@ -52,12 +65,44 @@ pub const Parser = struct { // Sequence iterator goes up to the equal, action is after. We can // parse the action now. return .{ - .unconsumed = unconsumed, .trigger_it = .{ .input = input[0..eql_idx] }, .action = try Action.parse(input[eql_idx + 1 ..]), + .flags = flags, }; } + fn parseFlags(raw_input: []const u8) Error!struct { Flags, usize } { + var flags: Flags = .{}; + + var start_idx: usize = 0; + var input: []const u8 = raw_input; + while (true) { + // Find the next prefix + const idx = std.mem.indexOf(u8, input, ":") orelse break; + const prefix = input[0..idx]; + + // If the prefix is one of our flags then set it. + if (std.mem.eql(u8, prefix, "unconsumed")) { + if (!flags.consumed) return Error.InvalidFormat; + flags.consumed = false; + } else if (std.mem.eql(u8, prefix, "global")) { + if (flags.global) return Error.InvalidFormat; + flags.global = true; + } else { + // If we don't recognize the prefix then we're done. + // There are trigger-specific prefixes like "physical:" so + // this lets us fall into that. + break; + } + + // Move past the prefix + start_idx += idx + 1; + input = input[idx + 1 ..]; + } + + return .{ flags, start_idx }; + } + pub fn next(self: *Parser) Error!?Elem { // Get our trigger. If we're out of triggers then we're done. const trigger = (try self.trigger_it.next()) orelse return null; @@ -69,7 +114,8 @@ pub const Parser = struct { return .{ .binding = .{ .trigger = trigger, .action = self.action, - .consumed = !self.unconsumed, + .consumed = self.flags.consumed, + .global = self.flags.global, } }; } @@ -1241,6 +1287,41 @@ test "parse: triggers" { try testing.expectError(Error.InvalidFormat, parseSingle("a+b=ignore")); } +test "parse: global triggers" { + const testing = std.testing; + + // global keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .global = true, + }, try parseSingle("global:shift+a=ignore")); + + // global physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .a }, + }, + .action = .{ .ignore = {} }, + .global = true, + }, try parseSingle("global:physical:a+shift=ignore")); + + // global unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .consumed = false, + .global = true, + }, try parseSingle("unconsumed:global:a+shift=ignore")); +} + test "parse: modifier aliases" { const testing = std.testing; From 66143a33efca9467a530dc9188ca33fe2e8d4493 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 10:16:35 -0700 Subject: [PATCH 052/139] input: move flags to a packed struct --- src/Surface.zig | 3 +- src/cli/list_keybinds.zig | 2 +- src/config/Config.zig | 41 +++++---- src/input/Binding.zig | 174 ++++++++++++++++++++++---------------- 4 files changed, 129 insertions(+), 91 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index a9b2c17d6..aa37b462b 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1605,8 +1605,7 @@ fn maybeHandleBinding( return .consumed; }, - .action => |v| .{ v, true }, - .action_unconsumed => |v| .{ v, false }, + .leaf => |leaf| .{ leaf.action, leaf.flags.consumed }, }; // We have an action, so at this point we're handling SOMETHING so diff --git a/src/cli/list_keybinds.zig b/src/cli/list_keybinds.zig index b12694625..9e734d1ec 100644 --- a/src/cli/list_keybinds.zig +++ b/src/cli/list_keybinds.zig @@ -116,7 +116,7 @@ fn prettyPrint(alloc: Allocator, keybinds: Config.Keybinds) !u8 { while (iter.next()) |bind| { const action = switch (bind.value_ptr.*) { .leader => continue, // TODO: support this - .action, .action_unconsumed => |action| action, + .leaf => |leaf| leaf.action, }; const key = switch (bind.key_ptr.key) { .translated => |k| try std.fmt.bufPrint(&buf, "{s}", .{@tagName(k)}), diff --git a/src/config/Config.zig b/src/config/Config.zig index 628afa4ad..f654b7fa8 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -743,6 +743,20 @@ class: ?[:0]const u8 = null, /// The keybind trigger can be prefixed with some special values to change /// the behavior of the keybind. These are: /// +/// * `all:` - Make the keybind apply to all terminal surfaces. By default, +/// keybinds only apply to the focused terminal surface. If this is true, +/// then the keybind will be sent to all terminal surfaces. This only +/// applies to actions that are surface-specific. For actions that +/// are already global (i.e. `quit`), this prefix has no effect. +/// +/// * `global:` - Make the keybind global. By default, keybinds only work +/// within Ghostty and under the right conditions (application focused, +/// sometimes terminal focused, etc.). If you want a keybind to work +/// globally across your system (i.e. even when Ghostty is not focused), +/// specify this prefix. This prefix implies `all:`. Note: this does not +/// work in all environments; see the additional notes below for more +/// information. +/// /// * `unconsumed:` - Do not consume the input. By default, a keybind /// will consume the input, meaning that the associated encoding (if /// any) will not be sent to the running program in the terminal. If @@ -750,13 +764,6 @@ class: ?[:0]const u8 = null, /// `unconsumed:` prefix before the entire keybind. For example: /// `unconsumed:ctrl+a=reload_config` /// -/// * `global:` - Make the keybind global. By default, keybinds only work -/// within Ghostty and under the right conditions (application focused, -/// sometimes terminal focused, etc.). If you want a keybind to work -/// globally across your system (i.e. even when Ghostty is not focused), -/// specify this prefix. Note: this does not work in all environments; -/// see the additional notes below for more information. -/// /// Multiple prefixes can be specified. For example, /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global /// and not consume the input to reload the config. @@ -767,11 +774,6 @@ class: ?[:0]const u8 = null, /// Ghostty will attempt to request these permissions. If the permissions are /// not granted, the keybind will not work. On macOS, you can find these /// permissions in System Preferences -> Privacy & Security -> Accessibility. -/// -/// Additionally, `global:` keybinds associated with actions that affect -/// a specific terminal surface will target the last focused terminal surface -/// within Ghostty. There is not a way to target a specific terminal surface -/// with a `global:` keybind. keybind: Keybinds = .{}, /// Horizontal window padding. This applies padding between the terminal cells @@ -3735,11 +3737,16 @@ pub const Keybinds = struct { )) return false, // Actions are compared by field directly - inline .action, .action_unconsumed => |_, tag| if (!equalField( - inputpkg.Binding.Action, - @field(self_entry.value_ptr.*, @tagName(tag)), - @field(other_entry.value_ptr.*, @tagName(tag)), - )) return false, + .leaf => { + const self_leaf = self_entry.value_ptr.*.leaf; + const other_leaf = other_entry.value_ptr.*.leaf; + + if (!equalField( + inputpkg.Binding.Set.Leaf, + self_leaf, + other_leaf, + )) return false; + }, } } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b491756c8..b49f153b6 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -13,14 +13,8 @@ trigger: Trigger, /// The action to take if this binding matches action: Action, -/// True if this binding should consume the input when the -/// action is triggered. -consumed: bool = true, - -/// True if this binding is global. Global bindings should work system-wide -/// and not just while Ghostty is focused. This may not work on all platforms. -/// See the keybind config documentation for more information. -global: bool = false, +/// Boolean flags that can be set per binding. +flags: Flags = .{}, pub const Error = error{ InvalidFormat, @@ -33,6 +27,10 @@ pub const Flags = packed struct { /// action is triggered. consumed: bool = true, + /// True if this binding should be forwarded to all active surfaces + /// in the application. + all: bool = false, + /// True if this binding is global. Global bindings should work system-wide /// and not just while Ghostty is focused. This may not work on all platforms. /// See the keybind config documentation for more information. @@ -82,12 +80,15 @@ pub const Parser = struct { const prefix = input[0..idx]; // If the prefix is one of our flags then set it. - if (std.mem.eql(u8, prefix, "unconsumed")) { - if (!flags.consumed) return Error.InvalidFormat; - flags.consumed = false; + if (std.mem.eql(u8, prefix, "all")) { + if (flags.all) return Error.InvalidFormat; + flags.all = true; } else if (std.mem.eql(u8, prefix, "global")) { if (flags.global) return Error.InvalidFormat; flags.global = true; + } else if (std.mem.eql(u8, prefix, "unconsumed")) { + if (!flags.consumed) return Error.InvalidFormat; + flags.consumed = false; } else { // If we don't recognize the prefix then we're done. // There are trigger-specific prefixes like "physical:" so @@ -114,8 +115,7 @@ pub const Parser = struct { return .{ .binding = .{ .trigger = trigger, .action = self.action, - .consumed = self.flags.consumed, - .global = self.flags.global, + .flags = self.flags, } }; } @@ -590,10 +590,15 @@ pub const Action = union(enum) { /// action. pub fn hash(self: Action) u64 { var hasher = std.hash.Wyhash.init(0); + self.hashIncremental(&hasher); + return hasher.final(); + } + /// Hash the action into the given hasher. + fn hashIncremental(self: Action, hasher: anytype) void { // Always has the active tag. const Tag = @typeInfo(Action).Union.tag_type.?; - std.hash.autoHash(&hasher, @as(Tag, self)); + std.hash.autoHash(hasher, @as(Tag, self)); // Hash the value of the field. switch (self) { @@ -608,25 +613,23 @@ pub const Action = union(enum) { // signed zeros but these are not cases we expect for // our bindings. f32 => std.hash.autoHash( - &hasher, + hasher, @as(u32, @bitCast(field)), ), f64 => std.hash.autoHash( - &hasher, + hasher, @as(u64, @bitCast(field)), ), // Everything else automatically handle. else => std.hash.autoHashStrat( - &hasher, + hasher, field, .DeepRecursive, ), } }, } - - return hasher.final(); } }; @@ -783,11 +786,16 @@ pub const Trigger = struct { /// Returns a hash code that can be used to uniquely identify this trigger. pub fn hash(self: Trigger) u64 { var hasher = std.hash.Wyhash.init(0); - std.hash.autoHash(&hasher, self.key); - std.hash.autoHash(&hasher, self.mods.binding()); + self.hashIncremental(&hasher); return hasher.final(); } + /// Hash the trigger into the given hasher. + fn hashIncremental(self: Trigger, hasher: anytype) void { + std.hash.autoHash(hasher, self.key); + std.hash.autoHash(hasher, self.mods.binding()); + } + /// Convert the trigger to a C API compatible trigger. pub fn cval(self: Trigger) C { return .{ @@ -864,10 +872,8 @@ pub const Set = struct { leader: *Set, /// This trigger completes a sequence and the value is the action - /// to take. The "_unconsumed" variant is used for triggers that - /// should not consume the input. - action: Action, - action_unconsumed: Action, + /// to take along with the flags that may define binding behavior. + leaf: Leaf, /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. @@ -892,14 +898,28 @@ pub const Set = struct { } }, - .action, .action_unconsumed => |action| { + .leaf => |leaf| { // action implements the format - try writer.print("={s}", .{action}); + try writer.print("={s}", .{leaf.action}); }, } } }; + /// Leaf node of a set is an action to trigger. This is a "leaf" compared + /// to the inner nodes which are "leaders" for sequences. + pub const Leaf = struct { + action: Action, + flags: Flags, + + pub fn hash(self: Leaf) u64 { + var hasher = std.hash.Wyhash.init(0); + self.action.hash(&hasher); + std.hash.autoHash(&hasher, self.flags); + return hasher.final(); + } + }; + pub fn deinit(self: *Set, alloc: Allocator) void { // Clear any leaders if we have them var it = self.bindings.iterator(); @@ -908,7 +928,7 @@ pub const Set = struct { s.deinit(alloc); alloc.destroy(s); }, - .action, .action_unconsumed => {}, + .leaf => {}, }; self.bindings.deinit(alloc); @@ -980,7 +1000,7 @@ pub const Set = struct { error.OutOfMemory => return error.OutOfMemory, }, - .action, .action_unconsumed => { + .leaf => { // Remove the existing action. Fallthrough as if // we don't have a leader. set.remove(alloc, t); @@ -1004,11 +1024,11 @@ pub const Set = struct { set.remove(alloc, t); if (old) |entry| switch (entry) { .leader => unreachable, // Handled above - inline .action, .action_unconsumed => |action, tag| set.put_( + .leaf => |leaf| set.put_( alloc, t, - action, - tag == .action, + leaf.action, + leaf.flags, ) catch {}, }; }, @@ -1023,7 +1043,7 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => if (b.consumed) { + else => if (b.flags.consumed) { try set.put(alloc, b.trigger, b.action); } else { try set.putUnconsumed(alloc, b.trigger, b.action); @@ -1040,7 +1060,7 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, true); + try self.put_(alloc, t, action, .{}); } /// Same as put but marks the trigger as unconsumed. An unconsumed @@ -1054,7 +1074,7 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, false); + try self.put_(alloc, t, action, .{ .consumed = false }); } fn put_( @@ -1062,7 +1082,7 @@ pub const Set = struct { alloc: Allocator, t: Trigger, action: Action, - consumed: bool, + flags: Flags, ) Allocator.Error!void { // unbind should never go into the set, it should be handled prior assert(action != .unbind); @@ -1078,7 +1098,7 @@ pub const Set = struct { // If we have an existing binding for this trigger, we have to // update the reverse mapping to remove the old action. - .action, .action_unconsumed => { + .leaf => { const t_hash = t.hash(); var it = self.reverse.iterator(); while (it.next()) |reverse_entry| it: { @@ -1090,11 +1110,10 @@ pub const Set = struct { }, }; - gop.value_ptr.* = if (consumed) .{ + gop.value_ptr.* = .{ .leaf = .{ .action = action, - } else .{ - .action_unconsumed = action, - }; + .flags = flags, + } }; errdefer _ = self.bindings.remove(t); try self.reverse.put(alloc, action, t); errdefer _ = self.reverse.remove(action); @@ -1129,15 +1148,16 @@ pub const Set = struct { // Note: we'd LIKE to replace this with the most recent binding but // our hash map obviously has no concept of ordering so we have to // choose whatever. Maybe a switch to an array hash map here. - .action, .action_unconsumed => |action| { - const action_hash = action.hash(); + .leaf => |leaf| { + const action_hash = leaf.action.hash(); + var it = self.bindings.iterator(); while (it.next()) |it_entry| { switch (it_entry.value_ptr.*) { .leader => {}, - .action, .action_unconsumed => |action_search| { - if (action_search.hash() == action_hash) { - self.reverse.putAssumeCapacity(action, it_entry.key_ptr.*); + .leaf => |leaf_search| { + if (leaf_search.action.hash() == action_hash) { + self.reverse.putAssumeCapacity(leaf.action, it_entry.key_ptr.*); break; } }, @@ -1145,7 +1165,7 @@ pub const Set = struct { } else { // No over trigger points to this action so we remove // the reverse mapping completely. - _ = self.reverse.remove(action); + _ = self.reverse.remove(leaf.action); } }, } @@ -1162,7 +1182,7 @@ pub const Set = struct { var it = result.bindings.iterator(); while (it.next()) |entry| switch (entry.value_ptr.*) { // No data to clone - .action, .action_unconsumed => {}, + .leaf => {}, // Must be deep cloned. .leader => |*s| { @@ -1264,7 +1284,7 @@ test "parse: triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, + .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:shift+a=ignore")); // unconsumed physical keys @@ -1274,7 +1294,7 @@ test "parse: triggers" { .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, + .flags = .{ .consumed = false }, }, try parseSingle("unconsumed:physical:a+shift=ignore")); // invalid key @@ -1297,7 +1317,7 @@ test "parse: global triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .global = true, + .flags = .{ .global = true }, }, try parseSingle("global:shift+a=ignore")); // global physical keys @@ -1307,7 +1327,7 @@ test "parse: global triggers" { .key = .{ .physical = .a }, }, .action = .{ .ignore = {} }, - .global = true, + .flags = .{ .global = true }, }, try parseSingle("global:physical:a+shift=ignore")); // global unconsumed keys @@ -1317,8 +1337,10 @@ test "parse: global triggers" { .key = .{ .translated = .a }, }, .action = .{ .ignore = {} }, - .consumed = false, - .global = true, + .flags = .{ + .global = true, + .consumed = false, + }, }, try parseSingle("unconsumed:global:a+shift=ignore")); } @@ -1547,8 +1569,9 @@ test "set: parseAndPut typical binding" { // Creates forward mapping { - const action = s.get(.{ .key = .{ .translated = .a } }).?.action; - try testing.expect(action == .new_window); + const action = s.get(.{ .key = .{ .translated = .a } }).?.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{}, action.flags); } // Creates reverse mapping @@ -1570,8 +1593,9 @@ test "set: parseAndPut unconsumed binding" { // Creates forward mapping { const trigger: Trigger = .{ .key = .{ .translated = .a } }; - const action = s.get(trigger).?.action_unconsumed; - try testing.expect(action == .new_window); + const action = s.get(trigger).?.leaf; + try testing.expect(action.action == .new_window); + try testing.expectEqual(Flags{ .consumed = false }, action.flags); } // Creates reverse mapping @@ -1617,8 +1641,9 @@ test "set: parseAndPut sequence" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1641,14 +1666,16 @@ test "set: parseAndPut sequence with two actions" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } { const t: Trigger = .{ .key = .{ .translated = .c } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_tab); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_tab); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1671,8 +1698,9 @@ test "set: parseAndPut overwrite sequence" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1695,8 +1723,9 @@ test "set: parseAndPut overwrite leader" { { const t: Trigger = .{ .key = .{ .translated = .b } }; const e = current.get(t).?; - try testing.expect(e == .action); - try testing.expect(e.action == .new_window); + try testing.expect(e == .leaf); + try testing.expect(e.leaf.action == .new_window); + try testing.expectEqual(Flags{}, e.leaf.flags); } } @@ -1825,11 +1854,14 @@ test "set: consumed state" { defer s.deinit(alloc); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action_unconsumed); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); try s.put(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); - try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .action); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); + try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); } From 070cc221726e74f3a88bc8b9a5efb826f8071583 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 14:12:29 -0700 Subject: [PATCH 053/139] input: global/all bindings can't be sequenced --- src/config/Config.zig | 8 +++++++- src/input/Binding.zig | 12 +++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index f654b7fa8..18d34171f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -704,6 +704,9 @@ class: ?[:0]const u8 = null, /// `ctrl+a>t`, and then bind `ctrl+a` directly, both `ctrl+a>n` and /// `ctrl+a>t` will become unbound. /// +/// * Trigger sequences are not allowed for `global:` or `all:`-prefixed +/// triggers. This is a limitation we could remove in the future. +/// /// Action is the action to take when the trigger is satisfied. It takes the /// format `action` or `action:param`. The latter form is only valid if the /// action requires a parameter. @@ -762,7 +765,10 @@ class: ?[:0]const u8 = null, /// any) will not be sent to the running program in the terminal. If /// you wish to send the encoded value to the program, specify the /// `unconsumed:` prefix before the entire keybind. For example: -/// `unconsumed:ctrl+a=reload_config` +/// `unconsumed:ctrl+a=reload_config`. `global:` and `all:`-prefixed +/// keybinds will always consume the input regardless of this setting. +/// Since they are not associated with a specific terminal surface, +/// they're never encoded. /// /// Multiple prefixes can be specified. For example, /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global diff --git a/src/input/Binding.zig b/src/input/Binding.zig index b49f153b6..97798463f 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -109,7 +109,11 @@ pub const Parser = struct { const trigger = (try self.trigger_it.next()) orelse return null; // If this is our last trigger then it is our final binding. - if (!self.trigger_it.done()) return .{ .leader = trigger }; + if (!self.trigger_it.done()) { + // Global/all bindings can't be sequences + if (self.flags.global or self.flags.all) return error.InvalidFormat; + return .{ .leader = trigger }; + } // Out of triggers, yield the final action. return .{ .binding = .{ @@ -1342,6 +1346,12 @@ test "parse: global triggers" { .consumed = false, }, }, try parseSingle("unconsumed:global:a+shift=ignore")); + + // global sequences not allowed + { + var p = try Parser.init("global:a>b=ignore"); + try testing.expectError(Error.InvalidFormat, p.next()); + } } test "parse: modifier aliases" { From 81c50e588c1d9ed469f21a666c4517c8a66c02a6 Mon Sep 17 00:00:00 2001 From: Tristan Partin Date: Mon, 23 Sep 2024 16:52:55 -0500 Subject: [PATCH 054/139] apprt/gtk: override the top bar colors in libadwaita correctly This is the way to override the color in libadwaita < 1.6. We can transition to named colors, specifically headerbar-{fg,bg}-color for libadwaita 1.6. Fixes: #2266 Signed-off-by: Tristan Partin --- src/apprt/gtk/App.zig | 4 +--- src/apprt/gtk/Window.zig | 5 ----- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 035e4d347..1224036f0 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -443,9 +443,7 @@ fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void { \\ opacity: {d:.2}; \\ background-color: rgb({d},{d},{d}); \\}} - \\window.ghostty-theme-inherit headerbar, - \\window.ghostty-theme-inherit toolbarview > revealer > windowhandle, - \\window.ghostty-theme-inherit box > tabbar {{ + \\.top-bar {{ \\ background-color: rgb({d},{d},{d}); \\ color: rgb({d},{d},{d}); \\}} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 7ece474ac..a40b9c89d 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -95,11 +95,6 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); - // Apply class to color headerbar if window-theme is set to `ghostty`. - if (app.config.@"window-theme" == .ghostty) { - c.gtk_widget_add_css_class(@ptrCast(gtk_window), "ghostty-theme-inherit"); - } - // Remove the window's background if any of the widgets need to be transparent if (app.config.@"background-opacity" < 1) { c.gtk_widget_remove_css_class(@ptrCast(window), "background"); From 92d310a66024ed445c43614770cd90d60f3af9c2 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 16:55:11 -0600 Subject: [PATCH 055/139] macOS: prevent ctrl-return key equivalent macOS 15 adds this as a default key equivalent to show the context menu, this is annoying, so we capture the event and tell macOS we handled it. --- macos/Sources/Ghostty/SurfaceView_AppKit.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 2aee1cd5a..3c2c17a81 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -692,6 +692,11 @@ extension Ghostty { // sound and we don't like the beep sound. equivalent = "_" + case "\r": + // Pass C- through verbatim + // (prevent the default context menu equivalent) + equivalent = "\r" + default: // Ignore other events return false From 6917bcacad036e89d9efa666b0e607d840067d2c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Thu, 8 Aug 2024 11:13:30 -0400 Subject: [PATCH 056/139] font/sprite: fix 1px gap at right edge of dotted and dashed underlines --- src/font/sprite/underline.zig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/font/sprite/underline.zig b/src/font/sprite/underline.zig index b22db4f52..c60fd71b7 100644 --- a/src/font/sprite/underline.zig +++ b/src/font/sprite/underline.zig @@ -133,7 +133,7 @@ const Draw = struct { while (i < dot_count) : (i += 2) { // Ensure we never go out of bounds for the rect const x = @min(i * dot_width, self.width - 1); - const width = @min(self.width - 1 - x, dot_width); + const width = @min(self.width - x, dot_width); canvas.rect(.{ .x = @intCast(i * dot_width), .y = @intCast(y), @@ -154,7 +154,7 @@ const Draw = struct { while (i < dash_count) : (i += 2) { // Ensure we never go out of bounds for the rect const x = @min(i * dash_width, self.width - 1); - const width = @min(self.width - 1 - x, dash_width); + const width = @min(self.width - x, dash_width); canvas.rect(.{ .x = @intCast(x), .y = @intCast(y), From b3a7901b791b5639f3c68954c67a6046470a5012 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 18:12:56 -0600 Subject: [PATCH 057/139] font/sprite: rework underline rendering, adjust positioning --- src/font/sprite/underline.zig | 281 ++++++++++++++++------------------ 1 file changed, 129 insertions(+), 152 deletions(-) diff --git a/src/font/sprite/underline.zig b/src/font/sprite/underline.zig index c60fd71b7..af0dd739a 100644 --- a/src/font/sprite/underline.zig +++ b/src/font/sprite/underline.zig @@ -27,189 +27,166 @@ pub fn renderGlyph( line_pos: u32, line_thickness: u32, ) !font.Glyph { - // Create the canvas we'll use to draw. We draw the underline in - // a full cell size and position it according to "pos". - var canvas = try font.sprite.Canvas.init(alloc, width, height); - defer canvas.deinit(alloc); + // _ = height; - // Perform the actual drawing - (Draw{ - .width = width, - .height = height, - .pos = line_pos, - .thickness = line_thickness, - }).draw(&canvas, sprite); + // Draw the appropriate sprite + var canvas: font.sprite.Canvas, const offset_y: i32 = switch (sprite) { + .underline => try drawSingle(alloc, width, line_thickness), + .underline_double => try drawDouble(alloc, width, line_thickness), + .underline_dotted => try drawDotted(alloc, width, line_thickness), + .underline_dashed => try drawDashed(alloc, width, line_thickness), + .underline_curly => try drawCurly(alloc, width, line_thickness), + .strikethrough => try drawSingle(alloc, width, line_thickness), + else => unreachable, + }; + defer canvas.deinit(alloc); // Write the drawing to the atlas const region = try canvas.writeAtlas(alloc, atlas); - // Our coordinates start at the BOTTOM for our renderers so we have to - // specify an offset of the full height because we rendered a full size - // cell. - const offset_y = @as(i32, @intCast(height)); - return font.Glyph{ .width = width, - .height = height, + .height = @intCast(region.height), .offset_x = 0, - .offset_y = offset_y, + .offset_y = @as(i32, @intCast(height + region.height)) - @as(i32, @intCast(line_pos)) + offset_y + 1, .atlas_x = region.x, .atlas_y = region.y, .advance_x = @floatFromInt(width), }; } -/// Stores drawing state. -const Draw = struct { - width: u32, - height: u32, - pos: u32, - thickness: u32, +/// Draw a single underline. +fn drawSingle(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { + const height: u32 = thickness; + var canvas = try font.sprite.Canvas.init(alloc, width, height); - /// Draw a specific underline sprite to the canvas. - fn draw(self: Draw, canvas: *font.sprite.Canvas, sprite: Sprite) void { - switch (sprite) { - .underline => self.drawSingle(canvas), - .underline_double => self.drawDouble(canvas), - .underline_dotted => self.drawDotted(canvas), - .underline_dashed => self.drawDashed(canvas), - .underline_curly => self.drawCurly(canvas), - .strikethrough => self.drawSingle(canvas), - else => unreachable, - } - } + canvas.rect(.{ + .x = 0, + .y = 0, + .width = width, + .height = thickness, + }, .on); - /// Draw a single underline. - fn drawSingle(self: Draw, canvas: *font.sprite.Canvas) void { - // Ensure we never overflow out of bounds on the canvas - const y_max = self.height -| 1; - const bottom = @min(self.pos + self.thickness, y_max); - const y = bottom -| self.thickness; - const max_height = self.height - y; + const offset_y: i32 = 0; + return .{ canvas, offset_y }; +} + +/// Draw a double underline. +fn drawDouble(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { + const height: u32 = thickness * 3; + var canvas = try font.sprite.Canvas.init(alloc, width, height); + + canvas.rect(.{ + .x = 0, + .y = 0, + .width = width, + .height = thickness, + }, .on); + + canvas.rect(.{ + .x = 0, + .y = @intCast(thickness * 2), + .width = width, + .height = thickness, + }, .on); + + const offset_y: i32 = -@as(i32, @intCast(thickness)); + + return .{ canvas, offset_y }; +} + +/// Draw a dotted underline. +fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { + const height: u32 = thickness; + var canvas = try font.sprite.Canvas.init(alloc, width, height); + + const dot_width = @max(thickness, 3); + const dot_count = width / dot_width; + var i: u32 = 0; + while (i < dot_count) : (i += 2) { + // Ensure we never go out of bounds for the rect + const x = @min(i * dot_width, width - 1); + const rect_width = @min(width - x, dot_width); canvas.rect(.{ - .x = 0, - .y = @intCast(y), - .width = self.width, - .height = @min(self.thickness, max_height), + .x = @intCast(i * dot_width), + .y = 0, + .width = rect_width, + .height = thickness, }, .on); } - /// Draw a double underline. - fn drawDouble(self: Draw, canvas: *font.sprite.Canvas) void { - // The maximum y value has to have space for the bottom underline. - // If we underflow (saturated) to 0, then we don't draw. This should - // never happen but we don't want to draw something undefined. - const y_max = self.height -| 1 -| self.thickness; - if (y_max == 0) return; + const offset_y: i32 = 0; - const space = self.thickness * 2; - const bottom = @min(self.pos + space, y_max); - const top = bottom - space; + return .{ canvas, offset_y }; +} +/// Draw a dashed underline. +fn drawDashed(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { + const height: u32 = thickness; + var canvas = try font.sprite.Canvas.init(alloc, width, height); + + const dash_width = width / 3 + 1; + const dash_count = (width / dash_width) + 1; + var i: u32 = 0; + while (i < dash_count) : (i += 2) { + // Ensure we never go out of bounds for the rect + const x = @min(i * dash_width, width - 1); + const rect_width = @min(width - x, dash_width); canvas.rect(.{ - .x = 0, - .y = @intCast(top), - .width = self.width, - .height = self.thickness, - }, .on); - - canvas.rect(.{ - .x = 0, - .y = @intCast(bottom), - .width = self.width, - .height = self.thickness, + .x = @intCast(x), + .y = 0, + .width = rect_width, + .height = thickness, }, .on); } - /// Draw a dotted underline. - fn drawDotted(self: Draw, canvas: *font.sprite.Canvas) void { - const y_max = self.height -| 1 -| self.thickness; - if (y_max == 0) return; - const y = @min(self.pos, y_max); - const dot_width = @max(self.thickness, 3); - const dot_count = self.width / dot_width; - var i: u32 = 0; - while (i < dot_count) : (i += 2) { - // Ensure we never go out of bounds for the rect - const x = @min(i * dot_width, self.width - 1); - const width = @min(self.width - x, dot_width); - canvas.rect(.{ - .x = @intCast(i * dot_width), - .y = @intCast(y), - .width = width, - .height = self.thickness, - }, .on); + const offset_y: i32 = 0; + + return .{ canvas, offset_y }; +} + +/// Draw a curly underline. Thanks to Wez Furlong for providing +/// the basic math structure for this since I was lazy with the +/// geometry. +fn drawCurly(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { + const height: u32 = thickness * 4; + var canvas = try font.sprite.Canvas.init(alloc, width, height); + + // Calculate the wave period for a single character + // `2 * pi...` = 1 peak per character + // `4 * pi...` = 2 peaks per character + const wave_period = 2 * std.math.pi / @as(f64, @floatFromInt(width - 1)); + + // The full amplitude of the wave can be from the bottom to the + // underline position. We also calculate our mid y point of the wave + const half_amplitude: f64 = @as(f64, @floatFromInt(thickness)); + const y_mid: f64 = half_amplitude + 1; + + // follow Xiaolin Wu's antialias algorithm to draw the curve + var x: u32 = 0; + while (x < width) : (x += 1) { + const cosx: f64 = @cos(@as(f64, @floatFromInt(x)) * wave_period); + const y: f64 = y_mid + half_amplitude * cosx; + const y_upper: u32 = @intFromFloat(@floor(y)); + const y_lower: u32 = y_upper + thickness + (thickness >> 1); + const alpha: u8 = @intFromFloat(255 * @abs(y - @floor(y))); + + // upper and lower bounds + canvas.pixel(x, @min(y_upper, height), @enumFromInt(255 - alpha)); + canvas.pixel(x, @min(y_lower, height), @enumFromInt(alpha)); + + // fill between upper and lower bound + var y_fill: u32 = y_upper + 1; + while (y_fill < y_lower) : (y_fill += 1) { + canvas.pixel(x, @min(y_fill, height), .on); } } - /// Draw a dashed underline. - fn drawDashed(self: Draw, canvas: *font.sprite.Canvas) void { - const y_max = self.height -| 1 -| self.thickness; - if (y_max == 0) return; - const y = @min(self.pos, y_max); - const dash_width = self.width / 3 + 1; - const dash_count = (self.width / dash_width) + 1; - var i: u32 = 0; - while (i < dash_count) : (i += 2) { - // Ensure we never go out of bounds for the rect - const x = @min(i * dash_width, self.width - 1); - const width = @min(self.width - x, dash_width); - canvas.rect(.{ - .x = @intCast(x), - .y = @intCast(y), - .width = width, - .height = self.thickness, - }, .on); - } - } + const offset_y: i32 = -@as(i32, @intCast(thickness * 2)); - /// Draw a curly underline. Thanks to Wez Furlong for providing - /// the basic math structure for this since I was lazy with the - /// geometry. - fn drawCurly(self: Draw, canvas: *font.sprite.Canvas) void { - // This is the lowest that the curl can go. - const y_max = self.height - 1; - - // Calculate the wave period for a single character - // `2 * pi...` = 1 peak per character - // `4 * pi...` = 2 peaks per character - const wave_period = 2 * std.math.pi / @as(f64, @floatFromInt(self.width - 1)); - - // Some fonts put the underline too close to the bottom of the - // cell height and this doesn't allow us to make a high enough - // wave. This constant is arbitrary, change it for aesthetics. - const pos: u32 = pos: { - const MIN_AMPLITUDE: u32 = @max(self.height / 9, 2); - break :pos y_max - (MIN_AMPLITUDE * 2); - }; - - // The full amplitude of the wave can be from the bottom to the - // underline position. We also calculate our mid y point of the wave - const double_amplitude: f64 = @floatFromInt(y_max - pos); - const half_amplitude: f64 = @max(1, double_amplitude / 4); - const y_mid: u32 = pos + @as(u32, @intFromFloat(2 * half_amplitude)); - - // follow Xiaolin Wu's antialias algorithm to draw the curve - var x: u32 = 0; - while (x < self.width) : (x += 1) { - const y: f64 = @as(f64, @floatFromInt(y_mid)) + (half_amplitude * @cos(@as(f64, @floatFromInt(x)) * wave_period)); - const y_upper: u32 = @intFromFloat(@floor(y)); - const y_lower: u32 = y_upper + self.thickness; - const alpha: u8 = @intFromFloat(255 * @abs(y - @floor(y))); - - // upper and lower bounds - canvas.pixel(x, @min(y_upper, y_max), @enumFromInt(255 - alpha)); - canvas.pixel(x, @min(y_lower, y_max), @enumFromInt(alpha)); - - // fill between upper and lower bound - var y_fill: u32 = y_upper + 1; - while (y_fill < y_lower) : (y_fill += 1) { - canvas.pixel(x, @min(y_fill, y_max), .on); - } - } - } -}; + return .{ canvas, offset_y }; +} test "single" { const testing = std.testing; From bf2794f90fff5fd3c37047048022bdeb677f88db Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 18:31:39 -0600 Subject: [PATCH 058/139] renderer: draw underlines below text to improve legibility --- src/renderer/Metal.zig | 75 +++++++++++++++++---------------- src/renderer/OpenGL.zig | 91 +++++++++++++++++++++-------------------- 2 files changed, 86 insertions(+), 80 deletions(-) diff --git a/src/renderer/Metal.zig b/src/renderer/Metal.zig index bb2a27f44..1790711c8 100644 --- a/src/renderer/Metal.zig +++ b/src/renderer/Metal.zig @@ -2523,6 +2523,45 @@ fn updateCell( } } + // If the cell has an underline, draw it before the character glyph, + // so that it layers underneath instead of overtop, since that can + // make text difficult to read. + if (underline != .none) { + const sprite: font.Sprite = switch (underline) { + .none => unreachable, + .single => .underline, + .double => .underline_double, + .dotted => .underline_dotted, + .dashed => .underline_dashed, + .curly => .underline_curly, + }; + + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = if (cell.wide == .wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ); + + const color = style.underlineColor(palette) orelse colors.fg; + + try self.cells.add(self.alloc, .underline, .{ + .mode = .fg, + .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, + .constraint_width = cell.gridWidth(), + .color = .{ color.r, color.g, color.b, alpha }, + .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, + .glyph_size = .{ render.glyph.width, render.glyph.height }, + .bearings = .{ + @intCast(render.glyph.offset_x), + @intCast(render.glyph.offset_y), + }, + }); + } + // If the shaper cell has a glyph, draw it. if (shaper_cell.glyph_index) |glyph_index| glyph: { // Render @@ -2566,42 +2605,6 @@ fn updateCell( }); } - if (underline != .none) { - const sprite: font.Sprite = switch (underline) { - .none => unreachable, - .single => .underline, - .double => .underline_double, - .dotted => .underline_dotted, - .dashed => .underline_dashed, - .curly => .underline_curly, - }; - - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = if (cell.wide == .wide) 2 else 1, - .grid_metrics = self.grid_metrics, - }, - ); - - const color = style.underlineColor(palette) orelse colors.fg; - - try self.cells.add(self.alloc, .underline, .{ - .mode = .fg, - .grid_pos = .{ @intCast(coord.x), @intCast(coord.y) }, - .constraint_width = cell.gridWidth(), - .color = .{ color.r, color.g, color.b, alpha }, - .glyph_pos = .{ render.glyph.atlas_x, render.glyph.atlas_y }, - .glyph_size = .{ render.glyph.width, render.glyph.height }, - .bearings = .{ - @intCast(render.glyph.offset_x), - @intCast(render.glyph.offset_y), - }, - }); - } - if (style.flags.strikethrough) { const render = try self.font_grid.renderGlyph( self.alloc, diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index a8f7c385c..082f330c5 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -1761,6 +1761,53 @@ fn updateCell( @intFromFloat(@max(0, @min(255, @round(self.config.background_opacity * 255)))), }; + // If the cell has an underline, draw it before the character glyph, + // so that it layers underneath instead of overtop, since that can + // make text difficult to read. + if (underline != .none) { + const sprite: font.Sprite = switch (underline) { + .none => unreachable, + .single => .underline, + .double => .underline_double, + .dotted => .underline_dotted, + .dashed => .underline_dashed, + .curly => .underline_curly, + }; + + const render = try self.font_grid.renderGlyph( + self.alloc, + font.sprite_index, + @intFromEnum(sprite), + .{ + .cell_width = if (cell.wide == .wide) 2 else 1, + .grid_metrics = self.grid_metrics, + }, + ); + + const color = style.underlineColor(palette) orelse colors.fg; + + try self.cells.append(self.alloc, .{ + .mode = .fg, + .grid_col = @intCast(x), + .grid_row = @intCast(y), + .grid_width = cell.gridWidth(), + .glyph_x = render.glyph.atlas_x, + .glyph_y = render.glyph.atlas_y, + .glyph_width = render.glyph.width, + .glyph_height = render.glyph.height, + .glyph_offset_x = render.glyph.offset_x, + .glyph_offset_y = render.glyph.offset_y, + .r = color.r, + .g = color.g, + .b = color.b, + .a = alpha, + .bg_r = bg[0], + .bg_g = bg[1], + .bg_b = bg[2], + .bg_a = bg[3], + }); + } + // If the cell has a character, draw it if (cell.hasText()) fg: { // Render @@ -1807,50 +1854,6 @@ fn updateCell( }); } - if (underline != .none) { - const sprite: font.Sprite = switch (underline) { - .none => unreachable, - .single => .underline, - .double => .underline_double, - .dotted => .underline_dotted, - .dashed => .underline_dashed, - .curly => .underline_curly, - }; - - const render = try self.font_grid.renderGlyph( - self.alloc, - font.sprite_index, - @intFromEnum(sprite), - .{ - .cell_width = if (cell.wide == .wide) 2 else 1, - .grid_metrics = self.grid_metrics, - }, - ); - - const color = style.underlineColor(palette) orelse colors.fg; - - try self.cells.append(self.alloc, .{ - .mode = .fg, - .grid_col = @intCast(x), - .grid_row = @intCast(y), - .grid_width = cell.gridWidth(), - .glyph_x = render.glyph.atlas_x, - .glyph_y = render.glyph.atlas_y, - .glyph_width = render.glyph.width, - .glyph_height = render.glyph.height, - .glyph_offset_x = render.glyph.offset_x, - .glyph_offset_y = render.glyph.offset_y, - .r = color.r, - .g = color.g, - .b = color.b, - .a = alpha, - .bg_r = bg[0], - .bg_g = bg[1], - .bg_b = bg[2], - .bg_a = bg[3], - }); - } - if (style.flags.strikethrough) { const render = try self.font_grid.renderGlyph( self.alloc, From ecb3c543b39a848fa58cce0780e5e73400f993d3 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 18:35:20 -0600 Subject: [PATCH 059/139] renderer/OpenGL: use better logic for whether to render glyph Metal already had this change made, so I copied it over from there. This logic is more straightforward. Also copied the check to skip 0-sized glyphs, since sometimes, for example, spaces are emitted as glyphs by the shaper for some reason, even though they have no actual content, and we want to avoid sending a bunch of useless stuff to the GPU. --- src/renderer/OpenGL.zig | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/renderer/OpenGL.zig b/src/renderer/OpenGL.zig index 082f330c5..760721af3 100644 --- a/src/renderer/OpenGL.zig +++ b/src/renderer/OpenGL.zig @@ -1808,19 +1808,25 @@ fn updateCell( }); } - // If the cell has a character, draw it - if (cell.hasText()) fg: { + // If the shaper cell has a glyph, draw it. + if (shaper_cell.glyph_index) |glyph_index| glyph: { // Render const render = try self.font_grid.renderGlyph( self.alloc, shaper_run.font_index, - shaper_cell.glyph_index orelse break :fg, + glyph_index, .{ .grid_metrics = self.grid_metrics, .thicken = self.config.font_thicken, }, ); + // If the glyph is 0 width or height, it will be invisible + // when drawn, so don't bother adding it to the buffer. + if (render.glyph.width == 0 or render.glyph.height == 0) { + break :glyph; + } + // If we're rendering a color font, we use the color atlas const mode: CellProgram.CellMode = switch (try fgMode( render.presentation, From 3ec36e4d239d71b52ef7820e179367f9e2b97c28 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 19:01:15 -0600 Subject: [PATCH 060/139] coretext: improve strikethrough position calculation --- pkg/macos/text/font.zig | 8 ++++++++ src/font/face/coretext.zig | 20 ++++++++++++-------- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/pkg/macos/text/font.zig b/pkg/macos/text/font.zig index c08e8ee14..85f7de47e 100644 --- a/pkg/macos/text/font.zig +++ b/pkg/macos/text/font.zig @@ -188,6 +188,14 @@ pub const Font = opaque { return c.CTFontGetUnderlineThickness(@ptrCast(self)); } + pub fn getCapHeight(self: *Font) f64 { + return c.CTFontGetCapHeight(@ptrCast(self)); + } + + pub fn getXHeight(self: *Font) f64 { + return c.CTFontGetXHeight(@ptrCast(self)); + } + pub fn getUnitsPerEm(self: *Font) u32 { return c.CTFontGetUnitsPerEm(@ptrCast(self)); } diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 5e141e053..3a69ef95b 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -596,15 +596,19 @@ pub const Face = struct { const cell_baseline = @ceil(layout_metrics.height - layout_metrics.ascent); const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness()))); const strikethrough_position = strikethrough_position: { - // This is the height above baseline consumed by text. We must take - // into account that our cell height splits the leading between two - // rows so we subtract leading space (blank space). - const above_baseline = layout_metrics.ascent - (layout_metrics.leading / 2); + // This is the height of lower case letters in our font. + const ex_height = ct_font.getXHeight(); - // We want to position the strikethrough at 65% of the height. - // This generally gives a nice visual appearance. The number 65% - // is somewhat arbitrary but is a common value across terminals. - const pos = above_baseline * 0.65; + // We want to position the strikethrough so that it's + // vertically centered on any lower case text. This is + // a fairly standard choice for strikethrough positioning. + // + // Because our `strikethrough_position` is relative to the + // top of the cell we start with the ascent metric, which + // is the distance from the top down to the baseline, then + // we subtract half of the ex height to go back up to the + // correct height that should evenly split lowercase text. + const pos = layout_metrics.ascent - ex_height * 0.5 + 1; break :strikethrough_position @ceil(pos); }; From c7d6227befef09a607b4b1c3858902c3a02eaf85 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 19:18:44 -0600 Subject: [PATCH 061/139] freetype: improve strikethrough position guess by using ex height --- src/font/face/freetype.zig | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 5004a040a..04f037c85 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -607,6 +607,20 @@ pub const Face = struct { break :cell_width f26dot6ToFloat(size_metrics.max_advance); }; + // Ex height is calculated by measuring the height of the `x` glyph. + // If that fails then we just pretend it's 65% of the ascent height. + const ex_height: f32 = ex_height: { + if (face.getCharIndex('x')) |glyph_index| { + if (face.loadGlyph(glyph_index, .{ .render = true })) { + break :ex_height f26dot6ToFloat(face.handle.*.glyph.*.metrics.height); + } else |_| { + // Ignore the error since we just fall back to 65% of the ascent below + } + } + + break :ex_height f26dot6ToFloat(size_metrics.ascender) * 0.65; + }; + // Cell height is calculated as the maximum of multiple things in order // to handle edge cases in fonts: (1) the height as reported in metadata // by the font designer (2) the maximum glyph height as measured in the @@ -689,7 +703,9 @@ pub const Face = struct { }, .thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize)), } else .{ - .pos = cell_baseline * 0.6, + // Exactly 50% of the ex height so that our strikethrough is + // centered through lowercase text. This is a common choice. + .pos = cell_baseline - ex_height * 0.5 + 1, .thickness = underline_thickness, }; From 7f8c1a37ffa2e644af65c0d0ec6bbbee9c98635b Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 15:05:36 -0700 Subject: [PATCH 062/139] core: handle app bindings in the App struct --- src/App.zig | 52 +++++++++++++++++ src/Surface.zig | 26 ++++----- src/input/Binding.zig | 132 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 13 deletions(-) diff --git a/src/App.zig b/src/App.zig index f933b7126..4b9c2673e 100644 --- a/src/App.zig +++ b/src/App.zig @@ -262,6 +262,58 @@ pub fn setQuit(self: *App) !void { self.quit = true; } +/// Perform a binding action. This only accepts actions that are scoped +/// to the app. Callers can use performAllAction to perform any action +/// and any non-app-scoped actions will be performed on all surfaces. +pub fn performAction( + self: *App, + rt_app: *apprt.App, + action: input.Binding.Action.Scoped(.app), +) !void { + switch (action) { + .unbind => unreachable, + .ignore => {}, + .quit => try self.setQuit(), + .open_config => try self.openConfig(rt_app), + .reload_config => try self.reloadConfig(rt_app), + .close_all_windows => { + if (@hasDecl(apprt.App, "closeAllWindows")) { + rt_app.closeAllWindows(); + } else log.warn("runtime doesn't implement closeAllWindows", .{}); + }, + } +} + +/// Perform an app-wide binding action. If the action is surface-specific +/// then it will be performed on all surfaces. To perform only app-scoped +/// actions, use performAction. +pub fn performAllAction( + self: *App, + rt_app: *apprt.App, + action: input.Binding.Action, +) !void { + switch (action.scope()) { + // App-scoped actions are handled by the app so that they aren't + // repeated for each surface (since each surface forwards + // app-scoped actions back up). + .app => try self.performAction( + rt_app, + action.scoped(.app).?, // asserted through the scope match + ), + + // Surface-scoped actions are performed on all surfaces. Errors + // are logged but processing continues. + .surface => for (self.surfaces.items) |surface| { + _ = surface.core_surface.performBindingAction(action) catch |err| { + log.warn("error performing binding action on surface ptr={X} err={}", .{ + @intFromPtr(surface), + err, + }); + }; + }, + } +} + /// Handle a window message fn surfaceMessage(self: *App, surface: *Surface, msg: apprt.surface.Message) !void { // We want to ensure our window is still active. Window messages diff --git a/src/Surface.zig b/src/Surface.zig index aa37b462b..9e25ef0ad 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3400,14 +3400,22 @@ fn showMouse(self: *Surface) void { /// will ever return false. We can expand this in the future if it becomes /// useful. We did previous/next tab so we could implement #498. pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { - switch (action) { - .unbind => unreachable, - .ignore => {}, + // Handle app-scoped bindings by sending it to the app. + switch (action.scope()) { + .app => { + try self.app.performAction( + self.rt_app, + action.scoped(.app).?, + ); - .open_config => try self.app.openConfig(self.rt_app), + return true; + }, - .reload_config => try self.app.reloadConfig(self.rt_app), + // Surface fallthrough and handle + .surface => {}, + } + switch (action.scoped(.surface).?) { .csi, .esc => |data| { // We need to send the CSI/ESC sequence as a single write request. // If you split it across two then the shell can interpret it @@ -3757,14 +3765,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .close_window => try self.app.closeSurface(self), - .close_all_windows => { - if (@hasDecl(apprt.Surface, "closeAllWindows")) { - self.rt_surface.closeAllWindows(); - } else log.warn("runtime doesn't implement closeAllWindows", .{}); - }, - - .quit => try self.app.setQuit(), - .crash => |location| switch (location) { .main => @panic("crash binding action, crashing intentionally"), diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 97798463f..ba7b62af2 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -539,6 +539,138 @@ pub const Action = union(enum) { return Error.InvalidAction; } + /// The scope of an action. The scope is the context in which an action + /// must be executed. + pub const Scope = enum { + app, + surface, + }; + + /// Returns the scope of an action. + pub fn scope(self: Action) Scope { + return switch (self) { + // Doesn't really matter, so we'll see app. + .ignore, + .unbind, + => .app, + + // Obviously app actions. + .open_config, + .reload_config, + .close_all_windows, + .quit, + => .app, + + // Obviously surface actions. + .csi, + .esc, + .text, + .cursor_key, + .reset, + .copy_to_clipboard, + .paste_from_clipboard, + .paste_from_selection, + .increase_font_size, + .decrease_font_size, + .reset_font_size, + .clear_screen, + .select_all, + .scroll_to_top, + .scroll_to_bottom, + .scroll_page_up, + .scroll_page_down, + .scroll_page_fractional, + .scroll_page_lines, + .adjust_selection, + .jump_to_prompt, + .write_scrollback_file, + .write_screen_file, + .write_selection_file, + .close_surface, + .close_window, + .toggle_fullscreen, + .toggle_window_decorations, + .toggle_secure_input, + .crash, + + // These are less obvious surface actions. They're surface + // actions because they are relevant to the surface they + // come from. For example `new_window` needs to be sourced to + // a surface so inheritance can be done correctly. + .new_window, + .new_tab, + .previous_tab, + .next_tab, + .last_tab, + .goto_tab, + .new_split, + .goto_split, + .toggle_split_zoom, + .resize_split, + .equalize_splits, + .inspector, + => .surface, + }; + } + + /// Returns a union type that only contains actions that are scoped to + /// the given scope. + pub fn Scoped(comptime s: Scope) type { + const all_fields = @typeInfo(Action).Union.fields; + + // Find all fields that are app-scoped + var i: usize = 0; + var union_fields: [all_fields.len]std.builtin.Type.UnionField = undefined; + var enum_fields: [all_fields.len]std.builtin.Type.EnumField = undefined; + for (all_fields) |field| { + const action = @unionInit(Action, field.name, undefined); + if (action.scope() == s) { + union_fields[i] = field; + enum_fields[i] = .{ .name = field.name, .value = i }; + i += 1; + } + } + + // Build our union + return @Type(.{ .Union = .{ + .layout = .auto, + .tag_type = @Type(.{ .Enum = .{ + .tag_type = std.math.IntFittingRange(0, i), + .fields = enum_fields[0..i], + .decls = &.{}, + .is_exhaustive = true, + } }), + .fields = union_fields[0..i], + .decls = &.{}, + } }); + } + + /// Returns the scoped version of this action. If the action is not + /// scoped to the given scope then this returns null. + /// + /// The benefit of this function is that it allows us to use Zig's + /// exhaustive switch safety to ensure we always properly handle certain + /// scoped actions. + pub fn scoped(self: Action, comptime s: Scope) ?Scoped(s) { + switch (self) { + inline else => |v, tag| { + // Use comptime to prune out non-app actions + if (comptime @unionInit( + Action, + @tagName(tag), + undefined, + ).scope() != s) return null; + + // Initialize our app action + return @unionInit( + Scoped(s), + @tagName(tag), + v, + ); + }, + } + } + /// Implements the formatter for the fmt package. This encodes the /// action back into the format used by parse. pub fn format( From 49a3008919378f2dde829941a5fb0e33defbf39c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 19:41:49 -0600 Subject: [PATCH 063/139] font/sprite: reduce uneven gaps in dotted underline --- src/font/sprite/underline.zig | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/font/sprite/underline.zig b/src/font/sprite/underline.zig index af0dd739a..f38bc50e6 100644 --- a/src/font/sprite/underline.zig +++ b/src/font/sprite/underline.zig @@ -102,14 +102,15 @@ fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprit var canvas = try font.sprite.Canvas.init(alloc, width, height); const dot_width = @max(thickness, 3); - const dot_count = width / dot_width; + const dot_count = @max((width / dot_width) / 2, 1); + const gap_width = try std.math.divCeil(u32, width -| (dot_count * dot_width), dot_count); var i: u32 = 0; - while (i < dot_count) : (i += 2) { + while (i < dot_count) : (i += 1) { // Ensure we never go out of bounds for the rect - const x = @min(i * dot_width, width - 1); + const x = @min(i * (dot_width + gap_width), width - 1); const rect_width = @min(width - x, dot_width); canvas.rect(.{ - .x = @intCast(i * dot_width), + .x = @intCast(x), .y = 0, .width = rect_width, .height = thickness, From 17caeb5fac1a13aeb0d261556ceacef583d7c328 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 19:20:34 -0700 Subject: [PATCH 064/139] core: "all" bindings work --- src/Surface.zig | 35 +++++++++++++++++--- src/input/Binding.zig | 75 ++++++++++++++++++++++++++++++------------- 2 files changed, 84 insertions(+), 26 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 9e25ef0ad..49017883a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1590,7 +1590,7 @@ fn maybeHandleBinding( }; // Determine if this entry has an action or if its a leader key. - const action: input.Binding.Action, const consumed: bool = switch (entry) { + const leaf: input.Binding.Set.Leaf = switch (entry) { .leader => |set| { // Setup the next set we'll look at. self.keyboard.bindings = set; @@ -1605,7 +1605,20 @@ fn maybeHandleBinding( return .consumed; }, - .leaf => |leaf| .{ leaf.action, leaf.flags.consumed }, + .leaf => |leaf| leaf, + }; + const action = leaf.action; + + // consumed determines if the input is consumed or if we continue + // encoding the key (if we have a key to encode). + const consumed = consumed: { + // If the consumed flag is explicitly set, then we are consumed. + if (leaf.flags.consumed) break :consumed true; + + // If the global or all flag is set, we always consume. + if (leaf.flags.global or leaf.flags.all) break :consumed true; + + break :consumed false; }; // We have an action, so at this point we're handling SOMETHING so @@ -1617,8 +1630,22 @@ fn maybeHandleBinding( self.keyboard.bindings = null; // Attempt to perform the action - log.debug("key event binding consumed={} action={}", .{ consumed, action }); - const performed = try self.performBindingAction(action); + log.debug("key event binding flags={} action={}", .{ + leaf.flags, + action, + }); + const performed = performed: { + // If this is a global or all action, then we perform it on + // the app and it applies to every surface. + if (leaf.flags.global or leaf.flags.all) { + try self.app.performAllAction(self.rt_app, action); + + // "All" actions are always performed since they are global. + break :performed true; + } + + break :performed try self.performBindingAction(action); + }; // If we performed an action and it was a closing action, // our "self" pointer is not safe to use anymore so we need to diff --git a/src/input/Binding.zig b/src/input/Binding.zig index ba7b62af2..57c98f351 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -1160,7 +1160,7 @@ pub const Set = struct { set.remove(alloc, t); if (old) |entry| switch (entry) { .leader => unreachable, // Handled above - .leaf => |leaf| set.put_( + .leaf => |leaf| set.putFlags( alloc, t, leaf.action, @@ -1179,11 +1179,12 @@ pub const Set = struct { return error.SequenceUnbind; }, - else => if (b.flags.consumed) { - try set.put(alloc, b.trigger, b.action); - } else { - try set.putUnconsumed(alloc, b.trigger, b.action); - }, + else => try set.putFlags( + alloc, + b.trigger, + b.action, + b.flags, + ), }, } } @@ -1196,24 +1197,11 @@ pub const Set = struct { t: Trigger, action: Action, ) Allocator.Error!void { - try self.put_(alloc, t, action, .{}); + try self.putFlags(alloc, t, action, .{}); } - /// Same as put but marks the trigger as unconsumed. An unconsumed - /// trigger will evaluate the action and continue to encode for the - /// terminal. - /// - /// This is a separate function because this case is rare. - pub fn putUnconsumed( - self: *Set, - alloc: Allocator, - t: Trigger, - action: Action, - ) Allocator.Error!void { - try self.put_(alloc, t, action, .{ .consumed = false }); - } - - fn put_( + /// Add a binding to the set with explicit flags. + pub fn putFlags( self: *Set, alloc: Allocator, t: Trigger, @@ -1486,6 +1474,49 @@ test "parse: global triggers" { } } +test "parse: all triggers" { + const testing = std.testing; + + // all keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .all = true }, + }, try parseSingle("all:shift+a=ignore")); + + // all physical keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .physical = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ .all = true }, + }, try parseSingle("all:physical:a+shift=ignore")); + + // all unconsumed keys + try testing.expectEqual(Binding{ + .trigger = .{ + .mods = .{ .shift = true }, + .key = .{ .translated = .a }, + }, + .action = .{ .ignore = {} }, + .flags = .{ + .all = true, + .consumed = false, + }, + }, try parseSingle("unconsumed:all:a+shift=ignore")); + + // all sequences not allowed + { + var p = try Parser.init("all:a>b=ignore"); + try testing.expectError(Error.InvalidFormat, p.next()); + } +} + test "parse: modifier aliases" { const testing = std.testing; From c5eeb514cdf8658c32835f9404791b30199b0ec4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 20:39:36 -0700 Subject: [PATCH 065/139] input: fix tests --- src/input/Binding.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 57c98f351..20fd80716 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -2030,7 +2030,12 @@ test "set: consumed state" { try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); try testing.expect(s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); - try s.putUnconsumed(alloc, .{ .key = .{ .translated = .a } }, .{ .new_window = {} }); + try s.putFlags( + alloc, + .{ .key = .{ .translated = .a } }, + .{ .new_window = {} }, + .{ .consumed = false }, + ); try testing.expect(s.get(.{ .key = .{ .translated = .a } }).? == .leaf); try testing.expect(!s.get(.{ .key = .{ .translated = .a } }).?.leaf.flags.consumed); From 0f3f01483e8734e3f7c89ba422a2ed535ccc3535 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 20:45:46 -0700 Subject: [PATCH 066/139] apprt/embedded: API for checking if there are global keybinds --- include/ghostty.h | 1 + src/apprt/embedded.zig | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 072a8536a..676cbd5e0 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -531,6 +531,7 @@ void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_reload_config(ghostty_app_t); bool ghostty_app_needs_confirm_quit(ghostty_app_t); +bool ghostty_app_has_global_keybinds(ghostty_app_t); ghostty_surface_config_s ghostty_surface_config_new(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index b59ab1c9d..be3df896a 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -161,6 +161,19 @@ pub const App = struct { self.keymap.deinit(); } + /// Returns true if there are any global keybinds in the configuration. + pub fn hasGlobalKeybinds(self: *const App) bool { + var it = self.config.keybind.set.bindings.iterator(); + while (it.next()) |entry| { + switch (entry.value_ptr.*) { + .leader => {}, + .leaf => |leaf| if (leaf.flags.global) return true, + } + } + + return false; + } + /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { // Reload the keymap @@ -1514,6 +1527,11 @@ pub const CAPI = struct { return v.core_app.needsConfirmQuit(); } + /// Returns true if the app has global keybinds. + export fn ghostty_app_has_global_keybinds(v: *App) bool { + return v.hasGlobalKeybinds(); + } + /// Returns initial surface options. export fn ghostty_surface_config_new() apprt.Surface.Options { return .{}; From 7a1d304fa91ddd6961c0973be3a6ea2d99fb6538 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 22:10:43 -0600 Subject: [PATCH 067/139] font: further improve ul/st position calculations --- src/font/face/coretext.zig | 14 +++++++----- src/font/face/freetype.zig | 44 ++++++++++++++++++-------------------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index 3a69ef95b..ee2460572 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -594,7 +594,10 @@ pub const Face = struct { // All of these metrics are based on our layout above. const cell_height = @ceil(layout_metrics.height); const cell_baseline = @ceil(layout_metrics.height - layout_metrics.ascent); + const underline_thickness = @ceil(@as(f32, @floatCast(ct_font.getUnderlineThickness()))); + const strikethrough_thickness = underline_thickness; + const strikethrough_position = strikethrough_position: { // This is the height of lower case letters in our font. const ex_height = ct_font.getXHeight(); @@ -608,20 +611,21 @@ pub const Face = struct { // is the distance from the top down to the baseline, then // we subtract half of the ex height to go back up to the // correct height that should evenly split lowercase text. - const pos = layout_metrics.ascent - ex_height * 0.5 + 1; + const pos = layout_metrics.ascent - + ex_height * 0.5 + + strikethrough_thickness * 0.5 + + 1; break :strikethrough_position @ceil(pos); }; - const strikethrough_thickness = underline_thickness; // Underline position reported is usually something like "-1" to // represent the amount under the baseline. We add this to our real // baseline to get the actual value from the bottom (+y is up). // The final underline position is +y from the TOP (confusing) // so we have to subtract from the cell height. - const underline_position = cell_height - - (cell_baseline + @ceil(@as(f32, @floatCast(ct_font.getUnderlinePosition())))) + - 1; + const underline_position = @ceil(layout_metrics.ascent - + @as(f32, @floatCast(ct_font.getUnderlinePosition())) + 1); // Note: is this useful? // const units_per_em = ct_font.getUnitsPerEm(); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 04f037c85..32664a1fd 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -660,52 +660,50 @@ pub const Face = struct { // is reversed. const cell_baseline = -1 * f26dot6ToFloat(size_metrics.descender); + const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY( + face, + face.handle.*.underline_thickness, + )); + // The underline position. This is a value from the top where the // underline should go. const underline_position: f32 = underline_pos: { - // The ascender is already scaled for scalable fonts, but the - // underline position is not. - const ascender_px = @as(i32, @intCast(size_metrics.ascender)) >> 6; - const declared_px = freetype.mulFix( + const declared_px = @as(f32, @floatFromInt(freetype.mulFix( face.handle.*.underline_position, @intCast(face.handle.*.size.*.metrics.y_scale), - ) >> 6; + ))) / 64; // We use the declared underline position if its available - const declared = ascender_px - declared_px; + const declared = cell_height - cell_baseline - declared_px; if (declared > 0) - break :underline_pos @floatFromInt(declared); + break :underline_pos declared; // If we have no declared underline position, we go slightly under the // cell height (mainly: non-scalable fonts, i.e. emoji) break :underline_pos cell_height - 1; }; - const underline_thickness = @max(@as(f32, 1), fontUnitsToPxY( - face, - face.handle.*.underline_thickness, - )); // The strikethrough position. We use the position provided by the // font if it exists otherwise we calculate a best guess. const strikethrough: struct { pos: f32, thickness: f32, - } = if (face.getSfntTable(.os2)) |os2| .{ - .pos = pos: { - // Ascender is scaled, strikeout pos is not - const ascender_px = @as(i32, @intCast(size_metrics.ascender)) >> 6; - const declared_px = freetype.mulFix( - os2.yStrikeoutPosition, - @as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)), - ) >> 6; + } = if (face.getSfntTable(.os2)) |os2| st: { + const thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize)); - break :pos @floatFromInt(ascender_px - declared_px); - }, - .thickness = @max(@as(f32, 1), fontUnitsToPxY(face, os2.yStrikeoutSize)), + const pos = @as(f32, @floatFromInt(freetype.mulFix( + os2.yStrikeoutPosition, + @as(i32, @intCast(face.handle.*.size.*.metrics.y_scale)), + ))) / 64; + + break :st .{ + .pos = @ceil(cell_height - cell_baseline - pos + thickness + 1), + .thickness = thickness, + }; } else .{ // Exactly 50% of the ex height so that our strikethrough is // centered through lowercase text. This is a common choice. - .pos = cell_baseline - ex_height * 0.5 + 1, + .pos = @ceil(cell_height - cell_baseline - ex_height * 0.5 + underline_thickness), .thickness = underline_thickness, }; From ac68686036012ad851367946baa884b8fd87be48 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Mon, 23 Sep 2024 22:30:59 -0600 Subject: [PATCH 068/139] freetype: fix underline position calculation --- src/font/face/freetype.zig | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index 32664a1fd..f764ac61d 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -668,13 +668,18 @@ pub const Face = struct { // The underline position. This is a value from the top where the // underline should go. const underline_position: f32 = underline_pos: { + // From the FreeType docs: + // > `underline_position` + // > The position, in font units, of the underline line for + // > this face. It is the center of the underlining stem. + const declared_px = @as(f32, @floatFromInt(freetype.mulFix( face.handle.*.underline_position, @intCast(face.handle.*.size.*.metrics.y_scale), ))) / 64; // We use the declared underline position if its available - const declared = cell_height - cell_baseline - declared_px; + const declared = @ceil(cell_height - cell_baseline - declared_px - underline_thickness * 0.5 + 1); if (declared > 0) break :underline_pos declared; From b41f45d7a353f29172cb8af0f87a80890d088d79 Mon Sep 17 00:00:00 2001 From: Paul Date: Tue, 24 Sep 2024 08:10:06 +0200 Subject: [PATCH 069/139] apprt/gtk: support window-theme != ghostty - support for gtk-tabs-location=bottom - support for gtk-titlebar=false --- src/apprt/gtk/App.zig | 4 +++- src/apprt/gtk/Window.zig | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 1224036f0..73c25ec87 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -443,7 +443,9 @@ fn loadRuntimeCss(config: *const Config, provider: *c.GtkCssProvider) !void { \\ opacity: {d:.2}; \\ background-color: rgb({d},{d},{d}); \\}} - \\.top-bar {{ + \\window.window-theme-ghostty .top-bar, + \\window.window-theme-ghostty .bottom-bar, + \\window.window-theme-ghostty box > tabbar {{ \\ background-color: rgb({d},{d},{d}); \\ color: rgb({d},{d},{d}); \\}} diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index a40b9c89d..efb0d2ea4 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -95,6 +95,11 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_window_set_icon_name(gtk_window, "com.mitchellh.ghostty"); + // Apply class to color headerbar if window-theme is set to `ghostty`. + if (app.config.@"window-theme" == .ghostty) { + c.gtk_widget_add_css_class(@ptrCast(gtk_window), "window-theme-ghostty"); + } + // Remove the window's background if any of the widgets need to be transparent if (app.config.@"background-opacity" < 1) { c.gtk_widget_remove_css_class(@ptrCast(window), "background"); From 1ad904478d2c6f3622268e0fd775f6936159d9e7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 23 Sep 2024 20:58:37 -0700 Subject: [PATCH 070/139] Tap events, core API to handle global keybinds --- include/ghostty.h | 1 + macos/Ghostty.xcodeproj/project.pbxproj | 12 + macos/Sources/App/macOS/AppDelegate.swift | 8 + .../Global Keybinds/GlobalEventTap.swift | 150 ++++++ src/App.zig | 43 ++ src/Surface.zig | 15 +- src/apprt/embedded.zig | 487 ++++++++++-------- src/input/Binding.zig | 26 + 8 files changed, 516 insertions(+), 226 deletions(-) create mode 100644 macos/Sources/Features/Global Keybinds/GlobalEventTap.swift diff --git a/include/ghostty.h b/include/ghostty.h index 676cbd5e0..77671140f 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -527,6 +527,7 @@ ghostty_app_t ghostty_app_new(const ghostty_runtime_config_s*, void ghostty_app_free(ghostty_app_t); bool ghostty_app_tick(ghostty_app_t); void* ghostty_app_userdata(ghostty_app_t); +bool ghostty_app_key(ghostty_app_t, ghostty_input_key_s); void ghostty_app_keyboard_changed(ghostty_app_t); void ghostty_app_open_config(ghostty_app_t); void ghostty_app_reload_config(ghostty_app_t); diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index d2b3cff83..9bff35757 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; + A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -131,6 +132,7 @@ A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -199,6 +201,7 @@ A53426362A7DC53000EBB7A2 /* Features */ = { isa = PBXGroup; children = ( + A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, @@ -370,6 +373,14 @@ name = Products; sourceTree = ""; }; + A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { + isa = PBXGroup; + children = ( + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, + ); + path = "Global Keybinds"; + sourceTree = ""; + }; A5CEAFDA29B8005900646FDA /* SplitView */ = { isa = PBXGroup; children = ( @@ -529,6 +540,7 @@ A59630972AEE163600D64628 /* HostingWindow.swift in Sources */, A59630A02AEF6AEB00D64628 /* TerminalManager.swift in Sources */, A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */, + A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */, AEE8B3452B9AA39600260C5E /* NSPasteboard+Extension.swift in Sources */, A53426352A7DA53D00EBB7A2 /* AppDelegate.swift in Sources */, A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 41815631d..76dfdb5ec 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -418,6 +418,14 @@ class AppDelegate: NSObject, c.showWindow(self) } } + + // If our reload adds global keybinds and we don't have ax permissions then + // we need to request them. + global: if (ghostty_app_has_global_keybinds(ghostty.app!)) { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + GlobalEventTap.shared.enable() + } + } } /// Sync the appearance of our app with the theme specified in the config. diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift new file mode 100644 index 000000000..3a768df79 --- /dev/null +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -0,0 +1,150 @@ +import Cocoa +import CoreGraphics +import Carbon +import OSLog +import GhosttyKit + +// Manages the event tap to monitor global events, currently only used for +// global keybindings. +class GlobalEventTap { + static let shared = GlobalEventTap() + + fileprivate static let logger = Logger( + subsystem: Bundle.main.bundleIdentifier!, + category: String(describing: GlobalEventTap.self) + ) + + // The event tap used for global event listening. This is non-nil if it is + // created. + private var eventTap: CFMachPort? = nil + + // This is the timer used to retry enabling the global event tap if we + // don't have permissions. + private var enableTimer: Timer? = nil + + private init() {} + + deinit { + disable() + } + + // Enable the global event tap. This is safe to call if it is already enabled. + // If enabling fails due to permissions, this will start a timer to retry since + // accessibility permissions take affect immediately. + func enable() { + if (eventTap != nil) { + // Already enabled + return + } + + // If we are already trying to enable, then stop the timer and restart it. + if let enableTimer { + enableTimer.invalidate() + } + + // Try to enable the event tap immediately. If this succeeds then we're done! + if (tryEnable()) { + return + } + + // Failed, probably due to permissions. The permissions dialog should've + // popped up. We retry on a timer since once the permisisons are granted + // then they take affect immediately. + enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in + _ = self.tryEnable() + } + } + + // Disable the global event tap. This is safe to call if it is already disabled. + func disable() { + // Stop our enable timer if it is on + if let enableTimer { + enableTimer.invalidate() + self.enableTimer = nil + } + + // Stop our event tap + if let eventTap { + Self.logger.debug("invalidating event tap mach port") + CFMachPortInvalidate(eventTap) + self.eventTap = nil + } + } + + // Try to enable the global event type, returns false if it fails. + private func tryEnable() -> Bool { + // The events we care about + let eventMask = [ + CGEventType.keyDown + ].reduce(CGEventMask(0), { $0 | (1 << $1.rawValue)}) + + // Try to create it + guard let eventTap = CGEvent.tapCreate( + tap: .cgSessionEventTap, + place: .headInsertEventTap, + options: .defaultTap, + eventsOfInterest: eventMask, + callback: cgEventFlagsChangedHandler(proxy:type:cgEvent:userInfo:), + userInfo: nil + ) else { + // Return false if creation failed. This is usually because we don't have + // Accessibility permissions but can probably be other reasons I don't + // know about. + Self.logger.debug("creating global event tap failed, missing permissions?") + return false + } + + // Store our event tap + self.eventTap = eventTap + + // If we have an enable timer we always want to disable it + if let enableTimer { + enableTimer.invalidate() + self.enableTimer = nil + } + + // Attach our event tap to the main run loop. Note if you don't do this then + // the event tap will block every + CFRunLoopAddSource( + CFRunLoopGetMain(), + CFMachPortCreateRunLoopSource(nil, eventTap, 0), + .commonModes + ) + + Self.logger.info("global event tap enabled for global keybinds") + return true + } +} + +fileprivate func cgEventFlagsChangedHandler( + proxy: CGEventTapProxy, + type: CGEventType, + cgEvent: CGEvent, + userInfo: UnsafeMutableRawPointer? +) -> Unmanaged? { + let result = Unmanaged.passUnretained(cgEvent) + + // We only care about keydown events + guard type == .keyDown else { return result } + + // We need an app delegate to get the Ghostty app instance + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return result } + guard let ghostty = appDelegate.ghostty.app else { return result } + + // We need an NSEvent for our logic below + guard let event: NSEvent = .init(cgEvent: cgEvent) else { return result } + + // Build our event input and call ghostty + var key_ev = ghostty_input_key_s() + key_ev.action = GHOSTTY_ACTION_PRESS + key_ev.mods = Ghostty.ghosttyMods(event.modifierFlags) + key_ev.keycode = UInt32(event.keyCode) + key_ev.text = nil + key_ev.composing = false + if (ghostty_app_key(ghostty, key_ev)) { + GlobalEventTap.logger.info("global key event handled event=\(event)") + return nil + } + + return result +} diff --git a/src/App.zig b/src/App.zig index 4b9c2673e..31f3e451b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -262,6 +262,49 @@ pub fn setQuit(self: *App) !void { self.quit = true; } +/// Handle a key event at the app-scope. If this key event is used, +/// this will return true and the caller shouldn't continue processing +/// the event. If the event is not used, this will return false. +pub fn keyEvent( + self: *App, + rt_app: *apprt.App, + event: input.KeyEvent, +) bool { + switch (event.action) { + // We don't care about key release events. + .release => return false, + + // Continue processing key press events. + .press, .repeat => {}, + } + + // Get the keybind entry for this event. We don't support key sequences + // so we can look directly in the top-level set. + const entry = rt_app.config.keybind.set.getEvent(event) orelse return false; + const leaf: input.Binding.Set.Leaf = switch (entry) { + // Sequences aren't supported. Our configuration parser verifies + // this for global keybinds but we may still get an entry for + // a non-global keybind. + .leader => return false, + + // Leaf entries are good + .leaf => |leaf| leaf, + }; + + // We only care about global keybinds + if (!leaf.flags.global) return false; + + // Perform the action + self.performAllAction(rt_app, leaf.action) catch |err| { + log.warn("error performing global keybind action action={s} err={}", .{ + @tagName(leaf.action), + err, + }); + }; + + return true; +} + /// Perform a binding action. This only accepts actions that are scoped /// to the app. Callers can use performAllAction to perform any action /// and any non-app-scoped actions will be performed on all surfaces. diff --git a/src/Surface.zig b/src/Surface.zig index 49017883a..b5f7b9293 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1561,19 +1561,8 @@ fn maybeHandleBinding( const entry: input.Binding.Set.Entry = entry: { const set = self.keyboard.bindings orelse &self.config.keybind.set; - var trigger: input.Binding.Trigger = .{ - .mods = event.mods.binding(), - .key = .{ .translated = event.key }, - }; - if (set.get(trigger)) |v| break :entry v; - - trigger.key = .{ .physical = event.physical_key }; - if (set.get(trigger)) |v| break :entry v; - - if (event.unshifted_codepoint > 0) { - trigger.key = .{ .unicode = event.unshifted_codepoint }; - if (set.get(trigger)) |v| break :entry v; - } + // Get our entry from the set for the given event. + if (set.getEvent(event)) |v| break :entry v; // No entry found. If we're not looking at the root set of the // bindings we need to encode everything up to this point and diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index be3df896a..e0eecf8cc 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -143,17 +143,37 @@ pub const App = struct { toggle_secure_input: ?*const fn () callconv(.C) void = null, }; + /// This is the key event sent for ghostty_surface_key and + /// ghostty_app_key. + pub const KeyEvent = struct { + /// The three below are absolutely required. + action: input.Action, + mods: input.Mods, + keycode: u32, + + /// Optionally, the embedder can handle text translation and send + /// the text value here. If text is non-nil, it is assumed that the + /// embedder also handles dead key states and sets composing as necessary. + text: ?[:0]const u8, + composing: bool, + }; + core_app: *CoreApp, config: *const Config, opts: Options, keymap: input.Keymap, + /// The keymap state is used for global keybinds only. Each surface + /// also has its own keymap state for focused keybinds. + keymap_state: input.Keymap.State, + pub fn init(core_app: *CoreApp, config: *const Config, opts: Options) !App { return .{ .core_app = core_app, .config = config, .opts = opts, .keymap = try input.Keymap.init(), + .keymap_state = .{}, }; } @@ -174,6 +194,241 @@ pub const App = struct { return false; } + /// The target of a key event. This is used to determine some subtly + /// different behavior between app and surface key events. + pub const KeyTarget = union(enum) { + app, + surface: *Surface, + }; + + /// See CoreApp.keyEvent. + pub fn keyEvent( + self: *App, + target: KeyTarget, + event: KeyEvent, + ) !bool { + // NOTE: If this is updated, take a look at Surface.keyCallback as well. + // Their logic is very similar but not identical. + + const action = event.action; + const keycode = event.keycode; + const mods = event.mods; + + // True if this is a key down event + const is_down = action == .press or action == .repeat; + + // If we're on macOS and we have macos-option-as-alt enabled, + // then we strip the alt modifier from the mods for translation. + const translate_mods = translate_mods: { + var translate_mods = mods; + if (comptime builtin.target.isDarwin()) { + const strip = switch (self.config.@"macos-option-as-alt") { + .false => false, + .true => mods.alt, + .left => mods.sides.alt == .left, + .right => mods.sides.alt == .right, + }; + if (strip) translate_mods.alt = false; + } + + // On macOS we strip ctrl because UCKeyTranslate + // converts to the masked values (i.e. ctrl+c becomes 3) + // and we don't want that behavior. + // + // We also strip super because its not used for translation + // on macos and it results in a bad translation. + if (comptime builtin.target.isDarwin()) { + translate_mods.ctrl = false; + translate_mods.super = false; + } + + break :translate_mods translate_mods; + }; + + const event_text: ?[]const u8 = event_text: { + // This logic only applies to macOS. + if (comptime builtin.os.tag != .macos) break :event_text event.text; + + // If the modifiers are ONLY "control" then we never process + // the event text because we want to do our own translation so + // we can handle ctrl+c, ctrl+z, etc. + // + // This is specifically because on macOS using the + // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as + // "/" (the physical key that is z on a qwerty keyboard). But on + // other layouts, ctrl+ is not translated by AppKit. So, + // we just avoid this by never allowing AppKit to translate + // ctrl+ and instead do it ourselves. + const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); + break :event_text if (mods.binding().int() == ctrl_only) null else event.text; + }; + + // Translate our key using the keymap for our localized keyboard layout. + // We only translate for keydown events. Otherwise, we only care about + // the raw keycode. + var buf: [128]u8 = undefined; + const result: input.Keymap.Translation = if (is_down) translate: { + // If the event provided us with text, then we use this as a result + // and do not do manual translation. + const result: input.Keymap.Translation = if (event_text) |text| .{ + .text = text, + .composing = event.composing, + } else try self.keymap.translate( + &buf, + switch (target) { + .app => &self.keymap_state, + .surface => |surface| &surface.keymap_state, + }, + @intCast(keycode), + translate_mods, + ); + + // If this is a dead key, then we're composing a character and + // we need to set our proper preedit state if we're targeting a + // surface. + if (result.composing) { + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback( + result.text, + ) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return false; + }, + } + } else { + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback(null) catch |err| { + log.err("error in preedit callback err={}", .{err}); + return false; + }, + } + + // If the text is just a single non-printable ASCII character + // then we clear the text. We handle non-printables in the + // key encoder manual (such as tab, ctrl+c, etc.) + if (result.text.len == 1 and result.text[0] < 0x20) { + break :translate .{ .composing = false, .text = "" }; + } + } + + break :translate result; + } else .{ .composing = false, .text = "" }; + + // UCKeyTranslate always consumes all mods, so if we have any output + // then we've consumed our translate mods. + const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{}; + + // We need to always do a translation with no modifiers at all in + // order to get the "unshifted_codepoint" for the key event. + const unshifted_codepoint: u21 = unshifted: { + var nomod_buf: [128]u8 = undefined; + var nomod_state: input.Keymap.State = .{}; + const nomod = try self.keymap.translate( + &nomod_buf, + &nomod_state, + @intCast(keycode), + .{}, + ); + + const view = std.unicode.Utf8View.init(nomod.text) catch |err| { + log.warn("cannot build utf8 view over text: {}", .{err}); + break :unshifted 0; + }; + var it = view.iterator(); + break :unshifted it.nextCodepoint() orelse 0; + }; + + // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ + // action, + // keycode, + // result.composing, + // result.text.len, + // result.text, + // result.text, + // mods, + // }); + + // We want to get the physical unmapped key to process keybinds. + const physical_key = keycode: for (input.keycodes.entries) |entry| { + if (entry.native == keycode) break :keycode entry.key; + } else .invalid; + + // If the resulting text has length 1 then we can take its key + // and attempt to translate it to a key enum and call the key callback. + // If the length is greater than 1 then we're going to call the + // charCallback. + // + // We also only do key translation if this is not a dead key. + const key = if (!result.composing) key: { + // If our physical key is a keypad key, we use that. + if (physical_key.keypad()) break :key physical_key; + + // A completed key. If the length of the key is one then we can + // attempt to translate it to a key enum and call the key + // callback. First try plain ASCII. + if (result.text.len > 0) { + if (input.Key.fromASCII(result.text[0])) |key| { + break :key key; + } + } + + // If the above doesn't work, we use the unmodified value. + if (std.math.cast(u8, unshifted_codepoint)) |ascii| { + if (input.Key.fromASCII(ascii)) |key| { + break :key key; + } + } + + break :key physical_key; + } else .invalid; + + // Build our final key event + const input_event: input.KeyEvent = .{ + .action = action, + .key = key, + .physical_key = physical_key, + .mods = mods, + .consumed_mods = consumed_mods, + .composing = result.composing, + .utf8 = result.text, + .unshifted_codepoint = unshifted_codepoint, + }; + + // Invoke the core Ghostty logic to handle this input. + const effect: CoreSurface.InputEffect = switch (target) { + .app => if (self.core_app.keyEvent( + self, + input_event, + )) + .consumed + else + .ignored, + + .surface => |surface| try surface.core_surface.keyCallback(input_event), + }; + + return switch (effect) { + .closed => true, + .ignored => false, + .consumed => consumed: { + if (is_down) { + // If we consume the key then we want to reset the dead + // key state. + self.keymap_state = .{}; + + switch (target) { + .app => {}, + .surface => |surface| surface.core_surface.preeditCallback(null) catch {}, + } + } + + break :consumed true; + }, + }; + } + /// This should be called whenever the keyboard layout was changed. pub fn reloadKeymap(self: *App) !void { // Reload the keymap @@ -349,20 +604,6 @@ pub const Surface = struct { command: [*:0]const u8 = "", }; - /// This is the key event sent for ghostty_surface_key. - pub const KeyEvent = struct { - /// The three below are absolutely required. - action: input.Action, - mods: input.Mods, - keycode: u32, - - /// Optionally, the embedder can handle text translation and send - /// the text value here. If text is non-nil, it is assumed that the - /// embedder also handles dead key states and sets composing as necessary. - text: ?[:0]const u8, - composing: bool, - }; - pub fn init(self: *Surface, app: *App, opts: Options) !void { self.* = .{ .app = app, @@ -800,198 +1041,6 @@ pub const Surface = struct { }; } - pub fn keyCallback( - self: *Surface, - event: KeyEvent, - ) !void { - const action = event.action; - const keycode = event.keycode; - const mods = event.mods; - - // True if this is a key down event - const is_down = action == .press or action == .repeat; - - // If we're on macOS and we have macos-option-as-alt enabled, - // then we strip the alt modifier from the mods for translation. - const translate_mods = translate_mods: { - var translate_mods = mods; - if (comptime builtin.target.isDarwin()) { - const strip = switch (self.app.config.@"macos-option-as-alt") { - .false => false, - .true => mods.alt, - .left => mods.sides.alt == .left, - .right => mods.sides.alt == .right, - }; - if (strip) translate_mods.alt = false; - } - - // On macOS we strip ctrl because UCKeyTranslate - // converts to the masked values (i.e. ctrl+c becomes 3) - // and we don't want that behavior. - // - // We also strip super because its not used for translation - // on macos and it results in a bad translation. - if (comptime builtin.target.isDarwin()) { - translate_mods.ctrl = false; - translate_mods.super = false; - } - - break :translate_mods translate_mods; - }; - - const event_text: ?[]const u8 = event_text: { - // This logic only applies to macOS. - if (comptime builtin.os.tag != .macos) break :event_text event.text; - - // If the modifiers are ONLY "control" then we never process - // the event text because we want to do our own translation so - // we can handle ctrl+c, ctrl+z, etc. - // - // This is specifically because on macOS using the - // "Dvorak - QWERTY ⌘" keyboard layout, ctrl+z is translated as - // "/" (the physical key that is z on a qwerty keyboard). But on - // other layouts, ctrl+ is not translated by AppKit. So, - // we just avoid this by never allowing AppKit to translate - // ctrl+ and instead do it ourselves. - const ctrl_only = comptime (input.Mods{ .ctrl = true }).int(); - break :event_text if (mods.binding().int() == ctrl_only) null else event.text; - }; - - // Translate our key using the keymap for our localized keyboard layout. - // We only translate for keydown events. Otherwise, we only care about - // the raw keycode. - var buf: [128]u8 = undefined; - const result: input.Keymap.Translation = if (is_down) translate: { - // If the event provided us with text, then we use this as a result - // and do not do manual translation. - const result: input.Keymap.Translation = if (event_text) |text| .{ - .text = text, - .composing = event.composing, - } else try self.app.keymap.translate( - &buf, - &self.keymap_state, - @intCast(keycode), - translate_mods, - ); - - // If this is a dead key, then we're composing a character and - // we need to set our proper preedit state. - if (result.composing) { - self.core_surface.preeditCallback(result.text) catch |err| { - log.err("error in preedit callback err={}", .{err}); - return; - }; - } else { - // If we aren't composing, then we set our preedit to - // empty no matter what. - self.core_surface.preeditCallback(null) catch {}; - - // If the text is just a single non-printable ASCII character - // then we clear the text. We handle non-printables in the - // key encoder manual (such as tab, ctrl+c, etc.) - if (result.text.len == 1 and result.text[0] < 0x20) { - break :translate .{ .composing = false, .text = "" }; - } - } - - break :translate result; - } else .{ .composing = false, .text = "" }; - - // UCKeyTranslate always consumes all mods, so if we have any output - // then we've consumed our translate mods. - const consumed_mods: input.Mods = if (result.text.len > 0) translate_mods else .{}; - - // We need to always do a translation with no modifiers at all in - // order to get the "unshifted_codepoint" for the key event. - const unshifted_codepoint: u21 = unshifted: { - var nomod_buf: [128]u8 = undefined; - var nomod_state: input.Keymap.State = .{}; - const nomod = try self.app.keymap.translate( - &nomod_buf, - &nomod_state, - @intCast(keycode), - .{}, - ); - - const view = std.unicode.Utf8View.init(nomod.text) catch |err| { - log.warn("cannot build utf8 view over text: {}", .{err}); - break :unshifted 0; - }; - var it = view.iterator(); - break :unshifted it.nextCodepoint() orelse 0; - }; - - // log.warn("TRANSLATE: action={} keycode={x} dead={} key_len={} key={any} key_str={s} mods={}", .{ - // action, - // keycode, - // result.composing, - // result.text.len, - // result.text, - // result.text, - // mods, - // }); - - // We want to get the physical unmapped key to process keybinds. - const physical_key = keycode: for (input.keycodes.entries) |entry| { - if (entry.native == keycode) break :keycode entry.key; - } else .invalid; - - // If the resulting text has length 1 then we can take its key - // and attempt to translate it to a key enum and call the key callback. - // If the length is greater than 1 then we're going to call the - // charCallback. - // - // We also only do key translation if this is not a dead key. - const key = if (!result.composing) key: { - // If our physical key is a keypad key, we use that. - if (physical_key.keypad()) break :key physical_key; - - // A completed key. If the length of the key is one then we can - // attempt to translate it to a key enum and call the key - // callback. First try plain ASCII. - if (result.text.len > 0) { - if (input.Key.fromASCII(result.text[0])) |key| { - break :key key; - } - } - - // If the above doesn't work, we use the unmodified value. - if (std.math.cast(u8, unshifted_codepoint)) |ascii| { - if (input.Key.fromASCII(ascii)) |key| { - break :key key; - } - } - - break :key physical_key; - } else .invalid; - - // Invoke the core Ghostty logic to handle this input. - const effect = self.core_surface.keyCallback(.{ - .action = action, - .key = key, - .physical_key = physical_key, - .mods = mods, - .consumed_mods = consumed_mods, - .composing = result.composing, - .utf8 = result.text, - .unshifted_codepoint = unshifted_codepoint, - }) catch |err| { - log.err("error in key callback err={}", .{err}); - return; - }; - - switch (effect) { - .closed => return, - .ignored => {}, - .consumed => if (is_down) { - // If we consume the key then we want to reset the dead - // key state. - self.keymap_state = .{}; - self.core_surface.preeditCallback(null) catch {}; - }, - } - } - pub fn textCallback(self: *Surface, text: []const u8) void { _ = self.core_surface.textCallback(text) catch |err| { log.err("error in key callback err={}", .{err}); @@ -1411,7 +1460,7 @@ pub const CAPI = struct { composing: bool, /// Convert to surface key event. - fn keyEvent(self: KeyEvent) Surface.KeyEvent { + fn keyEvent(self: KeyEvent) App.KeyEvent { return .{ .action = self.action, .mods = @bitCast(@as( @@ -1497,6 +1546,19 @@ pub const CAPI = struct { core_app.destroy(); } + /// Notify the app of a global keypress capture. This will return + /// true if the key was captured by the app, in which case the caller + /// should not process the key. + export fn ghostty_app_key( + app: *App, + event: KeyEvent, + ) bool { + return app.keyEvent(.app, event.keyEvent()) catch |err| { + log.warn("error processing key event err={}", .{err}); + return false; + }; + } + /// Notify the app that the keyboard was changed. This causes the /// keyboard layout to be reloaded from the OS. export fn ghostty_app_keyboard_changed(v: *App) void { @@ -1690,16 +1752,15 @@ pub const CAPI = struct { /// Send this for raw keypresses (i.e. the keyDown event on macOS). /// This will handle the keymap translation and send the appropriate /// key and char events. - /// - /// You do NOT need to also send "ghostty_surface_char" unless - /// you want to send a unicode character that is not associated - /// with a keypress, i.e. IME keyboard. export fn ghostty_surface_key( surface: *Surface, event: KeyEvent, ) void { - surface.keyCallback(event.keyEvent()) catch |err| { - log.err("error processing key event err={}", .{err}); + _ = surface.app.keyEvent( + .{ .surface = surface }, + event.keyEvent(), + ) catch |err| { + log.warn("error processing key event err={}", .{err}); return; }; } diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 20fd80716..93e046d1a 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -6,6 +6,7 @@ const std = @import("std"); const Allocator = std.mem.Allocator; const assert = std.debug.assert; const key = @import("key.zig"); +const KeyEvent = key.KeyEvent; /// The trigger that needs to be performed to execute the action. trigger: Trigger, @@ -1254,6 +1255,31 @@ pub const Set = struct { return self.reverse.get(a); } + /// Get an entry for the given key event. This will attempt to find + /// a binding using multiple parts of the event in the following order: + /// + /// 1. Translated key (event.key) + /// 2. Physical key (event.physical_key) + /// 3. Unshifted Unicode codepoint (event.unshifted_codepoint) + /// + pub fn getEvent(self: *const Set, event: KeyEvent) ?Entry { + var trigger: Trigger = .{ + .mods = event.mods.binding(), + .key = .{ .translated = event.key }, + }; + if (self.get(trigger)) |v| return v; + + trigger.key = .{ .physical = event.physical_key }; + if (self.get(trigger)) |v| return v; + + if (event.unshifted_codepoint > 0) { + trigger.key = .{ .unicode = event.unshifted_codepoint }; + if (self.get(trigger)) |v| return v; + } + + return null; + } + /// Remove a binding for a given trigger. pub fn remove(self: *Set, alloc: Allocator, t: Trigger) void { const entry = self.bindings.get(t) orelse return; From ed7ac8aa2120aee674b19f70ecf59ecfb8241dc0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 10:39:20 -0700 Subject: [PATCH 071/139] macos: better enable timing depending on process launch time --- macos/Sources/App/macOS/AppDelegate.swift | 27 +++++++++++++++++-- .../Global Keybinds/GlobalEventTap.swift | 1 + 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 76dfdb5ec..1aeea6626 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -63,6 +63,10 @@ class AppDelegate: NSObject, /// This is only true before application has become active. private var applicationHasBecomeActive: Bool = false + /// This is set in applicationDidFinishLaunching with the system uptime so we can determine the + /// seconds since the process was launched. + private var applicationLaunchTime: TimeInterval = 0 + /// The ghostty global state. Only one per process. let ghostty: Ghostty.App = Ghostty.App() @@ -73,6 +77,11 @@ class AppDelegate: NSObject, let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() + /// The elapsed time since the process was started + var timeSinceLaunch: TimeInterval { + return ProcessInfo.processInfo.systemUptime - applicationLaunchTime + } + override init() { terminalManager = TerminalManager(ghostty) updaterController = SPUStandardUpdaterController( @@ -106,6 +115,9 @@ class AppDelegate: NSObject, "ApplePressAndHoldEnabled": false, ]) + // Store our start time + applicationLaunchTime = ProcessInfo.processInfo.systemUptime + // Check if secure input was enabled when we last quit. if (UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled) { toggleSecureInput(self) @@ -421,10 +433,21 @@ class AppDelegate: NSObject, // If our reload adds global keybinds and we don't have ax permissions then // we need to request them. - global: if (ghostty_app_has_global_keybinds(ghostty.app!)) { - DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + if (ghostty_app_has_global_keybinds(ghostty.app!)) { + if (timeSinceLaunch > 5) { + // If the process has been running for awhile we enable right away + // because no windows are likely to pop up. GlobalEventTap.shared.enable() + } else { + // If the process just started, we wait a couple seconds to allow + // the initial windows and so on to load so our permissions dialog + // doesn't get buried. + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(2)) { + GlobalEventTap.shared.enable() + } } + } else { + GlobalEventTap.shared.disable() } } diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 3a768df79..73c081df3 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -22,6 +22,7 @@ class GlobalEventTap { // don't have permissions. private var enableTimer: Timer? = nil + // Private init so it can't be constructed outside of our singleton private init() {} deinit { From 689ee0f3858c4220cb35c4e2b5a1c7a174ec07f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 16:38:41 -0700 Subject: [PATCH 072/139] typos --- macos/Sources/Features/Global Keybinds/GlobalEventTap.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift index 73c081df3..f1bb93506 100644 --- a/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift +++ b/macos/Sources/Features/Global Keybinds/GlobalEventTap.swift @@ -49,7 +49,7 @@ class GlobalEventTap { } // Failed, probably due to permissions. The permissions dialog should've - // popped up. We retry on a timer since once the permisisons are granted + // popped up. We retry on a timer since once the permissions are granted // then they take affect immediately. enableTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { _ in _ = self.tryEnable() From bea24f772564ebdf3d31ecd54bd33add32cfec22 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 16:41:26 -0700 Subject: [PATCH 073/139] config: clarify docs --- src/config/Config.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 18d34171f..efa741307 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -770,6 +770,11 @@ class: ?[:0]const u8 = null, /// Since they are not associated with a specific terminal surface, /// they're never encoded. /// +/// Keybind trigger are not unique per prefix combination. For example, +/// `ctrl+a` and `global:ctrl+a` are not two separate keybinds. The keybind +/// set later will overwrite the keybind set earlier. In this case, the +/// `global:` keybind will be used. +/// /// Multiple prefixes can be specified. For example, /// `global:unconsumed:ctrl+a=reload_config` will make the keybind global /// and not consume the input to reload the config. From 1b316638659bcc09633618ff499bd198cb86bb62 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 17:00:38 -0700 Subject: [PATCH 074/139] apprt/embedded: new_window can be called without a parent --- .../Features/Terminal/TerminalManager.swift | 6 +++++ macos/Sources/Ghostty/Ghostty.App.swift | 6 ++++- src/App.zig | 1 + src/Surface.zig | 27 ++++++++++--------- src/apprt/embedded.zig | 18 ++++++++----- src/input/Binding.zig | 9 +++++-- 6 files changed, 45 insertions(+), 22 deletions(-) diff --git a/macos/Sources/Features/Terminal/TerminalManager.swift b/macos/Sources/Features/Terminal/TerminalManager.swift index 8b9ed3cad..3930012df 100644 --- a/macos/Sources/Features/Terminal/TerminalManager.swift +++ b/macos/Sources/Features/Terminal/TerminalManager.swift @@ -78,6 +78,12 @@ class TerminalManager { window.toggleFullScreen(nil) } + // If our app isn't active, we make it active. All new_window actions + // force our app to be active. + if !NSApp.isActive { + NSApp.activate(ignoringOtherApps: true) + } + // We're dispatching this async because otherwise the lastCascadePoint doesn't // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 7b8c5688f..30efb289e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -631,7 +631,11 @@ extension Ghostty { } static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - let surface = self.surfaceUserdata(from: userdata) + let surface: SurfaceView? = if let userdata { + self.surfaceUserdata(from: userdata) + } else { + nil + } NotificationCenter.default.post( name: Notification.ghosttyNewWindow, diff --git a/src/App.zig b/src/App.zig index 31f3e451b..d93e00a2a 100644 --- a/src/App.zig +++ b/src/App.zig @@ -317,6 +317,7 @@ pub fn performAction( .unbind => unreachable, .ignore => {}, .quit => try self.setQuit(), + .new_window => try self.newWindow(rt_app, .{ .parent = null }), .open_config => try self.openConfig(rt_app), .reload_config => try self.reloadConfig(rt_app), .close_all_windows => { diff --git a/src/Surface.zig b/src/Surface.zig index b5f7b9293..85a5face0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3416,19 +3416,22 @@ fn showMouse(self: *Surface) void { /// will ever return false. We can expand this in the future if it becomes /// useful. We did previous/next tab so we could implement #498. pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool { - // Handle app-scoped bindings by sending it to the app. - switch (action.scope()) { - .app => { - try self.app.performAction( + // Forward app-scoped actions to the app. Some app-scoped actions are + // special-cased here because they do some special things when performed + // from the surface. + if (action.scoped(.app)) |app_action| { + switch (app_action) { + .new_window => try self.app.newWindow( + self.rt_app, + .{ .parent = self }, + ), + + else => try self.app.performAction( self.rt_app, action.scoped(.app).?, - ); - - return true; - }, - - // Surface fallthrough and handle - .surface => {}, + ), + } + return true; } switch (action.scoped(.surface).?) { @@ -3653,8 +3656,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool v, ), - .new_window => try self.app.newWindow(self.rt_app, .{ .parent = self }), - .new_tab => { if (@hasDecl(apprt.Surface, "newTab")) { try self.rt_surface.newTab(); diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e0eecf8cc..01e3d0743 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -86,7 +86,8 @@ pub const App = struct { /// New tab with options. new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - /// New window with options. + /// New window with options. The surface may be null if there is no + /// target surface. new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, /// Control the inspector visibility @@ -495,14 +496,19 @@ pub const App = struct { } pub fn newWindow(self: *App, parent: ?*CoreSurface) !void { - _ = self; - - // Right now we only support creating a new window with a parent - // through this code. - // The other case is handled by the embedding runtime. + // If we have a parent, the surface logic handles it. if (parent) |surface| { try surface.rt_surface.newWindow(); + return; } + + // No parent, call the new window callback. + const func = self.opts.new_window orelse { + log.info("runtime embedder does not support new_window", .{}); + return; + }; + + func(null, .{}); } }; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 93e046d1a..8f129065d 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -279,7 +279,8 @@ pub const Action = union(enum) { /// available values. write_selection_file: WriteScreenAction, - /// Open a new window. + /// Open a new window. If the application isn't currently focused, + /// this will bring it to the front. new_window: void, /// Open a new tab. @@ -562,6 +563,10 @@ pub const Action = union(enum) { .quit, => .app, + // These are app but can be special-cased in a surface context. + .new_window, + => .app, + // Obviously surface actions. .csi, .esc, @@ -593,12 +598,12 @@ pub const Action = union(enum) { .toggle_window_decorations, .toggle_secure_input, .crash, + => .surface, // These are less obvious surface actions. They're surface // actions because they are relevant to the surface they // come from. For example `new_window` needs to be sourced to // a surface so inheritance can be done correctly. - .new_window, .new_tab, .previous_tab, .next_tab, From 6d6052d204a19bfa41776e7864d6f42eb32dc587 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 24 Sep 2024 20:53:32 -0700 Subject: [PATCH 075/139] small comment changes --- macos/Sources/App/macOS/AppDelegate.swift | 4 ++-- src/apprt/embedded.zig | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1aeea6626..44f0f0291 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -431,8 +431,8 @@ class AppDelegate: NSObject, } } - // If our reload adds global keybinds and we don't have ax permissions then - // we need to request them. + // We need to handle our global event tap depending on if there are global + // events that we care about in Ghostty. if (ghostty_app_has_global_keybinds(ghostty.app!)) { if (timeSinceLaunch > 5) { // If the process has been running for awhile we enable right away diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 01e3d0743..c540694d0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -208,9 +208,6 @@ pub const App = struct { target: KeyTarget, event: KeyEvent, ) !bool { - // NOTE: If this is updated, take a look at Surface.keyCallback as well. - // Their logic is very similar but not identical. - const action = event.action; const keycode = event.keycode; const mods = event.mods; From 861531a1fd510081195a64ca213b9ca26ddfdb01 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Wed, 25 Sep 2024 09:01:39 -0700 Subject: [PATCH 076/139] terminal/kitty: increase value buffer, make 'H' and 'V' i32 This commit increases the value buffer to 11 characters - this is technically what it should be to allow for large negative integers (i.e., 2.4 billion plus the sign character). Additionally corrected the types for 'H' (horizontal offset) and 'V' (vertical offset) - these are supposed to be i32 versus u32. Added some extra tests to test all of this stuff as well. I've also added a few more tests to help track these cases. --- src/terminal/apc.zig | 20 ++++ src/terminal/kitty/graphics_command.zig | 125 ++++++++++++++++++++---- src/terminal/kitty/graphics_exec.zig | 34 +++++++ 3 files changed, 161 insertions(+), 18 deletions(-) diff --git a/src/terminal/apc.zig b/src/terminal/apc.zig index 6a6b8cc36..26c59729a 100644 --- a/src/terminal/apc.zig +++ b/src/terminal/apc.zig @@ -122,6 +122,26 @@ test "garbage Kitty command" { try testing.expect(h.end() == null); } +test "Kitty command with overflow u32" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("Ga=p,i=10000000000") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + +test "Kitty command with overflow i32" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + h.start(); + for ("Ga=p,i=1,z=-9999999999") |c| h.feed(alloc, c); + try testing.expect(h.end() == null); +} + test "valid Kitty command" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index 0ef054293..d199711d3 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -23,10 +23,10 @@ pub const Parser = struct { /// This is the list of KV pairs that we're building up. kv: KV = .{}, - /// This is used as a buffer to store the key/value of a KV pair. - /// The value of a KV pair is at most a 32-bit integer which at most - /// is 10 characters (4294967295). - kv_temp: [10]u8 = undefined, + /// This is used as a buffer to store the key/value of a KV pair. The value + /// of a KV pair is at most a 32-bit integer which at most is 10 characters + /// (4294967295), plus one character for the sign bit on signed ints. + kv_temp: [11]u8 = undefined, kv_temp_len: u4 = 0, kv_current: u8 = 0, // Current kv key @@ -237,16 +237,14 @@ pub const Parser = struct { } } - // Only "z" is currently signed. This is a bit of a kloodge; if more - // fields become signed we can rethink this but for now we parse - // "z" as i32 then bitcast it to u32 then bitcast it back later. - if (self.kv_current == 'z') { - const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10); - try self.kv.put(alloc, self.kv_current, @bitCast(v)); - } else { - const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10); - try self.kv.put(alloc, self.kv_current, v); - } + // Handle integer fields, parsing signed fields accordingly. We still + // store the fields as u32 as they can be bitcast back later during + // building of the higher-level command tree. + const v: u32 = switch (self.kv_current) { + 'z', 'H', 'V' => @bitCast(try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10)), + else => try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10), + }; + try self.kv.put(alloc, self.kv_current, v); // Clear our temp buffer self.kv_temp_len = 0; @@ -505,8 +503,8 @@ pub const Display = struct { virtual_placement: bool = false, // U parent_id: u32 = 0, // P parent_placement_id: u32 = 0, // Q - horizontal_offset: u32 = 0, // H - vertical_offset: u32 = 0, // V + horizontal_offset: i32 = 0, // H + vertical_offset: i32 = 0, // V z: i32 = 0, // z pub const CursorMovement = enum { @@ -591,11 +589,13 @@ pub const Display = struct { } if (kv.get('H')) |v| { - result.horizontal_offset = v; + // We can bitcast here because of how we parse it earlier. + result.horizontal_offset = @bitCast(v); } if (kv.get('V')) |v| { - result.vertical_offset = v; + // We can bitcast here because of how we parse it earlier. + result.vertical_offset = @bitCast(v); } return result; @@ -1069,6 +1069,95 @@ test "ignore very long values" { try testing.expectEqual(@as(u32, 0), v.height); } +test "ensure very large negative values don't get skipped" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=p,i=1,z=-2000000000"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-2000000000, v.z); +} + +test "ensure proper overflow error for u32" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=p,i=10000000000"; + for (input) |c| try p.feed(c); + try testing.expectError(error.Overflow, p.complete()); +} + +test "ensure proper overflow error for i32" { + const testing = std.testing; + const alloc = testing.allocator; + var p = Parser.init(alloc); + defer p.deinit(); + + const input = "a=p,i=1,z=-9999999999"; + for (input) |c| try p.feed(c); + try testing.expectError(error.Overflow, p.complete()); +} + +test "all i32 values" { + const testing = std.testing; + const alloc = testing.allocator; + + { + // 'z' (usually z-axis values) + var p = Parser.init(alloc); + defer p.deinit(); + const input = "a=p,i=1,z=-1"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-1, v.z); + } + + { + // 'H' (relative placement, horizontal offset) + var p = Parser.init(alloc); + defer p.deinit(); + const input = "a=p,i=1,H=-1"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-1, v.horizontal_offset); + } + + { + // 'V' (relative placement, vertical offset) + var p = Parser.init(alloc); + defer p.deinit(); + const input = "a=p,i=1,V=-1"; + for (input) |c| try p.feed(c); + const command = try p.complete(); + defer command.deinit(alloc); + + try testing.expect(command.control == .display); + const v = command.control.display; + try testing.expectEqual(1, v.image_id); + try testing.expectEqual(-1, v.vertical_offset); + } +} + test "response: encode nothing without ID or image number" { const testing = std.testing; var buf: [1024]u8 = undefined; diff --git a/src/terminal/kitty/graphics_exec.zig b/src/terminal/kitty/graphics_exec.zig index c43bbbb9f..42f12ea07 100644 --- a/src/terminal/kitty/graphics_exec.zig +++ b/src/terminal/kitty/graphics_exec.zig @@ -495,3 +495,37 @@ test "kittygfx default format is rgba" { const img = storage.imageById(1).?; try testing.expectEqual(command.Transmission.Format.rgba, img.format); } + +test "kittygfx test valid u32 (expect invalid image ID)" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=4294967295", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(resp.message, "ENOENT: image not found"); +} + +test "kittygfx test valid i32 (expect invalid image ID)" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + const cmd = try command.Parser.parseString( + alloc, + "a=p,i=1,z=-2147483648", + ); + defer cmd.deinit(alloc); + const resp = execute(alloc, &t, &cmd).?; + try testing.expect(!resp.ok()); + try testing.expectEqual(resp.message, "ENOENT: image not found"); +} From 74d24a53ab5f2d09417250c5cbb703534fb3a36d Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Sep 2024 11:41:12 -0600 Subject: [PATCH 077/139] correct diagram/description (+Y is down not up) --- src/renderer/shaders/cell.metal | 44 ++++++++++++++++----------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/renderer/shaders/cell.metal b/src/renderer/shaders/cell.metal index 734608c76..b6af33824 100644 --- a/src/renderer/shaders/cell.metal +++ b/src/renderer/shaders/cell.metal @@ -224,30 +224,30 @@ vertex CellTextVertexOut cell_text_vertex( out.color = float4(in.color) / 255.0f; // === Grid Cell === - // - // offset.x = bearings.x - // .|. - // | | - // +-------+_. - // ._| | | - // | | .###. | | - // | | #...# | +- bearings.y - // glyph_size.y -+ | ##### | | - // | | #.... | | - // ^ |_| .#### |_| _. - // | | | +- offset.y = cell_size.y - bearings.y - // . cell_pos -> +-------+ -' - // +Y. |_._| - // . | - // | glyph_size.x - // 0,0--...-> // +X + // 0,0--...-> + // | + // . offset.x = bearings.x + // +Y. .|. + // . | | + // | cell_pos -> +-------+ _. + // v ._| |_. _|- offset.y = cell_size.y - bearings.y + // | | .###. | | + // | | #...# | | + // glyph_size.y -+ | ##### | | + // | | #.... | +- bearings.y + // |_| .#### | | + // | |_| + // +-------+ + // |_._| + // | + // glyph_size.x // - // In order to get the bottom left of the glyph, we compute an offset based - // on the bearings. The Y bearing is the distance from the top of the cell - // to the bottom of the glyph, so we subtract it from the cell height to get - // the y offset. The X bearing is the distance from the left of the cell to - // the left of the glyph, so it works as the x offset directly. + // In order to get the top left of the glyph, we compute an offset based on + // the bearings. The Y bearing is the distance from the bottom of the cell + // to the top of the glyph, so we subtract it from the cell height to get + // the y offset. The X bearing is the distance from the left of the cell + // to the left of the glyph, so it works as the x offset directly. float2 size = float2(in.glyph_size); float2 offset = float2(in.bearings); From 9a87001fa649eb5a8ee2ff9afc31a943963d912c Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Sep 2024 11:46:43 -0600 Subject: [PATCH 078/139] font/sprite: correct underline placement calculation --- src/font/sprite/underline.zig | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/src/font/sprite/underline.zig b/src/font/sprite/underline.zig index f38bc50e6..08e74c781 100644 --- a/src/font/sprite/underline.zig +++ b/src/font/sprite/underline.zig @@ -27,9 +27,7 @@ pub fn renderGlyph( line_pos: u32, line_thickness: u32, ) !font.Glyph { - // _ = height; - - // Draw the appropriate sprite + // Draw the appropriate sprite var canvas: font.sprite.Canvas, const offset_y: i32 = switch (sprite) { .underline => try drawSingle(alloc, width, line_thickness), .underline_double => try drawDouble(alloc, width, line_thickness), @@ -48,15 +46,24 @@ pub fn renderGlyph( .width = width, .height = @intCast(region.height), .offset_x = 0, - .offset_y = @as(i32, @intCast(height + region.height)) - @as(i32, @intCast(line_pos)) + offset_y + 1, + // Glyph.offset_y is the distance between the top of the glyph and the + // bottom of the cell. We want the top of the glyph to be at line_pos + // from the TOP of the cell, and then offset by the offset_y from the + // draw function. + .offset_y = @as(i32, @intCast(height - line_pos)) - offset_y, .atlas_x = region.x, .atlas_y = region.y, .advance_x = @floatFromInt(width), }; } +/// A tuple with the canvas that the desired sprite was drawn on and +/// a recommended offset (+Y = down) to shift its Y position by, to +/// correct for underline styles with additional thickness. +const CanvasAndOffset = struct { font.sprite.Canvas, i32 }; + /// Draw a single underline. -fn drawSingle(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { +fn drawSingle(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset { const height: u32 = thickness; var canvas = try font.sprite.Canvas.init(alloc, width, height); @@ -73,7 +80,7 @@ fn drawSingle(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprit } /// Draw a double underline. -fn drawDouble(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { +fn drawDouble(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset { const height: u32 = thickness * 3; var canvas = try font.sprite.Canvas.init(alloc, width, height); @@ -97,7 +104,7 @@ fn drawDouble(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprit } /// Draw a dotted underline. -fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { +fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset { const height: u32 = thickness; var canvas = try font.sprite.Canvas.init(alloc, width, height); @@ -123,7 +130,7 @@ fn drawDotted(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprit } /// Draw a dashed underline. -fn drawDashed(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { +fn drawDashed(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset { const height: u32 = thickness; var canvas = try font.sprite.Canvas.init(alloc, width, height); @@ -150,7 +157,7 @@ fn drawDashed(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprit /// Draw a curly underline. Thanks to Wez Furlong for providing /// the basic math structure for this since I was lazy with the /// geometry. -fn drawCurly(alloc: Allocator, width: u32, thickness: u32) !struct { font.sprite.Canvas, i32 } { +fn drawCurly(alloc: Allocator, width: u32, thickness: u32) !CanvasAndOffset { const height: u32 = thickness * 4; var canvas = try font.sprite.Canvas.init(alloc, width, height); From 003b100707a7125f1e84ce44b42e0b1bd8b3484d Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Sep 2024 12:01:24 -0600 Subject: [PATCH 079/139] font: remove fudge factors in ul and st position calculations These were present because of an incorrect calculation in the underline sprite renderer, and are no longer necessary. --- src/font/face/coretext.zig | 7 +++---- src/font/face/freetype.zig | 8 ++++---- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/src/font/face/coretext.zig b/src/font/face/coretext.zig index ee2460572..dacb79476 100644 --- a/src/font/face/coretext.zig +++ b/src/font/face/coretext.zig @@ -612,9 +612,8 @@ pub const Face = struct { // we subtract half of the ex height to go back up to the // correct height that should evenly split lowercase text. const pos = layout_metrics.ascent - - ex_height * 0.5 + - strikethrough_thickness * 0.5 + - 1; + ex_height * 0.5 - + strikethrough_thickness * 0.5; break :strikethrough_position @ceil(pos); }; @@ -625,7 +624,7 @@ pub const Face = struct { // The final underline position is +y from the TOP (confusing) // so we have to subtract from the cell height. const underline_position = @ceil(layout_metrics.ascent - - @as(f32, @floatCast(ct_font.getUnderlinePosition())) + 1); + @as(f32, @floatCast(ct_font.getUnderlinePosition()))); // Note: is this useful? // const units_per_em = ct_font.getUnitsPerEm(); diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index f764ac61d..dae46d6d6 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -678,8 +678,8 @@ pub const Face = struct { @intCast(face.handle.*.size.*.metrics.y_scale), ))) / 64; - // We use the declared underline position if its available - const declared = @ceil(cell_height - cell_baseline - declared_px - underline_thickness * 0.5 + 1); + // We use the declared underline position if its available. + const declared = @ceil(cell_height - cell_baseline - declared_px - underline_thickness * 0.5); if (declared > 0) break :underline_pos declared; @@ -702,13 +702,13 @@ pub const Face = struct { ))) / 64; break :st .{ - .pos = @ceil(cell_height - cell_baseline - pos + thickness + 1), + .pos = @ceil(cell_height - cell_baseline - pos), .thickness = thickness, }; } else .{ // Exactly 50% of the ex height so that our strikethrough is // centered through lowercase text. This is a common choice. - .pos = @ceil(cell_height - cell_baseline - ex_height * 0.5 + underline_thickness), + .pos = @ceil(cell_height - cell_baseline - ex_height * 0.5 - underline_thickness * 0.5), .thickness = underline_thickness, }; From 13603c51a922392e925fd5f8bd2f0221ac438dbb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Sep 2024 11:01:35 -0700 Subject: [PATCH 080/139] apprt: begin transition to making actions an enum and not use hasDecl --- src/App.zig | 22 ++++++++++------------ src/apprt.zig | 6 ++++++ src/apprt/action.zig | 29 +++++++++++++++++++++++++++++ src/apprt/glfw.zig | 22 +++++++++++++++++----- 4 files changed, 62 insertions(+), 17 deletions(-) create mode 100644 src/apprt/action.zig diff --git a/src/App.zig b/src/App.zig index d93e00a2a..65153127a 100644 --- a/src/App.zig +++ b/src/App.zig @@ -241,19 +241,17 @@ fn redrawInspector(self: *App, rt_app: *apprt.App, surface: *apprt.Surface) !voi /// Create a new window pub fn newWindow(self: *App, rt_app: *apprt.App, msg: Message.NewWindow) !void { - if (!@hasDecl(apprt.App, "newWindow")) { - log.warn("newWindow is not supported by this runtime", .{}); - return; - } + const target: apprt.Target = target: { + const parent = msg.parent orelse break :target .app; + if (self.hasSurface(parent)) break :target .{ .surface = parent }; + break :target .app; + }; - const parent = if (msg.parent) |parent| parent: { - break :parent if (self.hasSurface(parent)) - parent - else - null; - } else null; - - try rt_app.newWindow(parent); + try rt_app.performAction( + target, + .new_window, + {}, + ); } /// Start quitting diff --git a/src/apprt.zig b/src/apprt.zig index 491f1b8b5..f6952c9ec 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -12,6 +12,7 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("build_config.zig"); +const action = @import("apprt/action.zig"); const structs = @import("apprt/structs.zig"); pub const glfw = @import("apprt/glfw.zig"); @@ -21,6 +22,9 @@ pub const browser = @import("apprt/browser.zig"); pub const embedded = @import("apprt/embedded.zig"); pub const surface = @import("apprt/surface.zig"); +pub const Action = action.Action; +pub const Target = action.Target; + pub const ContentScale = structs.ContentScale; pub const Clipboard = structs.Clipboard; pub const ClipboardRequest = structs.ClipboardRequest; @@ -84,4 +88,6 @@ pub const Runtime = enum { test { _ = Runtime; _ = runtime; + _ = action; + _ = structs; } diff --git a/src/apprt/action.zig b/src/apprt/action.zig new file mode 100644 index 000000000..38b318109 --- /dev/null +++ b/src/apprt/action.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const assert = std.debug.assert; +const CoreSurface = @import("../Surface.zig"); + +/// The possible actions an apprt has to react to. +pub const Action = union(enum) { + new_window, + + /// The enum of keys in the tagged union. + pub const Key = @typeInfo(Action).Union.tag_type.?; + + /// Returns the value type for the given key. + pub fn Value(comptime key: Key) type { + inline for (@typeInfo(Action).Union.fields) |field| { + const field_key = @field(Key, field.name); + if (field_key == key) return field.type; + } + + unreachable; + } +}; + +/// The target for an action. This is generally the thing that had focus +/// while the action was made but the concept of "focus" is not guaranteed +/// since actions can also be triggered by timers, scripts, etc. +pub const Target = union(enum) { + app, + surface: *CoreSurface, +}; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 57cb257b4..e5405eb32 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -127,6 +127,23 @@ pub const App = struct { glfw.postEmptyEvent(); } + /// Perform a given action. + pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), + ) !void { + _ = value; + + switch (action) { + .new_window => _ = try self.newSurface(switch (target) { + .app => null, + .surface => |v| v, + }), + } + } + /// Open the configuration in the system editor. pub fn openConfig(self: *App) !void { try configpkg.edit.open(self.app.alloc); @@ -195,11 +212,6 @@ pub const App = struct { win.setMonitor(monitor, 0, 0, video_mode.getWidth(), video_mode.getHeight(), 0); } - /// Create a new window for the app. - pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void { - _ = try self.newSurface(parent_); - } - /// Create a new tab in the parent surface. fn newTab(self: *App, parent: *CoreSurface) !void { if (!Darwin.enabled) { From a0f017d6fdad3cf5ec4952498d7f09b12b997132 Mon Sep 17 00:00:00 2001 From: Qwerasd Date: Wed, 25 Sep 2024 12:11:04 -0600 Subject: [PATCH 081/139] freetype: update expected ul pos in tests to account for removed fudge factor --- src/font/face/freetype.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/font/face/freetype.zig b/src/font/face/freetype.zig index dae46d6d6..3ff9e9ffa 100644 --- a/src/font/face/freetype.zig +++ b/src/font/face/freetype.zig @@ -851,7 +851,7 @@ test "metrics" { .cell_width = 16, .cell_height = 35, .cell_baseline = 7, - .underline_position = 36, + .underline_position = 35, .underline_thickness = 2, .strikethrough_position = 20, .strikethrough_thickness = 2, From 0e043bc0e479d17b60c4401ca82742ee2269e4f0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Sep 2024 11:25:20 -0700 Subject: [PATCH 082/139] apprt: transition all hasDecls in App.zig to use the new action dispatch --- src/App.zig | 33 +++++++++++++++++---------------- src/apprt/action.zig | 26 +++++++++++++++++++++++++- src/apprt/glfw.zig | 12 +++++++----- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/src/App.zig b/src/App.zig index 65153127a..4462c7c83 100644 --- a/src/App.zig +++ b/src/App.zig @@ -138,7 +138,13 @@ pub fn addSurface(self: *App, rt_surface: *apprt.Surface) !void { // Since we have non-zero surfaces, we can cancel the quit timer. // It is up to the apprt if there is a quit timer at all and if it // should be canceled. - if (@hasDecl(apprt.App, "cancelQuitTimer")) rt_surface.app.cancelQuitTimer(); + rt_surface.app.performAction( + .{ .surface = &rt_surface.core_surface }, + .quit_timer, + .stop, + ) catch |err| { + log.warn("error stopping quit timer err={}", .{err}); + }; } /// Delete the surface from the known surface list. This will NOT call the @@ -166,8 +172,13 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void { // If we have no surfaces, we can start the quit timer. It is up to the // apprt to determine if this is necessary. - if (@hasDecl(apprt.App, "startQuitTimer") and - self.surfaces.items.len == 0) rt_surface.app.startQuitTimer(); + if (self.surfaces.items.len == 0) rt_surface.app.performAction( + .{ .surface = &rt_surface.core_surface }, + .quit_timer, + .start, + ) catch |err| { + log.warn("error starting quit timer err={}", .{err}); + }; } /// The last focused surface. This is only valid while on the main thread @@ -194,7 +205,7 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { log.debug("mailbox message={s}", .{@tagName(message)}); switch (message) { .reload_config => try self.reloadConfig(rt_app), - .open_config => try self.openConfig(rt_app), + .open_config => try self.performAction(rt_app, .open_config), .new_window => |msg| try self.newWindow(rt_app, msg), .close => |surface| try self.closeSurface(surface), .quit => try self.setQuit(), @@ -205,12 +216,6 @@ fn drainMailbox(self: *App, rt_app: *apprt.App) !void { } } -pub fn openConfig(self: *App, rt_app: *apprt.App) !void { - _ = self; - log.debug("opening configuration", .{}); - try rt_app.openConfig(); -} - pub fn reloadConfig(self: *App, rt_app: *apprt.App) !void { log.debug("reloading configuration", .{}); if (try rt_app.reloadConfig()) |new| { @@ -316,13 +321,9 @@ pub fn performAction( .ignore => {}, .quit => try self.setQuit(), .new_window => try self.newWindow(rt_app, .{ .parent = null }), - .open_config => try self.openConfig(rt_app), + .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try self.reloadConfig(rt_app), - .close_all_windows => { - if (@hasDecl(apprt.App, "closeAllWindows")) { - rt_app.closeAllWindows(); - } else log.warn("runtime doesn't implement closeAllWindows", .{}); - }, + .close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}), } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 38b318109..d0f551646 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -2,10 +2,34 @@ const std = @import("std"); const assert = std.debug.assert; const CoreSurface = @import("../Surface.zig"); -/// The possible actions an apprt has to react to. +/// The possible actions an apprt has to react to. Actions are one-way +/// messages that are sent to the app runtime to trigger some behavior. +/// +/// Actions are very often key binding actions but can also be triggered +/// by lifecycle events. For example, the `quit_timer` action is not bindable. +/// +/// Importantly, actions are generally OPTIONAL to implement by an apprt. +/// Required functionality is called directly on the runtime structure so +/// there is a compiler error if an action is not implemented. pub const Action = union(enum) { + /// Open a new window. The target determines whether properties such + /// as font size should be inherited. new_window, + /// Close all open windows. + close_all_windows, + + /// Open the Ghostty configuration. This is platform-specific about + /// what it means; it can mean opening a dedicated UI or just opening + /// a file in a text editor. + open_config, + + /// Called when there are no more surfaces and the app should quit + /// after the configured delay. This can be cancelled by sending + /// another quit_timer action with "stop". Multiple "starts" shouldn't + /// happen and can be ignored or cause a restart it isn't that important. + quit_timer: enum { start, stop }, + /// The enum of keys in the tagged union. pub const Key = @typeInfo(Action).Union.tag_type.?; diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index e5405eb32..8bf07c8a4 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -141,12 +141,14 @@ pub const App = struct { .app => null, .surface => |v| v, }), - } - } - /// Open the configuration in the system editor. - pub fn openConfig(self: *App) !void { - try configpkg.edit.open(self.app.alloc); + .open_config => try configpkg.edit.open(self.app.alloc), + + // Unimplemented + .close_all_windows, + .quit_timer, + => log.warn("unimplemented action={}", .{action}), + } } /// Reload the configuration. This should return the new configuration. From ae98dddead79e01a00e6e760def770ce0a62bf4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Sep 2024 11:31:05 -0700 Subject: [PATCH 083/139] apprt/embedded: support new performAction API --- src/apprt/embedded.zig | 34 +++++++++++++++++++++++++++------- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index c540694d0..61b798bfa 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -440,10 +440,6 @@ pub const App = struct { } } - pub fn openConfig(self: *App) !void { - try configpkg.edit.open(self.core_app.alloc); - } - pub fn reloadConfig(self: *App) !?*const Config { // Reload if (self.opts.reload_config(self.opts.userdata)) |new| { @@ -492,7 +488,7 @@ pub const App = struct { surface.queueInspectorRender(); } - pub fn newWindow(self: *App, parent: ?*CoreSurface) !void { + fn newWindow(self: *App, parent: ?*CoreSurface) !void { // If we have a parent, the surface logic handles it. if (parent) |surface| { try surface.rt_surface.newWindow(); @@ -507,6 +503,30 @@ pub const App = struct { func(null, .{}); } + + /// Perform a given action. + pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), + ) !void { + _ = value; + + switch (action) { + .new_window => _ = try self.newWindow(switch (target) { + .app => null, + .surface => |v| v, + }), + + .open_config => try configpkg.edit.open(self.core_app.alloc), + + // Unimplemented + .close_all_windows, + .quit_timer, + => log.warn("unimplemented action={}", .{action}), + } + } }; /// Platform-specific configuration for libghostty. @@ -1111,7 +1131,7 @@ pub const Surface = struct { func(self.userdata, options); } - pub fn newWindow(self: *const Surface) !void { + fn newWindow(self: *const Surface) !void { const func = self.app.opts.new_window orelse { log.info("runtime embedder does not support new_window", .{}); return; @@ -1573,7 +1593,7 @@ pub const CAPI = struct { /// Open the configuration. export fn ghostty_app_open_config(v: *App) void { - _ = v.core_app.openConfig(v) catch |err| { + v.performAction(.app, .open_config, {}) catch |err| { log.err("error reloading config err={}", .{err}); return; }; From 02d7e766e150d44fdecbe3df6d43e9a79a4c3e42 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Sep 2024 11:43:35 -0700 Subject: [PATCH 084/139] core: move password input into action enum --- macos/Sources/Ghostty/Ghostty.App.swift | 3 +++ src/Surface.zig | 13 +++++++++--- src/apprt/action.zig | 7 +++++++ src/apprt/embedded.zig | 27 +++++++++++++++---------- src/apprt/glfw.zig | 3 ++- 5 files changed, 38 insertions(+), 15 deletions(-) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 30efb289e..3b3dd9626 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -549,6 +549,9 @@ extension Ghostty { } static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) { + // We don't currently allow global password input being set from this. + guard let userdata else { return } + let surfaceView = self.surfaceUserdata(from: userdata) guard let appState = self.appState(fromView: surfaceView) else { return } guard appState.config.autoSecureInput else { return } diff --git a/src/Surface.zig b/src/Surface.zig index 85a5face0..72b5a7060 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -838,9 +838,16 @@ fn passwordInput(self: *Surface, v: bool) !void { } // Notify our apprt so it can do whatever it wants. - if (@hasDecl(apprt.Surface, "setPasswordInput")) { - self.rt_surface.setPasswordInput(v); - } + self.rt_app.performAction( + .{ .surface = self }, + .secure_input, + v, + ) catch |err| { + // We ignore this error because we don't want to fail this + // entire operation just because the apprt failed to set + // the secure input state. + log.warn("apprt failed to set secure input state err={}", .{err}); + }; try self.queueRender(); } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index d0f551646..e1486130f 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -30,6 +30,13 @@ pub const Action = union(enum) { /// happen and can be ignored or cause a restart it isn't that important. quit_timer: enum { start, stop }, + /// Set the secure input functionality on or off. "Secure input" means + /// that the user is currently at some sort of prompt where they may be + /// entering a password or other sensitive information. This can be used + /// by the app runtime to change the appearance of the cursor, setup + /// system APIs to not log the input, etc. + secure_input: bool, + /// The enum of keys in the tagged union. pub const Key = @typeInfo(Action).Union.tag_type.?; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 61b798bfa..e247a1934 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -138,6 +138,8 @@ pub const App = struct { /// Notifies that a password input has been started for the given /// surface. The apprt can use this to modify UI, enable features /// such as macOS secure input, etc. + /// + /// The surface userdata will be null if a surface isn't focused. set_password_input: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, /// Toggle secure input for the application. @@ -504,6 +506,18 @@ pub const App = struct { func(null, .{}); } + fn setPasswordInput(self: *App, target: apprt.Target, v: bool) void { + const func = self.opts.set_password_input orelse { + log.info("runtime embedder does not set_password_input", .{}); + return; + }; + + func(switch (target) { + .app => null, + .surface => |surface| surface.rt_surface.userdata, + }, v); + } + /// Perform a given action. pub fn performAction( self: *App, @@ -511,8 +525,6 @@ pub const App = struct { comptime action: apprt.Action.Key, value: apprt.Action.Value(action), ) !void { - _ = value; - switch (action) { .new_window => _ = try self.newWindow(switch (target) { .app => null, @@ -521,6 +533,8 @@ pub const App = struct { .open_config => try configpkg.edit.open(self.core_app.alloc), + .secure_input => self.setPasswordInput(target, value), + // Unimplemented .close_all_windows, .quit_timer, @@ -1112,15 +1126,6 @@ pub const Surface = struct { func(); } - pub fn setPasswordInput(self: *Surface, v: bool) void { - const func = self.app.opts.set_password_input orelse { - log.info("runtime embedder does not set_password_input", .{}); - return; - }; - - func(self.userdata, v); - } - pub fn newTab(self: *const Surface) !void { const func = self.app.opts.new_tab orelse { log.info("runtime embedder does not support new_tab", .{}); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 8bf07c8a4..439efa3e4 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -147,7 +147,8 @@ pub const App = struct { // Unimplemented .close_all_windows, .quit_timer, - => log.warn("unimplemented action={}", .{action}), + .secure_input, + => log.info("unimplemented action={}", .{action}), } } From 26cba70b692f297ab6b4b9601261fdbc5d311e98 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 08:34:07 -0700 Subject: [PATCH 085/139] core: no need for hasTabs apprt function --- src/Surface.zig | 21 --------------------- src/apprt/gtk/Surface.zig | 5 ----- src/apprt/gtk/Window.zig | 5 ----- 3 files changed, 31 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 72b5a7060..c8a50239e 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3670,39 +3670,18 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, .previous_tab => { - if (@hasDecl(apprt.Surface, "hasTabs")) { - if (!self.rt_surface.hasTabs()) { - log.debug("surface has no tabs, ignoring previous_tab binding", .{}); - return false; - } - } - if (@hasDecl(apprt.Surface, "gotoTab")) { self.rt_surface.gotoTab(.previous); } else log.warn("runtime doesn't implement gotoTab", .{}); }, .next_tab => { - if (@hasDecl(apprt.Surface, "hasTabs")) { - if (!self.rt_surface.hasTabs()) { - log.debug("surface has no tabs, ignoring next_tab binding", .{}); - return false; - } - } - if (@hasDecl(apprt.Surface, "gotoTab")) { self.rt_surface.gotoTab(.next); } else log.warn("runtime doesn't implement gotoTab", .{}); }, .last_tab => { - if (@hasDecl(apprt.Surface, "hasTabs")) { - if (!self.rt_surface.hasTabs()) { - log.debug("surface has no tabs, ignoring last_tab binding", .{}); - return false; - } - } - if (@hasDecl(apprt.Surface, "gotoTab")) { self.rt_surface.gotoTab(.last); } else log.warn("runtime doesn't implement gotoTab", .{}); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index c1146d348..caa4653f0 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -790,11 +790,6 @@ pub fn newTab(self: *Surface) !void { try window.newTab(&self.core_surface); } -pub fn hasTabs(self: *const Surface) bool { - const window = self.container.window() orelse return false; - return window.hasTabs(); -} - pub fn gotoTab(self: *Surface, tab: apprt.GotoTab) void { const window = self.container.window() orelse { log.info( diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index efb0d2ea4..b6f896592 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -421,11 +421,6 @@ pub fn closeTab(self: *Window, tab: *Tab) void { self.notebook.closeTab(tab); } -/// Returns true if this window has any tabs. -pub fn hasTabs(self: *const Window) bool { - return self.notebook.nPages() > 0; -} - /// Go to the previous tab for a surface. pub fn gotoPreviousTab(self: *Window, surface: *Surface) void { const tab = surface.container.tab() orelse { From 1e010b8e087ca9620924588455220fe8c552f3ee Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 09:37:24 -0700 Subject: [PATCH 086/139] core: more actions --- src/Surface.zig | 48 +++++++++++++++--------------------- src/apprt.zig | 2 +- src/apprt/action.zig | 19 ++++++++++++++ src/apprt/embedded.zig | 56 +++++++++++++++++++++++++----------------- src/apprt/glfw.zig | 18 +++++++++----- src/apprt/structs.zig | 10 -------- 6 files changed, 85 insertions(+), 68 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index c8a50239e..309294e61 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3663,35 +3663,27 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool v, ), - .new_tab => { - if (@hasDecl(apprt.Surface, "newTab")) { - try self.rt_surface.newTab(); - } else log.warn("runtime doesn't implement newTab", .{}); - }, + .new_tab => try self.rt_app.performAction( + .{ .surface = self }, + .new_tab, + {}, + ), - .previous_tab => { - if (@hasDecl(apprt.Surface, "gotoTab")) { - self.rt_surface.gotoTab(.previous); - } else log.warn("runtime doesn't implement gotoTab", .{}); - }, - - .next_tab => { - if (@hasDecl(apprt.Surface, "gotoTab")) { - self.rt_surface.gotoTab(.next); - } else log.warn("runtime doesn't implement gotoTab", .{}); - }, - - .last_tab => { - if (@hasDecl(apprt.Surface, "gotoTab")) { - self.rt_surface.gotoTab(.last); - } else log.warn("runtime doesn't implement gotoTab", .{}); - }, - - .goto_tab => |n| { - if (@hasDecl(apprt.Surface, "gotoTab")) { - self.rt_surface.gotoTab(@enumFromInt(n)); - } else log.warn("runtime doesn't implement gotoTab", .{}); - }, + inline .previous_tab, + .next_tab, + .last_tab, + .goto_tab, + => |v, tag| try self.rt_app.performAction( + .{ .surface = self }, + .goto_tab, + switch (tag) { + .previous_tab => .previous, + .next_tab => .next, + .last_tab => .last, + .goto_tab => @enumFromInt(v), + else => comptime unreachable, + }, + ), .new_split => |direction| { if (@hasDecl(apprt.Surface, "newSplit")) { diff --git a/src/apprt.zig b/src/apprt.zig index f6952c9ec..21a0e7805 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -12,9 +12,9 @@ const std = @import("std"); const builtin = @import("builtin"); const build_config = @import("build_config.zig"); -const action = @import("apprt/action.zig"); const structs = @import("apprt/structs.zig"); +pub const action = @import("apprt/action.zig"); pub const glfw = @import("apprt/glfw.zig"); pub const gtk = @import("apprt/gtk.zig"); pub const none = @import("apprt/none.zig"); diff --git a/src/apprt/action.zig b/src/apprt/action.zig index e1486130f..3f34ae8d8 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -16,6 +16,15 @@ pub const Action = union(enum) { /// as font size should be inherited. new_window, + /// Open a new tab. If the target is a surface it should be opened in + /// the same window as the surface. If the target is the app then + /// the tab should be opened in a new window. + new_tab, + + /// Jump to a specific tab. Must handle the scenario that the tab + /// value is invalid. + goto_tab: GotoTab, + /// Close all open windows. close_all_windows, @@ -58,3 +67,13 @@ pub const Target = union(enum) { app, surface: *CoreSurface, }; + +/// The tab to jump to. This is non-exhaustive so that integer values represent +/// the index (zero-based) of the tab to jump to. Negative values are special +/// values. +pub const GotoTab = enum(c_int) { + previous = -1, + next = -2, + last = -3, + _, +}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index e247a1934..8c602b5b0 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -83,7 +83,8 @@ pub const App = struct { /// views then this can be null. new_split: ?*const fn (SurfaceUD, apprt.SplitDirection, apprt.Surface.Options) callconv(.C) void = null, - /// New tab with options. + /// New tab with options. The surface may be null if there is no target + /// surface in which case the apprt is expected to create a new window. new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, /// New window with options. The surface may be null if there is no @@ -109,7 +110,7 @@ pub const App = struct { toggle_split_zoom: ?*const fn (SurfaceUD) callconv(.C) void = null, /// Goto tab - goto_tab: ?*const fn (SurfaceUD, apprt.GotoTab) callconv(.C) void = null, + goto_tab: ?*const fn (SurfaceUD, apprt.action.GotoTab) callconv(.C) void = null, /// Toggle fullscreen for current window. toggle_fullscreen: ?*const fn (SurfaceUD, configpkg.NonNativeFullscreen) callconv(.C) void = null, @@ -506,9 +507,36 @@ pub const App = struct { func(null, .{}); } + fn newTab(self: *const App, target: apprt.Target) void { + const func = self.opts.new_tab orelse { + log.info("runtime embedder does not support new_tab", .{}); + return; + }; + + switch (target) { + .app => func(null, .{}), + .surface => |v| func( + v.rt_surface.userdata, + v.rt_surface.newSurfaceOptions(), + ), + } + } + + fn gotoTab(self: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { + const func = self.opts.goto_tab orelse { + log.info("runtime embedder does not support goto_tab", .{}); + return; + }; + + switch (target) { + .app => {}, + .surface => |v| func(v.rt_surface.userdata, tab), + } + } + fn setPasswordInput(self: *App, target: apprt.Target, v: bool) void { const func = self.opts.set_password_input orelse { - log.info("runtime embedder does not set_password_input", .{}); + log.info("runtime embedder does not support set_password_input", .{}); return; }; @@ -531,8 +559,9 @@ pub const App = struct { .surface => |v| v, }), + .new_tab => self.newTab(target), + .goto_tab => self.gotoTab(target, value), .open_config => try configpkg.edit.open(self.core_app.alloc), - .secure_input => self.setPasswordInput(target, value), // Unimplemented @@ -1099,15 +1128,6 @@ pub const Surface = struct { }; } - pub fn gotoTab(self: *Surface, tab: apprt.GotoTab) void { - const func = self.app.opts.goto_tab orelse { - log.info("runtime embedder does not goto_tab", .{}); - return; - }; - - func(self.userdata, tab); - } - pub fn toggleFullscreen(self: *Surface, nonNativeFullscreen: configpkg.NonNativeFullscreen) void { const func = self.app.opts.toggle_fullscreen orelse { log.info("runtime embedder does not toggle_fullscreen", .{}); @@ -1126,16 +1146,6 @@ pub const Surface = struct { func(); } - pub fn newTab(self: *const Surface) !void { - const func = self.app.opts.new_tab orelse { - log.info("runtime embedder does not support new_tab", .{}); - return; - }; - - const options = self.newSurfaceOptions(); - func(self.userdata, options); - } - fn newWindow(self: *const Surface) !void { const func = self.app.opts.new_window orelse { log.info("runtime embedder does not support new_window", .{}); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 439efa3e4..a3854a173 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -142,10 +142,16 @@ pub const App = struct { .surface => |v| v, }), + .new_tab => try self.newTab(switch (target) { + .app => null, + .surface => |v| v, + }), + .open_config => try configpkg.edit.open(self.app.alloc), // Unimplemented .close_all_windows, + .goto_tab, .quit_timer, .secure_input, => log.info("unimplemented action={}", .{action}), @@ -216,12 +222,17 @@ pub const App = struct { } /// Create a new tab in the parent surface. - fn newTab(self: *App, parent: *CoreSurface) !void { + fn newTab(self: *App, parent_: ?*CoreSurface) !void { if (!Darwin.enabled) { log.warn("tabbing is not supported on this platform", .{}); return; } + const parent = parent_ orelse { + _ = try self.newSurface(null); + return; + }; + // Create the new window const window = try self.newSurface(parent); @@ -540,11 +551,6 @@ pub const Surface = struct { } } - /// Create a new tab in the window containing this surface. - pub fn newTab(self: *Surface) !void { - try self.app.newTab(&self.core_surface); - } - /// Checks if the glfw window is in fullscreen. pub fn isFullscreen(self: *Surface) bool { return self.window.getMonitor() != null; diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1e14b1b7c..1982cc497 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -62,16 +62,6 @@ pub const DesktopNotification = struct { body: []const u8, }; -/// The tab to jump to. This is non-exhaustive so that integer values represent -/// the index (zero-based) of the tab to jump to. Negative values are special -/// values. -pub const GotoTab = enum(c_int) { - previous = -1, - next = -2, - last = -3, - _, -}; - // This is made extern (c_int) to make interop easier with our embedded // runtime. The small size cost doesn't make a difference in our union. pub const SplitDirection = enum(c_int) { From 9202cba1f5ac5e7753e9a5669e2f33321d5f5ae1 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 10:05:04 -0700 Subject: [PATCH 087/139] core: many more actions --- src/Surface.zig | 101 ++++++++++-------- src/apprt.zig | 2 - src/apprt/action.zig | 64 +++++++++++- src/apprt/embedded.zig | 227 ++++++++++++++++++++++++++++------------- src/apprt/glfw.zig | 6 ++ src/apprt/structs.zig | 7 -- src/input/Binding.zig | 6 +- 7 files changed, 280 insertions(+), 133 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 309294e61..029a1b221 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -841,7 +841,7 @@ fn passwordInput(self: *Surface, v: bool) !void { self.rt_app.performAction( .{ .surface = self }, .secure_input, - v, + if (v) .on else .off, ) catch |err| { // We ignore this error because we don't want to fail this // entire operation just because the apprt failed to set @@ -3685,44 +3685,55 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool }, ), - .new_split => |direction| { - if (@hasDecl(apprt.Surface, "newSplit")) { - try self.rt_surface.newSplit(switch (direction) { - .right => .right, - .down => .down, - .auto => if (self.screen_size.width > self.screen_size.height) - .right - else - .down, - }); - } else log.warn("runtime doesn't implement newSplit", .{}); - }, + .new_split => |direction| try self.rt_app.performAction( + .{ .surface = self }, + .new_split, + switch (direction) { + .right => .right, + .down => .down, + .auto => if (self.screen_size.width > self.screen_size.height) + .right + else + .down, + }, + ), - .goto_split => |direction| { - if (@hasDecl(apprt.Surface, "gotoSplit")) { - self.rt_surface.gotoSplit(direction); - } else log.warn("runtime doesn't implement gotoSplit", .{}); - }, + .goto_split => |direction| try self.rt_app.performAction( + .{ .surface = self }, + .goto_split, + switch (direction) { + inline else => |tag| @field( + apprt.action.GotoSplit, + @tagName(tag), + ), + }, + ), - .resize_split => |param| { - if (@hasDecl(apprt.Surface, "resizeSplit")) { - const direction = param[0]; - const amount = param[1]; - self.rt_surface.resizeSplit(direction, amount); - } else log.warn("runtime doesn't implement resizeSplit", .{}); - }, + .resize_split => |value| try self.rt_app.performAction( + .{ .surface = self }, + .resize_split, + .{ + .amount = value[1], + .direction = switch (value[0]) { + inline else => |tag| @field( + apprt.action.ResizeSplit.Direction, + @tagName(tag), + ), + }, + }, + ), - .equalize_splits => { - if (@hasDecl(apprt.Surface, "equalizeSplits")) { - self.rt_surface.equalizeSplits(); - } else log.warn("runtime doesn't implement equalizeSplits", .{}); - }, + .equalize_splits => try self.rt_app.performAction( + .{ .surface = self }, + .equalize_splits, + {}, + ), - .toggle_split_zoom => { - if (@hasDecl(apprt.Surface, "toggleSplitZoom")) { - self.rt_surface.toggleSplitZoom(); - } else log.warn("runtime doesn't implement toggleSplitZoom", .{}); - }, + .toggle_split_zoom => try self.rt_app.performAction( + .{ .surface = self }, + .toggle_split_zoom, + {}, + ), .toggle_fullscreen => { if (@hasDecl(apprt.Surface, "toggleFullscreen")) { @@ -3730,17 +3741,17 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } else log.warn("runtime doesn't implement toggleFullscreen", .{}); }, - .toggle_window_decorations => { - if (@hasDecl(apprt.Surface, "toggleWindowDecorations")) { - self.rt_surface.toggleWindowDecorations(); - } else log.warn("runtime doesn't implement toggleWindowDecorations", .{}); - }, + .toggle_window_decorations => try self.rt_app.performAction( + .{ .surface = self }, + .toggle_window_decorations, + {}, + ), - .toggle_secure_input => { - if (@hasDecl(apprt.Surface, "toggleSecureInput")) { - self.rt_surface.toggleSecureInput(); - } else log.warn("runtime doesn't implement toggleSecureInput", .{}); - }, + .toggle_secure_input => try self.rt_app.performAction( + .{ .surface = self }, + .secure_input, + .toggle, + ), .select_all => { const sel = self.io.terminal.screen.selectAll(); diff --git a/src/apprt.zig b/src/apprt.zig index 21a0e7805..7651ace9b 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -32,10 +32,8 @@ pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; pub const CursorPos = structs.CursorPos; pub const DesktopNotification = structs.DesktopNotification; -pub const GotoTab = structs.GotoTab; pub const IMEPos = structs.IMEPos; pub const Selection = structs.Selection; -pub const SplitDirection = structs.SplitDirection; pub const SurfaceSize = structs.SurfaceSize; /// The implementation to use for the app runtime. This is comptime chosen diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 3f34ae8d8..72f0aadd5 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -21,12 +21,32 @@ pub const Action = union(enum) { /// the tab should be opened in a new window. new_tab, + /// Create a new split. The value determines the location of the split + /// relative to the target. + new_split: SplitDirection, + + /// Close all open windows. + close_all_windows, + + /// Toggle whether window directions are shown. + toggle_window_decorations, + /// Jump to a specific tab. Must handle the scenario that the tab /// value is invalid. goto_tab: GotoTab, - /// Close all open windows. - close_all_windows, + /// Jump to a specific split. + goto_split: GotoSplit, + + /// Resize the split in the given direction. + resize_split: ResizeSplit, + + /// Equalize all the splits in the target window. + equalize_splits, + + /// Toggle whether a split is zoomed or not. A zoomed split is resized + /// to take up the entire window. + toggle_split_zoom, /// Open the Ghostty configuration. This is platform-specific about /// what it means; it can mean opening a dedicated UI or just opening @@ -44,7 +64,7 @@ pub const Action = union(enum) { /// entering a password or other sensitive information. This can be used /// by the app runtime to change the appearance of the cursor, setup /// system APIs to not log the input, etc. - secure_input: bool, + secure_input: SecureInput, /// The enum of keys in the tagged union. pub const Key = @typeInfo(Action).Union.tag_type.?; @@ -68,6 +88,38 @@ pub const Target = union(enum) { surface: *CoreSurface, }; +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const SplitDirection = enum(c_int) { + right, + down, +}; + +// This is made extern (c_int) to make interop easier with our embedded +// runtime. The small size cost doesn't make a difference in our union. +pub const GotoSplit = enum(c_int) { + previous, + next, + + top, + left, + bottom, + right, +}; + +/// The amount to resize the split by and the direction to resize it in. +pub const ResizeSplit = struct { + amount: u16, + direction: Direction, + + pub const Direction = enum(c_int) { + up, + down, + left, + right, + }; +}; + /// The tab to jump to. This is non-exhaustive so that integer values represent /// the index (zero-based) of the tab to jump to. Negative values are special /// values. @@ -77,3 +129,9 @@ pub const GotoTab = enum(c_int) { last = -3, _, }; + +pub const SecureInput = enum(c_int) { + on, + off, + toggle, +}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 8c602b5b0..a0e4230be 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -81,7 +81,7 @@ pub const App = struct { /// Create a new split view. If the embedder doesn't support split /// views then this can be null. - new_split: ?*const fn (SurfaceUD, apprt.SplitDirection, apprt.Surface.Options) callconv(.C) void = null, + new_split: ?*const fn (SurfaceUD, apprt.action.SplitDirection, apprt.Surface.Options) callconv(.C) void = null, /// New tab with options. The surface may be null if there is no target /// surface in which case the apprt is expected to create a new window. @@ -98,10 +98,10 @@ pub const App = struct { close_surface: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, /// Focus the previous/next split (if any). - focus_split: ?*const fn (SurfaceUD, input.SplitFocusDirection) callconv(.C) void = null, + focus_split: ?*const fn (SurfaceUD, apprt.action.GotoSplit) callconv(.C) void = null, /// Resize the current split. - resize_split: ?*const fn (SurfaceUD, input.SplitResizeDirection, u16) callconv(.C) void = null, + resize_split: ?*const fn (SurfaceUD, apprt.action.ResizeSplit.Direction, u16) callconv(.C) void = null, /// Equalize all splits in the current window equalize_splits: ?*const fn (SurfaceUD) callconv(.C) void = null, @@ -534,16 +534,113 @@ pub const App = struct { } } - fn setPasswordInput(self: *App, target: apprt.Target, v: bool) void { - const func = self.opts.set_password_input orelse { - log.info("runtime embedder does not support set_password_input", .{}); + fn newSplit( + self: *const App, + target: apprt.Target, + direction: apprt.action.SplitDirection, + ) void { + const func = self.opts.new_split orelse { + log.info("runtime embedder does not support splits", .{}); return; }; - func(switch (target) { - .app => null, - .surface => |surface| surface.rt_surface.userdata, - }, v); + switch (target) { + .app => func(null, direction, .{}), + .surface => |v| func( + v.rt_surface.userdata, + direction, + v.rt_surface.newSurfaceOptions(), + ), + } + } + + fn gotoSplit( + self: *const App, + target: apprt.Target, + direction: apprt.action.GotoSplit, + ) void { + const func = self.opts.focus_split orelse { + log.info("runtime embedder does not support focus split", .{}); + return; + }; + + switch (target) { + .app => {}, + .surface => |v| func(v.rt_surface.userdata, direction), + } + } + + fn resizeSplit( + self: *const App, + target: apprt.Target, + resize: apprt.action.ResizeSplit, + ) void { + const func = self.opts.resize_split orelse { + log.info("runtime embedder does not support resize split", .{}); + return; + }; + + switch (target) { + .app => {}, + .surface => |v| func( + v.rt_surface.userdata, + resize.direction, + resize.amount, + ), + } + } + + pub fn equalizeSplits(self: *const App, target: apprt.Target) void { + const func = self.opts.equalize_splits orelse { + log.info("runtime embedder does not support equalize splits", .{}); + return; + }; + + switch (target) { + .app => func(null), + .surface => |v| func(v.rt_surface.userdata), + } + } + + fn toggleSplitZoom(self: *const App, target: apprt.Target) void { + const func = self.opts.toggle_split_zoom orelse { + log.info("runtime embedder does not support split zoom", .{}); + return; + }; + + switch (target) { + .app => func(null), + .surface => |v| func(v.rt_surface.userdata), + } + } + + fn setPasswordInput(self: *App, target: apprt.Target, v: apprt.action.SecureInput) void { + switch (v) { + inline .on, .off => |tag| { + const func = self.opts.set_password_input orelse { + log.info("runtime embedder does not support set_password_input", .{}); + return; + }; + + func(switch (target) { + .app => null, + .surface => |surface| surface.rt_surface.userdata, + }, switch (tag) { + .on => true, + .off => false, + else => comptime unreachable, + }); + }, + + .toggle => { + const func = self.opts.toggle_secure_input orelse { + log.info("runtime embedder does not support toggle_secure_input", .{}); + return; + }; + + func(); + }, + } } /// Perform a given action. @@ -561,11 +658,17 @@ pub const App = struct { .new_tab => self.newTab(target), .goto_tab => self.gotoTab(target, value), + .new_split => self.newSplit(target, value), + .resize_split => self.resizeSplit(target, value), + .equalize_splits => self.equalizeSplits(target), + .toggle_split_zoom => self.toggleSplitZoom(target), + .goto_split => self.gotoSplit(target, value), .open_config => try configpkg.edit.open(self.core_app.alloc), .secure_input => self.setPasswordInput(target, value), // Unimplemented .close_all_windows, + .toggle_window_decorations, .quit_timer, => log.warn("unimplemented action={}", .{action}), } @@ -795,16 +898,6 @@ pub const Surface = struct { func(self.userdata, mode); } - pub fn newSplit(self: *const Surface, direction: apprt.SplitDirection) !void { - const func = self.app.opts.new_split orelse { - log.info("runtime embedder does not support splits", .{}); - return; - }; - - const options = self.newSurfaceOptions(); - func(self.userdata, direction, options); - } - pub fn close(self: *const Surface, process_alive: bool) void { const func = self.app.opts.close_surface orelse { log.info("runtime embedder does not support closing a surface", .{}); @@ -814,42 +907,6 @@ pub const Surface = struct { func(self.userdata, process_alive); } - pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void { - const func = self.app.opts.focus_split orelse { - log.info("runtime embedder does not support focus split", .{}); - return; - }; - - func(self.userdata, direction); - } - - pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void { - const func = self.app.opts.resize_split orelse { - log.info("runtime embedder does not support resize split", .{}); - return; - }; - - func(self.userdata, direction, amount); - } - - pub fn equalizeSplits(self: *const Surface) void { - const func = self.app.opts.equalize_splits orelse { - log.info("runtime embedder does not support equalize splits", .{}); - return; - }; - - func(self.userdata); - } - - pub fn toggleSplitZoom(self: *const Surface) void { - const func = self.app.opts.toggle_split_zoom orelse { - log.info("runtime embedder does not support split zoom", .{}); - return; - }; - - func(self.userdata); - } - pub fn getContentScale(self: *const Surface) !apprt.ContentScale { return self.content_scale; } @@ -1137,15 +1194,6 @@ pub const Surface = struct { func(self.userdata, nonNativeFullscreen); } - pub fn toggleSecureInput(self: *Surface) void { - const func = self.app.opts.toggle_secure_input orelse { - log.info("runtime embedder does not toggle_secure_input", .{}); - return; - }; - - func(); - } - fn newWindow(self: *const Surface) !void { const func = self.app.opts.new_window orelse { log.info("runtime embedder does not support new_window", .{}); @@ -1899,26 +1947,61 @@ pub const CAPI = struct { } /// Request that the surface split in the given direction. - export fn ghostty_surface_split(ptr: *Surface, direction: apprt.SplitDirection) void { - ptr.newSplit(direction) catch {}; + export fn ghostty_surface_split(ptr: *Surface, direction: apprt.action.SplitDirection) void { + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .new_split, + direction, + ) catch |err| { + log.err("error creating new split err={}", .{err}); + return; + }; } /// Focus on the next split (if any). - export fn ghostty_surface_split_focus(ptr: *Surface, direction: input.SplitFocusDirection) void { - ptr.gotoSplit(direction); + export fn ghostty_surface_split_focus( + ptr: *Surface, + direction: apprt.action.GotoSplit, + ) void { + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .goto_split, + direction, + ) catch |err| { + log.err("error creating new split err={}", .{err}); + return; + }; } /// Resize the current split by moving the split divider in the given /// direction. `direction` specifies which direction the split divider will /// move relative to the focused split. `amount` is a fractional value /// between 0 and 1 that specifies by how much the divider will move. - export fn ghostty_surface_split_resize(ptr: *Surface, direction: input.SplitResizeDirection, amount: u16) void { - ptr.resizeSplit(direction, amount); + export fn ghostty_surface_split_resize( + ptr: *Surface, + direction: apprt.action.ResizeSplit.Direction, + amount: u16, + ) void { + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .resize_split, + .{ .direction = direction, .amount = amount }, + ) catch |err| { + log.err("error resizing split err={}", .{err}); + return; + }; } /// Equalize the size of all splits in the current window. export fn ghostty_surface_split_equalize(ptr: *Surface) void { - ptr.equalizeSplits(); + ptr.app.performAction( + .{ .surface = &ptr.core_surface }, + .equalize_splits, + {}, + ) catch |err| { + log.err("error equalizing splits err={}", .{err}); + return; + }; } /// Invoke an action on the surface. diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index a3854a173..dabf63c2b 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -150,7 +150,13 @@ pub const App = struct { .open_config => try configpkg.edit.open(self.app.alloc), // Unimplemented + .new_split, + .goto_split, + .resize_split, + .equalize_splits, + .toggle_split_zoom, .close_all_windows, + .toggle_window_decorations, .goto_tab, .quit_timer, .secure_input, diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index 1982cc497..f8bcd72cf 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -62,13 +62,6 @@ pub const DesktopNotification = struct { body: []const u8, }; -// This is made extern (c_int) to make interop easier with our embedded -// runtime. The small size cost doesn't make a difference in our union. -pub const SplitDirection = enum(c_int) { - right, - down, -}; - /// The color scheme in use (light vs dark). pub const ColorScheme = enum(u2) { light = 0, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 8f129065d..f39510554 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -410,8 +410,7 @@ pub const Action = union(enum) { // Note: we don't support top or left yet }; - // Extern because it is used in the embedded runtime ABI. - pub const SplitFocusDirection = enum(c_int) { + pub const SplitFocusDirection = enum { previous, next, @@ -421,8 +420,7 @@ pub const Action = union(enum) { right, }; - // Extern because it is used in the embedded runtime ABI. - pub const SplitResizeDirection = enum(c_int) { + pub const SplitResizeDirection = enum { up, down, left, From e29918ebb849f8502ac490b6f87490d7fce5050f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 10:20:40 -0700 Subject: [PATCH 088/139] core: more actions --- src/Surface.zig | 51 +++++++++++++--------- src/apprt/action.zig | 35 +++++++++++++++ src/apprt/embedded.zig | 96 +++++++++++++++++++++++++++--------------- src/apprt/glfw.zig | 15 ++++--- src/input/Binding.zig | 2 +- 5 files changed, 140 insertions(+), 59 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 029a1b221..298bb6a0a 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3735,11 +3735,15 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), - .toggle_fullscreen => { - if (@hasDecl(apprt.Surface, "toggleFullscreen")) { - self.rt_surface.toggleFullscreen(self.config.macos_non_native_fullscreen); - } else log.warn("runtime doesn't implement toggleFullscreen", .{}); - }, + .toggle_fullscreen => try self.rt_app.performAction( + .{ .surface = self }, + .toggle_fullscreen, + switch (self.config.macos_non_native_fullscreen) { + .false => .native, + .true => .macos_non_native, + .@"visible-menu" => .macos_non_native_visible_menu, + }, + ), .toggle_window_decorations => try self.rt_app.performAction( .{ .surface = self }, @@ -3761,11 +3765,16 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool } }, - .inspector => |mode| { - if (@hasDecl(apprt.Surface, "controlInspector")) { - self.rt_surface.controlInspector(mode); - } else log.warn("runtime doesn't implement controlInspector", .{}); - }, + .inspector => |mode| try self.rt_app.performAction( + .{ .surface = self }, + .inspector, + switch (mode) { + inline else => |tag| @field( + apprt.action.Inspector, + @tagName(tag), + ), + }, + ), .close_surface => self.close(), @@ -4152,11 +4161,6 @@ fn completeClipboardReadOSC52( } fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const u8) !void { - if (comptime !@hasDecl(apprt.Surface, "showDesktopNotification")) { - log.warn("runtime doesn't support desktop notifications", .{}); - return; - } - // Wyhash is used to hash the contents of the desktop notification to limit // how fast identical notifications can be sent sequentially. const hash_algorithm = std.hash.Wyhash; @@ -4192,7 +4196,14 @@ fn showDesktopNotification(self: *Surface, title: [:0]const u8, body: [:0]const self.app.last_notification_time = now; self.app.last_notification_digest = new_digest; - try self.rt_surface.showDesktopNotification(title, body); + try self.rt_app.performAction( + .{ .surface = self }, + .desktop_notification, + .{ + .title = title, + .body = body, + }, + ); } fn crashThreadState(self: *Surface) crash.sentry.ThreadState { @@ -4205,9 +4216,11 @@ fn crashThreadState(self: *Surface) crash.sentry.ThreadState { /// Tell the surface to present itself to the user. This may involve raising the /// window and switching tabs. fn presentSurface(self: *Surface) !void { - if (@hasDecl(apprt.Surface, "presentSurface")) { - self.rt_surface.presentSurface(); - } else log.warn("runtime doesn't support presentSurface", .{}); + try self.rt_app.performAction( + .{ .surface = self }, + .present_terminal, + {}, + ); } pub const face_ttf = @embedFile("font/res/JetBrainsMono-Regular.ttf"); diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 72f0aadd5..35bdcb3c2 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -28,6 +28,9 @@ pub const Action = union(enum) { /// Close all open windows. close_all_windows, + /// Toggle fullscreen mode. + toggle_fullscreen: Fullscreen, + /// Toggle whether window directions are shown. toggle_window_decorations, @@ -48,6 +51,15 @@ pub const Action = union(enum) { /// to take up the entire window. toggle_split_zoom, + /// Present the target terminal whether its a tab, split, or window. + present_terminal, + + /// Control whether the inspector is shown or hidden. + inspector: Inspector, + + /// Show a desktop notification. + desktop_notification: DesktopNotification, + /// Open the Ghostty configuration. This is platform-specific about /// what it means; it can mean opening a dedicated UI or just opening /// a file in a text editor. @@ -130,8 +142,31 @@ pub const GotoTab = enum(c_int) { _, }; +/// The fullscreen mode to toggle to if we're moving to fullscreen. +pub const Fullscreen = enum(c_int) { + native, + + /// macOS has a non-native fullscreen mode that is more like a maximized + /// window. This is much faster to enter and exit than the native mode. + macos_non_native, + macos_non_native_visible_menu, +}; + pub const SecureInput = enum(c_int) { on, off, toggle, }; + +/// The inspector mode to toggle to if we're toggling the inspector. +pub const Inspector = enum(c_int) { + toggle, + show, + hide, +}; + +/// The desktop notification to show. +pub const DesktopNotification = struct { + title: [:0]const u8, + body: [:0]const u8, +}; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index a0e4230be..ca53f137e 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -92,7 +92,7 @@ pub const App = struct { new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, /// Control the inspector visibility - control_inspector: ?*const fn (SurfaceUD, input.InspectorMode) callconv(.C) void = null, + control_inspector: ?*const fn (SurfaceUD, apprt.action.Inspector) callconv(.C) void = null, /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, @@ -125,7 +125,8 @@ 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 a desktop notification to the user. The surface may be null + /// if the notification is global. show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null, /// Called when the health of the renderer changes. @@ -507,6 +508,29 @@ pub const App = struct { func(null, .{}); } + fn toggleFullscreen( + self: *App, + target: apprt.Target, + fullscreen: apprt.action.Fullscreen, + ) void { + const func = self.opts.toggle_fullscreen orelse { + log.info("runtime embedder does not toggle_fullscreen", .{}); + return; + }; + + switch (target) { + .app => {}, + .surface => |v| func( + v.rt_surface.userdata, + switch (fullscreen) { + .native => .false, + .macos_non_native => .true, + .macos_non_native_visible_menu => .@"visible-menu", + }, + ), + } + } + fn newTab(self: *const App, target: apprt.Target) void { const func = self.opts.new_tab orelse { log.info("runtime embedder does not support new_tab", .{}); @@ -614,6 +638,38 @@ pub const App = struct { } } + fn controlInspector( + self: *const App, + target: apprt.Target, + value: apprt.action.Inspector, + ) void { + const func = self.opts.control_inspector orelse { + log.info("runtime embedder does not support the terminal inspector", .{}); + return; + }; + + switch (target) { + .app => {}, + .surface => |v| func(v.rt_surface.userdata, value), + } + } + + fn showDesktopNotification( + self: *const App, + target: apprt.Target, + notification: apprt.action.DesktopNotification, + ) void { + const func = self.opts.show_desktop_notification orelse { + log.info("runtime embedder does not support show_desktop_notification", .{}); + return; + }; + + func(switch (target) { + .app => null, + .surface => |v| v.rt_surface.userdata, + }, notification.title, notification.body); + } + fn setPasswordInput(self: *App, target: apprt.Target, v: apprt.action.SecureInput) void { switch (v) { inline .on, .off => |tag| { @@ -655,6 +711,7 @@ pub const App = struct { .app => null, .surface => |v| v, }), + .toggle_fullscreen => self.toggleFullscreen(target, value), .new_tab => self.newTab(target), .goto_tab => self.gotoTab(target, value), @@ -664,9 +721,12 @@ pub const App = struct { .toggle_split_zoom => self.toggleSplitZoom(target), .goto_split => self.gotoSplit(target, value), .open_config => try configpkg.edit.open(self.core_app.alloc), + .inspector => self.controlInspector(target, value), + .desktop_notification => self.showDesktopNotification(target, value), .secure_input => self.setPasswordInput(target, value), // Unimplemented + .present_terminal, .close_all_windows, .toggle_window_decorations, .quit_timer, @@ -889,15 +949,6 @@ pub const Surface = struct { } } - pub fn controlInspector(self: *const Surface, mode: input.InspectorMode) void { - const func = self.app.opts.control_inspector orelse { - log.info("runtime embedder does not support the terminal inspector", .{}); - return; - }; - - func(self.userdata, mode); - } - pub fn close(self: *const Surface, process_alive: bool) void { const func = self.app.opts.close_surface orelse { log.info("runtime embedder does not support closing a surface", .{}); @@ -1185,15 +1236,6 @@ pub const Surface = struct { }; } - pub fn toggleFullscreen(self: *Surface, nonNativeFullscreen: configpkg.NonNativeFullscreen) void { - const func = self.app.opts.toggle_fullscreen orelse { - log.info("runtime embedder does not toggle_fullscreen", .{}); - return; - }; - - func(self.userdata, nonNativeFullscreen); - } - fn newWindow(self: *const Surface) !void { const func = self.app.opts.new_window orelse { log.info("runtime embedder does not support new_window", .{}); @@ -1249,20 +1291,6 @@ pub const Surface = struct { 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.userdata, title, body); - } - /// Update the health of the renderer. pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { const func = self.app.opts.update_renderer_health orelse { diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index dabf63c2b..4be6aaf89 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -147,6 +147,8 @@ pub const App = struct { .surface => |v| v, }), + .toggle_fullscreen => self.toggleFullscreen(target), + .open_config => try configpkg.edit.open(self.app.alloc), // Unimplemented @@ -155,11 +157,14 @@ pub const App = struct { .resize_split, .equalize_splits, .toggle_split_zoom, + .present_terminal, .close_all_windows, .toggle_window_decorations, .goto_tab, + .inspector, .quit_timer, .secure_input, + .desktop_notification, => log.info("unimplemented action={}", .{action}), } } @@ -182,8 +187,12 @@ pub const App = struct { } /// Toggle the window to fullscreen mode. - pub fn toggleFullscreen(self: *App, surface: *Surface) void { + fn toggleFullscreen(self: *App, target: apprt.Target) void { _ = self; + const surface: *Surface = switch (target) { + .app => return, + .surface => |v| v.rt_surface, + }; const win = surface.window; if (surface.isFullscreen()) { @@ -562,10 +571,6 @@ pub const Surface = struct { return self.window.getMonitor() != null; } - pub fn toggleFullscreen(self: *Surface, _: Config.NonNativeFullscreen) void { - self.app.toggleFullscreen(self); - } - /// Close this surface. pub fn close(self: *Surface, processActive: bool) void { _ = processActive; diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f39510554..45ec24126 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -438,7 +438,7 @@ pub const Action = union(enum) { }; // Extern because it is used in the embedded runtime ABI. - pub const InspectorMode = enum(c_int) { + pub const InspectorMode = enum { toggle, show, hide, From 4e2781fdec96f1784f02da94de990bfbff0d090a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 10:35:31 -0700 Subject: [PATCH 089/139] apprt/gtk --- src/apprt/action.zig | 23 ++-- src/apprt/gtk/App.zig | 255 ++++++++++++++++++++++++++++++++++++-- src/apprt/gtk/Split.zig | 17 +-- src/apprt/gtk/Surface.zig | 90 +------------- src/apprt/gtk/Window.zig | 2 +- 5 files changed, 279 insertions(+), 108 deletions(-) diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 35bdcb3c2..edeb02d7a 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -2,6 +2,14 @@ const std = @import("std"); const assert = std.debug.assert; const CoreSurface = @import("../Surface.zig"); +/// The target for an action. This is generally the thing that had focus +/// while the action was made but the concept of "focus" is not guaranteed +/// since actions can also be triggered by timers, scripts, etc. +pub const Target = union(enum) { + app, + surface: *CoreSurface, +}; + /// The possible actions an apprt has to react to. Actions are one-way /// messages that are sent to the app runtime to trigger some behavior. /// @@ -69,7 +77,7 @@ pub const Action = union(enum) { /// after the configured delay. This can be cancelled by sending /// another quit_timer action with "stop". Multiple "starts" shouldn't /// happen and can be ignored or cause a restart it isn't that important. - quit_timer: enum { start, stop }, + quit_timer: QuitTimer, /// Set the secure input functionality on or off. "Secure input" means /// that the user is currently at some sort of prompt where they may be @@ -92,14 +100,6 @@ pub const Action = union(enum) { } }; -/// The target for an action. This is generally the thing that had focus -/// while the action was made but the concept of "focus" is not guaranteed -/// since actions can also be triggered by timers, scripts, etc. -pub const Target = union(enum) { - app, - surface: *CoreSurface, -}; - // This is made extern (c_int) to make interop easier with our embedded // runtime. The small size cost doesn't make a difference in our union. pub const SplitDirection = enum(c_int) { @@ -165,6 +165,11 @@ pub const Inspector = enum(c_int) { hide, }; +pub const QuitTimer = enum(c_int) { + start, + stop, +}; + /// The desktop notification to show. pub const DesktopNotification = struct { title: [:0]const u8, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 73c25ec87..e5a51d66d 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -27,6 +27,7 @@ const Surface = @import("Surface.zig"); const Window = @import("Window.zig"); const ConfigErrorsWindow = @import("ConfigErrorsWindow.zig"); const ClipboardConfirmationWindow = @import("ClipboardConfirmationWindow.zig"); +const Split = @import("Split.zig"); const c = @import("c.zig").c; const inspector = @import("inspector.zig"); const key = @import("key.zig"); @@ -341,9 +342,249 @@ pub fn terminate(self: *App) void { self.config.deinit(); } -/// Open the configuration in the system editor. -pub fn openConfig(self: *App) !void { - try configpkg.edit.open(self.core_app.alloc); +/// Perform a given action. +pub fn performAction( + self: *App, + target: apprt.Target, + comptime action: apprt.Action.Key, + value: apprt.Action.Value(action), +) !void { + switch (action) { + .new_window => _ = try self.newWindow(switch (target) { + .app => null, + .surface => |v| v, + }), + .toggle_fullscreen => self.toggleFullscreen(target, value), + + .new_tab => try self.newTab(target), + .goto_tab => self.gotoTab(target, value), + .new_split => try self.newSplit(target, value), + .resize_split => self.resizeSplit(target, value), + .equalize_splits => self.equalizeSplits(target), + .goto_split => self.gotoSplit(target, value), + .open_config => try configpkg.edit.open(self.core_app.alloc), + .inspector => self.controlInspector(target, value), + .desktop_notification => self.showDesktopNotification(target, value), + .present_terminal => self.presentTerminal(target), + .toggle_window_decorations => self.toggleWindowDecorations(target), + .quit_timer => self.quitTimer(value), + + // Unimplemented + .close_all_windows, + .toggle_split_zoom, + .secure_input, + => log.warn("unimplemented action={}", .{action}), + } +} + +fn newTab(_: *App, target: apprt.Target) !void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "new_tab invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + try window.newTab(v); + }, + } +} + +fn gotoTab(_: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "gotoTab invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + switch (tab) { + .previous => window.gotoPreviousTab(v.rt_surface), + .next => window.gotoNextTab(v.rt_surface), + .last => window.gotoLastTab(), + else => window.gotoTab(@intCast(@intFromEnum(tab))), + } + }, + } +} + +fn newSplit( + self: *App, + target: apprt.Target, + direction: apprt.action.SplitDirection, +) !void { + switch (target) { + .app => {}, + .surface => |v| { + const alloc = self.core_app.alloc; + _ = try Split.create(alloc, v.rt_surface, direction); + }, + } +} + +fn equalizeSplits(_: *App, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| { + const tab = v.rt_surface.container.tab() orelse return; + const top_split = switch (tab.elem) { + .split => |s| s, + else => return, + }; + _ = top_split.equalize(); + }, + } +} + +fn gotoSplit( + _: *const App, + target: apprt.Target, + direction: apprt.action.GotoSplit, +) void { + switch (target) { + .app => {}, + .surface => |v| { + const s = v.rt_surface.container.split() orelse return; + const map = s.directionMap(switch (v.rt_surface.container) { + .split_tl => .top_left, + .split_br => .bottom_right, + .none, .tab_ => unreachable, + }); + const surface_ = map.get(direction) orelse return; + if (surface_) |surface| surface.grabFocus(); + }, + } +} + +fn resizeSplit( + _: *const App, + target: apprt.Target, + resize: apprt.action.ResizeSplit, +) void { + switch (target) { + .app => {}, + .surface => |v| { + const s = v.rt_surface.container.firstSplitWithOrientation( + Split.Orientation.fromResizeDirection(resize.direction), + ) orelse return; + s.moveDivider(resize.direction, resize.amount); + }, + } +} + +fn presentTerminal( + _: *const App, + target: apprt.Target, +) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.present(), + } +} + +fn controlInspector( + _: *const App, + target: apprt.Target, + mode: apprt.action.Inspector, +) void { + const surface: *Surface = switch (target) { + .app => return, + .surface => |v| v.rt_surface, + }; + + surface.controlInspector(mode); +} + +fn toggleFullscreen( + _: *App, + target: apprt.Target, + _: apprt.action.Fullscreen, +) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "toggleFullscreen invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + window.toggleFullscreen(); + }, + } +} + +fn toggleWindowDecorations( + _: *App, + target: apprt.Target, +) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "toggleFullscreen invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + window.toggleWindowDecorations(); + }, + } +} + +fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { + switch (mode) { + .start => self.startQuitTimer(), + .stop => self.stopQuitTimer(), + } +} + +fn showDesktopNotification( + self: *App, + target: apprt.Target, + n: apprt.action.DesktopNotification, +) void { + // Set a default title if we don't already have one + const t = switch (n.title.len) { + 0 => "Ghostty", + else => n.title, + }; + + const notification = c.g_notification_new(t.ptr); + defer c.g_object_unref(notification); + c.g_notification_set_body(notification, n.body.ptr); + + const icon = c.g_themed_icon_new("com.mitchellh.ghostty"); + defer c.g_object_unref(icon); + c.g_notification_set_icon(notification, icon); + + const pointer = c.g_variant_new_uint64(switch (target) { + .app => 0, + .surface => |v| @intFromPtr(v), + }); + c.g_notification_set_default_action_and_target_value( + notification, + "app.present-surface", + pointer, + ); + + const g_app: *c.GApplication = @ptrCast(self.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, n.body.ptr, notification); } /// Reload the configuration. This should return the new configuration. @@ -565,9 +806,9 @@ pub fn gtkQuitTimerExpired(ud: ?*anyopaque) callconv(.C) c.gboolean { } /// This will get called when there are no more open surfaces. -pub fn startQuitTimer(self: *App) void { +fn startQuitTimer(self: *App) void { // Cancel any previous timer. - self.cancelQuitTimer(); + self.stopQuitTimer(); // This is a no-op unless we are configured to quit after last window is closed. if (!self.config.@"quit-after-last-window-closed") return; @@ -582,7 +823,7 @@ pub fn startQuitTimer(self: *App) void { } /// This will get called when a new surface gets opened. -pub fn cancelQuitTimer(self: *App) void { +fn stopQuitTimer(self: *App) void { switch (self.quit_timer) { .off => {}, .expired => self.quit_timer = .{ .off = {} }, @@ -608,7 +849,7 @@ pub fn redrawInspector(self: *App, surface: *Surface) void { } /// Called by CoreApp to create a new window with a new surface. -pub fn newWindow(self: *App, parent_: ?*CoreSurface) !void { +fn newWindow(self: *App, parent_: ?*CoreSurface) !void { const alloc = self.core_app.alloc; // Allocate a fixed pointer for our window. We try to minimize diff --git a/src/apprt/gtk/Split.zig b/src/apprt/gtk/Split.zig index 105646c7c..7a3645d1b 100644 --- a/src/apprt/gtk/Split.zig +++ b/src/apprt/gtk/Split.zig @@ -7,7 +7,6 @@ const Allocator = std.mem.Allocator; const assert = std.debug.assert; const apprt = @import("../../apprt.zig"); const font = @import("../../font/main.zig"); -const input = @import("../../input.zig"); const CoreSurface = @import("../../Surface.zig"); const Surface = @import("Surface.zig"); @@ -21,14 +20,14 @@ pub const Orientation = enum { horizontal, vertical, - pub fn fromDirection(direction: apprt.SplitDirection) Orientation { + pub fn fromDirection(direction: apprt.action.SplitDirection) Orientation { return switch (direction) { .right => .horizontal, .down => .vertical, }; } - pub fn fromResizeDirection(direction: input.SplitResizeDirection) Orientation { + pub fn fromResizeDirection(direction: apprt.action.ResizeSplit.Direction) Orientation { return switch (direction) { .up, .down => .vertical, .left, .right => .horizontal, @@ -58,7 +57,7 @@ bottom_right: Surface.Container.Elem, pub fn create( alloc: Allocator, sibling: *Surface, - direction: apprt.SplitDirection, + direction: apprt.action.SplitDirection, ) !*Split { var split = try alloc.create(Split); errdefer alloc.destroy(split); @@ -69,7 +68,7 @@ pub fn create( pub fn init( self: *Split, sibling: *Surface, - direction: apprt.SplitDirection, + direction: apprt.action.SplitDirection, ) !void { // Create the new child surface for the other direction. const alloc = sibling.app.core_app.alloc; @@ -164,7 +163,11 @@ fn removeChild( } /// Move the divider in the given direction by the given amount. -pub fn moveDivider(self: *Split, direction: input.SplitResizeDirection, amount: u16) void { +pub fn moveDivider( + self: *Split, + direction: apprt.action.ResizeSplit.Direction, + amount: u16, +) void { const min_pos = 10; const pos = c.gtk_paned_get_position(self.paned); @@ -263,7 +266,7 @@ fn updateChildren(self: *const Split) void { /// A mapping of direction to the element (if any) in that direction. pub const DirectionMap = std.EnumMap( - input.SplitFocusDirection, + apprt.action.GotoSplit, ?*Surface, ); diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index caa4653f0..657b6abf4 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -688,7 +688,10 @@ pub fn close(self: *Surface, processActive: bool) void { c.gtk_widget_show(alert); } -pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void { +pub fn controlInspector( + self: *Surface, + mode: apprt.action.Inspector, +) void { const show = switch (mode) { .toggle => self.inspector == null, .show => true, @@ -715,30 +718,6 @@ pub fn controlInspector(self: *Surface, mode: input.InspectorMode) void { }; } -pub fn toggleFullscreen(self: *Surface, mac_non_native: configpkg.NonNativeFullscreen) void { - const window = self.container.window() orelse { - log.info( - "toggleFullscreen invalid for container={s}", - .{@tagName(self.container)}, - ); - return; - }; - - window.toggleFullscreen(mac_non_native); -} - -pub fn toggleWindowDecorations(self: *Surface) void { - const window = self.container.window() orelse { - log.info( - "toggleWindowDecorations invalid for container={s}", - .{@tagName(self.container)}, - ); - return; - }; - - window.toggleWindowDecorations(); -} - pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget { switch (self.title) { .none => return null, @@ -749,64 +728,6 @@ pub fn getTitleLabel(self: *Surface) ?*c.GtkWidget { } } -pub fn newSplit(self: *Surface, direction: apprt.SplitDirection) !void { - const alloc = self.app.core_app.alloc; - _ = try Split.create(alloc, self, direction); -} - -pub fn gotoSplit(self: *const Surface, direction: input.SplitFocusDirection) void { - const s = self.container.split() orelse return; - const map = s.directionMap(switch (self.container) { - .split_tl => .top_left, - .split_br => .bottom_right, - .none, .tab_ => unreachable, - }); - const surface_ = map.get(direction) orelse return; - if (surface_) |surface| surface.grabFocus(); -} - -pub fn resizeSplit(self: *const Surface, direction: input.SplitResizeDirection, amount: u16) void { - const s = self.container.firstSplitWithOrientation( - Split.Orientation.fromResizeDirection(direction), - ) orelse return; - s.moveDivider(direction, amount); -} - -pub fn equalizeSplits(self: *const Surface) void { - const tab = self.container.tab() orelse return; - const top_split = switch (tab.elem) { - .split => |s| s, - else => return, - }; - _ = top_split.equalize(); -} - -pub fn newTab(self: *Surface) !void { - const window = self.container.window() orelse { - log.info("surface cannot create new tab when not attached to a window", .{}); - return; - }; - - try window.newTab(&self.core_surface); -} - -pub fn gotoTab(self: *Surface, tab: apprt.GotoTab) void { - const window = self.container.window() orelse { - log.info( - "gotoTab invalid for container={s}", - .{@tagName(self.container)}, - ); - return; - }; - - switch (tab) { - .previous => window.gotoPreviousTab(self), - .next => window.gotoNextTab(self), - .last => window.gotoLastTab(), - else => window.gotoTab(@intCast(@intFromEnum(tab))), - } -} - pub fn setShouldClose(self: *Surface) void { _ = self; } @@ -1975,7 +1896,7 @@ fn translateMods(state: c.GdkModifierType) input.Mods { return mods; } -pub fn presentSurface(self: *Surface) void { +pub fn present(self: *Surface) void { if (self.container.window()) |window| { if (self.container.tab()) |tab| { if (window.notebook.getTabPosition(tab)) |position| @@ -1983,5 +1904,6 @@ pub fn presentSurface(self: *Surface) void { } c.gtk_window_present(window.window); } + self.grabFocus(); } diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index b6f896592..80bbd0944 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -459,7 +459,7 @@ pub fn gotoTab(self: *Window, n: usize) void { } /// Toggle fullscreen for this window. -pub fn toggleFullscreen(self: *Window, _: configpkg.NonNativeFullscreen) void { +pub fn toggleFullscreen(self: *Window) void { const is_fullscreen = c.gtk_window_is_fullscreen(self.window); if (is_fullscreen == 0) { c.gtk_window_fullscreen(self.window); From 4cc4eb5ed033bb578f0d43e0c9ba840871a725f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 14:21:01 -0700 Subject: [PATCH 090/139] core: remove more hasdecls --- src/Surface.zig | 13 +++---------- src/apprt/glfw.zig | 18 +++++++++++++++++- src/apprt/gtk/Surface.zig | 20 ++++++++++++++++++++ 3 files changed, 40 insertions(+), 11 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 298bb6a0a..ed83b2af8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -747,11 +747,7 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { }, .report_title => |style| { - const title: ?[:0]const u8 = title: { - if (!@hasDecl(apprt.runtime.Surface, "getTitle")) break :title null; - break :title self.rt_surface.getTitle(); - }; - + const title: ?[:0]const u8 = self.rt_surface.getTitle(); const data = switch (style) { .csi_21_t => try std.fmt.allocPrint( self.alloc, @@ -901,7 +897,6 @@ fn modsChanged(self: *Surface, mods: input.Mods) void { /// Called when our renderer health state changes. fn updateRendererHealth(self: *Surface, health: renderer.Health) void { log.warn("renderer health status change status={}", .{health}); - if (!@hasDecl(apprt.runtime.Surface, "updateRendererHealth")) return; self.rt_surface.updateRendererHealth(health); } @@ -1158,10 +1153,8 @@ fn setSelection(self: *Surface, sel_: ?terminal.Selection) !void { // Check if our runtime supports the selection clipboard at all. // We can save a lot of work if it doesn't. - if (@hasDecl(apprt.runtime.Surface, "supportsClipboard")) { - if (!self.rt_surface.supportsClipboard(clipboard)) { - return; - } + if (!self.rt_surface.supportsClipboard(clipboard)) { + return; } const buf = self.io.terminal.screen.selectionString(self.alloc, .{ diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 4be6aaf89..b73aefced 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -411,7 +411,6 @@ pub const Surface = struct { /// Initialize the surface into the given self pointer. This gives a /// stable pointer to the destination that can be used for callbacks. pub fn init(self: *Surface, app: *App) !void { - // Create our window const win = glfw.Window.create( 640, @@ -715,6 +714,23 @@ pub const Surface = struct { self.window.setInputModeCursor(if (visible) .normal else .hidden); } + pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { + // We don't support this in GLFW. + _ = self; + _ = health; + } + + pub fn supportsClipboard( + self: *const Surface, + clipboard_type: apprt.Clipboard, + ) bool { + _ = self; + return switch (clipboard_type) { + .standard => true, + .selection, .primary => comptime builtin.os.tag == .linux, + }; + } + /// Start an async clipboard request. pub fn clipboardRequest( self: *Surface, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 657b6abf4..054eb675d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -9,6 +9,7 @@ const configpkg = @import("../../config.zig"); const apprt = @import("../../apprt.zig"); const font = @import("../../font/main.zig"); const input = @import("../../input.zig"); +const renderer = @import("../../renderer.zig"); const terminal = @import("../../terminal/main.zig"); const CoreSurface = @import("../../Surface.zig"); const internal_os = @import("../../os/main.zig"); @@ -942,6 +943,19 @@ pub fn mouseOverLink(self: *Surface, uri_: ?[]const u8) void { self.url_widget = URLWidget.init(self, uriZ); } +pub fn supportsClipboard( + self: *const Surface, + clipboard_type: apprt.Clipboard, +) bool { + _ = self; + return switch (clipboard_type) { + .standard, + .selection, + .primary, + => true, + }; +} + pub fn clipboardRequest( self: *Surface, clipboard_type: apprt.Clipboard, @@ -1907,3 +1921,9 @@ pub fn present(self: *Surface) void { self.grabFocus(); } + +pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { + // We don't support this in GTK. + _ = self; + _ = health; +} From 3b419a2ba21bb5a359c18e966c0847f2bdb63daa Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 14:40:24 -0700 Subject: [PATCH 091/139] apprt/gtk: handle zero value notification pointers --- src/apprt/gtk/App.zig | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index e5a51d66d..1d97731fa 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -1098,8 +1098,12 @@ fn gtkActionPresentSurface( return; } - // Convert that u64 to pointer to a core surface. - const surface: *CoreSurface = @ptrFromInt(c.g_variant_get_uint64(parameter)); + // Convert that u64 to pointer to a core surface. A value of zero + // means that there was no target surface for the notification so + // we dont' focus any surface. + const ptr_int: u64 = c.g_variant_get_uint64(parameter); + if (ptr_int == 0) return; + const surface: *CoreSurface = @ptrFromInt(ptr_int); // Send a message through the core app mailbox rather than presenting the // surface directly so that it can validate that the surface pointer is From 196c9dc3ba39e2042bd3f8498ad948b7267ac874 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Thu, 26 Sep 2024 17:24:16 -0500 Subject: [PATCH 092/139] nix: fix up zig hook in package.nix --- nix/package.nix | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nix/package.nix b/nix/package.nix index 47bf5ac48..b8d69eef9 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -34,7 +34,7 @@ # https://github.com/ziglang/zig/issues/14281#issuecomment-1624220653 is # ultimately acted on and has made its way to a nixpkgs implementation, this # can probably be removed in favor of that. - zig012Hook = zig_0_13.hook.overrideAttrs { + zig_hook = zig_0_13.hook.overrideAttrs { zig_default_flags = "-Dcpu=baseline -Doptimize=${optimize}"; }; @@ -79,7 +79,7 @@ name = "ghostty-cache"; nativeBuildInputs = [ git - zig_0_13.hook + zig_hook ]; dontConfigure = true; @@ -117,7 +117,7 @@ in ncurses pandoc pkg-config - zig012Hook + zig_hook wrapGAppsHook4 ]; From 8b6e99348143f26cc7b5636845c70cf7f0e3a681 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 00:40:37 +0000 Subject: [PATCH 093/139] build(deps): bump namespacelabs/nscloud-cache-action from 1.1.7 to 1.1.8 Bumps [namespacelabs/nscloud-cache-action](https://github.com/namespacelabs/nscloud-cache-action) from 1.1.7 to 1.1.8. - [Release notes](https://github.com/namespacelabs/nscloud-cache-action/releases) - [Commits](https://github.com/namespacelabs/nscloud-cache-action/compare/v1.1.7...v1.1.8) --- updated-dependencies: - dependency-name: namespacelabs/nscloud-cache-action dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/test.yml | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 91bfd679c..821e3d22c 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -11,7 +11,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d887699a9..370b5361a 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -32,7 +32,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -64,7 +64,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -93,7 +93,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -126,7 +126,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -305,7 +305,7 @@ jobs: uses: actions/checkout@v4 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -364,7 +364,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -391,7 +391,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix @@ -418,7 +418,7 @@ jobs: steps: - uses: actions/checkout@v4 # Check out repo so we can lint it - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@v1.1.7 + uses: namespacelabs/nscloud-cache-action@v1.1.8 with: path: | /nix From 998827e4c666300e6a2645690a9e3ec99b357369 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 27 Sep 2024 00:40:39 +0000 Subject: [PATCH 094/139] build(deps): bump cachix/install-nix-action from V28 to 29 Bumps [cachix/install-nix-action](https://github.com/cachix/install-nix-action) from V28 to 29. This release includes the previously tagged commit. - [Release notes](https://github.com/cachix/install-nix-action/releases) - [Commits](https://github.com/cachix/install-nix-action/compare/V28...v29) --- updated-dependencies: - dependency-name: cachix/install-nix-action dependency-type: direct:production ... Signed-off-by: dependabot[bot] --- .github/workflows/nix.yml | 2 +- .github/workflows/release-pr.yml | 4 ++-- .github/workflows/release-tip.yml | 6 +++--- .github/workflows/test.yml | 22 +++++++++++----------- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml index 91bfd679c..5190b8643 100644 --- a/.github/workflows/nix.yml +++ b/.github/workflows/nix.yml @@ -17,7 +17,7 @@ jobs: /nix /zig - name: Setup Nix - uses: cachix/install-nix-action@V28 + uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/release-pr.yml b/.github/workflows/release-pr.yml index d1a3b321c..8d325f5c0 100644 --- a/.github/workflows/release-pr.yml +++ b/.github/workflows/release-pr.yml @@ -57,7 +57,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -208,7 +208,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index fb1bb84b4..5ed8040d8 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -105,7 +105,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -289,7 +289,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -461,7 +461,7 @@ jobs: fetch-depth: 0 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d887699a9..6d55fecb9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -39,7 +39,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -71,7 +71,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -100,7 +100,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -133,7 +133,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -152,7 +152,7 @@ jobs: uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -188,7 +188,7 @@ jobs: uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -312,7 +312,7 @@ jobs: /zig # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -343,7 +343,7 @@ jobs: uses: actions/checkout@v4 # Install Nix and use that to run our tests so our environment matches exactly. - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -369,7 +369,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -396,7 +396,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 @@ -423,7 +423,7 @@ jobs: path: | /nix /zig - - uses: cachix/install-nix-action@V28 + - uses: cachix/install-nix-action@v29 with: nix_path: nixpkgs=channel:nixos-unstable - uses: cachix/cachix-action@v15 From 4ae20212bfe707ec3bedd9555321d51cfed1e3d4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 26 Sep 2024 16:18:11 -0700 Subject: [PATCH 095/139] libghostty: unified action dispatch First, this commit modifies libghostty to use a single unified action dispatch system based on a tagged union versus the one-off callback system that was previously in place. This change simplifies the code on both the core and consumer sides of the library. Importantly, as we introduce new actions, we can now maintain ABI compatibility so long as our union size does not change (something I don't promise yet). Second, this moves a lot more of the functions call on a surface into the action system. This affects all apprts and continues the previous work of introducing a more unified API for optional surface features. --- include/ghostty.h | 380 +++++--- macos/Sources/App/macOS/AppDelegate.swift | 23 +- .../Terminal/TerminalController.swift | 21 +- macos/Sources/Ghostty/Ghostty.App.swift | 889 ++++++++++++------ .../Ghostty/Ghostty.TerminalSplit.swift | 6 +- macos/Sources/Ghostty/InspectorView.swift | 2 +- macos/Sources/Ghostty/Package.swift | 76 +- .../Sources/Ghostty/SurfaceView_AppKit.swift | 8 +- macos/Sources/Helpers/FullScreenHandler.swift | 16 +- src/Surface.zig | 138 ++- src/apprt.zig | 1 - src/apprt/action.zig | 232 ++++- src/apprt/embedded.zig | 437 +-------- src/apprt/glfw.zig | 72 +- src/apprt/gtk/App.zig | 70 ++ src/apprt/gtk/Surface.zig | 18 - src/apprt/structs.zig | 10 - 17 files changed, 1451 insertions(+), 948 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 77671140f..571cbd904 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -30,7 +30,9 @@ typedef void* ghostty_config_t; typedef void* ghostty_surface_t; typedef void* ghostty_inspector_t; -// Enums are up top so we can reference them later. +// All the types below are fully defined and must be kept in sync with +// their Zig counterparts. Any changes to these types MUST have an associated +// Zig change. typedef enum { GHOSTTY_PLATFORM_INVALID, GHOSTTY_PLATFORM_MACOS, @@ -48,33 +50,6 @@ typedef enum { GHOSTTY_CLIPBOARD_REQUEST_OSC_52_WRITE, } ghostty_clipboard_request_e; -typedef enum { - GHOSTTY_SPLIT_RIGHT, - GHOSTTY_SPLIT_DOWN -} ghostty_split_direction_e; - -typedef enum { - GHOSTTY_SPLIT_FOCUS_PREVIOUS, - GHOSTTY_SPLIT_FOCUS_NEXT, - GHOSTTY_SPLIT_FOCUS_TOP, - GHOSTTY_SPLIT_FOCUS_LEFT, - GHOSTTY_SPLIT_FOCUS_BOTTOM, - GHOSTTY_SPLIT_FOCUS_RIGHT, -} ghostty_split_focus_direction_e; - -typedef enum { - GHOSTTY_SPLIT_RESIZE_UP, - GHOSTTY_SPLIT_RESIZE_DOWN, - GHOSTTY_SPLIT_RESIZE_LEFT, - GHOSTTY_SPLIT_RESIZE_RIGHT, -} ghostty_split_resize_direction_e; - -typedef enum { - GHOSTTY_INSPECTOR_TOGGLE, - GHOSTTY_INSPECTOR_SHOW, - GHOSTTY_INSPECTOR_HIDE, -} ghostty_inspector_mode_e; - typedef enum { GHOSTTY_MOUSE_RELEASE, GHOSTTY_MOUSE_PRESS, @@ -97,55 +72,6 @@ typedef enum { GHOSTTY_MOUSE_MOMENTUM_MAY_BEGIN, } ghostty_input_mouse_momentum_e; -typedef enum { - GHOSTTY_MOUSE_SHAPE_DEFAULT, - GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, - GHOSTTY_MOUSE_SHAPE_HELP, - GHOSTTY_MOUSE_SHAPE_POINTER, - GHOSTTY_MOUSE_SHAPE_PROGRESS, - GHOSTTY_MOUSE_SHAPE_WAIT, - GHOSTTY_MOUSE_SHAPE_CELL, - GHOSTTY_MOUSE_SHAPE_CROSSHAIR, - GHOSTTY_MOUSE_SHAPE_TEXT, - GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, - GHOSTTY_MOUSE_SHAPE_ALIAS, - GHOSTTY_MOUSE_SHAPE_COPY, - GHOSTTY_MOUSE_SHAPE_MOVE, - GHOSTTY_MOUSE_SHAPE_NO_DROP, - GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, - GHOSTTY_MOUSE_SHAPE_GRAB, - GHOSTTY_MOUSE_SHAPE_GRABBING, - GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, - GHOSTTY_MOUSE_SHAPE_COL_RESIZE, - GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, - GHOSTTY_MOUSE_SHAPE_N_RESIZE, - GHOSTTY_MOUSE_SHAPE_E_RESIZE, - GHOSTTY_MOUSE_SHAPE_S_RESIZE, - GHOSTTY_MOUSE_SHAPE_W_RESIZE, - GHOSTTY_MOUSE_SHAPE_NE_RESIZE, - GHOSTTY_MOUSE_SHAPE_NW_RESIZE, - GHOSTTY_MOUSE_SHAPE_SE_RESIZE, - GHOSTTY_MOUSE_SHAPE_SW_RESIZE, - GHOSTTY_MOUSE_SHAPE_EW_RESIZE, - GHOSTTY_MOUSE_SHAPE_NS_RESIZE, - GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, - GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, - GHOSTTY_MOUSE_SHAPE_ZOOM_IN, - GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, -} ghostty_mouse_shape_e; - -typedef enum { - GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE, - GHOSTTY_NON_NATIVE_FULLSCREEN_TRUE, - GHOSTTY_NON_NATIVE_FULLSCREEN_VISIBLE_MENU, -} ghostty_non_native_fullscreen_e; - -typedef enum { - GHOSTTY_TAB_PREVIOUS = -1, - GHOSTTY_TAB_NEXT = -2, - GHOSTTY_TAB_LAST = -3, -} ghostty_tab_e; - typedef enum { GHOSTTY_COLOR_SCHEME_LIGHT = 0, GHOSTTY_COLOR_SCHEME_DARK = 1, @@ -357,14 +283,6 @@ typedef enum { GHOSTTY_BUILD_MODE_RELEASE_SMALL, } ghostty_build_mode_e; -typedef enum { - GHOSTTY_RENDERER_HEALTH_OK, - GHOSTTY_RENDERER_HEALTH_UNHEALTHY, -} ghostty_renderer_health_e; - -// Fully defined types. This MUST be kept in sync with equivalent Zig -// structs. To find the Zig struct, grep for this type name. The documentation -// for all of these types is available in the Zig source. typedef struct { ghostty_build_mode_e build_mode; const char* version; @@ -414,13 +332,229 @@ typedef struct { uint32_t cell_height_px; } ghostty_surface_size_s; +// apprt.Target.Key +typedef enum { + GHOSTTY_TARGET_APP, + GHOSTTY_TARGET_SURFACE, +} ghostty_target_tag_e; + +typedef union { + ghostty_surface_t surface; +} ghostty_target_u; + +typedef struct { + ghostty_target_tag_e tag; + ghostty_target_u target; +} ghostty_target_s; + +// apprt.action.SplitDirection +typedef enum { + GHOSTTY_SPLIT_DIRECTION_RIGHT, + GHOSTTY_SPLIT_DIRECTION_DOWN, +} ghostty_action_split_direction_e; + +// apprt.action.GotoSplit +typedef enum { + GHOSTTY_GOTO_SPLIT_PREVIOUS, + GHOSTTY_GOTO_SPLIT_NEXT, + GHOSTTY_GOTO_SPLIT_TOP, + GHOSTTY_GOTO_SPLIT_LEFT, + GHOSTTY_GOTO_SPLIT_BOTTOM, + GHOSTTY_GOTO_SPLIT_RIGHT, +} ghostty_action_goto_split_e; + +// apprt.action.ResizeSplit.Direction +typedef enum { + GHOSTTY_RESIZE_SPLIT_UP, + GHOSTTY_RESIZE_SPLIT_DOWN, + GHOSTTY_RESIZE_SPLIT_LEFT, + GHOSTTY_RESIZE_SPLIT_RIGHT, +} ghostty_action_resize_split_direction_e; + +// apprt.action.ResizeSplit +typedef struct { + uint16_t amount; + ghostty_action_resize_split_direction_e direction; +} ghostty_action_resize_split_s; + +// apprt.action.GotoTab +typedef enum { + GHOSTTY_GOTO_TAB_PREVIOUS, + GHOSTTY_GOTO_TAB_NEXT, + GHOSTTY_GOTO_TAB_LAST, +} ghostty_action_goto_tab_e; + +// apprt.action.Fullscreen +typedef enum { + GHOSTTY_FULLSCREEN_NATIVE, + GHOSTTY_FULLSCREEN_NON_NATIVE, + GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU, +} ghostty_action_fullscreen_e; + +// apprt.action.SecureInput +typedef enum { + GHOSTTY_SECURE_INPUT_ON, + GHOSTTY_SECURE_INPUT_OFF, + GHOSTTY_SECURE_INPUT_TOGGLE, +} ghostty_action_secure_input_e; + +// apprt.action.Inspector +typedef enum { + GHOSTTY_INSPECTOR_TOGGLE, + GHOSTTY_INSPECTOR_SHOW, + GHOSTTY_INSPECTOR_HIDE, +} ghostty_action_inspector_e; + +// apprt.action.QuitTimer +typedef enum { + GHOSTTY_QUIT_TIMER_START, + GHOSTTY_QUIT_TIMER_STOP, +} ghostty_action_quit_timer_e; + +// apprt.action.DesktopNotification.C +typedef struct { + const char* title; + const char* body; +} ghostty_action_desktop_notification_s; + +// apprt.action.SetTitle.C +typedef struct { + const char* title; +} ghostty_action_set_title_s; + +// terminal.MouseShape +typedef enum { + GHOSTTY_MOUSE_SHAPE_DEFAULT, + GHOSTTY_MOUSE_SHAPE_CONTEXT_MENU, + GHOSTTY_MOUSE_SHAPE_HELP, + GHOSTTY_MOUSE_SHAPE_POINTER, + GHOSTTY_MOUSE_SHAPE_PROGRESS, + GHOSTTY_MOUSE_SHAPE_WAIT, + GHOSTTY_MOUSE_SHAPE_CELL, + GHOSTTY_MOUSE_SHAPE_CROSSHAIR, + GHOSTTY_MOUSE_SHAPE_TEXT, + GHOSTTY_MOUSE_SHAPE_VERTICAL_TEXT, + GHOSTTY_MOUSE_SHAPE_ALIAS, + GHOSTTY_MOUSE_SHAPE_COPY, + GHOSTTY_MOUSE_SHAPE_MOVE, + GHOSTTY_MOUSE_SHAPE_NO_DROP, + GHOSTTY_MOUSE_SHAPE_NOT_ALLOWED, + GHOSTTY_MOUSE_SHAPE_GRAB, + GHOSTTY_MOUSE_SHAPE_GRABBING, + GHOSTTY_MOUSE_SHAPE_ALL_SCROLL, + GHOSTTY_MOUSE_SHAPE_COL_RESIZE, + GHOSTTY_MOUSE_SHAPE_ROW_RESIZE, + GHOSTTY_MOUSE_SHAPE_N_RESIZE, + GHOSTTY_MOUSE_SHAPE_E_RESIZE, + GHOSTTY_MOUSE_SHAPE_S_RESIZE, + GHOSTTY_MOUSE_SHAPE_W_RESIZE, + GHOSTTY_MOUSE_SHAPE_NE_RESIZE, + GHOSTTY_MOUSE_SHAPE_NW_RESIZE, + GHOSTTY_MOUSE_SHAPE_SE_RESIZE, + GHOSTTY_MOUSE_SHAPE_SW_RESIZE, + GHOSTTY_MOUSE_SHAPE_EW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NS_RESIZE, + GHOSTTY_MOUSE_SHAPE_NESW_RESIZE, + GHOSTTY_MOUSE_SHAPE_NWSE_RESIZE, + GHOSTTY_MOUSE_SHAPE_ZOOM_IN, + GHOSTTY_MOUSE_SHAPE_ZOOM_OUT, +} ghostty_action_mouse_shape_e; + +// apprt.action.MouseVisibility +typedef enum { + GHOSTTY_MOUSE_VISIBLE, + GHOSTTY_MOUSE_HIDDEN, +} ghostty_action_mouse_visibility_e; + +// apprt.action.MouseOverLink +typedef struct { + const char* url; + size_t len; +} ghostty_action_mouse_over_link_s; + +// apprt.action.SizeLimit +typedef struct { + uint32_t min_width; + uint32_t min_height; + uint32_t max_width; + uint32_t max_height; +} ghostty_action_size_limit_s; + +// apprt.action.InitialSize +typedef struct { + uint32_t width; + uint32_t height; +} ghostty_action_initial_size_s; + +// apprt.action.CellSize +typedef struct { + uint32_t width; + uint32_t height; +} ghostty_action_cell_size_s; + +// renderer.Health +typedef enum { + GHOSTTY_RENDERER_HEALTH_OK, + GHOSTTY_RENDERER_HEALTH_UNHEALTHY, +} ghostty_action_renderer_health_e; + +// apprt.Action.Key +typedef enum { + GHOSTTY_ACTION_NEW_WINDOW, + GHOSTTY_ACTION_NEW_TAB, + GHOSTTY_ACTION_NEW_SPLIT, + GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, + GHOSTTY_ACTION_TOGGLE_FULLSCREEN, + GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, + GHOSTTY_ACTION_GOTO_TAB, + GHOSTTY_ACTION_GOTO_SPLIT, + GHOSTTY_ACTION_RESIZE_SPLIT, + GHOSTTY_ACTION_EQUALIZE_SPLITS, + GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM, + GHOSTTY_ACTION_PRESENT_TERMINAL, + GHOSTTY_ACTION_SIZE_LIMIT, + GHOSTTY_ACTION_INITIAL_SIZE, + GHOSTTY_ACTION_CELL_SIZE, + GHOSTTY_ACTION_INSPECTOR, + GHOSTTY_ACTION_RENDER_INSPECTOR, + GHOSTTY_ACTION_DESKTOP_NOTIFICATION, + GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_MOUSE_SHAPE, + GHOSTTY_ACTION_MOUSE_VISIBILITY, + GHOSTTY_ACTION_MOUSE_OVER_LINK, + GHOSTTY_ACTION_RENDERER_HEALTH, + GHOSTTY_ACTION_OPEN_CONFIG, + GHOSTTY_ACTION_QUIT_TIMER, + GHOSTTY_ACTION_SECURE_INPUT, +} ghostty_action_tag_e; + +typedef union { + ghostty_action_split_direction_e new_split; + ghostty_action_fullscreen_e toggle_fullscreen; + ghostty_action_goto_tab_e goto_tab; + ghostty_action_goto_split_e goto_split; + ghostty_action_resize_split_s resize_split; + ghostty_action_size_limit_s size_limit; + ghostty_action_initial_size_s initial_size; + ghostty_action_cell_size_s cell_size; + ghostty_action_inspector_e inspector; + ghostty_action_desktop_notification_s desktop_notification; + ghostty_action_set_title_s set_title; + ghostty_action_mouse_shape_e mouse_shape; + ghostty_action_mouse_visibility_e mouse_visibility; + ghostty_action_mouse_over_link_s mouse_over_link; + ghostty_action_renderer_health_e renderer_health; + ghostty_action_quit_timer_e quit_timer; + ghostty_action_secure_input_e secure_input; +} ghostty_action_u; + +typedef struct { + ghostty_action_tag_e tag; + ghostty_action_u action; +} ghostty_action_s; + typedef void (*ghostty_runtime_wakeup_cb)(void*); typedef const ghostty_config_t (*ghostty_runtime_reload_config_cb)(void*); -typedef void (*ghostty_runtime_open_config_cb)(void*); -typedef void (*ghostty_runtime_set_title_cb)(void*, const char*); -typedef void (*ghostty_runtime_set_mouse_shape_cb)(void*, - ghostty_mouse_shape_e); -typedef void (*ghostty_runtime_set_mouse_visibility_cb)(void*, bool); typedef void (*ghostty_runtime_read_clipboard_cb)(void*, ghostty_clipboard_e, void*); @@ -433,71 +567,21 @@ typedef void (*ghostty_runtime_write_clipboard_cb)(void*, const char*, ghostty_clipboard_e, bool); -typedef void (*ghostty_runtime_new_split_cb)(void*, - ghostty_split_direction_e, - ghostty_surface_config_s); -typedef void (*ghostty_runtime_new_tab_cb)(void*, ghostty_surface_config_s); -typedef void (*ghostty_runtime_new_window_cb)(void*, ghostty_surface_config_s); -typedef void (*ghostty_runtime_control_inspector_cb)(void*, - ghostty_inspector_mode_e); typedef void (*ghostty_runtime_close_surface_cb)(void*, bool); -typedef void (*ghostty_runtime_focus_split_cb)(void*, - ghostty_split_focus_direction_e); -typedef void (*ghostty_runtime_resize_split_cb)( - void*, - ghostty_split_resize_direction_e, - uint16_t); -typedef void (*ghostty_runtime_equalize_splits_cb)(void*); -typedef void (*ghostty_runtime_toggle_split_zoom_cb)(void*); -typedef void (*ghostty_runtime_goto_tab_cb)(void*, int32_t); -typedef void (*ghostty_runtime_toggle_fullscreen_cb)( - void*, - ghostty_non_native_fullscreen_e); -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 void ( - *ghostty_runtime_update_renderer_health)(void*, ghostty_renderer_health_e); -typedef void (*ghostty_runtime_mouse_over_link_cb)(void*, const char*, size_t); -typedef void (*ghostty_runtime_set_password_input_cb)(void*, bool); -typedef void (*ghostty_runtime_toggle_secure_input_cb)(); +typedef void (*ghostty_runtime_action_cb)(ghostty_app_t, + ghostty_target_s, + ghostty_action_s); typedef struct { void* userdata; bool supports_selection_clipboard; ghostty_runtime_wakeup_cb wakeup_cb; + ghostty_runtime_action_cb action_cb; ghostty_runtime_reload_config_cb reload_config_cb; - ghostty_runtime_open_config_cb open_config_cb; - ghostty_runtime_set_title_cb set_title_cb; - ghostty_runtime_set_mouse_shape_cb set_mouse_shape_cb; - ghostty_runtime_set_mouse_visibility_cb set_mouse_visibility_cb; ghostty_runtime_read_clipboard_cb read_clipboard_cb; ghostty_runtime_confirm_read_clipboard_cb confirm_read_clipboard_cb; ghostty_runtime_write_clipboard_cb write_clipboard_cb; - ghostty_runtime_new_split_cb new_split_cb; - ghostty_runtime_new_tab_cb new_tab_cb; - ghostty_runtime_new_window_cb new_window_cb; - ghostty_runtime_control_inspector_cb control_inspector_cb; ghostty_runtime_close_surface_cb close_surface_cb; - ghostty_runtime_focus_split_cb focus_split_cb; - ghostty_runtime_resize_split_cb resize_split_cb; - ghostty_runtime_equalize_splits_cb equalize_splits_cb; - ghostty_runtime_toggle_split_zoom_cb toggle_split_zoom_cb; - ghostty_runtime_goto_tab_cb goto_tab_cb; - ghostty_runtime_toggle_fullscreen_cb toggle_fullscreen_cb; - 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_update_renderer_health update_renderer_health_cb; - ghostty_runtime_mouse_over_link_cb mouse_over_link_cb; - ghostty_runtime_set_password_input_cb set_password_input_cb; - ghostty_runtime_toggle_secure_input_cb toggle_secure_input_cb; } ghostty_runtime_config_s; //------------------------------------------------------------------- @@ -538,7 +622,9 @@ ghostty_surface_config_s ghostty_surface_config_new(); ghostty_surface_t ghostty_surface_new(ghostty_app_t, ghostty_surface_config_s*); void ghostty_surface_free(ghostty_surface_t); +void* ghostty_surface_userdata(ghostty_surface_t); ghostty_app_t ghostty_surface_app(ghostty_surface_t); +ghostty_surface_config_s ghostty_surface_inherited_config(ghostty_surface_t); bool ghostty_surface_needs_confirm_quit(ghostty_surface_t); void ghostty_surface_refresh(ghostty_surface_t); void ghostty_surface_draw(ghostty_surface_t); @@ -569,11 +655,11 @@ void ghostty_surface_mouse_scroll(ghostty_surface_t, void ghostty_surface_mouse_pressure(ghostty_surface_t, uint32_t, double); void ghostty_surface_ime_point(ghostty_surface_t, double*, double*); void ghostty_surface_request_close(ghostty_surface_t); -void ghostty_surface_split(ghostty_surface_t, ghostty_split_direction_e); +void ghostty_surface_split(ghostty_surface_t, ghostty_action_split_direction_e); void ghostty_surface_split_focus(ghostty_surface_t, - ghostty_split_focus_direction_e); + ghostty_action_goto_split_e); void ghostty_surface_split_resize(ghostty_surface_t, - ghostty_split_resize_direction_e, + ghostty_action_resize_split_direction_e, uint16_t); void ghostty_surface_split_equalize(ghostty_surface_t); bool ghostty_surface_binding_action(ghostty_surface_t, const char*, uintptr_t); diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 44f0f0291..fc24345a8 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -484,6 +484,24 @@ class AppDelegate: NSObject, dockMenu.addItem(newTab) } + //MARK: - Global State + + func setSecureInput(_ mode: Ghostty.SetSecureInput) { + let input = SecureInput.shared + switch (mode) { + case .on: + input.global = true + + case .off: + input.global = false + + case .toggle: + input.global.toggle() + } + self.menuSecureInput?.state = if (input.global) { .on } else { .off } + UserDefaults.standard.set(input.global, forKey: "SecureInput") + } + //MARK: - IB Actions @IBAction func openConfig(_ sender: Any?) { @@ -525,9 +543,6 @@ class AppDelegate: NSObject, } @IBAction func toggleSecureInput(_ sender: Any) { - let input = SecureInput.shared - input.global.toggle() - self.menuSecureInput?.state = if (input.global) { .on } else { .off } - UserDefaults.standard.set(input.global, forKey: "SecureInput") + setSecureInput(.toggle) } } diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 74702c621..5bb6dbef6 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -551,12 +551,12 @@ class TerminalController: NSWindowController, NSWindowDelegate, @IBAction func splitRight(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_RIGHT) + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) } @IBAction func splitDown(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DOWN) + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) } @IBAction func splitZoom(_ sender: Any) { @@ -732,8 +732,9 @@ class TerminalController: NSWindowController, NSWindowDelegate, guard let window = self.window else { return } // Get the tab index from the notification - guard let tabIndexAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return } - guard let tabIndex = tabIndexAny as? Int32 else { return } + guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return } + guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return } + let tabIndex: Int32 = .init(bitPattern: tabEnum.rawValue) guard let windowController = window.windowController else { return } guard let tabGroup = windowController.window?.tabGroup else { return } @@ -747,19 +748,19 @@ class TerminalController: NSWindowController, NSWindowDelegate, guard let selectedWindow = tabGroup.selectedWindow else { return } guard let selectedIndex = tabbedWindows.firstIndex(where: { $0 == selectedWindow }) else { return } - if (tabIndex == GHOSTTY_TAB_PREVIOUS.rawValue) { + if (tabIndex == GHOSTTY_GOTO_TAB_PREVIOUS.rawValue) { if (selectedIndex == 0) { finalIndex = tabbedWindows.count - 1 } else { finalIndex = selectedIndex - 1 } - } else if (tabIndex == GHOSTTY_TAB_NEXT.rawValue) { + } else if (tabIndex == GHOSTTY_GOTO_TAB_NEXT.rawValue) { if (selectedIndex == tabbedWindows.count - 1) { finalIndex = 0 } else { finalIndex = selectedIndex + 1 } - } else if (tabIndex == GHOSTTY_TAB_LAST.rawValue) { + } else if (tabIndex == GHOSTTY_GOTO_TAB_LAST.rawValue) { finalIndex = tabbedWindows.count - 1 } else { return @@ -783,9 +784,9 @@ class TerminalController: NSWindowController, NSWindowDelegate, guard let window = self.window else { return } // Check whether we use non-native fullscreen - guard let useNonNativeFullscreenAny = notification.userInfo?[Ghostty.Notification.NonNativeFullscreenKey] else { return } - guard let useNonNativeFullscreen = useNonNativeFullscreenAny as? ghostty_non_native_fullscreen_e else { return } - self.fullscreenHandler.toggleFullscreen(window: window, nonNativeFullscreen: useNonNativeFullscreen) + guard let fullscreenModeAny = notification.userInfo?[Ghostty.Notification.FullscreenModeKey] else { return } + guard let fullscreenMode = fullscreenModeAny as? ghostty_action_fullscreen_e else { return } + self.fullscreenHandler.toggleFullscreen(window: window, mode: fullscreenMode) // For some reason focus always gets lost when we toggle fullscreen, so we set it back. if let focusedSurface { diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 3b3dd9626..f0128e2f7 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -67,36 +67,12 @@ extension Ghostty { userdata: Unmanaged.passUnretained(self).toOpaque(), supports_selection_clipboard: false, wakeup_cb: { userdata in App.wakeup(userdata) }, + action_cb: { app, target, action in App.action(app!, target: target, action: action) }, reload_config_cb: { userdata in App.reloadConfig(userdata) }, - open_config_cb: { userdata in App.openConfig(userdata) }, - set_title_cb: { userdata, title in App.setTitle(userdata, title: title) }, - set_mouse_shape_cb: { userdata, shape in App.setMouseShape(userdata, shape: shape) }, - set_mouse_visibility_cb: { userdata, visible in App.setMouseVisibility(userdata, visible: visible) }, read_clipboard_cb: { userdata, loc, state in App.readClipboard(userdata, location: loc, state: state) }, confirm_read_clipboard_cb: { userdata, str, state, request in App.confirmReadClipboard(userdata, string: str, state: state, request: request ) }, write_clipboard_cb: { userdata, str, loc, confirm in App.writeClipboard(userdata, string: str, location: loc, confirm: confirm) }, - new_split_cb: { userdata, direction, surfaceConfig in App.newSplit(userdata, direction: direction, config: surfaceConfig) }, - new_tab_cb: { userdata, surfaceConfig in App.newTab(userdata, config: surfaceConfig) }, - new_window_cb: { userdata, surfaceConfig in App.newWindow(userdata, config: surfaceConfig) }, - control_inspector_cb: { userdata, mode in App.controlInspector(userdata, mode: mode) }, - close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) }, - focus_split_cb: { userdata, direction in App.focusSplit(userdata, direction: direction) }, - resize_split_cb: { userdata, direction, amount in - App.resizeSplit(userdata, direction: direction, amount: amount) }, - equalize_splits_cb: { userdata in - App.equalizeSplits(userdata) }, - toggle_split_zoom_cb: { userdata in App.toggleSplitZoom(userdata) }, - goto_tab_cb: { userdata, n in App.gotoTab(userdata, n: n) }, - toggle_fullscreen_cb: { userdata, nonNativeFullscreen in App.toggleFullscreen(userdata, nonNativeFullscreen: nonNativeFullscreen) }, - set_initial_window_size_cb: { userdata, width, height in App.setInitialWindowSize(userdata, width: width, height: height) }, - render_inspector_cb: { userdata in App.renderInspector(userdata) }, - set_cell_size_cb: { userdata, width, height in App.setCellSize(userdata, width: width, height: height) }, - show_desktop_notification_cb: { userdata, title, body in - App.showUserNotification(userdata, title: title, body: body) }, - update_renderer_health_cb: { userdata, health in App.updateRendererHealth(userdata, health: health) }, - mouse_over_link_cb: { userdata, ptr, len in App.mouseOverLink(userdata, uri: ptr, len: len) }, - set_password_input_cb: { userdata, value in App.setPasswordInput(userdata, value: value) }, - toggle_secure_input_cb: { App.toggleSecureInput() } + close_surface_cb: { userdata, processAlive in App.closeSurface(userdata, processAlive: processAlive) } ) // Create the ghostty app. @@ -185,7 +161,7 @@ extension Ghostty { } } - func split(surface: ghostty_surface_t, direction: ghostty_split_direction_e) { + func split(surface: ghostty_surface_t, direction: ghostty_action_split_direction_e) { ghostty_surface_split(surface, direction) } @@ -254,11 +230,8 @@ extension Ghostty { // MARK: Ghostty Callbacks (iOS) static func wakeup(_ userdata: UnsafeMutableRawPointer?) {} + static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) {} static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { return nil } - static func openConfig(_ userdata: UnsafeMutableRawPointer?) {} - static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) {} - static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) {} - static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) {} static func readClipboard( _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, @@ -279,30 +252,7 @@ extension Ghostty { confirm: Bool ) {} - static func newSplit( - _ userdata: UnsafeMutableRawPointer?, - direction: ghostty_split_direction_e, - config: ghostty_surface_config_s - ) {} - - static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {} - static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) {} - static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) {} static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) {} - static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) {} - static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) {} - static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) {} - static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) {} - static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) {} - static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) {} - static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {} - static func renderInspector(_ userdata: UnsafeMutableRawPointer?) {} - static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) {} - static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) {} - static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) {} - static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) {} - static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) {} - static func toggleSecureInput() {} #endif #if os(macOS) @@ -318,14 +268,6 @@ extension Ghostty { // MARK: Ghostty Callbacks (macOS) - static func newSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_direction_e, config: ghostty_surface_config_s) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post(name: Notification.ghosttyNewSplit, object: surface, userInfo: [ - "direction": direction, - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), - ]) - } - static func closeSurface(_ userdata: UnsafeMutableRawPointer?, processAlive: Bool) { let surface = self.surfaceUserdata(from: userdata) NotificationCenter.default.post(name: Notification.ghosttyCloseSurface, object: surface, userInfo: [ @@ -333,56 +275,6 @@ extension Ghostty { ]) } - static func focusSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_focus_direction_e) { - let surface = self.surfaceUserdata(from: userdata) - guard let splitDirection = SplitFocusDirection.from(direction: direction) else { return } - NotificationCenter.default.post( - name: Notification.ghosttyFocusSplit, - object: surface, - userInfo: [ - Notification.SplitDirectionKey: splitDirection, - ] - ) - } - - static func resizeSplit(_ userdata: UnsafeMutableRawPointer?, direction: ghostty_split_resize_direction_e, amount: UInt16) { - let surface = self.surfaceUserdata(from: userdata) - guard let resizeDirection = SplitResizeDirection.from(direction: direction) else { return } - NotificationCenter.default.post( - name: Notification.didResizeSplit, - object: surface, - userInfo: [ - Notification.ResizeSplitDirectionKey: resizeDirection, - Notification.ResizeSplitAmountKey: amount, - ] - ) - } - - static func equalizeSplits(_ userdata: UnsafeMutableRawPointer?) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post(name: Notification.didEqualizeSplits, object: surface) - } - - static func toggleSplitZoom(_ userdata: UnsafeMutableRawPointer?) { - let surface = self.surfaceUserdata(from: userdata) - - NotificationCenter.default.post( - name: Notification.didToggleSplitZoom, - object: surface - ) - } - - static func gotoTab(_ userdata: UnsafeMutableRawPointer?, n: Int32) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post( - name: Notification.ghosttyGotoTab, - object: surface, - userInfo: [ - Notification.GotoTabKey: n, - ] - ) - } - 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". @@ -454,10 +346,6 @@ extension Ghostty { ) } - static func openConfig(_ userdata: UnsafeMutableRawPointer?) { - ghostty_config_open(); - } - static func reloadConfig(_ userdata: UnsafeMutableRawPointer?) -> ghostty_config_t? { let newConfig = Config() guard newConfig.loaded else { @@ -488,99 +376,648 @@ extension Ghostty { DispatchQueue.main.async { state.appTick() } } - static func renderInspector(_ userdata: UnsafeMutableRawPointer?) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post( - name: Notification.inspectorNeedsDisplay, - object: surface - ) + /// 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 uuidString = userInfo["surface"] as? String, + let uuid = UUID(uuidString: uuidString), + let surface = delegate?.findSurface(forUUID: uuid), + let window = surface.window else { return false } + return !window.isKeyWindow || !surface.focused } - static func setTitle(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?) { - let surfaceView = self.surfaceUserdata(from: userdata) - guard let titleStr = String(cString: title!, encoding: .utf8) else { return } - DispatchQueue.main.async { - surfaceView.title = titleStr - } + /// Returns the GhosttyState from the given userdata value. + static private func appState(fromView view: SurfaceView) -> App? { + guard let surface = view.surface else { return nil } + guard let app = ghostty_surface_app(surface) else { return nil } + guard let app_ud = ghostty_app_userdata(app) else { return nil } + return Unmanaged.fromOpaque(app_ud).takeUnretainedValue() } - static func setMouseShape(_ userdata: UnsafeMutableRawPointer?, shape: ghostty_mouse_shape_e) { - let surfaceView = self.surfaceUserdata(from: userdata) - surfaceView.setCursorShape(shape) + /// Returns the surface view from the userdata. + static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView { + return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() } - static func setMouseVisibility(_ userdata: UnsafeMutableRawPointer?, visible: Bool) { - let surfaceView = self.surfaceUserdata(from: userdata) - surfaceView.setCursorVisibility(visible) + static private func surfaceView(from surface: ghostty_surface_t) -> SurfaceView? { + guard let surface_ud = ghostty_surface_userdata(surface) else { return nil } + return Unmanaged.fromOpaque(surface_ud).takeUnretainedValue() } - static func toggleFullscreen(_ userdata: UnsafeMutableRawPointer?, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post( - name: Notification.ghosttyToggleFullscreen, - object: surface, - userInfo: [ - Notification.NonNativeFullscreenKey: nonNativeFullscreen, - ] - ) - } + // MARK: Actions (macOS) - static func setInitialWindowSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { - // We need a window to set the frame - let surfaceView = self.surfaceUserdata(from: userdata) - surfaceView.initialSize = NSMakeSize(Double(width), Double(height)) - } + static func action(_ app: ghostty_app_t, target: ghostty_target_s, action: ghostty_action_s) { + // Make sure it a target we understand so all our action handlers can assert + switch (target.tag) { + case GHOSTTY_TARGET_APP, GHOSTTY_TARGET_SURFACE: + break - static func setCellSize(_ userdata: UnsafeMutableRawPointer?, width: UInt32, height: UInt32) { - let surfaceView = self.surfaceUserdata(from: userdata) - let backingSize = NSSize(width: Double(width), height: Double(height)) - surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) - } - - static func mouseOverLink(_ userdata: UnsafeMutableRawPointer?, uri: UnsafePointer?, len: Int) { - let surfaceView = self.surfaceUserdata(from: userdata) - guard len > 0 else { - surfaceView.hoverUrl = nil + default: + Ghostty.logger.warning("unknown action target=\(target.tag.rawValue)") return } - let buffer = Data(bytes: uri!, count: len) - surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) + // Action dispatch + switch (action.tag) { + case GHOSTTY_ACTION_NEW_WINDOW: + newWindow(app, target: target) + + case GHOSTTY_ACTION_NEW_TAB: + newTab(app, target: target) + + case GHOSTTY_ACTION_NEW_SPLIT: + newSplit(app, target: target, direction: action.action.new_split) + + case GHOSTTY_ACTION_TOGGLE_FULLSCREEN: + toggleFullscreen(app, target: target, mode: action.action.toggle_fullscreen) + + case GHOSTTY_ACTION_GOTO_TAB: + gotoTab(app, target: target, tab: action.action.goto_tab) + + case GHOSTTY_ACTION_GOTO_SPLIT: + gotoSplit(app, target: target, direction: action.action.goto_split) + + case GHOSTTY_ACTION_RESIZE_SPLIT: + resizeSplit(app, target: target, resize: action.action.resize_split) + + case GHOSTTY_ACTION_EQUALIZE_SPLITS: + equalizeSplits(app, target: target) + + case GHOSTTY_ACTION_TOGGLE_SPLIT_ZOOM: + toggleSplitZoom(app, target: target) + + case GHOSTTY_ACTION_INSPECTOR: + controlInspector(app, target: target, mode: action.action.inspector) + + case GHOSTTY_ACTION_RENDER_INSPECTOR: + renderInspector(app, target: target) + + case GHOSTTY_ACTION_DESKTOP_NOTIFICATION: + showDesktopNotification(app, target: target, n: action.action.desktop_notification) + + case GHOSTTY_ACTION_SET_TITLE: + setTitle(app, target: target, v: action.action.set_title) + + case GHOSTTY_ACTION_OPEN_CONFIG: + ghostty_config_open() + + case GHOSTTY_ACTION_SECURE_INPUT: + toggleSecureInput(app, target: target, mode: action.action.secure_input) + + case GHOSTTY_ACTION_MOUSE_SHAPE: + setMouseShape(app, target: target, shape: action.action.mouse_shape) + + case GHOSTTY_ACTION_MOUSE_VISIBILITY: + setMouseVisibility(app, target: target, v: action.action.mouse_visibility) + + case GHOSTTY_ACTION_MOUSE_OVER_LINK: + setMouseOverLink(app, target: target, v: action.action.mouse_over_link) + + case GHOSTTY_ACTION_INITIAL_SIZE: + setInitialSize(app, target: target, v: action.action.initial_size) + + case GHOSTTY_ACTION_CELL_SIZE: + setCellSize(app, target: target, v: action.action.cell_size) + + case GHOSTTY_ACTION_RENDERER_HEALTH: + rendererHealth(app, target: target, v: action.action.renderer_health) + + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: + fallthrough + case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: + fallthrough + case GHOSTTY_ACTION_PRESENT_TERMINAL: + fallthrough + case GHOSTTY_ACTION_SIZE_LIMIT: + fallthrough + case GHOSTTY_ACTION_QUIT_TIMER: + Ghostty.logger.info("known but unimplemented action action=\(action.tag.rawValue)") + + default: + Ghostty.logger.warning("unknown action action=\(action.tag.rawValue)") + } } - static func setPasswordInput(_ userdata: UnsafeMutableRawPointer?, value: Bool) { - // We don't currently allow global password input being set from this. - guard let userdata else { return } - - let surfaceView = self.surfaceUserdata(from: userdata) - guard let appState = self.appState(fromView: surfaceView) else { return } - guard appState.config.autoSecureInput else { return } - surfaceView.passwordInput = value + private static func newWindow(_ app: ghostty_app_t, target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + NotificationCenter.default.post( + name: Notification.ghosttyNewWindow, + object: nil, + userInfo: [:] + ) + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyNewWindow, + object: surfaceView, + userInfo: [ + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + ] + ) + + + default: + assertionFailure() + } } - static func toggleSecureInput() { - guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } - appDelegate.toggleSecureInput(self) - } + private static func newTab(_ app: ghostty_app_t, target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + NotificationCenter.default.post( + name: Notification.ghosttyNewTab, + object: nil, + userInfo: [:] + ) - static func showUserNotification(_ userdata: UnsafeMutableRawPointer?, title: UnsafePointer?, body: UnsafePointer?) { - let surfaceView = self.surfaceUserdata(from: userdata) - guard let title = String(cString: title!, encoding: .utf8) else { return } - guard let body = String(cString: body!, encoding: .utf8) else { return } - - let center = UNUserNotificationCenter.current() - center.requestAuthorization(options: [.alert, .sound]) { _, error in - if let error = error { - AppDelegate.logger.error("Error while requesting notification authorization: \(error)") + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let appState = self.appState(fromView: surfaceView) else { return } + guard appState.config.windowDecorations else { + let alert = NSAlert() + alert.messageText = "Tabs are disabled" + alert.informativeText = "Enable window decorations to use tabs" + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + _ = alert.runModal() + return } - } - center.getNotificationSettings() { settings in - guard settings.authorizationStatus == .authorized else { return } - surfaceView.showUserNotification(title: title, body: body) + NotificationCenter.default.post( + name: Notification.ghosttyNewTab, + object: surfaceView, + userInfo: [ + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + ] + ) + + + default: + assertionFailure() } } + private static func newSplit( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_split_direction_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + // New split does nothing with an app target + Ghostty.logger.warning("new split does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + + NotificationCenter.default.post( + name: Notification.ghosttyNewSplit, + object: surfaceView, + userInfo: [ + "direction": direction, + Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: ghostty_surface_inherited_config(surface)), + ] + ) + + + default: + assertionFailure() + } + } + + private static func toggleFullscreen( + _ app: ghostty_app_t, + target: ghostty_target_s, + mode: ghostty_action_fullscreen_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle fullscreen does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyToggleFullscreen, + object: surfaceView, + userInfo: [ + Notification.FullscreenModeKey: mode, + ] + ) + + + default: + assertionFailure() + } + } + + private static func gotoTab( + _ app: ghostty_app_t, + target: ghostty_target_s, + tab: ghostty_action_goto_tab_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("goto tab does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyGotoTab, + object: surfaceView, + userInfo: [ + Notification.GotoTabKey: tab, + ] + ) + + default: + assertionFailure() + } + } + + private static func gotoSplit( + _ app: ghostty_app_t, + target: ghostty_target_s, + direction: ghostty_action_goto_split_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("goto split does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.ghosttyFocusSplit, + object: surfaceView, + userInfo: [ + Notification.SplitDirectionKey: SplitFocusDirection.from(direction: direction) as Any, + ] + ) + + default: + assertionFailure() + } + } + + private static func resizeSplit( + _ app: ghostty_app_t, + target: ghostty_target_s, + resize: ghostty_action_resize_split_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("resize split does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let resizeDirection = SplitResizeDirection.from(direction: resize.direction) else { return } + NotificationCenter.default.post( + name: Notification.didResizeSplit, + object: surfaceView, + userInfo: [ + Notification.ResizeSplitDirectionKey: resizeDirection, + Notification.ResizeSplitAmountKey: resize.amount, + ] + ) + + default: + assertionFailure() + } + } + + private static func equalizeSplits( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("equalize splits does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.didEqualizeSplits, + object: surfaceView + ) + + + default: + assertionFailure() + } + } + + private static func toggleSplitZoom( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle split zoom does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.didToggleSplitZoom, + object: surfaceView + ) + + + default: + assertionFailure() + } + } + + private static func controlInspector( + _ app: ghostty_app_t, + target: ghostty_target_s, + mode: ghostty_action_inspector_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle split zoom does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.didControlInspector, + object: surfaceView, + userInfo: ["mode": mode] + ) + + + default: + assertionFailure() + } + } + + private static func showDesktopNotification( + _ app: ghostty_app_t, + target: ghostty_target_s, + n: ghostty_action_desktop_notification_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("toggle split zoom does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let title = String(cString: n.title!, encoding: .utf8) else { return } + guard let body = String(cString: n.body!, encoding: .utf8) else { return } + + let center = UNUserNotificationCenter.current() + center.requestAuthorization(options: [.alert, .sound]) { _, error in + if let error = error { + Ghostty.logger.error("Error while requesting notification authorization: \(error)") + } + } + + center.getNotificationSettings() { settings in + guard settings.authorizationStatus == .authorized else { return } + surfaceView.showUserNotification(title: title, body: body) + } + + + default: + assertionFailure() + } + } + + private static func toggleSecureInput( + _ app: ghostty_app_t, + target: ghostty_target_s, + mode mode_raw: ghostty_action_secure_input_e + ) { + guard let mode = SetSecureInput.from(mode_raw) else { return } + + switch (target.tag) { + case GHOSTTY_TARGET_APP: + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.setSecureInput(mode) + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let appState = self.appState(fromView: surfaceView) else { return } + guard appState.config.autoSecureInput else { return } + + switch (mode) { + case .on: + surfaceView.passwordInput = true + + case .off: + surfaceView.passwordInput = false + + case .toggle: + surfaceView.passwordInput = !surfaceView.passwordInput + } + + default: + assertionFailure() + } + } + + private static func setTitle( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_set_title_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set title does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let title = String(cString: v.title!, encoding: .utf8) else { return } + + // We must set this in a dispatchqueue to avoid a deadlock on startup on some + // versions of macOS. I unfortunately didn't document the exact versions so + // I don't know when its safe to remove this. + DispatchQueue.main.async { + surfaceView.title = title + } + + + default: + assertionFailure() + } + } + + private static func setMouseShape( + _ app: ghostty_app_t, + target: ghostty_target_s, + shape: ghostty_action_mouse_shape_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set mouse shapes nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + surfaceView.setCursorShape(shape) + + + default: + assertionFailure() + } + } + + private static func setMouseVisibility( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_mouse_visibility_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set mouse shapes nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + switch (v) { + case GHOSTTY_MOUSE_VISIBLE: + surfaceView.setCursorVisibility(true) + + case GHOSTTY_MOUSE_HIDDEN: + surfaceView.setCursorVisibility(false) + + default: + return + } + + + default: + assertionFailure() + } + } + + private static func setMouseOverLink( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_mouse_over_link_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("mouse over link does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + guard v.len > 0 else { + surfaceView.hoverUrl = nil + return + } + + let buffer = Data(bytes: v.url!, count: v.len) + surfaceView.hoverUrl = String(data: buffer, encoding: .utf8) + + + default: + assertionFailure() + } + } + + private static func setInitialSize( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_initial_size_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("mouse over link does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + surfaceView.initialSize = NSMakeSize(Double(v.width), Double(v.height)) + + + default: + assertionFailure() + } + } + + private static func setCellSize( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_cell_size_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("mouse over link does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + let backingSize = NSSize(width: Double(v.width), height: Double(v.height)) + surfaceView.cellSize = surfaceView.convertFromBacking(backingSize) + + default: + assertionFailure() + } + } + + private static func renderInspector( + _ app: ghostty_app_t, + target: ghostty_target_s) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("mouse over link does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.inspectorNeedsDisplay, + object: surfaceView + ) + + default: + assertionFailure() + } + } + + private static func rendererHealth( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_renderer_health_e) { + switch (target.tag) { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("mouse over link does nothing with an app target") + return + + case GHOSTTY_TARGET_SURFACE: + guard let surface = target.target.surface else { return } + guard let surfaceView = self.surfaceView(from: surface) else { return } + NotificationCenter.default.post( + name: Notification.didUpdateRendererHealth, + object: surfaceView, + userInfo: [ + "health": v, + ] + ) + + default: + assertionFailure() + } + } + + // MARK: User Notifications + /// 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 @@ -600,86 +1037,6 @@ extension Ghostty { } } - /// 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 uuidString = userInfo["surface"] as? String, - let uuid = UUID(uuidString: uuidString), - let surface = delegate?.findSurface(forUUID: uuid), - let window = surface.window else { return false } - return !window.isKeyWindow || !surface.focused - } - - static func newTab(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - let surface = self.surfaceUserdata(from: userdata) - - guard let appState = self.appState(fromView: surface) else { return } - guard appState.config.windowDecorations else { - let alert = NSAlert() - alert.messageText = "Tabs are disabled" - alert.informativeText = "Enable window decorations to use tabs" - alert.addButton(withTitle: "OK") - alert.alertStyle = .warning - _ = alert.runModal() - return - } - - NotificationCenter.default.post( - name: Notification.ghosttyNewTab, - object: surface, - userInfo: [ - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), - ] - ) - } - - static func newWindow(_ userdata: UnsafeMutableRawPointer?, config: ghostty_surface_config_s) { - let surface: SurfaceView? = if let userdata { - self.surfaceUserdata(from: userdata) - } else { - nil - } - - NotificationCenter.default.post( - name: Notification.ghosttyNewWindow, - object: surface, - userInfo: [ - Notification.NewSurfaceConfigKey: SurfaceConfiguration(from: config), - ] - ) - } - - static func controlInspector(_ userdata: UnsafeMutableRawPointer?, mode: ghostty_inspector_mode_e) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post(name: Notification.didControlInspector, object: surface, userInfo: [ - "mode": mode, - ]) - } - - static func updateRendererHealth(_ userdata: UnsafeMutableRawPointer?, health: ghostty_renderer_health_e) { - let surface = self.surfaceUserdata(from: userdata) - NotificationCenter.default.post( - name: Notification.didUpdateRendererHealth, - object: surface, - userInfo: [ - "health": health, - ] - ) - } - - /// Returns the GhosttyState from the given userdata value. - static private func appState(fromView view: SurfaceView) -> App? { - guard let surface = view.surface else { return nil } - guard let app = ghostty_surface_app(surface) else { return nil } - guard let app_ud = ghostty_app_userdata(app) else { return nil } - return Unmanaged.fromOpaque(app_ud).takeUnretainedValue() - } - - /// Returns the surface view from the userdata. - static private func surfaceUserdata(from userdata: UnsafeMutableRawPointer?) -> SurfaceView { - return Unmanaged.fromOpaque(userdata!).takeUnretainedValue() - } - #endif } } diff --git a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift index c9429ab79..fa8335416 100644 --- a/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift +++ b/macos/Sources/Ghostty/Ghostty.TerminalSplit.swift @@ -219,13 +219,13 @@ extension Ghostty { // Determine our desired direction guard let directionAny = notification.userInfo?["direction"] else { return } - guard let direction = directionAny as? ghostty_split_direction_e else { return } + guard let direction = directionAny as? ghostty_action_split_direction_e else { return } var splitDirection: SplitViewDirection switch (direction) { - case GHOSTTY_SPLIT_RIGHT: + case GHOSTTY_SPLIT_DIRECTION_RIGHT: splitDirection = .horizontal - case GHOSTTY_SPLIT_DOWN: + case GHOSTTY_SPLIT_DIRECTION_DOWN: splitDirection = .vertical default: diff --git a/macos/Sources/Ghostty/InspectorView.swift b/macos/Sources/Ghostty/InspectorView.swift index 2d867e000..b6147647e 100644 --- a/macos/Sources/Ghostty/InspectorView.swift +++ b/macos/Sources/Ghostty/InspectorView.swift @@ -55,7 +55,7 @@ extension Ghostty { private func onControlInspector(_ notification: SwiftUI.Notification) { // Determine our mode guard let modeAny = notification.userInfo?["mode"] else { return } - guard let mode = modeAny as? ghostty_inspector_mode_e else { return } + guard let mode = modeAny as? ghostty_action_inspector_e else { return } switch (mode) { case GHOSTTY_INSPECTOR_TOGGLE: diff --git a/macos/Sources/Ghostty/Package.swift b/macos/Sources/Ghostty/Package.swift index 475d68733..c55af2357 100644 --- a/macos/Sources/Ghostty/Package.swift +++ b/macos/Sources/Ghostty/Package.swift @@ -39,32 +39,54 @@ extension Ghostty { } } -// MARK: Surface Notifications +// MARK: Swift Types for C Types extension Ghostty { + enum SetSecureInput { + case on + case off + case toggle + + static func from(_ c: ghostty_action_secure_input_e) -> Self? { + switch (c) { + case GHOSTTY_SECURE_INPUT_ON: + return .on + + case GHOSTTY_SECURE_INPUT_OFF: + return .off + + case GHOSTTY_SECURE_INPUT_TOGGLE: + return .toggle + + default: + return nil + } + } + } + /// An enum that is used for the directions that a split focus event can change. enum SplitFocusDirection { case previous, next, top, bottom, left, right /// Initialize from a Ghostty API enum. - static func from(direction: ghostty_split_focus_direction_e) -> Self? { + static func from(direction: ghostty_action_goto_split_e) -> Self? { switch (direction) { - case GHOSTTY_SPLIT_FOCUS_PREVIOUS: + case GHOSTTY_GOTO_SPLIT_PREVIOUS: return .previous - case GHOSTTY_SPLIT_FOCUS_NEXT: + case GHOSTTY_GOTO_SPLIT_NEXT: return .next - case GHOSTTY_SPLIT_FOCUS_TOP: + case GHOSTTY_GOTO_SPLIT_TOP: return .top - case GHOSTTY_SPLIT_FOCUS_BOTTOM: + case GHOSTTY_GOTO_SPLIT_BOTTOM: return .bottom - case GHOSTTY_SPLIT_FOCUS_LEFT: + case GHOSTTY_GOTO_SPLIT_LEFT: return .left - case GHOSTTY_SPLIT_FOCUS_RIGHT: + case GHOSTTY_GOTO_SPLIT_RIGHT: return .right default: @@ -72,25 +94,25 @@ extension Ghostty { } } - func toNative() -> ghostty_split_focus_direction_e { + func toNative() -> ghostty_action_goto_split_e { switch (self) { case .previous: - return GHOSTTY_SPLIT_FOCUS_PREVIOUS + return GHOSTTY_GOTO_SPLIT_PREVIOUS case .next: - return GHOSTTY_SPLIT_FOCUS_NEXT + return GHOSTTY_GOTO_SPLIT_NEXT case .top: - return GHOSTTY_SPLIT_FOCUS_TOP + return GHOSTTY_GOTO_SPLIT_TOP case .bottom: - return GHOSTTY_SPLIT_FOCUS_BOTTOM + return GHOSTTY_GOTO_SPLIT_BOTTOM case .left: - return GHOSTTY_SPLIT_FOCUS_LEFT + return GHOSTTY_GOTO_SPLIT_LEFT case .right: - return GHOSTTY_SPLIT_FOCUS_RIGHT + return GHOSTTY_GOTO_SPLIT_RIGHT } } } @@ -99,31 +121,31 @@ extension Ghostty { enum SplitResizeDirection { case up, down, left, right - static func from(direction: ghostty_split_resize_direction_e) -> Self? { + static func from(direction: ghostty_action_resize_split_direction_e) -> Self? { switch (direction) { - case GHOSTTY_SPLIT_RESIZE_UP: + case GHOSTTY_RESIZE_SPLIT_UP: return .up; - case GHOSTTY_SPLIT_RESIZE_DOWN: + case GHOSTTY_RESIZE_SPLIT_DOWN: return .down; - case GHOSTTY_SPLIT_RESIZE_LEFT: + case GHOSTTY_RESIZE_SPLIT_LEFT: return .left; - case GHOSTTY_SPLIT_RESIZE_RIGHT: + case GHOSTTY_RESIZE_SPLIT_RIGHT: return .right; default: return nil } } - func toNative() -> ghostty_split_resize_direction_e { + func toNative() -> ghostty_action_resize_split_direction_e { switch (self) { case .up: - return GHOSTTY_SPLIT_RESIZE_UP; + return GHOSTTY_RESIZE_SPLIT_UP; case .down: - return GHOSTTY_SPLIT_RESIZE_DOWN; + return GHOSTTY_RESIZE_SPLIT_DOWN; case .left: - return GHOSTTY_SPLIT_RESIZE_LEFT; + return GHOSTTY_RESIZE_SPLIT_LEFT; case .right: - return GHOSTTY_SPLIT_RESIZE_RIGHT; + return GHOSTTY_RESIZE_SPLIT_RIGHT; } } } @@ -174,6 +196,8 @@ extension Ghostty { } } +// MARK: Surface Notifications + extension Ghostty.Notification { /// Used to pass a configuration along when creating a new tab/window/split. static let NewSurfaceConfigKey = "com.mitchellh.ghostty.newSurfaceConfig" @@ -201,7 +225,7 @@ extension Ghostty.Notification { /// Toggle fullscreen of current window static let ghosttyToggleFullscreen = Notification.Name("com.mitchellh.ghostty.toggleFullscreen") - static let NonNativeFullscreenKey = ghosttyToggleFullscreen.rawValue + static let FullscreenModeKey = ghosttyToggleFullscreen.rawValue /// Notification that a surface is becoming focused. This is only sent on macOS 12 to /// work around bugs. macOS 13+ should use the ".focused()" attribute. diff --git a/macos/Sources/Ghostty/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/SurfaceView_AppKit.swift index 3c2c17a81..a5e6b2f04 100644 --- a/macos/Sources/Ghostty/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/SurfaceView_AppKit.swift @@ -251,7 +251,7 @@ extension Ghostty { } } - func setCursorShape(_ shape: ghostty_mouse_shape_e) { + func setCursorShape(_ shape: ghostty_action_mouse_shape_e) { switch (shape) { case GHOSTTY_MOUSE_SHAPE_DEFAULT: pointerStyle = .default @@ -312,7 +312,7 @@ extension Ghostty { @objc private func onUpdateRendererHealth(notification: SwiftUI.Notification) { guard let healthAny = notification.userInfo?["health"] else { return } - guard let health = healthAny as? ghostty_renderer_health_e else { return } + guard let health = healthAny as? ghostty_action_renderer_health_e else { return } healthy = health == GHOSTTY_RENDERER_HEALTH_OK } @@ -926,12 +926,12 @@ extension Ghostty { @IBAction func splitRight(_ sender: Any) { guard let surface = self.surface else { return } - ghostty_surface_split(surface, GHOSTTY_SPLIT_RIGHT) + ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_RIGHT) } @IBAction func splitDown(_ sender: Any) { guard let surface = self.surface else { return } - ghostty_surface_split(surface, GHOSTTY_SPLIT_DOWN) + ghostty_surface_split(surface, GHOSTTY_SPLIT_DIRECTION_DOWN) } @objc func resetTerminal(_ sender: Any) { diff --git a/macos/Sources/Helpers/FullScreenHandler.swift b/macos/Sources/Helpers/FullScreenHandler.swift index c9d6e594e..d12809d71 100644 --- a/macos/Sources/Helpers/FullScreenHandler.swift +++ b/macos/Sources/Helpers/FullScreenHandler.swift @@ -13,8 +13,18 @@ class FullScreenHandler { var isInNonNativeFullscreen: Bool = false var isInFullscreen: Bool = false - func toggleFullscreen(window: NSWindow, nonNativeFullscreen: ghostty_non_native_fullscreen_e) { - let useNonNativeFullscreen = nonNativeFullscreen != GHOSTTY_NON_NATIVE_FULLSCREEN_FALSE + func toggleFullscreen(window: NSWindow, mode: ghostty_action_fullscreen_e) { + let useNonNativeFullscreen = switch (mode) { + case GHOSTTY_FULLSCREEN_NATIVE: + false + + case GHOSTTY_FULLSCREEN_NON_NATIVE, GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU: + true + + default: + false + } + if isInFullscreen { if useNonNativeFullscreen || isInNonNativeFullscreen { leaveFullscreen(window: window) @@ -27,7 +37,7 @@ class FullScreenHandler { isInFullscreen = false } else { if useNonNativeFullscreen { - let hideMenu = nonNativeFullscreen != GHOSTTY_NON_NATIVE_FULLSCREEN_VISIBLE_MENU + let hideMenu = mode != GHOSTTY_FULLSCREEN_NON_NATIVE_VISIBLE_MENU enterFullscreen(window: window, hideMenu: hideMenu) isInNonNativeFullscreen = true } else { diff --git a/src/Surface.zig b/src/Surface.zig index ed83b2af8..a37f0a7e8 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -515,14 +515,25 @@ pub fn init( errdefer self.io.deinit(); // Report initial cell size on surface creation - try rt_surface.setCellSize(cell_size.width, cell_size.height); + try rt_app.performAction( + .{ .surface = self }, + .cell_size, + .{ .width = cell_size.width, .height = cell_size.height }, + ); // Set a minimum size that is cols=10 h=4. This matches Mac's Terminal.app // but is otherwise somewhat arbitrary. - try rt_surface.setSizeLimits(.{ - .width = cell_size.width * 10, - .height = cell_size.height * 4, - }, null); + try rt_app.performAction( + .{ .surface = self }, + .size_limit, + .{ + .min_width = cell_size.width * 10, + .min_height = cell_size.height * 4, + // No max: + .max_width = 0, + .max_height = 0, + }, + ); // Call our size callback which handles all our retina setup // Note: this shouldn't be necessary and when we clean up the surface @@ -576,13 +587,23 @@ pub fn init( padding.top + padding.bottom; - rt_surface.setInitialWindowSize(final_width, final_height) catch |err| { + rt_app.performAction( + .{ .surface = self }, + .initial_size, + .{ .width = final_width, .height = final_height }, + ) catch |err| { + // We don't treat this as a fatal error because not setting + // an initial size shouldn't stop our terminal from working. log.warn("unable to set initial window size: {s}", .{err}); }; } if (config.title) |title| { - try rt_surface.setTitle(title); + try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); } else if ((comptime builtin.os.tag == .linux) and config.@"_xdg-terminal-exec") xdg: { @@ -599,7 +620,11 @@ pub fn init( break :xdg; }; defer alloc.free(title); - try rt_surface.setTitle(title); + try rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); } } } @@ -743,7 +768,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { // We know that our title should end in 0. const slice = std.mem.sliceTo(@as([*:0]const u8, @ptrCast(v)), 0); log.debug("changing title \"{s}\"", .{slice}); - try self.rt_surface.setTitle(slice); + try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = slice }, + ); }, .report_title => |style| { @@ -769,7 +798,11 @@ pub fn handleMessage(self: *Surface, msg: Message) !void { .set_mouse_shape => |shape| { log.debug("changing mouse shape: {}", .{shape}); - try self.rt_surface.setMouseShape(shape); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + shape, + ); }, .clipboard_read => |clipboard| { @@ -897,7 +930,13 @@ fn modsChanged(self: *Surface, mods: input.Mods) void { /// Called when our renderer health state changes. fn updateRendererHealth(self: *Surface, health: renderer.Health) void { log.warn("renderer health status change status={}", .{health}); - self.rt_surface.updateRendererHealth(health); + self.rt_app.performAction( + .{ .surface = self }, + .renderer_health, + health, + ) catch |err| { + log.warn("failed to notify app of renderer health change err={}", .{err}); + }; } /// Update our configuration at runtime. @@ -1194,7 +1233,11 @@ fn setCellSize(self: *Surface, size: renderer.CellSize) !void { }, .unlocked); // Notify the window - try self.rt_surface.setCellSize(size.width, size.height); + try self.rt_app.performAction( + .{ .surface = self }, + .cell_size, + .{ .width = size.width, .height = size.height }, + ); } /// Change the font size. @@ -1214,10 +1257,14 @@ pub fn setFontSize(self: *Surface, size: font.face.DesiredSize) !void { errdefer self.app.font_grid_set.deref(font_grid_key); // Set our cell size - try self.setCellSize(.{ - .width = font_grid.metrics.cell_width, - .height = font_grid.metrics.cell_height, - }); + try self.rt_app.performAction( + .{ .surface = self }, + .cell_size, + .{ + .width = font_grid.metrics.cell_width, + .height = font_grid.metrics.cell_height, + }, + ); // Notify our render thread of the new font stack. The renderer // MUST accept the new font grid and deref the old. @@ -1472,8 +1519,11 @@ pub fn keyCallback( .mods = self.mouse.mods, .over_link = self.mouse.over_link, .hidden = self.mouse.hidden, - }).keyToMouseShape()) |shape| - try self.rt_surface.setMouseShape(shape); + }).keyToMouseShape()) |shape| try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + shape, + ); // We've processed a key event that produced some data so we want to // track the last pressed key. @@ -2975,7 +3025,11 @@ pub fn cursorPosCallback( // We also queue a render so the renderer can undo the rendered link // state. if (over_link) { - self.rt_surface.mouseOverLink(null); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); try self.queueRender(); } @@ -3061,7 +3115,11 @@ pub fn cursorPosCallback( self.renderer_state.mouse.point = pos_vp; self.mouse.over_link = true; self.renderer_state.terminal.screen.dirty.hyperlink_hover = true; - try self.rt_surface.setMouseShape(.pointer); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + .pointer, + ); switch (link[0]) { .open => { @@ -3070,7 +3128,11 @@ pub fn cursorPosCallback( .trim = false, }); defer self.alloc.free(str); - self.rt_surface.mouseOverLink(str); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = str }, + ); }, ._open_osc8 => link: { @@ -3080,14 +3142,26 @@ pub fn cursorPosCallback( log.warn("failed to get URI for OSC8 hyperlink", .{}); break :link; }; - self.rt_surface.mouseOverLink(uri); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = uri }, + ); }, } try self.queueRender(); } else if (over_link) { - try self.rt_surface.setMouseShape(self.io.terminal.mouse_shape); - self.rt_surface.mouseOverLink(null); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_shape, + self.io.terminal.mouse_shape, + ); + try self.rt_app.performAction( + .{ .surface = self }, + .mouse_over_link, + .{ .url = "" }, + ); try self.queueRender(); } } @@ -3396,13 +3470,25 @@ fn scrollToBottom(self: *Surface) !void { fn hideMouse(self: *Surface) void { if (self.mouse.hidden) return; self.mouse.hidden = true; - self.rt_surface.setMouseVisibility(false); + self.rt_app.performAction( + .{ .surface = self }, + .mouse_visibility, + .hidden, + ) catch |err| { + log.warn("apprt failed to set mouse visibility err={}", .{err}); + }; } fn showMouse(self: *Surface) void { if (!self.mouse.hidden) return; self.mouse.hidden = false; - self.rt_surface.setMouseVisibility(true); + self.rt_app.performAction( + .{ .surface = self }, + .mouse_visibility, + .visible, + ) catch |err| { + log.warn("apprt failed to set mouse visibility err={}", .{err}); + }; } /// Perform a binding action. A binding is a keybinding. This function diff --git a/src/apprt.zig b/src/apprt.zig index 7651ace9b..dd726b3f2 100644 --- a/src/apprt.zig +++ b/src/apprt.zig @@ -31,7 +31,6 @@ pub const ClipboardRequest = structs.ClipboardRequest; pub const ClipboardRequestType = structs.ClipboardRequestType; pub const ColorScheme = structs.ColorScheme; pub const CursorPos = structs.CursorPos; -pub const DesktopNotification = structs.DesktopNotification; pub const IMEPos = structs.IMEPos; pub const Selection = structs.Selection; pub const SurfaceSize = structs.SurfaceSize; diff --git a/src/apprt/action.zig b/src/apprt/action.zig index edeb02d7a..70c189c8f 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -1,13 +1,45 @@ const std = @import("std"); const assert = std.debug.assert; +const apprt = @import("../apprt.zig"); +const renderer = @import("../renderer.zig"); +const terminal = @import("../terminal/main.zig"); const CoreSurface = @import("../Surface.zig"); /// The target for an action. This is generally the thing that had focus /// while the action was made but the concept of "focus" is not guaranteed /// since actions can also be triggered by timers, scripts, etc. -pub const Target = union(enum) { +pub const Target = union(Key) { app, surface: *CoreSurface, + + // Sync with: ghostty_target_tag_e + pub const Key = enum(c_int) { + app, + surface, + }; + + // Sync with: ghostty_target_u + pub const CValue = extern union { + app: void, + surface: *apprt.Surface, + }; + + // Sync with: ghostty_target_s + pub const C = extern struct { + key: Key, + value: CValue, + }; + + /// Convert to ghostty_target_s. + pub fn cval(self: Target) C { + return .{ + .key = @as(Key, self), + .value = switch (self) { + .app => .{ .app = {} }, + .surface => |v| .{ .surface = v.rt_surface }, + }, + }; + } }; /// The possible actions an apprt has to react to. Actions are one-way @@ -19,7 +51,23 @@ pub const Target = union(enum) { /// Importantly, actions are generally OPTIONAL to implement by an apprt. /// Required functionality is called directly on the runtime structure so /// there is a compiler error if an action is not implemented. -pub const Action = union(enum) { +pub const Action = union(Key) { + // A GUIDE TO ADDING NEW ACTIONS: + // + // 1. Add the action to the `Key` enum. The order of the enum matters + // because it maps directly to the libghostty C enum. For ABI + // compatibility, new actions should be added to the end of the enum. + // + // 2. Add the action and optional value to the Action union. + // + // 3. If the value type is not void, ensure the value is C ABI + // compatible (extern). If it is not, add a `C` decl to the value + // and a `cval` function to convert to the C ABI compatible value. + // + // 4. Update `include/ghostty.h`: add the new key, value, and union + // entry. If the value type is void then only the key needs to be + // added. Ensure the order matches exactly with the Zig code. + /// Open a new window. The target determines whether properties such /// as font size should be inherited. new_window, @@ -62,12 +110,42 @@ pub const Action = union(enum) { /// Present the target terminal whether its a tab, split, or window. present_terminal, + /// Sets a size limit (in pixels) for the target terminal. + size_limit: SizeLimit, + + /// Specifies the initial size of the target terminal. This will be + /// sent only during the initialization of a surface. If it is received + /// after the surface is initialized it should be ignored. + initial_size: InitialSize, + + /// The cell size has changed to the given dimensions in pixels. + cell_size: CellSize, + /// Control whether the inspector is shown or hidden. inspector: Inspector, + /// The inspector for the given target has changes and should be + /// rendered at the next opportunity. + render_inspector, + /// Show a desktop notification. desktop_notification: DesktopNotification, + /// Set the title of the target. + set_title: SetTitle, + + /// Set the mouse cursor shape. + mouse_shape: terminal.MouseShape, + + /// Set whether the mouse cursor is visible or not. + mouse_visibility: MouseVisibility, + + /// Called when the mouse is over or recently left a link. + mouse_over_link: MouseOverLink, + + /// The health of the renderer has changed. + renderer_health: renderer.Health, + /// Open the Ghostty configuration. This is platform-specific about /// what it means; it can mean opening a dedicated UI or just opening /// a file in a text editor. @@ -86,8 +164,69 @@ pub const Action = union(enum) { /// system APIs to not log the input, etc. secure_input: SecureInput, - /// The enum of keys in the tagged union. - pub const Key = @typeInfo(Action).Union.tag_type.?; + /// Sync with: ghostty_action_tag_e + pub const Key = enum(c_int) { + new_window, + new_tab, + new_split, + close_all_windows, + toggle_fullscreen, + toggle_window_decorations, + goto_tab, + goto_split, + resize_split, + equalize_splits, + toggle_split_zoom, + present_terminal, + size_limit, + initial_size, + cell_size, + inspector, + render_inspector, + desktop_notification, + set_title, + mouse_shape, + mouse_visibility, + mouse_over_link, + renderer_health, + open_config, + quit_timer, + secure_input, + }; + + /// Sync with: ghostty_action_u + pub const CValue = cvalue: { + const key_fields = @typeInfo(Key).Enum.fields; + var union_fields: [key_fields.len]std.builtin.Type.UnionField = undefined; + for (key_fields, 0..) |field, i| { + const action = @unionInit(Action, field.name, undefined); + const Type = t: { + const Type = @TypeOf(@field(action, field.name)); + // Types can provide custom types for their CValue. + if (Type != void and @hasDecl(Type, "C")) break :t Type.C; + break :t Type; + }; + + union_fields[i] = .{ + .name = field.name, + .type = Type, + .alignment = @alignOf(Type), + }; + } + + break :cvalue @Type(.{ .Union = .{ + .layout = .@"extern", + .tag_type = Key, + .fields = &union_fields, + .decls = &.{}, + } }); + }; + + /// Sync with: ghostty_action_s + pub const C = extern struct { + key: Key, + value: CValue, + }; /// Returns the value type for the given key. pub fn Value(comptime key: Key) type { @@ -98,6 +237,22 @@ pub const Action = union(enum) { unreachable; } + + /// Convert to ghostty_action_s. + pub fn cval(self: Action) C { + const value: CValue = switch (self) { + inline else => |v, tag| @unionInit( + CValue, + @tagName(tag), + if (@TypeOf(v) != void and @hasDecl(@TypeOf(v), "cval")) v.cval() else v, + ), + }; + + return .{ + .key = @as(Key, self), + .value = value, + }; + } }; // This is made extern (c_int) to make interop easier with our embedded @@ -120,7 +275,7 @@ pub const GotoSplit = enum(c_int) { }; /// The amount to resize the split by and the direction to resize it in. -pub const ResizeSplit = struct { +pub const ResizeSplit = extern struct { amount: u16, direction: Direction, @@ -170,8 +325,75 @@ pub const QuitTimer = enum(c_int) { stop, }; +pub const MouseVisibility = enum(c_int) { + visible, + hidden, +}; + +pub const MouseOverLink = struct { + url: []const u8, + + // Sync with: ghostty_action_mouse_over_link_s + pub const C = extern struct { + url: [*]const u8, + len: usize, + }; + + pub fn cval(self: MouseOverLink) C { + return .{ + .url = self.url.ptr, + .len = self.url.len, + }; + } +}; + +pub const SizeLimit = extern struct { + min_width: u32, + min_height: u32, + max_width: u32, + max_height: u32, +}; + +pub const InitialSize = extern struct { + width: u32, + height: u32, +}; + +pub const CellSize = extern struct { + width: u32, + height: u32, +}; + +pub const SetTitle = struct { + title: [:0]const u8, + + // Sync with: ghostty_action_set_title_s + pub const C = extern struct { + title: [*:0]const u8, + }; + + pub fn cval(self: SetTitle) C { + return .{ + .title = self.title.ptr, + }; + } +}; + /// The desktop notification to show. pub const DesktopNotification = struct { title: [:0]const u8, body: [:0]const u8, + + // Sync with: ghostty_action_desktop_notification_s + pub const C = extern struct { + title: [*:0]const u8, + body: [*:0]const u8, + }; + + pub fn cval(self: DesktopNotification) C { + return .{ + .title = self.title.ptr, + .body = self.body.ptr, + }; + } }; diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index ca53f137e..88a69050f 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -44,23 +44,14 @@ pub const App = struct { /// a full tick of the app loop. wakeup: *const fn (AppUD) callconv(.C) void, + /// Callback called to handle an action. + action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.C) void, + /// Reload the configuration and return the new configuration. /// The old configuration can be freed immediately when this is /// called. reload_config: *const fn (AppUD) callconv(.C) ?*const Config, - /// Open the configuration file. - open_config: *const fn (AppUD) callconv(.C) void, - - /// Called to set the title of the window. - set_title: *const fn (SurfaceUD, [*]const u8) callconv(.C) void, - - /// Called to set the cursor shape. - set_mouse_shape: *const fn (SurfaceUD, terminal.MouseShape) callconv(.C) void, - - /// Called to set the mouse visibility. - set_mouse_visibility: *const fn (SurfaceUD, bool) callconv(.C) void, - /// Read the clipboard value. The return value must be preserved /// by the host until the next call. If there is no valid clipboard /// value then this should return null. @@ -79,73 +70,8 @@ pub const App = struct { /// Write the clipboard value. write_clipboard: *const fn (SurfaceUD, [*:0]const u8, c_int, bool) callconv(.C) void, - /// Create a new split view. If the embedder doesn't support split - /// views then this can be null. - new_split: ?*const fn (SurfaceUD, apprt.action.SplitDirection, apprt.Surface.Options) callconv(.C) void = null, - - /// New tab with options. The surface may be null if there is no target - /// surface in which case the apprt is expected to create a new window. - new_tab: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - - /// New window with options. The surface may be null if there is no - /// target surface. - new_window: ?*const fn (SurfaceUD, apprt.Surface.Options) callconv(.C) void = null, - - /// Control the inspector visibility - control_inspector: ?*const fn (SurfaceUD, apprt.action.Inspector) callconv(.C) void = null, - /// Close the current surface given by this function. close_surface: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, - - /// Focus the previous/next split (if any). - focus_split: ?*const fn (SurfaceUD, apprt.action.GotoSplit) callconv(.C) void = null, - - /// Resize the current split. - resize_split: ?*const fn (SurfaceUD, apprt.action.ResizeSplit.Direction, u16) callconv(.C) void = null, - - /// Equalize all splits in the current window - equalize_splits: ?*const fn (SurfaceUD) callconv(.C) void = null, - - /// Zoom the current split. - toggle_split_zoom: ?*const fn (SurfaceUD) callconv(.C) void = null, - - /// Goto tab - goto_tab: ?*const fn (SurfaceUD, apprt.action.GotoTab) callconv(.C) void = null, - - /// Toggle fullscreen for current window. - toggle_fullscreen: ?*const fn (SurfaceUD, configpkg.NonNativeFullscreen) callconv(.C) void = null, - - /// Set the initial window size. It is up to the user of libghostty to - /// determine if it is the initial window and set this appropriately. - set_initial_window_size: ?*const fn (SurfaceUD, u32, u32) callconv(.C) void = null, - - /// Render the inspector for the given surface. - render_inspector: ?*const fn (SurfaceUD) callconv(.C) void = null, - - /// 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. The surface may be null - /// if the notification is global. - show_desktop_notification: ?*const fn (SurfaceUD, [*:0]const u8, [*:0]const u8) void = null, - - /// Called when the health of the renderer changes. - update_renderer_health: ?*const fn (SurfaceUD, renderer.Health) void = null, - - /// Called when the mouse goes over a link. The link target is the - /// parameter. The link target will be null if the mouse is no longer - /// over a link. - mouse_over_link: ?*const fn (SurfaceUD, ?[*]const u8, usize) void = null, - - /// Notifies that a password input has been started for the given - /// surface. The apprt can use this to modify UI, enable features - /// such as macOS secure input, etc. - /// - /// The surface userdata will be null if a surface isn't focused. - set_password_input: ?*const fn (SurfaceUD, bool) callconv(.C) void = null, - - /// Toggle secure input for the application. - toggle_secure_input: ?*const fn () callconv(.C) void = null, }; /// This is the key event sent for ghostty_surface_key and @@ -492,213 +418,6 @@ pub const App = struct { surface.queueInspectorRender(); } - fn newWindow(self: *App, parent: ?*CoreSurface) !void { - // If we have a parent, the surface logic handles it. - if (parent) |surface| { - try surface.rt_surface.newWindow(); - return; - } - - // No parent, call the new window callback. - const func = self.opts.new_window orelse { - log.info("runtime embedder does not support new_window", .{}); - return; - }; - - func(null, .{}); - } - - fn toggleFullscreen( - self: *App, - target: apprt.Target, - fullscreen: apprt.action.Fullscreen, - ) void { - const func = self.opts.toggle_fullscreen orelse { - log.info("runtime embedder does not toggle_fullscreen", .{}); - return; - }; - - switch (target) { - .app => {}, - .surface => |v| func( - v.rt_surface.userdata, - switch (fullscreen) { - .native => .false, - .macos_non_native => .true, - .macos_non_native_visible_menu => .@"visible-menu", - }, - ), - } - } - - fn newTab(self: *const App, target: apprt.Target) void { - const func = self.opts.new_tab orelse { - log.info("runtime embedder does not support new_tab", .{}); - return; - }; - - switch (target) { - .app => func(null, .{}), - .surface => |v| func( - v.rt_surface.userdata, - v.rt_surface.newSurfaceOptions(), - ), - } - } - - fn gotoTab(self: *App, target: apprt.Target, tab: apprt.action.GotoTab) void { - const func = self.opts.goto_tab orelse { - log.info("runtime embedder does not support goto_tab", .{}); - return; - }; - - switch (target) { - .app => {}, - .surface => |v| func(v.rt_surface.userdata, tab), - } - } - - fn newSplit( - self: *const App, - target: apprt.Target, - direction: apprt.action.SplitDirection, - ) void { - const func = self.opts.new_split orelse { - log.info("runtime embedder does not support splits", .{}); - return; - }; - - switch (target) { - .app => func(null, direction, .{}), - .surface => |v| func( - v.rt_surface.userdata, - direction, - v.rt_surface.newSurfaceOptions(), - ), - } - } - - fn gotoSplit( - self: *const App, - target: apprt.Target, - direction: apprt.action.GotoSplit, - ) void { - const func = self.opts.focus_split orelse { - log.info("runtime embedder does not support focus split", .{}); - return; - }; - - switch (target) { - .app => {}, - .surface => |v| func(v.rt_surface.userdata, direction), - } - } - - fn resizeSplit( - self: *const App, - target: apprt.Target, - resize: apprt.action.ResizeSplit, - ) void { - const func = self.opts.resize_split orelse { - log.info("runtime embedder does not support resize split", .{}); - return; - }; - - switch (target) { - .app => {}, - .surface => |v| func( - v.rt_surface.userdata, - resize.direction, - resize.amount, - ), - } - } - - pub fn equalizeSplits(self: *const App, target: apprt.Target) void { - const func = self.opts.equalize_splits orelse { - log.info("runtime embedder does not support equalize splits", .{}); - return; - }; - - switch (target) { - .app => func(null), - .surface => |v| func(v.rt_surface.userdata), - } - } - - fn toggleSplitZoom(self: *const App, target: apprt.Target) void { - const func = self.opts.toggle_split_zoom orelse { - log.info("runtime embedder does not support split zoom", .{}); - return; - }; - - switch (target) { - .app => func(null), - .surface => |v| func(v.rt_surface.userdata), - } - } - - fn controlInspector( - self: *const App, - target: apprt.Target, - value: apprt.action.Inspector, - ) void { - const func = self.opts.control_inspector orelse { - log.info("runtime embedder does not support the terminal inspector", .{}); - return; - }; - - switch (target) { - .app => {}, - .surface => |v| func(v.rt_surface.userdata, value), - } - } - - fn showDesktopNotification( - self: *const App, - target: apprt.Target, - notification: apprt.action.DesktopNotification, - ) void { - const func = self.opts.show_desktop_notification orelse { - log.info("runtime embedder does not support show_desktop_notification", .{}); - return; - }; - - func(switch (target) { - .app => null, - .surface => |v| v.rt_surface.userdata, - }, notification.title, notification.body); - } - - fn setPasswordInput(self: *App, target: apprt.Target, v: apprt.action.SecureInput) void { - switch (v) { - inline .on, .off => |tag| { - const func = self.opts.set_password_input orelse { - log.info("runtime embedder does not support set_password_input", .{}); - return; - }; - - func(switch (target) { - .app => null, - .surface => |surface| surface.rt_surface.userdata, - }, switch (tag) { - .on => true, - .off => false, - else => comptime unreachable, - }); - }, - - .toggle => { - const func = self.opts.toggle_secure_input orelse { - log.info("runtime embedder does not support toggle_secure_input", .{}); - return; - }; - - func(); - }, - } - } - /// Perform a given action. pub fn performAction( self: *App, @@ -706,32 +425,27 @@ pub const App = struct { comptime action: apprt.Action.Key, value: apprt.Action.Value(action), ) !void { + // Special case certain actions before they are sent to the embedder switch (action) { - .new_window => _ = try self.newWindow(switch (target) { - .app => null, - .surface => |v| v, - }), - .toggle_fullscreen => self.toggleFullscreen(target, value), + .set_title => switch (target) { + .app => {}, + .surface => |surface| { + // Dupe the title so that we can store it. If we get an allocation + // error we just ignore it, since this only breaks a few minor things. + const alloc = self.core_app.alloc; + if (surface.rt_surface.title) |v| alloc.free(v); + surface.rt_surface.title = alloc.dupeZ(u8, value.title) catch null; + }, + }, - .new_tab => self.newTab(target), - .goto_tab => self.gotoTab(target, value), - .new_split => self.newSplit(target, value), - .resize_split => self.resizeSplit(target, value), - .equalize_splits => self.equalizeSplits(target), - .toggle_split_zoom => self.toggleSplitZoom(target), - .goto_split => self.gotoSplit(target, value), - .open_config => try configpkg.edit.open(self.core_app.alloc), - .inspector => self.controlInspector(target, value), - .desktop_notification => self.showDesktopNotification(target, value), - .secure_input => self.setPasswordInput(target, value), - - // Unimplemented - .present_terminal, - .close_all_windows, - .toggle_window_decorations, - .quit_timer, - => log.warn("unimplemented action={}", .{action}), + else => {}, } + + self.opts.action( + self, + target.cval(), + @unionInit(apprt.Action, @tagName(action), value).cval(), + ); } }; @@ -966,44 +680,10 @@ pub const Surface = struct { return self.size; } - pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { - _ = self; - _ = min; - _ = max_; - } - - pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { - // Dupe the title so that we can store it. If we get an allocation - // error we just ignore it, since this only breaks a few minor things. - const alloc = self.app.core_app.alloc; - if (self.title) |v| alloc.free(v); - self.title = alloc.dupeZ(u8, slice) catch null; - - self.app.opts.set_title( - self.userdata, - slice.ptr, - ); - } - pub fn getTitle(self: *Surface) ?[:0]const u8 { return self.title; } - pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { - self.app.opts.set_mouse_shape( - self.userdata, - shape, - ); - } - - /// Set the visibility of the mouse cursor. - pub fn setMouseVisibility(self: *Surface, visible: bool) void { - self.app.opts.set_mouse_visibility( - self.userdata, - visible, - ); - } - pub fn supportsClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, @@ -1236,44 +916,18 @@ pub const Surface = struct { }; } - fn newWindow(self: *const Surface) !void { - const func = self.app.opts.new_window orelse { - log.info("runtime embedder does not support new_window", .{}); + fn queueInspectorRender(self: *Surface) void { + self.app.performAction( + .{ .surface = &self.core_surface }, + .render_inspector, + {}, + ) catch |err| { + log.err("error rendering the inspector err={}", .{err}); return; }; - - const options = self.newSurfaceOptions(); - func(self.userdata, options); } - pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { - const func = self.app.opts.set_initial_window_size orelse { - log.info("runtime embedder does not set_initial_window_size", .{}); - return; - }; - - func(self.userdata, width, height); - } - - fn queueInspectorRender(self: *const Surface) void { - const func = self.app.opts.render_inspector orelse { - log.info("runtime embedder does not render_inspector", .{}); - return; - }; - - func(self.userdata); - } - - pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { - const func = self.app.opts.set_cell_size orelse { - log.info("runtime embedder does not support set_cell_size", .{}); - return; - }; - - func(self.userdata, width, height); - } - - fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options { + pub fn newSurfaceOptions(self: *const Surface) apprt.Surface.Options { const font_size: f32 = font_size: { if (!self.app.config.@"window-inherit-font-size") break :font_size 0; break :font_size self.core_surface.font_size.points; @@ -1290,29 +944,6 @@ pub const Surface = struct { const scale = try self.getContentScale(); return .{ .x = pos.x * scale.x, .y = pos.y * scale.y }; } - - /// Update the health of the renderer. - pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { - const func = self.app.opts.update_renderer_health orelse { - log.info("runtime embedder does not support update_renderer_health", .{}); - return; - }; - - func(self.userdata, health); - } - - pub fn mouseOverLink(self: *const Surface, uri: ?[]const u8) void { - const func = self.app.opts.mouse_over_link orelse { - log.info("runtime embedder does not support over_link", .{}); - return; - }; - - if (uri) |v| { - func(self.userdata, v.ptr, v.len); - } else { - func(self.userdata, null, 0); - } - } }; /// Inspector is the state required for the terminal inspector. A terminal @@ -1735,11 +1366,21 @@ pub const CAPI = struct { ptr.app.closeSurface(ptr); } + /// Returns the userdata associated with the surface. + export fn ghostty_surface_userdata(surface: *Surface) ?*anyopaque { + return surface.userdata; + } + /// Returns the app associated with a surface. export fn ghostty_surface_app(surface: *Surface) *App { return surface.app; } + /// Returns the config to use for surfaces that inherit from this one. + export fn ghostty_surface_inherited_config(surface: *Surface) Surface.Options { + return surface.newSurfaceOptions(); + } + /// Returns true if the surface needs to confirm quitting. export fn ghostty_surface_needs_confirm_quit(surface: *Surface) bool { return surface.core_surface.needsConfirmQuit(); diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index b73aefced..57667afb1 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -134,8 +134,6 @@ pub const App = struct { comptime action: apprt.Action.Key, value: apprt.Action.Value(action), ) !void { - _ = value; - switch (action) { .new_window => _ = try self.newSurface(switch (target) { .app => null, @@ -147,10 +145,47 @@ pub const App = struct { .surface => |v| v, }), + .size_limit => switch (target) { + .app => {}, + .surface => |surface| try surface.rt_surface.setSizeLimits(.{ + .width = value.min_width, + .height = value.min_height, + }, if (value.max_width > 0) .{ + .width = value.max_width, + .height = value.max_height, + } else null), + }, + + .initial_size => switch (target) { + .app => {}, + .surface => |surface| try surface.rt_surface.setInitialWindowSize( + value.width, + value.height, + ), + }, + .toggle_fullscreen => self.toggleFullscreen(target), .open_config => try configpkg.edit.open(self.app.alloc), + .set_title => switch (target) { + .app => {}, + .surface => |surface| try surface.rt_surface.setTitle(value.title), + }, + + .mouse_shape => switch (target) { + .app => {}, + .surface => |surface| try surface.rt_surface.setMouseShape(value), + }, + + .mouse_visibility => switch (target) { + .app => {}, + .surface => |surface| surface.rt_surface.setMouseVisibility(switch (value) { + .visible => true, + .hidden => false, + }), + }, + // Unimplemented .new_split, .goto_split, @@ -162,9 +197,13 @@ pub const App = struct { .toggle_window_decorations, .goto_tab, .inspector, + .render_inspector, .quit_timer, .secure_input, .desktop_notification, + .mouse_over_link, + .cell_size, + .renderer_health, => log.info("unimplemented action={}", .{action}), } } @@ -581,7 +620,7 @@ pub const Surface = struct { /// Set the initial window size. This is called exactly once at /// surface initialization time. This may be called before "self" /// is fully initialized. - pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { + fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void { const monitor = self.window.getMonitor() orelse glfw.Monitor.getPrimary() orelse { log.warn("window is not on a monitor, not setting initial size", .{}); return; @@ -594,18 +633,11 @@ pub const Surface = struct { }); } - /// Set the cell size. Unused by GLFW. - pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { - _ = self; - _ = width; - _ = height; - } - /// Set the size limits of the window. /// Note: this interface is not good, we should redo it if we plan /// to use this more. i.e. you can't set max width but no max height, /// or no mins. - pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { + fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { self.window.setSizeLimits(.{ .width = min.width, .height = min.height, @@ -655,7 +687,7 @@ pub const Surface = struct { } /// Set the title of the window. - pub fn setTitle(self: *Surface, slice: [:0]const u8) !void { + fn setTitle(self: *Surface, slice: [:0]const u8) !void { if (self.title_text) |t| self.core_surface.alloc.free(t); self.title_text = try self.core_surface.alloc.dupeZ(u8, slice); self.window.setTitle(self.title_text.?.ptr); @@ -667,7 +699,7 @@ pub const Surface = struct { } /// Set the shape of the cursor. - pub fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { + fn setMouseShape(self: *Surface, shape: terminal.MouseShape) !void { if ((comptime builtin.target.isDarwin()) and !internal_os.macosVersionAtLeast(13, 0, 0)) { @@ -703,23 +735,11 @@ pub const Surface = struct { self.cursor = new; } - pub fn mouseOverLink(self: *Surface, uri: ?[]const u8) void { - // We don't do anything in GLFW. - _ = self; - _ = uri; - } - /// Set the visibility of the mouse cursor. - pub fn setMouseVisibility(self: *Surface, visible: bool) void { + fn setMouseVisibility(self: *Surface, visible: bool) void { self.window.setInputModeCursor(if (visible) .normal else .hidden); } - pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { - // We don't support this in GLFW. - _ = self; - _ = health; - } - pub fn supportsClipboard( self: *const Surface, clipboard_type: apprt.Clipboard, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 1d97731fa..45031324a 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -17,6 +17,7 @@ const apprt = @import("../../apprt.zig"); const configpkg = @import("../../config.zig"); const input = @import("../../input.zig"); const internal_os = @import("../../os/main.zig"); +const terminal = @import("../../terminal/main.zig"); const Config = configpkg.Config; const CoreApp = @import("../../App.zig"); const CoreSurface = @import("../../Surface.zig"); @@ -365,14 +366,23 @@ pub fn performAction( .open_config => try configpkg.edit.open(self.core_app.alloc), .inspector => self.controlInspector(target, value), .desktop_notification => self.showDesktopNotification(target, value), + .set_title => try self.setTitle(target, value), .present_terminal => self.presentTerminal(target), + .initial_size => try self.setInitialSize(target, value), + .mouse_visibility => self.setMouseVisibility(target, value), + .mouse_shape => try self.setMouseShape(target, value), + .mouse_over_link => self.setMouseOverLink(target, value), .toggle_window_decorations => self.toggleWindowDecorations(target), .quit_timer => self.quitTimer(value), // Unimplemented .close_all_windows, .toggle_split_zoom, + .size_limit, + .cell_size, .secure_input, + .render_inspector, + .renderer_health, => log.warn("unimplemented action={}", .{action}), } } @@ -551,6 +561,66 @@ fn quitTimer(self: *App, mode: apprt.action.QuitTimer) void { } } +fn setTitle( + _: *App, + target: apprt.Target, + title: apprt.action.SetTitle, +) !void { + switch (target) { + .app => {}, + .surface => |v| try v.rt_surface.setTitle(title.title), + } +} + +fn setMouseVisibility( + _: *App, + target: apprt.Target, + visibility: apprt.action.MouseVisibility, +) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.setMouseVisibility(switch (visibility) { + .visible => true, + .hidden => false, + }), + } +} + +fn setMouseShape( + _: *App, + target: apprt.Target, + shape: terminal.MouseShape, +) !void { + switch (target) { + .app => {}, + .surface => |v| try v.rt_surface.setMouseShape(shape), + } +} + +fn setMouseOverLink( + _: *App, + target: apprt.Target, + value: apprt.action.MouseOverLink, +) void { + switch (target) { + .app => {}, + .surface => |v| v.rt_surface.mouseOverLink(value.url), + } +} + +fn setInitialSize( + _: *App, + target: apprt.Target, + value: apprt.action.InitialSize, +) !void { + switch (target) { + .app => {}, + .surface => |v| try v.rt_surface.setInitialWindowSize( + value.width, + value.height, + ), + } +} fn showDesktopNotification( self: *App, target: apprt.Target, diff --git a/src/apprt/gtk/Surface.zig b/src/apprt/gtk/Surface.zig index 054eb675d..73837d11d 100644 --- a/src/apprt/gtk/Surface.zig +++ b/src/apprt/gtk/Surface.zig @@ -784,18 +784,6 @@ pub fn setInitialWindowSize(self: *const Surface, width: u32, height: u32) !void ); } -pub fn setCellSize(self: *const Surface, width: u32, height: u32) !void { - _ = self; - _ = width; - _ = height; -} - -pub fn setSizeLimits(self: *Surface, min: apprt.SurfaceSize, max_: ?apprt.SurfaceSize) !void { - _ = self; - _ = min; - _ = max_; -} - pub fn grabFocus(self: *Surface) void { if (self.container.tab()) |tab| tab.focus_child = self; @@ -1921,9 +1909,3 @@ pub fn present(self: *Surface) void { self.grabFocus(); } - -pub fn updateRendererHealth(self: *const Surface, health: renderer.Health) void { - // We don't support this in GTK. - _ = self; - _ = health; -} diff --git a/src/apprt/structs.zig b/src/apprt/structs.zig index f8bcd72cf..e2e9b913d 100644 --- a/src/apprt/structs.zig +++ b/src/apprt/structs.zig @@ -52,16 +52,6 @@ pub const ClipboardRequest = union(ClipboardRequestType) { 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, -}; - /// The color scheme in use (light vs dark). pub const ColorScheme = enum(u2) { light = 0, From fe4f4fdc72695d8f5e43f41ce06e34785f02262c Mon Sep 17 00:00:00 2001 From: Paul Berg Date: Tue, 24 Sep 2024 11:18:00 +0200 Subject: [PATCH 096/139] apprt/gtk: fix build with -Dgtk-libadwaita=false --- src/apprt/gtk/Window.zig | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 80bbd0944..50be404eb 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -152,14 +152,15 @@ pub fn init(self: *Window, app: *App) !void { c.gtk_widget_set_tooltip_text(btn, "Main Menu"); c.gtk_menu_button_set_icon_name(@ptrCast(btn), "open-menu-symbolic"); c.gtk_menu_button_set_menu_model(@ptrCast(btn), @ptrCast(@alignCast(app.menu))); - if (self.isAdwWindow()) - c.adw_header_bar_pack_end(@ptrCast(header), btn) - else - c.gtk_header_bar_pack_end(@ptrCast(header), btn); + if (self.isAdwWindow()) { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + c.adw_header_bar_pack_end(@ptrCast(header), btn); + } else c.gtk_header_bar_pack_end(@ptrCast(header), btn); } // If we're using an AdwWindow then we can support the tab overview. if (tab_overview_) |tab_overview| { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; assert(self.isAdwWindow()); const btn = c.gtk_toggle_button_new(); @@ -236,6 +237,7 @@ pub fn init(self: *Window, app: *App) !void { // If we have a tab overview then we can set it on our notebook. if (tab_overview_) |tab_overview| { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; assert(self.notebook == .adw_tab_view); c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); } @@ -257,6 +259,7 @@ pub fn init(self: *Window, app: *App) !void { initActions(self); if (self.hasAdwToolbar()) { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); const header_widget: *c.GtkWidget = @ptrCast(@alignCast(self.header.?)); From 6ef87d298c33523a87a8ba6b9cdc04a4d305084f Mon Sep 17 00:00:00 2001 From: Paul Berg Date: Tue, 24 Sep 2024 11:08:55 +0200 Subject: [PATCH 097/139] apprt/gtk: remove Window.hasAdwToolbar this is the same as isAdwWindow --- src/apprt/gtk/Window.zig | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 50be404eb..4e88dae83 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -258,7 +258,7 @@ pub fn init(self: *Window, app: *App) !void { // Our actions for the menu initActions(self); - if (self.hasAdwToolbar()) { + if (self.isAdwWindow()) { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; const toolbar_view: *c.AdwToolbarView = @ptrCast(c.adw_toolbar_view_new()); @@ -394,18 +394,9 @@ pub fn deinit(self: *Window) void { /// paths that are not enabled. inline fn isAdwWindow(self: *Window) bool { return (comptime adwaita.versionAtLeast(1, 4, 0)) and - adwaita.enabled(&self.app.config) and - self.app.config.@"gtk-titlebar" and - adwaita.versionAtLeast(1, 4, 0); -} - -/// This must be `inline` so that the comptime check noops conditional -/// paths that are not enabled. -inline fn hasAdwToolbar(self: *Window) bool { - return ((comptime adwaita.versionAtLeast(1, 4, 0)) and adwaita.enabled(&self.app.config) and adwaita.versionAtLeast(1, 4, 0) and - self.app.config.@"gtk-titlebar"); + self.app.config.@"gtk-titlebar"; } /// Add a new tab to this window. From 7b8b58110c99d19026041104d12453e617805c66 Mon Sep 17 00:00:00 2001 From: Paul Berg Date: Tue, 24 Sep 2024 11:09:53 +0200 Subject: [PATCH 098/139] apprt/gtk: store tab_overview in Window struct --- src/apprt/gtk/Window.zig | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index 4e88dae83..e23a8abf3 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -35,6 +35,10 @@ window: *c.GtkWindow, /// GtkHeaderBar depending on if adw is enabled and linked. header: ?*c.GtkWidget, +/// The tab overview for the window. This is possibly null since there is no +/// taboverview without a AdwApplicationWindow (libadwaita >= 1.4.0). +tab_overview: ?*c.GtkWidget, + /// The notebook (tab grouping) for this window. /// can be either c.GtkNotebook or c.AdwTabView. notebook: Notebook, @@ -68,6 +72,7 @@ pub fn init(self: *Window, app: *App) !void { .app = app, .window = undefined, .header = null, + .tab_overview = null, .notebook = undefined, .context_menu = undefined, .toast_overlay = undefined, @@ -114,7 +119,7 @@ pub fn init(self: *Window, app: *App) !void { const box = c.gtk_box_new(c.GTK_ORIENTATION_VERTICAL, 0); // If we are using an AdwWindow then we can support the tab overview. - const tab_overview_: ?*c.GtkWidget = if (self.isAdwWindow()) overview: { + self.tab_overview = if (self.isAdwWindow()) overview: { const tab_overview = c.adw_tab_overview_new(); c.adw_tab_overview_set_enable_new_tab(@ptrCast(tab_overview), 1); _ = c.g_signal_connect_data( @@ -159,7 +164,7 @@ pub fn init(self: *Window, app: *App) !void { } // If we're using an AdwWindow then we can support the tab overview. - if (tab_overview_) |tab_overview| { + if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; assert(self.isAdwWindow()); @@ -236,7 +241,7 @@ pub fn init(self: *Window, app: *App) !void { }; // If we have a tab overview then we can set it on our notebook. - if (tab_overview_) |tab_overview| { + if (self.tab_overview) |tab_overview| { if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; assert(self.notebook == .adw_tab_view); c.adw_tab_overview_set_view(@ptrCast(tab_overview), self.notebook.adw_tab_view); @@ -292,7 +297,7 @@ pub fn init(self: *Window, app: *App) !void { // Set our application window content. The content depends on if // we're using an AdwTabOverview or not. - if (tab_overview_) |tab_overview| { + if (self.tab_overview) |tab_overview| { c.adw_tab_overview_set_child( @ptrCast(tab_overview), @ptrCast(@alignCast(toolbar_view)), From f1474c220d1a4896120058bddbaa6e8ba53a1b48 Mon Sep 17 00:00:00 2001 From: Paul Berg Date: Fri, 27 Sep 2024 09:36:45 +0200 Subject: [PATCH 099/139] bind: add toggle_tab_overview binding --- src/Surface.zig | 6 ++++++ src/apprt/action.zig | 3 +++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 18 ++++++++++++++++++ src/apprt/gtk/Window.zig | 9 +++++++++ src/input/Binding.zig | 5 +++++ 6 files changed, 42 insertions(+) diff --git a/src/Surface.zig b/src/Surface.zig index a37f0a7e8..28d66d25d 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3830,6 +3830,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool {}, ), + .toggle_tab_overview => try self.rt_app.performAction( + .{ .surface = self }, + .toggle_tab_overview, + {}, + ), + .toggle_secure_input => try self.rt_app.performAction( .{ .surface = self }, .secure_input, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 70c189c8f..0941d65c9 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -87,6 +87,9 @@ pub const Action = union(Key) { /// Toggle fullscreen mode. toggle_fullscreen: Fullscreen, + /// Toggle tab overview. + toggle_tab_overview, + /// Toggle whether window directions are shown. toggle_window_decorations, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index 57667afb1..fb31f7c2b 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -194,6 +194,7 @@ pub const App = struct { .toggle_split_zoom, .present_terminal, .close_all_windows, + .toggle_tab_overview, .toggle_window_decorations, .goto_tab, .inspector, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 45031324a..294954bd4 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -372,6 +372,7 @@ pub fn performAction( .mouse_visibility => self.setMouseVisibility(target, value), .mouse_shape => try self.setMouseShape(target, value), .mouse_over_link => self.setMouseOverLink(target, value), + .toggle_tab_overview => self.toggleTabOverview(target), .toggle_window_decorations => self.toggleWindowDecorations(target), .quit_timer => self.quitTimer(value), @@ -534,6 +535,23 @@ fn toggleFullscreen( } } +fn toggleTabOverview(_: *App, target: apprt.Target) void { + switch (target) { + .app => {}, + .surface => |v| { + const window = v.rt_surface.container.window() orelse { + log.info( + "toggleTabOverview invalid for container={s}", + .{@tagName(v.rt_surface.container)}, + ); + return; + }; + + window.toggleTabOverview(); + }, + } +} + fn toggleWindowDecorations( _: *App, target: apprt.Target, diff --git a/src/apprt/gtk/Window.zig b/src/apprt/gtk/Window.zig index e23a8abf3..ff8735ff9 100644 --- a/src/apprt/gtk/Window.zig +++ b/src/apprt/gtk/Window.zig @@ -457,6 +457,15 @@ pub fn gotoTab(self: *Window, n: usize) void { } } +/// Toggle tab overview (if present) +pub fn toggleTabOverview(self: *Window) void { + if (self.tab_overview) |tab_overview_widget| { + if (comptime !adwaita.versionAtLeast(1, 4, 0)) unreachable; + const tab_overview: *c.AdwTabOverview = @ptrCast(@alignCast(tab_overview_widget)); + c.adw_tab_overview_set_open(tab_overview, 1 - c.adw_tab_overview_get_open(tab_overview)); + } +} + /// Toggle fullscreen for this window. pub fn toggleFullscreen(self: *Window) void { const is_fullscreen = c.gtk_window_is_fullscreen(self.window); diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 45ec24126..f9921a87e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -298,6 +298,10 @@ pub const Action = union(enum) { /// Go to the tab with the specific number, 1-indexed. goto_tab: usize, + /// Toggle the tab overview. + /// This only works with libadwaita enabled currently. + toggle_tab_overview: void, + /// Create a new split in the given direction. The new split will appear in /// the direction given. new_split: SplitDirection, @@ -607,6 +611,7 @@ pub const Action = union(enum) { .next_tab, .last_tab, .goto_tab, + .toggle_tab_overview, .new_split, .goto_split, .toggle_split_zoom, From be0d71f62df2afdab19a30bb9bd980e0d3288fe0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 10:15:06 -0700 Subject: [PATCH 100/139] apprt/embedded: add the tab overview to the enum --- include/ghostty.h | 1 + src/apprt/action.zig | 1 + 2 files changed, 2 insertions(+) diff --git a/include/ghostty.h b/include/ghostty.h index 571cbd904..7c81dbe77 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -505,6 +505,7 @@ typedef enum { GHOSTTY_ACTION_NEW_SPLIT, GHOSTTY_ACTION_CLOSE_ALL_WINDOWS, GHOSTTY_ACTION_TOGGLE_FULLSCREEN, + GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 0941d65c9..9ed89b5a3 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -174,6 +174,7 @@ pub const Action = union(Key) { new_split, close_all_windows, toggle_fullscreen, + toggle_tab_overview, toggle_window_decorations, goto_tab, goto_split, From 1bd0999eef4d51a89a82205da93a3309cb5a155a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 10:17:54 -0700 Subject: [PATCH 101/139] macos: add unhandled action --- macos/Sources/Ghostty/Ghostty.App.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f0128e2f7..5b2efad3e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -484,6 +484,8 @@ extension Ghostty { case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough + case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: + fallthrough case GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS: fallthrough case GHOSTTY_ACTION_PRESENT_TERMINAL: From 08ee32b6330ff753b2c49e8bf93de93e6fa6f936 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 10:50:43 -0700 Subject: [PATCH 102/139] Forward std_options from entrypoint in main.zig --- src/main.zig | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main.zig b/src/main.zig index a1f8d4a44..895ccfe48 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,3 +1,4 @@ +const std = @import("std"); const build_config = @import("build_config.zig"); /// See build_config.ExeEntrypoint for why we do this. @@ -16,6 +17,12 @@ const entrypoint = switch (build_config.exe_entrypoint) { /// The main entrypoint for the program. pub const main = entrypoint.main; +/// Standard options such as logger overrides. +pub const std_options: std.Options = if (@hasDecl(entrypoint, "std_options")) + entrypoint.std_options +else + .{}; + test { _ = entrypoint; } From 743e54723568485b24cd5279f764c28ef80d1fb7 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Mon, 23 Sep 2024 21:49:24 -0500 Subject: [PATCH 103/139] cli: "fancy" theme preview This adds a "fancy" theme preview to the `+list-themes` CLI action. By default, if the command is connected to a TTY, it will display the fancy preview. If it is not connected to a TTY, or the user specifies `--plain` on the command line, a simple list of themes will be printed to stdout. While in the preview `F1` or `?` will show a help screen. --- build.zig | 1 - build.zig.zon | 4 +- nix/zigCacheHash.nix | 2 +- src/cli/list_themes.zig | 1301 +++++++++++++++++++++++++++++++++++++-- src/cli/lorem_ipsum.txt | 45 ++ 5 files changed, 1295 insertions(+), 58 deletions(-) create mode 100644 src/cli/lorem_ipsum.txt diff --git a/build.zig b/build.zig index ba3e4f553..b834f57a3 100644 --- a/build.zig +++ b/build.zig @@ -1038,7 +1038,6 @@ fn addDeps( .optimize = optimize, .libxev = false, .images = false, - .text_input = false, }); const wuffs_dep = b.dependency("wuffs", .{ .target = target, diff --git a/build.zig.zon b/build.zig.zon index fbbd52131..f2add6c07 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -54,8 +54,8 @@ .hash = "122056fbb29863ec1678b7954fb76b1533ad8c581a34577c1b2efe419e29e05596df", }, .vaxis = .{ - .url = "git+https://github.com/rockorager/libvaxis?ref=main#a8baf9ce371b89a84383130c82549bb91401d15a", - .hash = "12207f53d7dddd3e5ca6577fcdd137dcf1fa32c9f22cbb0911ad0701cde4095a1c4c", + .url = "git+https://github.com/rockorager/libvaxis?ref=main#2cc1eb77f842dd8587dfc9cf399d42e4c1369175", + .hash = "12203c2d83911e6aacfbfdd48d31d6fc36e89947dfc7aec104debe3ac85e9f3a44f2", }, }, } diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 62c7f4767..ad2e6a827 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-MocGI5dxh+WO79p01HbdFuc+wR+sXSxBnoFAmrX4p0s=" +"sha256-MAzGg4tWlyv2X/GjAwm7s2whojawIKNMx1xWR+cZffQ=" diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index 4a90df1c5..dd812d5ca 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -4,15 +4,18 @@ const args = @import("args.zig"); const Action = @import("action.zig").Action; const Config = @import("../config/Config.zig"); const themepkg = @import("../config/theme.zig"); +const tui = @import("tui.zig"); const internal_os = @import("../os/main.zig"); const global_state = &@import("../global.zig").state; +const vaxis = @import("vaxis"); + pub const Options = struct { /// If true, print the full path to the theme. path: bool = false, - /// If true, show a small preview of the theme. - preview: bool = false, + /// If true, force a plain list of themes. + plain: bool = false, pub fn deinit(self: Options) void { _ = self; @@ -25,8 +28,31 @@ pub const Options = struct { } }; -/// The `list-themes` command is used to list all the available themes for -/// Ghostty. +const ThemeListElement = struct { + location: themepkg.Location, + path: []const u8, + theme: []const u8, + + fn lessThan(_: void, lhs: @This(), rhs: @This()) bool { + // TODO: use Unicode-aware comparison + return std.ascii.orderIgnoreCase(lhs.theme, rhs.theme) == .lt; + } + + pub fn toUri(self: *const ThemeListElement, alloc: std.mem.Allocator) ![]const u8 { + const uri = std.Uri{ + .scheme = "file", + .host = .{ .raw = "" }, + .path = .{ .raw = self.path }, + }; + var buf = std.ArrayList(u8).init(alloc); + errdefer buf.deinit(); + try uri.writeToStream(.{ .scheme = true, .authority = true, .path = true }, buf.writer()); + return buf.toOwnedSlice(); + } +}; + +/// The `list-themes` command is used to preview or list all the available +/// themes for Ghostty. /// /// Two different directories will be searched for themes. /// @@ -48,7 +74,7 @@ pub const Options = struct { /// Flags: /// /// * `--path`: Show the full path to the theme. -/// * `--preview`: Show a short preview of the theme colors. +/// * `--plain`: Show a short preview of the theme colors. pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); @@ -69,16 +95,6 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { try stderr.print("Could not find the Ghostty resources directory. Please ensure " ++ "that Ghostty is installed correctly.\n", .{}); - const ThemeListElement = struct { - location: themepkg.Location, - path: []const u8, - theme: []const u8, - fn lessThan(_: void, lhs: @This(), rhs: @This()) bool { - // TODO: use Unicode-aware comparison - return std.ascii.orderIgnoreCase(lhs.theme, rhs.theme) == .lt; - } - }; - var count: usize = 0; var themes = std.ArrayList(ThemeListElement).init(alloc); @@ -108,53 +124,1230 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { } } + if (count == 0) { + try stderr.print("No themes found, check to make sure that the themes were installed correctly.", .{}); + return 1; + } + std.mem.sortUnstable(ThemeListElement, themes.items, {}, ThemeListElement.lessThan); + if (tui.can_pretty_print and !opts.plain and std.posix.isatty(std.io.getStdOut().handle)) { + try preview(gpa_alloc, themes.items); + return 0; + } + for (themes.items) |theme| { if (opts.path) try stdout.print("{s} ({s}) {s}\n", .{ theme.theme, @tagName(theme.location), theme.path }) else try stdout.print("{s} ({s})\n", .{ theme.theme, @tagName(theme.location) }); - - if (opts.preview) { - var config = try Config.default(gpa_alloc); - defer config.deinit(); - if (config.loadFile(config._arena.?.allocator(), theme.path)) |_| { - if (!config._errors.empty()) { - try stderr.print(" Problems were encountered trying to load the theme:\n", .{}); - for (config._errors.list.items) |err| { - try stderr.print(" {s}\n", .{err.message}); - } - } - try stdout.print("\n ", .{}); - for (0..8) |i| { - try stdout.print(" {d:2} \x1b[38;2;{d};{d};{d}m██\x1b[0m", .{ - i, - config.palette.value[i].r, - config.palette.value[i].g, - config.palette.value[i].b, - }); - } - try stdout.print("\n ", .{}); - for (8..16) |i| { - try stdout.print(" {d:2} \x1b[38;2;{d};{d};{d}m██\x1b[0m", .{ - i, - config.palette.value[i].r, - config.palette.value[i].g, - config.palette.value[i].b, - }); - } - try stdout.print("\n\n", .{}); - } else |err| { - try stderr.print("unable to load {s}: {}", .{ theme.path, err }); - } - } - } - - if (count == 0) { - try stderr.print("No themes found, check to make sure that the themes were installed correctly.", .{}); - return 1; } return 0; } + +const Event = union(enum) { + key_press: vaxis.Key, + mouse: vaxis.Mouse, + color_scheme: vaxis.Color.Scheme, + winsize: vaxis.Winsize, +}; + +const Preview = struct { + allocator: std.mem.Allocator, + should_quit: bool, + tty: vaxis.Tty, + vx: vaxis.Vaxis, + mouse: ?vaxis.Mouse, + themes: []ThemeListElement, + current: usize, + hex: bool, + help_visible: bool, + color_scheme: vaxis.Color.Scheme, + + pub fn init(allocator: std.mem.Allocator, themes: []ThemeListElement) !Preview { + return .{ + .allocator = allocator, + .should_quit = false, + .tty = try vaxis.Tty.init(), + .vx = try vaxis.init(allocator, .{}), + .mouse = null, + .themes = themes, + .current = 0, + .hex = false, + .help_visible = false, + .color_scheme = .light, + }; + } + + pub fn deinit(self: *Preview) void { + self.vx.deinit(self.allocator, self.tty.anyWriter()); + self.tty.deinit(); + } + + pub fn run(self: *Preview) !void { + var loop: vaxis.Loop(Event) = .{ + .tty = &self.tty, + .vaxis = &self.vx, + }; + try loop.init(); + try loop.start(); + + try self.vx.enterAltScreen(self.tty.anyWriter()); + try self.vx.setTitle(self.tty.anyWriter(), "👻 Ghostty Theme Preview 👻"); + try self.vx.queryTerminal(self.tty.anyWriter(), 1 * std.time.ns_per_s); + try self.vx.setMouseMode(self.tty.anyWriter(), true); + if (self.vx.caps.color_scheme_updates) + try self.vx.subscribeToColorSchemeUpdates(self.tty.anyWriter()); + + while (!self.should_quit) { + var arena = std.heap.ArenaAllocator.init(self.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + loop.pollEvent(); + while (loop.tryEvent()) |event| { + try self.update(event, alloc); + } + try self.draw(alloc); + + var buffered = self.tty.bufferedWriter(); + try self.vx.render(buffered.writer().any()); + try buffered.flush(); + } + } + + fn up(self: *Preview, count: usize) void { + self.current = std.math.sub(usize, self.current, count) catch self.themes.len + self.current - count; + } + + fn down(self: *Preview, count: usize) void { + self.current = (self.current + count) % self.themes.len; + } + + pub fn update(self: *Preview, event: Event, alloc: std.mem.Allocator) !void { + switch (event) { + .key_press => |key| { + if (key.matches('c', .{ .ctrl = true })) + self.should_quit = true; + if (key.matches('q', .{})) + self.should_quit = true; + if (key.matches(vaxis.Key.escape, .{})) + self.should_quit = true; + if (key.matches('?', .{})) + self.help_visible = !self.help_visible; + if (key.matches('h', .{ .ctrl = true })) + self.help_visible = !self.help_visible; + if (key.matches(vaxis.Key.f1, .{})) + self.help_visible = !self.help_visible; + if (key.matches('0', .{})) + self.current = 0; + if (key.matches(vaxis.Key.home, .{})) + self.current = 0; + if (key.matches(vaxis.Key.kp_home, .{})) + self.current = 0; + if (key.matches(vaxis.Key.end, .{})) + self.current = self.themes.len - 1; + if (key.matches(vaxis.Key.kp_end, .{})) + self.current = self.themes.len - 1; + if (key.matches('j', .{})) + self.down(1); + if (key.matches('+', .{})) + self.down(1); + if (key.matches(vaxis.Key.down, .{})) + self.down(1); + if (key.matches(vaxis.Key.kp_down, .{})) + self.down(1); + if (key.matches(vaxis.Key.kp_add, .{})) + self.down(1); + if (key.matches(vaxis.Key.page_down, .{})) + self.down(20); + if (key.matches(vaxis.Key.kp_page_down, .{})) + self.down(20); + if (key.matches('k', .{})) + self.up(1); + if (key.matches('-', .{})) + self.up(1); + if (key.matches(vaxis.Key.up, .{})) + self.up(1); + if (key.matches(vaxis.Key.kp_up, .{})) + self.up(1); + if (key.matches(vaxis.Key.kp_subtract, .{})) + self.up(1); + if (key.matches(vaxis.Key.page_up, .{})) + self.up(20); + if (key.matches(vaxis.Key.kp_page_up, .{})) + self.up(20); + if (key.matches('h', .{})) + self.hex = true; + if (key.matches('x', .{})) + self.hex = true; + if (key.matches('d', .{})) + self.hex = false; + if (key.matches('c', .{})) + try self.vx.copyToSystemClipboard( + self.tty.anyWriter(), + self.themes[self.current].theme, + alloc, + ); + if (key.matches('c', .{ .shift = true })) + try self.vx.copyToSystemClipboard( + self.tty.anyWriter(), + self.themes[self.current].path, + alloc, + ); + }, + .color_scheme => |color_scheme| self.color_scheme = color_scheme, + .mouse => |mouse| self.mouse = mouse, + .winsize => |ws| try self.vx.resize(self.allocator, self.tty.anyWriter(), ws), + } + } + + pub fn draw(self: *Preview, alloc: std.mem.Allocator) !void { + const win = self.vx.window(); + win.clear(); + + self.vx.setMouseShape(.default); + + const ui_fg: vaxis.Color = switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, + }; + const ui_bg: vaxis.Color = switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, + .dark => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, + }; + const ui_standard: vaxis.Style = .{ + .fg = ui_fg, + .bg = ui_bg, + }; + + const ui_hover_bg: vaxis.Color = switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xbb, 0xbb, 0xbb } }, + .dark => .{ .rgb = [_]u8{ 0x22, 0x22, 0x22 } }, + }; + + const ui_selected_fg: vaxis.Color = switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, + }; + const ui_selected_bg: vaxis.Color = switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xaa, 0xaa, 0xaa } }, + .dark => .{ .rgb = [_]u8{ 0x33, 0x33, 0x33 } }, + }; + const ui_selected: vaxis.Style = .{ + .fg = ui_selected_fg, + .bg = ui_selected_bg, + }; + + const theme_list = win.child(.{ + .x_off = 0, + .y_off = 0, + .width = .{ .limit = 32 }, + .height = .{ .limit = win.height }, + }); + + const split = theme_list.height / 2; + + var highlight: ?usize = null; + + if (self.mouse) |mouse| { + self.mouse = null; + if (mouse.button == .wheel_up) { + self.up(1); + } + if (mouse.button == .wheel_down) { + self.down(1); + } + if (theme_list.hasMouse(mouse)) |_| { + if (mouse.button == .left and mouse.type == .release) { + if (mouse.row < split) self.up(split - mouse.row); + if (mouse.row > split) self.down(mouse.row - split); + } + highlight = mouse.row; + } + } + + theme_list.fill(.{ .style = ui_standard }); + + for (0..split) |i| { + const j = std.math.sub(usize, self.current, i + 1) catch self.themes.len + self.current - i - 1; + const theme = self.themes[j]; + const row = split - i - 1; + + _ = try theme_list.printSegment( + .{ + .text = theme.theme, + .style = .{ + .fg = ui_fg, + .bg = bg: { + if (highlight) |h| if (h == row) break :bg ui_hover_bg; + break :bg ui_bg; + }, + }, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = row, + .col_offset = 2, + }, + ); + } + { + const theme = self.themes[self.current]; + _ = try theme_list.printSegment( + .{ + .text = "❯ ", + .style = ui_selected, + }, + .{ + .row_offset = split, + .col_offset = 0, + }, + ); + _ = try theme_list.printSegment( + .{ + .text = theme.theme, + .style = ui_selected, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = split, + .col_offset = 2, + }, + ); + if (theme.theme.len < theme_list.width - 4) { + for (2 + theme.theme.len..theme_list.width - 2) |i| + _ = try theme_list.printSegment( + .{ + .text = " ", + .style = ui_selected, + }, + .{ + .row_offset = split, + .col_offset = i, + }, + ); + } + _ = try theme_list.printSegment( + .{ + .text = " ❮", + .style = ui_selected, + }, + .{ + .row_offset = split, + .col_offset = theme_list.width - 2, + }, + ); + } + for (split + 1..theme_list.height) |i| { + const j = (self.current + i - split) % self.themes.len; + const row = i; + const theme = self.themes[j]; + _ = try theme_list.printSegment(.{ + .text = theme.theme, + .style = .{ + .fg = ui_fg, + .bg = bg: { + if (highlight) |h| if (h == row) break :bg ui_hover_bg; + break :bg ui_bg; + }, + }, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, .{ + .row_offset = i, + .col_offset = 2, + }); + } + + try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width, ui_fg, ui_bg); + + if (self.help_visible) { + const width = 60; + const height = 20; + const child = win.child( + .{ + .x_off = win.width / 2 -| width / 2, + .y_off = win.height / 2 -| height / 2, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = height, + }, + .border = .{ + .where = .all, + .style = ui_standard, + }, + }, + ); + + child.fill(.{ .style = ui_standard }); + + const key_help = [_]struct { keys: []const u8, help: []const u8 }{ + .{ .keys = "^C, q, ESC", .help = "Quit." }, + .{ .keys = "F1, ?, ^H", .help = "Toggle help window." }, + .{ .keys = "k, ↑", .help = "Move up 1 theme." }, + .{ .keys = "ScrollUp", .help = "Move up 1 theme." }, + .{ .keys = "PgUp", .help = "Move up 20 themes." }, + .{ .keys = "j, ↓", .help = "Move down 1 theme." }, + .{ .keys = "ScrollDown", .help = "Move down 1 theme." }, + .{ .keys = "PgDown", .help = "Move down 20 themes." }, + .{ .keys = "h, x", .help = "Show palette numbers in hexadecimal." }, + .{ .keys = "d", .help = "Show palette numbers in decimal." }, + .{ .keys = "c", .help = "Copy theme name to the clipboard." }, + .{ .keys = "C", .help = "Copy theme path to the clipboard." }, + .{ .keys = "0, Home", .help = "Go to the start of the list." }, + .{ .keys = "End", .help = "Go to the end of the list." }, + }; + + for (key_help, 0..) |help, i| { + _ = try child.printSegment( + .{ + .text = help.keys, + .style = ui_standard, + }, + .{ + .row_offset = i + 1, + .col_offset = 2, + }, + ); + _ = try child.printSegment( + .{ + .text = "—", + .style = ui_standard, + }, + .{ + .row_offset = i + 1, + .col_offset = 15, + }, + ); + _ = try child.printSegment( + .{ + .text = help.help, + .style = ui_standard, + }, + .{ + .row_offset = i + 1, + .col_offset = 17, + }, + ); + } + } + } + + pub fn drawPreview(self: *Preview, alloc: std.mem.Allocator, win: vaxis.Window, x_off: usize, ui_fg: vaxis.Color, ui_bg: vaxis.Color) !void { + const width = win.width - x_off; + + const ui_err_fg: vaxis.Color = switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, + }; + + const theme = self.themes[self.current]; + + var config = try Config.default(alloc); + defer config.deinit(); + + config.loadFile(config._arena.?.allocator(), theme.path) catch |err| { + const child = win.child( + .{ + .x_off = x_off, + .y_off = 0, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = win.height, + }, + }, + ); + child.fill(.{ .style = .{ .fg = ui_fg, .bg = ui_bg } }); + const middle = child.height / 2; + { + const text = try std.fmt.allocPrint(alloc, "Unable to open {s} from:", .{theme.theme}); + _ = try child.printSegment( + .{ + .text = text, + .style = .{ + .fg = ui_err_fg, + .bg = ui_bg, + }, + }, + .{ + .row_offset = middle -| 1, + .col_offset = child.width / 2 -| text.len / 2, + }, + ); + } + { + _ = try child.printSegment( + .{ + .text = theme.path, + .style = .{ + .fg = ui_err_fg, + .bg = ui_bg, + }, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = middle, + .col_offset = child.width / 2 -| theme.path.len / 2, + }, + ); + } + { + const text = try std.fmt.allocPrint(alloc, "{}", .{err}); + _ = try child.printSegment( + .{ + .text = text, + .style = .{ + .fg = ui_err_fg, + .bg = ui_bg, + }, + }, + .{ + .row_offset = middle + 1, + .col_offset = child.width / 2 -| text.len / 2, + }, + ); + } + return; + }; + + var next_start: usize = 0; + const fg: vaxis.Color = .{ + .rgb = [_]u8{ + config.foreground.r, + config.foreground.g, + config.foreground.b, + }, + }; + const bg: vaxis.Color = .{ + .rgb = [_]u8{ + config.background.r, + config.background.g, + config.background.b, + }, + }; + const standard: vaxis.Style = .{ + .fg = fg, + .bg = bg, + }; + const standard_bold: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .bold = true, + }; + const standard_italic: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .italic = true, + }; + const standard_bold_italic: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .bold = true, + .italic = true, + }; + const standard_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .single, + }; + const standard_double_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .double, + }; + const standard_dashed_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .dashed, + }; + const standard_curly_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .curly, + }; + const standard_dotted_underline: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .ul_style = .dotted, + }; + + { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = 4, + }, + }, + ); + child.fill(.{ .style = standard }); + _ = try child.printSegment( + .{ + .text = theme.theme, + .style = standard_bold_italic, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = 1, + .col_offset = child.width / 2 -| theme.theme.len / 2, + }, + ); + _ = try child.printSegment( + .{ + .text = theme.path, + .style = standard, + .link = .{ + .uri = try theme.toUri(alloc), + }, + }, + .{ + .row_offset = 2, + .col_offset = child.width / 2 -| theme.path.len / 2, + .wrap = .none, + }, + ); + next_start += child.height; + } + + if (!config._errors.empty()) { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = if (config._errors.empty()) 0 else 2 + config._errors.list.items.len, + }, + }, + ); + { + const text = "Problems were encountered trying to load the theme:"; + _ = try child.printSegment( + .{ + .text = text, + .style = .{ + .fg = ui_err_fg, + .bg = ui_bg, + }, + }, + .{ + .row_offset = 0, + .col_offset = child.width / 2 -| (text.len / 2), + }, + ); + } + for (config._errors.list.items, 0..) |err, i| { + _ = try child.printSegment( + .{ + .text = err.message, + .style = .{ + .fg = ui_err_fg, + .bg = ui_bg, + }, + }, + .{ + .row_offset = 2 + i, + .col_offset = 2, + }, + ); + } + next_start += child.height; + } + { + const child = win.child(.{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = 6, + }, + }); + + child.fill(.{ .style = standard }); + + for (0..16) |i| { + const r = i / 8; + const c = i % 8; + const text = if (self.hex) + try std.fmt.allocPrint(alloc, " {x:0>2}", .{i}) + else + try std.fmt.allocPrint(alloc, "{d:3}", .{i}); + _ = try child.printSegment( + .{ + .text = text, + .style = standard, + }, + .{ + .row_offset = 3 * r, + .col_offset = c * 8, + }, + ); + _ = try child.printSegment( + .{ + .text = "████", + .style = .{ + .fg = color(config, i), + .bg = bg, + }, + }, + .{ + .row_offset = 3 * r, + .col_offset = 4 + c * 8, + }, + ); + _ = try child.printSegment( + .{ + .text = "████", + .style = .{ + .fg = color(config, i), + .bg = bg, + }, + }, + .{ + .row_offset = 3 * r + 1, + .col_offset = 4 + c * 8, + }, + ); + } + next_start += child.height; + } + { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = 24, + }, + }, + ); + const bold: vaxis.Style = .{ + .fg = fg, + .bg = bg, + .bold = true, + }; + const color1: vaxis.Style = .{ + .fg = color(config, 1), + .bg = bg, + }; + const color2: vaxis.Style = .{ + .fg = color(config, 2), + .bg = bg, + }; + const color3: vaxis.Style = .{ + .fg = color(config, 3), + .bg = bg, + }; + const color4: vaxis.Style = .{ + .fg = color(config, 4), + .bg = bg, + }; + const color5: vaxis.Style = .{ + .fg = color(config, 5), + .bg = bg, + }; + const color6: vaxis.Style = .{ + .fg = color(config, 6), + .bg = bg, + }; + const color6ul: vaxis.Style = .{ + .fg = color(config, 6), + .bg = bg, + .ul_style = .single, + }; + const color10: vaxis.Style = .{ + .fg = color(config, 10), + .bg = bg, + }; + const color12: vaxis.Style = .{ + .fg = color(config, 12), + .bg = bg, + }; + const color238: vaxis.Style = .{ + .fg = color(config, 238), + .bg = bg, + }; + child.fill(.{ .style = standard }); + _ = try child.print( + &.{ + .{ .text = "→", .style = color2 }, + .{ .text = " ", .style = standard }, + .{ .text = "bat", .style = color4 }, + .{ .text = " ", .style = standard }, + .{ .text = "ziggzagg.zig", .style = color6ul }, + }, + .{ + .row_offset = 0, + .col_offset = 2, + }, + ); + { + _ = try child.print( + &.{ + .{ + .text = "───────┬", + .style = color238, + }, + }, + .{ + .row_offset = 1, + .col_offset = 2, + }, + ); + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, + }, + .{ + .row_offset = 1, + .col_offset = col, + }, + ); + } + } + _ = try child.print( + &.{ + .{ + .text = " │ ", + .style = color238, + }, + + .{ + .text = "File: ", + .style = standard, + }, + + .{ + .text = "ziggzag.zig", + .style = bold, + }, + }, + .{ + .row_offset = 2, + .col_offset = 2, + }, + ); + { + _ = try child.print( + &.{ + .{ + .text = "───────┼", + .style = color238, + }, + }, + .{ + .row_offset = 3, + .col_offset = 2, + }, + ); + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, + }, + .{ + .row_offset = 3, + .col_offset = col, + }, + ); + } + } + _ = try child.print( + &.{ + .{ .text = " 1 │ ", .style = color238 }, + .{ .text = "const", .style = color5 }, + .{ .text = " std ", .style = standard }, + .{ .text = "= @import", .style = color5 }, + .{ .text = "(", .style = standard }, + .{ .text = "\"std\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 4, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 2 │", .style = color238 }, + }, + .{ + .row_offset = 5, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 3 │ ", .style = color238 }, + .{ .text = "pub ", .style = color5 }, + .{ .text = "fn ", .style = color12 }, + .{ .text = "main", .style = color2 }, + .{ .text = "() ", .style = standard }, + .{ .text = "!", .style = color5 }, + .{ .text = "void", .style = color12 }, + .{ .text = " {", .style = standard }, + }, + .{ + .row_offset = 6, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 4 │ ", .style = color238 }, + .{ .text = "const ", .style = color5 }, + .{ .text = "stdout ", .style = standard }, + .{ .text = "=", .style = color5 }, + .{ .text = " std.io.getStdOut().writer();", .style = standard }, + }, + .{ + .row_offset = 7, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 5 │ ", .style = color238 }, + .{ .text = "var ", .style = color5 }, + .{ .text = "i:", .style = standard }, + .{ .text = " usize", .style = color12 }, + .{ .text = " =", .style = color5 }, + .{ .text = " 1", .style = color4 }, + .{ .text = ";", .style = standard }, + }, + .{ + .row_offset = 8, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 6 │ ", .style = color238 }, + .{ .text = "while ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "<= ", .style = color5 }, + .{ .text = "16", .style = color4 }, + .{ .text = ") : (i ", .style = standard }, + .{ .text = "+= ", .style = color5 }, + .{ .text = "1", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 9, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 7 │ ", .style = color238 }, + .{ .text = "if ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "% ", .style = color5 }, + .{ .text = "15 ", .style = color4 }, + .{ .text = "== ", .style = color5 }, + .{ .text = "0", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 10, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 8 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "\"ZiggZagg", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 11, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 9 │ ", .style = color238 }, + .{ .text = "} ", .style = standard }, + .{ .text = "else if ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "% ", .style = color5 }, + .{ .text = "3 ", .style = color4 }, + .{ .text = "== ", .style = color5 }, + .{ .text = "0", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 12, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 10 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "\"Zigg", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 13, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 11 │ ", .style = color238 }, + .{ .text = "} ", .style = standard }, + .{ .text = "else if ", .style = color5 }, + .{ .text = "(i ", .style = standard }, + .{ .text = "% ", .style = color5 }, + .{ .text = "5 ", .style = color4 }, + .{ .text = "== ", .style = color5 }, + .{ .text = "0", .style = color4 }, + .{ .text = ") {", .style = standard }, + }, + .{ + .row_offset = 14, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 12 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.writeAll(", .style = standard }, + .{ .text = "\"Zagg", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ");", .style = standard }, + }, + .{ + .row_offset = 15, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 13 │ ", .style = color238 }, + .{ .text = "} ", .style = standard }, + .{ .text = "else ", .style = color5 }, + .{ .text = "{", .style = standard }, + }, + .{ + .row_offset = 16, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 14 │ ", .style = color238 }, + .{ .text = "try ", .style = color5 }, + .{ .text = "stdout.print(", .style = standard }, + .{ .text = "\"{d}", .style = color10 }, + .{ .text = "\\n", .style = color12 }, + .{ .text = "\"", .style = color10 }, + .{ .text = ", .{i});", .style = standard }, + }, + .{ + .row_offset = 17, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 15 │ ", .style = color238 }, + .{ .text = "}", .style = standard }, + }, + .{ + .row_offset = 18, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 16 │ ", .style = color238 }, + .{ .text = "}", .style = standard }, + }, + .{ + .row_offset = 19, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = " 17 │ ", .style = color238 }, + .{ .text = "}", .style = standard }, + }, + .{ + .row_offset = 20, + .col_offset = 2, + }, + ); + { + _ = try child.print( + &.{ + .{ + .text = "───────┴", + .style = color238, + }, + }, + .{ + .row_offset = 21, + .col_offset = 2, + }, + ); + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, + }, + .{ + .row_offset = 21, + .col_offset = col, + }, + ); + } + } + _ = try child.print( + &.{ + .{ .text = "ghostty ", .style = color6 }, + .{ .text = "on ", .style = standard }, + .{ .text = " main ", .style = color4 }, + .{ .text = "[+] ", .style = color1 }, + .{ .text = "via ", .style = standard }, + .{ .text = " v0.13.0 ", .style = color3 }, + .{ .text = "via ", .style = standard }, + .{ .text = " impure (ghostty-env)", .style = color4 }, + }, + .{ + .row_offset = 22, + .col_offset = 2, + }, + ); + _ = try child.print( + &.{ + .{ .text = "✦ ", .style = color4 }, + .{ .text = "at ", .style = standard }, + .{ .text = "10:36:15 ", .style = color3 }, + .{ .text = "→", .style = color2 }, + }, + .{ + .row_offset = 23, + .col_offset = 2, + }, + ); + next_start += child.height; + } + if (next_start < win.height) { + const child = win.child( + .{ + .x_off = x_off, + .y_off = next_start, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = win.height - next_start, + }, + }, + ); + child.fill(.{ .style = standard }); + var it = std.mem.splitAny(u8, lorem_ipsum, " \n"); + var row: usize = 1; + var col: usize = 2; + while (row < child.height) { + const word = it.next() orelse line: { + it.reset(); + break :line it.next() orelse unreachable; + }; + if (col + word.len > child.width) { + row += 1; + col = 2; + } + const style: vaxis.Style = style: { + if (std.mem.eql(u8, "ipsum", word)) break :style .{ .fg = color(config, 2), .bg = bg }; + if (std.mem.eql(u8, "consectetur", word)) break :style standard_bold; + if (std.mem.eql(u8, "reprehenderit", word)) break :style standard_italic; + if (std.mem.eql(u8, "Praesent", word)) break :style standard_bold_italic; + if (std.mem.eql(u8, "auctor", word)) break :style standard_underline; + if (std.mem.eql(u8, "dui", word)) break :style standard_double_underline; + if (std.mem.eql(u8, "erat", word)) break :style standard_dashed_underline; + if (std.mem.eql(u8, "enim", word)) break :style standard_dotted_underline; + if (std.mem.eql(u8, "odio", word)) break :style standard_curly_underline; + break :style standard; + }; + _ = try child.printSegment( + .{ + .text = word, + .style = style, + }, + .{ + .row_offset = row, + .col_offset = col, + }, + ); + col += word.len + 1; + } + } + } +}; + +fn color(config: Config, palette: usize) vaxis.Color { + return .{ + .rgb = [_]u8{ + config.palette.value[palette].r, + config.palette.value[palette].g, + config.palette.value[palette].b, + }, + }; +} + +const lorem_ipsum = @embedFile("lorem_ipsum.txt"); + +fn preview(allocator: std.mem.Allocator, themes: []ThemeListElement) !void { + var app = try Preview.init(allocator, themes); + defer app.deinit(); + try app.run(); +} diff --git a/src/cli/lorem_ipsum.txt b/src/cli/lorem_ipsum.txt new file mode 100644 index 000000000..13b9a52c1 --- /dev/null +++ b/src/cli/lorem_ipsum.txt @@ -0,0 +1,45 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Cras hendrerit aliquet +turpis non dictum. Mauris pulvinar nisl sit amet dui cursus tempus. Pellentesque +ut dui justo. Etiam quis magna sagittis nisi pretium consequat vitae ut nisl. +Sed at metus id odio pulvinar sodales. Vestibulum sollicitudin, sem id tristique +vestibulum, neque ante dictum tortor, in convallis mi enim ac lorem. Suspendisse +orci ex, ullamcorper sed leo vitae, mattis egestas nisl. Morbi id est vel +ipsum mollis convallis vel at mauris. Duis vehicula facilisis placerat. Aliquam +venenatis auctor ipsum vel elementum. Proin ac tincidunt lacus. Sed facilisis +tellus ullamcorper bibendum lobortis. Pellentesque porta, lacus quis efficitur +pulvinar, sem mi varius ante, sed finibus diam ante et risus. + +Morbi ut sollicitudin justo. Nulla mattis mi ac mauris tincidunt tempor. Morbi +vel gravida erat. Ut eu risus quis nisi facilisis aliquet varius id orci. +Pellentesque tortor diam, porttitor nec urna nec, convallis consectetur dui. +Vestibulum et hendrerit ipsum. Morbi pharetra dictum turpis in elementum. Ut +nec volutpat nunc, at venenatis leo. Morbi eget nulla luctus, tincidunt dui vel, +cursus urna. Maecenas ac pellentesque nisi. Quisque ut lorem porta, eleifend +metus id, pellentesque tellus. + +Vivamus gravida convallis felis, at hendrerit dolor. Vestibulum tincidunt id +augue quis hendrerit. Praesent venenatis elit quis posuere gravida. Praesent +at massa a purus maximus tempus. Proin dui leo, feugiat et erat ac, tincidunt +aliquam risus. Aenean rutrum hendrerit turpis, sit amet consectetur justo porta +non. Sed auctor justo elit, sed mollis odio ullamcorper nec. Pellentesque ac +hendrerit tortor. Praesent quis viverra dui, sit amet imperdiet magna. + +Mauris iaculis maximus felis, aliquet vehicula neque sagittis nec. Duis +convallis purus enim, vel scelerisque purus dignissim eu. Donec congue sapien +a neque rhoncus, sit amet accumsan libero tincidunt. Proin vitae placerat urna. +Donec dolor sapien, fringilla sed semper sit amet, sollicitudin sit amet orci. +Mauris maximus convallis vehicula. Aliquam urna ipsum, fermentum ac iaculis vel, +blandit eget lorem. Sed enim ante, sodales a diam in, convallis interdum quam. +Duis non urna risus. Proin ac neque at risus ullamcorper mattis eu vel nunc. +Proin et ipsum euismod, ullamcorper justo et, imperdiet est. Curabitur quis +arcu faucibus, bibendum nisl nec, hendrerit sapien. Curabitur vitae ante risus. +Praesent eget sagittis tortor. + +Mauris aliquam nec nibh eu congue. Nullam congue auctor vestibulum. Donec +posuere sapien nec massa efficitur tincidunt. Vestibulum ante ipsum primis in +faucibus orci luctus et ultrices posuere cubilia curae; Proin molestie, nisl +in tincidunt condimentum, ante metus fermentum felis, ac molestie lacus dui vel +dolor. Donec ornare laoreet posuere. Etiam id tincidunt ante. Maecenas semper +diam ac tortor facilisis egestas. Nam eu bibendum nisl. Integer tempor nisl nec +ex consectetur, quis lobortis enim finibus. Sed ac erat posuere, fermentum metus +sed, suscipit nisl. From a6a4f9ff8eb908597844980febb106c6e02421d1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 24 Sep 2024 10:28:49 -0500 Subject: [PATCH 104/139] cli: list-themes should list symlinks Symbolic links should be listed in addition to normal files. Useful for system like home-manager that link config files to location in the Nix store. --- src/cli/list_themes.zig | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index dd812d5ca..d512dcc03 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -114,13 +114,17 @@ pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var walker = dir.iterate(); while (try walker.next()) |entry| { - if (entry.kind != .file) continue; - count += 1; - try themes.append(.{ - .location = loc.location, - .path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name }), - .theme = try alloc.dupe(u8, entry.name), - }); + switch (entry.kind) { + .file, .sym_link => { + count += 1; + try themes.append(.{ + .location = loc.location, + .path = try std.fs.path.join(alloc, &.{ loc.dir, entry.name }), + .theme = try alloc.dupe(u8, entry.name), + }); + }, + else => {}, + } } } From e313352c1f5b77bbd6f999ff64516e273c97a896 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Tue, 24 Sep 2024 10:42:57 -0500 Subject: [PATCH 105/139] cli: update +list-themes --help text --- src/cli/list_themes.zig | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index d512dcc03..f3368e813 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -54,6 +54,15 @@ const ThemeListElement = struct { /// The `list-themes` command is used to preview or list all the available /// themes for Ghostty. /// +/// If this command is run from a TTY, a TUI preview of the themes will be +/// shown. While in the preview, `F1` will bring up a help screen and `ESC` will +/// exit the preview. Other keys that can be used to navigate the preview are +/// listed in the help screen. +/// +/// If this command is not run from a TTY, or the output is piped to another +/// command, a plain list of theme names will be printed to the screen. A plain +/// list can be forced using the `--plain` CLI flag. +/// /// Two different directories will be searched for themes. /// /// The first directory is the `themes` subdirectory of your Ghostty @@ -74,7 +83,7 @@ const ThemeListElement = struct { /// Flags: /// /// * `--path`: Show the full path to the theme. -/// * `--plain`: Show a short preview of the theme colors. +/// * `--plain`: Force a plain listing of themes. pub fn run(gpa_alloc: std.mem.Allocator) !u8 { var opts: Options = .{}; defer opts.deinit(); From a969364f93230ae8ae766cfeed84bdcc85a84a0a Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Wed, 25 Sep 2024 12:07:14 -0500 Subject: [PATCH 106/139] cli/list-themes: add ability to search theme names --- build.zig | 5 + build.zig.zon | 8 +- nix/zigCacheHash.nix | 2 +- src/cli/list_themes.zig | 709 ++++++++++++++++++++++++---------------- 4 files changed, 434 insertions(+), 290 deletions(-) diff --git a/build.zig b/build.zig index b834f57a3..63ac1a2cf 100644 --- a/build.zig +++ b/build.zig @@ -1043,6 +1043,10 @@ fn addDeps( .target = target, .optimize = optimize, }); + const zf_dep = b.dependency("zf", .{ + .target = target, + .optimize = optimize, + }); // Wasm we do manually since it is such a different build. if (step.rootModuleTarget().cpu.arch == .wasm32) { @@ -1129,6 +1133,7 @@ fn addDeps( step.root_module.addImport("ziglyph", ziglyph_dep.module("ziglyph")); step.root_module.addImport("vaxis", vaxis_dep.module("vaxis")); step.root_module.addImport("wuffs", wuffs_dep.module("wuffs")); + step.root_module.addImport("zf", zf_dep.module("zf")); // Mac Stuff if (step.rootModuleTarget().isDarwin()) { diff --git a/build.zig.zon b/build.zig.zon index f2add6c07..973e6e6b5 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -54,8 +54,12 @@ .hash = "122056fbb29863ec1678b7954fb76b1533ad8c581a34577c1b2efe419e29e05596df", }, .vaxis = .{ - .url = "git+https://github.com/rockorager/libvaxis?ref=main#2cc1eb77f842dd8587dfc9cf399d42e4c1369175", - .hash = "12203c2d83911e6aacfbfdd48d31d6fc36e89947dfc7aec104debe3ac85e9f3a44f2", + .url = "git+https://github.com/rockorager/libvaxis?ref=main#1961712c1f0cf46b235dd31418dc1b52442abbd5", + .hash = "12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133", + }, + .zf = .{ + .url = "git+https://github.com/natecraddock/zf.git?ref=main#bb27a917c3513785c6a91f0b1c10002a5029cacc", + .hash = "1220a74107c7f153a2f809e41c7fa7e8dbf75c91043e39fad998247804e5edac2cc8", }, }, } diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index ad2e6a827..92e656264 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-MAzGg4tWlyv2X/GjAwm7s2whojawIKNMx1xWR+cZffQ=" +"sha256-qFt9sC3GekfU940Gd9oV9Gcbs5MdxVMojIMbkDo3m2A=" diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index f3368e813..ed375dff2 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -9,6 +9,7 @@ const internal_os = @import("../os/main.zig"); const global_state = &@import("../global.zig").state; const vaxis = @import("vaxis"); +const zf = @import("zf"); pub const Options = struct { /// If true, print the full path to the theme. @@ -32,6 +33,7 @@ const ThemeListElement = struct { location: themepkg.Location, path: []const u8, theme: []const u8, + rank: ?f64 = null, fn lessThan(_: void, lhs: @This(), rhs: @This()) bool { // TODO: use Unicode-aware comparison @@ -173,29 +175,51 @@ const Preview = struct { vx: vaxis.Vaxis, mouse: ?vaxis.Mouse, themes: []ThemeListElement, + filtered: std.ArrayList(usize), current: usize, + window: usize, hex: bool, - help_visible: bool, + mode: enum { + normal, + help, + search, + }, color_scheme: vaxis.Color.Scheme, + text_input: vaxis.widgets.TextInput, - pub fn init(allocator: std.mem.Allocator, themes: []ThemeListElement) !Preview { - return .{ + pub fn init(allocator: std.mem.Allocator, themes: []ThemeListElement) !*Preview { + const self = try allocator.create(Preview); + + self.* = .{ .allocator = allocator, .should_quit = false, .tty = try vaxis.Tty.init(), .vx = try vaxis.init(allocator, .{}), .mouse = null, .themes = themes, + .filtered = try std.ArrayList(usize).initCapacity(allocator, themes.len), .current = 0, + .window = 0, .hex = false, - .help_visible = false, + .mode = .normal, .color_scheme = .light, + .text_input = vaxis.widgets.TextInput.init(allocator, &self.vx.unicode), }; + + for (0..themes.len) |i| { + try self.filtered.append(i); + } + + return self; } pub fn deinit(self: *Preview) void { - self.vx.deinit(self.allocator, self.tty.anyWriter()); + const allocator = self.allocator; + self.filtered.deinit(); + self.text_input.deinit(); + self.vx.deinit(allocator, self.tty.anyWriter()); self.tty.deinit(); + allocator.destroy(self); } pub fn run(self: *Preview) !void { @@ -230,12 +254,92 @@ const Preview = struct { } } + fn updateFiltered(self: *Preview) !void { + const relative = self.current -| self.window; + const selected = self.themes[self.filtered.items[self.current]].theme; + + const hash_algorithm = std.hash.Wyhash; + + const old_digest = d: { + var hash = hash_algorithm.init(0); + for (self.filtered.items) |item| + hash.update(std.mem.asBytes(&item)); + break :d hash.final(); + }; + + self.filtered.clearRetainingCapacity(); + + if (self.text_input.buf.realLength() > 0) { + const first_half = self.text_input.buf.firstHalf(); + const second_half = self.text_input.buf.secondHalf(); + + const buffer = try self.allocator.alloc(u8, first_half.len + second_half.len); + defer self.allocator.free(buffer); + + @memcpy(buffer[0..first_half.len], first_half); + @memcpy(buffer[first_half.len..], second_half); + + const string = try std.ascii.allocLowerString(self.allocator, buffer); + defer self.allocator.free(string); + + var tokens = std.ArrayList([]const u8).init(self.allocator); + defer tokens.deinit(); + + var it = std.mem.tokenizeScalar(u8, string, ' '); + while (it.next()) |token| try tokens.append(token); + + for (self.themes, 0..) |*theme, i| { + theme.rank = zf.rank(theme.theme, tokens.items, false, true); + if (theme.rank) |_| + try self.filtered.append(i); + } + } else { + for (self.themes, 0..) |*theme, i| { + try self.filtered.append(i); + theme.rank = null; + } + } + + const new_digest = d: { + var hash = hash_algorithm.init(0); + for (self.filtered.items) |item| + hash.update(std.mem.asBytes(&item)); + break :d hash.final(); + }; + + if (old_digest == new_digest) return; + + if (self.filtered.items.len == 0) { + self.current = 0; + self.window = 0; + return; + } + + self.current, self.window = current: { + for (self.filtered.items, 0..) |index, i| { + if (std.mem.eql(u8, self.themes[index].theme, selected)) + break :current .{ i, i -| relative }; + } + break :current .{ 0, 0 }; + }; + } + fn up(self: *Preview, count: usize) void { - self.current = std.math.sub(usize, self.current, count) catch self.themes.len + self.current - count; + if (self.filtered.items.len == 0) { + self.current = 0; + return; + } + self.current -|= count; } fn down(self: *Preview, count: usize) void { - self.current = (self.current + count) % self.themes.len; + if (self.filtered.items.len == 0) { + self.current = 0; + return; + } + self.current += count; + if (self.current >= self.filtered.items.len) + self.current = self.filtered.items.len - 1; } pub fn update(self: *Preview, event: Event, alloc: std.mem.Allocator) !void { @@ -243,72 +347,71 @@ const Preview = struct { .key_press => |key| { if (key.matches('c', .{ .ctrl = true })) self.should_quit = true; - if (key.matches('q', .{})) - self.should_quit = true; - if (key.matches(vaxis.Key.escape, .{})) - self.should_quit = true; - if (key.matches('?', .{})) - self.help_visible = !self.help_visible; - if (key.matches('h', .{ .ctrl = true })) - self.help_visible = !self.help_visible; - if (key.matches(vaxis.Key.f1, .{})) - self.help_visible = !self.help_visible; - if (key.matches('0', .{})) - self.current = 0; - if (key.matches(vaxis.Key.home, .{})) - self.current = 0; - if (key.matches(vaxis.Key.kp_home, .{})) - self.current = 0; - if (key.matches(vaxis.Key.end, .{})) - self.current = self.themes.len - 1; - if (key.matches(vaxis.Key.kp_end, .{})) - self.current = self.themes.len - 1; - if (key.matches('j', .{})) - self.down(1); - if (key.matches('+', .{})) - self.down(1); - if (key.matches(vaxis.Key.down, .{})) - self.down(1); - if (key.matches(vaxis.Key.kp_down, .{})) - self.down(1); - if (key.matches(vaxis.Key.kp_add, .{})) - self.down(1); - if (key.matches(vaxis.Key.page_down, .{})) - self.down(20); - if (key.matches(vaxis.Key.kp_page_down, .{})) - self.down(20); - if (key.matches('k', .{})) - self.up(1); - if (key.matches('-', .{})) - self.up(1); - if (key.matches(vaxis.Key.up, .{})) - self.up(1); - if (key.matches(vaxis.Key.kp_up, .{})) - self.up(1); - if (key.matches(vaxis.Key.kp_subtract, .{})) - self.up(1); - if (key.matches(vaxis.Key.page_up, .{})) - self.up(20); - if (key.matches(vaxis.Key.kp_page_up, .{})) - self.up(20); - if (key.matches('h', .{})) - self.hex = true; - if (key.matches('x', .{})) - self.hex = true; - if (key.matches('d', .{})) - self.hex = false; - if (key.matches('c', .{})) - try self.vx.copyToSystemClipboard( - self.tty.anyWriter(), - self.themes[self.current].theme, - alloc, - ); - if (key.matches('c', .{ .shift = true })) - try self.vx.copyToSystemClipboard( - self.tty.anyWriter(), - self.themes[self.current].path, - alloc, - ); + switch (self.mode) { + .normal => { + if (key.matchesAny(&.{ 'q', vaxis.Key.escape }, .{})) + self.should_quit = true; + if (key.matchesAny(&.{ '?', vaxis.Key.f1 }, .{})) + self.mode = .help; + if (key.matches('h', .{ .ctrl = true })) + self.mode = .help; + if (key.matches('/', .{})) + self.mode = .search; + if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { + self.text_input.buf.clearRetainingCapacity(); + try self.updateFiltered(); + } + if (key.matchesAny(&.{ vaxis.Key.home, vaxis.Key.kp_home }, .{})) + self.current = 0; + if (key.matchesAny(&.{ vaxis.Key.end, vaxis.Key.kp_end }, .{})) + self.current = self.filtered.items.len - 1; + if (key.matchesAny(&.{ 'j', '+', vaxis.Key.down, vaxis.Key.kp_down, vaxis.Key.kp_add }, .{})) + self.down(1); + if (key.matchesAny(&.{ vaxis.Key.page_down, vaxis.Key.kp_down }, .{})) + self.down(20); + if (key.matchesAny(&.{ 'k', '-', vaxis.Key.up, vaxis.Key.kp_up, vaxis.Key.kp_subtract }, .{})) + self.up(1); + if (key.matchesAny(&.{ vaxis.Key.page_up, vaxis.Key.kp_page_up }, .{})) + self.up(20); + if (key.matchesAny(&.{ 'h', 'x' }, .{})) + self.hex = true; + if (key.matches('d', .{})) + self.hex = false; + if (key.matches('c', .{})) + try self.vx.copyToSystemClipboard( + self.tty.anyWriter(), + self.themes[self.filtered.items[self.current]].theme, + alloc, + ); + if (key.matches('c', .{ .shift = true })) + try self.vx.copyToSystemClipboard( + self.tty.anyWriter(), + self.themes[self.filtered.items[self.current]].path, + alloc, + ); + }, + .help => { + if (key.matches('q', .{})) + self.should_quit = true; + if (key.matchesAny(&.{ '?', vaxis.Key.escape, vaxis.Key.f1 }, .{})) + self.mode = .normal; + if (key.matches('h', .{ .ctrl = true })) + self.mode = .normal; + }, + .search => search: { + if (key.matchesAny(&.{ vaxis.Key.escape, vaxis.Key.enter }, .{})) { + self.mode = .normal; + break :search; + } + if (key.matchesAny(&.{ 'x', '/' }, .{ .ctrl = true })) { + self.text_input.clearRetainingCapacity(); + try self.updateFiltered(); + break :search; + } + try self.text_input.update(.{ .key_press = key }); + try self.updateFiltered(); + }, + } }, .color_scheme => |color_scheme| self.color_scheme = color_scheme, .mouse => |mouse| self.mouse = mouse, @@ -316,43 +419,82 @@ const Preview = struct { } } + pub fn ui_fg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, + }; + } + + pub fn ui_bg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, + .dark => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, + }; + } + + pub fn ui_standard(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_fg(), + .bg = self.ui_bg(), + }; + } + + pub fn ui_hover_bg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xbb, 0xbb, 0xbb } }, + .dark => .{ .rgb = [_]u8{ 0x22, 0x22, 0x22 } }, + }; + } + + pub fn ui_highlighted(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_fg(), + .bg = self.ui_hover_bg(), + }; + } + + pub fn ui_selected_fg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, + }; + } + + pub fn ui_selected_bg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xaa, 0xaa, 0xaa } }, + .dark => .{ .rgb = [_]u8{ 0x33, 0x33, 0x33 } }, + }; + } + + pub fn ui_selected(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_selected_fg(), + .bg = self.ui_selected_bg(), + }; + } + + pub fn ui_err_fg(self: *Preview) vaxis.Color { + return switch (self.color_scheme) { + .light => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, + .dark => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, + }; + } + + pub fn ui_err(self: *Preview) vaxis.Style { + return .{ + .fg = self.ui_err_fg(), + .bg = self.ui_bg(), + }; + } + pub fn draw(self: *Preview, alloc: std.mem.Allocator) !void { const win = self.vx.window(); win.clear(); self.vx.setMouseShape(.default); - const ui_fg: vaxis.Color = switch (self.color_scheme) { - .light => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, - .dark => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, - }; - const ui_bg: vaxis.Color = switch (self.color_scheme) { - .light => .{ .rgb = [_]u8{ 0xff, 0xff, 0xff } }, - .dark => .{ .rgb = [_]u8{ 0x00, 0x00, 0x00 } }, - }; - const ui_standard: vaxis.Style = .{ - .fg = ui_fg, - .bg = ui_bg, - }; - - const ui_hover_bg: vaxis.Color = switch (self.color_scheme) { - .light => .{ .rgb = [_]u8{ 0xbb, 0xbb, 0xbb } }, - .dark => .{ .rgb = [_]u8{ 0x22, 0x22, 0x22 } }, - }; - - const ui_selected_fg: vaxis.Color = switch (self.color_scheme) { - .light => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, - .dark => .{ .rgb = [_]u8{ 0x00, 0xaa, 0x00 } }, - }; - const ui_selected_bg: vaxis.Color = switch (self.color_scheme) { - .light => .{ .rgb = [_]u8{ 0xaa, 0xaa, 0xaa } }, - .dark => .{ .rgb = [_]u8{ 0x33, 0x33, 0x33 } }, - }; - const ui_selected: vaxis.Style = .{ - .fg = ui_selected_fg, - .bg = ui_selected_bg, - }; - const theme_list = win.child(.{ .x_off = 0, .y_off = 0, @@ -360,43 +502,73 @@ const Preview = struct { .height = .{ .limit = win.height }, }); - const split = theme_list.height / 2; + if (self.filtered.items.len == 0) { + self.current = 0; + self.window = 0; + } else { + const start = self.window; + const end = self.window + theme_list.height - 1; + if (self.current > end) + self.window = self.current - theme_list.height + 1; + if (self.current < start) + self.window = self.current; + if (self.window >= self.filtered.items.len) + self.window = self.filtered.items.len - 1; + } var highlight: ?usize = null; if (self.mouse) |mouse| { self.mouse = null; - if (mouse.button == .wheel_up) { - self.up(1); - } - if (mouse.button == .wheel_down) { - self.down(1); - } - if (theme_list.hasMouse(mouse)) |_| { - if (mouse.button == .left and mouse.type == .release) { - if (mouse.row < split) self.up(split - mouse.row); - if (mouse.row > split) self.down(mouse.row - split); + if (self.mode == .normal) { + if (mouse.button == .wheel_up) { + self.up(1); + } + if (mouse.button == .wheel_down) { + self.down(1); + } + if (theme_list.hasMouse(mouse)) |_| { + if (mouse.button == .left and mouse.type == .release) { + self.current = self.window + mouse.row; + } + highlight = mouse.row; } - highlight = mouse.row; } } - theme_list.fill(.{ .style = ui_standard }); + theme_list.fill(.{ .style = self.ui_standard() }); - for (0..split) |i| { - const j = std.math.sub(usize, self.current, i + 1) catch self.themes.len + self.current - i - 1; - const theme = self.themes[j]; - const row = split - i - 1; + for (0..theme_list.height) |row| { + const index = self.window + row; + if (index >= self.filtered.items.len) break; + const theme = self.themes[self.filtered.items[index]]; + + const style: enum { normal, highlighted, selected } = style: { + if (index == self.current) break :style .selected; + if (highlight) |h| if (h == row) break :style .highlighted; + break :style .normal; + }; + + if (style == .selected) { + _ = try theme_list.printSegment( + .{ + .text = "❯ ", + .style = self.ui_selected(), + }, + .{ + .row_offset = row, + .col_offset = 0, + }, + ); + } _ = try theme_list.printSegment( .{ .text = theme.theme, - .style = .{ - .fg = ui_fg, - .bg = bg: { - if (highlight) |h| if (h == row) break :bg ui_hover_bg; - break :bg ui_bg; - }, + .style = switch (style) { + .normal => self.ui_standard(), + .highlighted => self.ui_highlighted(), + .selected => self.ui_selected(), }, .link = .{ .uri = try theme.toUri(alloc), @@ -407,163 +579,140 @@ const Preview = struct { .col_offset = 2, }, ); - } - { - const theme = self.themes[self.current]; - _ = try theme_list.printSegment( - .{ - .text = "❯ ", - .style = ui_selected, - }, - .{ - .row_offset = split, - .col_offset = 0, - }, - ); - _ = try theme_list.printSegment( - .{ - .text = theme.theme, - .style = ui_selected, - .link = .{ - .uri = try theme.toUri(alloc), + if (style == .selected) { + if (theme.theme.len < theme_list.width - 4) { + for (2 + theme.theme.len..theme_list.width - 2) |i| + _ = try theme_list.printSegment( + .{ + .text = " ", + .style = self.ui_selected(), + }, + .{ + .row_offset = row, + .col_offset = i, + }, + ); + } + _ = try theme_list.printSegment( + .{ + .text = " ❮", + .style = self.ui_selected(), }, - }, - .{ - .row_offset = split, - .col_offset = 2, - }, - ); - if (theme.theme.len < theme_list.width - 4) { - for (2 + theme.theme.len..theme_list.width - 2) |i| - _ = try theme_list.printSegment( + .{ + .row_offset = row, + .col_offset = theme_list.width - 2, + }, + ); + } + } + + try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width); + + switch (self.mode) { + .normal => { + win.hideCursor(); + }, + .help => { + win.hideCursor(); + const width = 60; + const height = 20; + const child = win.child( + .{ + .x_off = win.width / 2 -| width / 2, + .y_off = win.height / 2 -| height / 2, + .width = .{ + .limit = width, + }, + .height = .{ + .limit = height, + }, + .border = .{ + .where = .all, + .style = self.ui_standard(), + }, + }, + ); + + child.fill(.{ .style = self.ui_standard() }); + + const key_help = [_]struct { keys: []const u8, help: []const u8 }{ + .{ .keys = "^C, q, ESC", .help = "Quit." }, + .{ .keys = "F1, ?, ^H", .help = "Toggle help window." }, + .{ .keys = "k, ↑", .help = "Move up 1 theme." }, + .{ .keys = "ScrollUp", .help = "Move up 1 theme." }, + .{ .keys = "PgUp", .help = "Move up 20 themes." }, + .{ .keys = "j, ↓", .help = "Move down 1 theme." }, + .{ .keys = "ScrollDown", .help = "Move down 1 theme." }, + .{ .keys = "PgDown", .help = "Move down 20 themes." }, + .{ .keys = "h, x", .help = "Show palette numbers in hexadecimal." }, + .{ .keys = "d", .help = "Show palette numbers in decimal." }, + .{ .keys = "c", .help = "Copy theme name to the clipboard." }, + .{ .keys = "C", .help = "Copy theme path to the clipboard." }, + .{ .keys = "Home", .help = "Go to the start of the list." }, + .{ .keys = "End", .help = "Go to the end of the list." }, + .{ .keys = "/", .help = "Start search." }, + .{ .keys = "^X, ^/", .help = "Clear search." }, + .{ .keys = "⏎", .help = "Close search window." }, + }; + + for (key_help, 0..) |help, i| { + _ = try child.printSegment( .{ - .text = " ", - .style = ui_selected, + .text = help.keys, + .style = self.ui_standard(), }, .{ - .row_offset = split, - .col_offset = i, + .row_offset = i + 1, + .col_offset = 2, }, ); - } - _ = try theme_list.printSegment( - .{ - .text = " ❮", - .style = ui_selected, - }, - .{ - .row_offset = split, - .col_offset = theme_list.width - 2, - }, - ); - } - for (split + 1..theme_list.height) |i| { - const j = (self.current + i - split) % self.themes.len; - const row = i; - const theme = self.themes[j]; - _ = try theme_list.printSegment(.{ - .text = theme.theme, - .style = .{ - .fg = ui_fg, - .bg = bg: { - if (highlight) |h| if (h == row) break :bg ui_hover_bg; - break :bg ui_bg; - }, - }, - .link = .{ - .uri = try theme.toUri(alloc), - }, - }, .{ - .row_offset = i, - .col_offset = 2, - }); - } - - try self.drawPreview(alloc, win, theme_list.x_off + theme_list.width, ui_fg, ui_bg); - - if (self.help_visible) { - const width = 60; - const height = 20; - const child = win.child( - .{ - .x_off = win.width / 2 -| width / 2, - .y_off = win.height / 2 -| height / 2, + _ = try child.printSegment( + .{ + .text = "—", + .style = self.ui_standard(), + }, + .{ + .row_offset = i + 1, + .col_offset = 15, + }, + ); + _ = try child.printSegment( + .{ + .text = help.help, + .style = self.ui_standard(), + }, + .{ + .row_offset = i + 1, + .col_offset = 17, + }, + ); + } + }, + .search => { + const child = win.child(.{ + .x_off = 20, + .y_off = win.height - 5, .width = .{ - .limit = width, + .limit = win.width - 40, }, .height = .{ - .limit = height, + .limit = 3, }, .border = .{ .where = .all, - .style = ui_standard, + .style = self.ui_standard(), }, - }, - ); - - child.fill(.{ .style = ui_standard }); - - const key_help = [_]struct { keys: []const u8, help: []const u8 }{ - .{ .keys = "^C, q, ESC", .help = "Quit." }, - .{ .keys = "F1, ?, ^H", .help = "Toggle help window." }, - .{ .keys = "k, ↑", .help = "Move up 1 theme." }, - .{ .keys = "ScrollUp", .help = "Move up 1 theme." }, - .{ .keys = "PgUp", .help = "Move up 20 themes." }, - .{ .keys = "j, ↓", .help = "Move down 1 theme." }, - .{ .keys = "ScrollDown", .help = "Move down 1 theme." }, - .{ .keys = "PgDown", .help = "Move down 20 themes." }, - .{ .keys = "h, x", .help = "Show palette numbers in hexadecimal." }, - .{ .keys = "d", .help = "Show palette numbers in decimal." }, - .{ .keys = "c", .help = "Copy theme name to the clipboard." }, - .{ .keys = "C", .help = "Copy theme path to the clipboard." }, - .{ .keys = "0, Home", .help = "Go to the start of the list." }, - .{ .keys = "End", .help = "Go to the end of the list." }, - }; - - for (key_help, 0..) |help, i| { - _ = try child.printSegment( - .{ - .text = help.keys, - .style = ui_standard, - }, - .{ - .row_offset = i + 1, - .col_offset = 2, - }, - ); - _ = try child.printSegment( - .{ - .text = "—", - .style = ui_standard, - }, - .{ - .row_offset = i + 1, - .col_offset = 15, - }, - ); - _ = try child.printSegment( - .{ - .text = help.help, - .style = ui_standard, - }, - .{ - .row_offset = i + 1, - .col_offset = 17, - }, - ); - } + }); + child.fill(.{ .style = self.ui_standard() }); + self.text_input.drawWithStyle(child, self.ui_standard()); + }, } } - pub fn drawPreview(self: *Preview, alloc: std.mem.Allocator, win: vaxis.Window, x_off: usize, ui_fg: vaxis.Color, ui_bg: vaxis.Color) !void { + pub fn drawPreview(self: *Preview, alloc: std.mem.Allocator, win: vaxis.Window, x_off: usize) !void { const width = win.width - x_off; - const ui_err_fg: vaxis.Color = switch (self.color_scheme) { - .light => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, - .dark => .{ .rgb = [_]u8{ 0xff, 0x00, 0x00 } }, - }; - - const theme = self.themes[self.current]; + const theme = self.themes[self.filtered.items[self.current]]; var config = try Config.default(alloc); defer config.deinit(); @@ -581,17 +730,14 @@ const Preview = struct { }, }, ); - child.fill(.{ .style = .{ .fg = ui_fg, .bg = ui_bg } }); + child.fill(.{ .style = self.ui_standard() }); const middle = child.height / 2; { const text = try std.fmt.allocPrint(alloc, "Unable to open {s} from:", .{theme.theme}); _ = try child.printSegment( .{ .text = text, - .style = .{ - .fg = ui_err_fg, - .bg = ui_bg, - }, + .style = self.ui_err(), }, .{ .row_offset = middle -| 1, @@ -603,10 +749,7 @@ const Preview = struct { _ = try child.printSegment( .{ .text = theme.path, - .style = .{ - .fg = ui_err_fg, - .bg = ui_bg, - }, + .style = self.ui_err(), .link = .{ .uri = try theme.toUri(alloc), }, @@ -622,10 +765,7 @@ const Preview = struct { _ = try child.printSegment( .{ .text = text, - .style = .{ - .fg = ui_err_fg, - .bg = ui_bg, - }, + .style = self.ui_err(), }, .{ .row_offset = middle + 1, @@ -637,6 +777,7 @@ const Preview = struct { }; var next_start: usize = 0; + const fg: vaxis.Color = .{ .rgb = [_]u8{ config.foreground.r, @@ -759,10 +900,7 @@ const Preview = struct { _ = try child.printSegment( .{ .text = text, - .style = .{ - .fg = ui_err_fg, - .bg = ui_bg, - }, + .style = self.ui_err(), }, .{ .row_offset = 0, @@ -774,10 +912,7 @@ const Preview = struct { _ = try child.printSegment( .{ .text = err.message, - .style = .{ - .fg = ui_err_fg, - .bg = ui_bg, - }, + .style = self.ui_err(), }, .{ .row_offset = 2 + i, From 5a3b942589b4c655057dda7816e0eb9f519818b4 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 10:58:28 -0700 Subject: [PATCH 107/139] typos: ignore lorem --- typos.toml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/typos.toml b/typos.toml index 9f2c96f11..a72944e5f 100644 --- a/typos.toml +++ b/typos.toml @@ -1,9 +1,9 @@ [files] extend-exclude = [ + # vendored code "vendor/*", "pkg/*", "src/stb/*", - "*.xib", # "grey" color names are valid "src/terminal/res/rgb.txt", # Do not self-check @@ -17,7 +17,9 @@ extend-exclude = [ "*.icns", # Other "*.pdf", - "*.data" + "*.data", + "*.xib", + "src/cli/lorem_ipsum.txt" ] [default] From 7befb5a418a8aaf174df541f57b850f430358678 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 12:05:04 -0700 Subject: [PATCH 108/139] macos: fix previous/next tab bindings, improve action logging --- include/ghostty.h | 6 +++--- macos/Sources/Features/Terminal/TerminalController.swift | 2 +- src/App.zig | 4 ++-- src/apprt/embedded.zig | 5 +++++ 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index 7c81dbe77..b5dd1609b 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -379,9 +379,9 @@ typedef struct { // apprt.action.GotoTab typedef enum { - GHOSTTY_GOTO_TAB_PREVIOUS, - GHOSTTY_GOTO_TAB_NEXT, - GHOSTTY_GOTO_TAB_LAST, + GHOSTTY_GOTO_TAB_PREVIOUS = -1, + GHOSTTY_GOTO_TAB_NEXT = -2, + GHOSTTY_GOTO_TAB_LAST = -3, } ghostty_action_goto_tab_e; // apprt.action.Fullscreen diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 5bb6dbef6..25bbd9b94 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -734,7 +734,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // Get the tab index from the notification guard let tabEnumAny = notification.userInfo?[Ghostty.Notification.GotoTabKey] else { return } guard let tabEnum = tabEnumAny as? ghostty_action_goto_tab_e else { return } - let tabIndex: Int32 = .init(bitPattern: tabEnum.rawValue) + let tabIndex: Int32 = tabEnum.rawValue guard let windowController = window.windowController else { return } guard let tabGroup = windowController.window?.tabGroup else { return } diff --git a/src/App.zig b/src/App.zig index 4462c7c83..2e8ac3cf6 100644 --- a/src/App.zig +++ b/src/App.zig @@ -139,7 +139,7 @@ pub fn addSurface(self: *App, rt_surface: *apprt.Surface) !void { // It is up to the apprt if there is a quit timer at all and if it // should be canceled. rt_surface.app.performAction( - .{ .surface = &rt_surface.core_surface }, + .app, .quit_timer, .stop, ) catch |err| { @@ -173,7 +173,7 @@ pub fn deleteSurface(self: *App, rt_surface: *apprt.Surface) void { // If we have no surfaces, we can start the quit timer. It is up to the // apprt to determine if this is necessary. if (self.surfaces.items.len == 0) rt_surface.app.performAction( - .{ .surface = &rt_surface.core_surface }, + .app, .quit_timer, .start, ) catch |err| { diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 88a69050f..dc6006caf 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -441,6 +441,11 @@ pub const App = struct { else => {}, } + log.debug("dispatching action target={s} action={} value={}", .{ + @tagName(target), + action, + value, + }); self.opts.action( self, target.cval(), From f6f91b5eb54711f5fc4656104c3e140bb9665664 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 12:27:55 -0700 Subject: [PATCH 109/139] core: fix invalid action call that wasn't setting our metrics properly Fixes #2308 --- src/Surface.zig | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 28d66d25d..298b7eabd 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -1257,14 +1257,10 @@ pub fn setFontSize(self: *Surface, size: font.face.DesiredSize) !void { errdefer self.app.font_grid_set.deref(font_grid_key); // Set our cell size - try self.rt_app.performAction( - .{ .surface = self }, - .cell_size, - .{ - .width = font_grid.metrics.cell_width, - .height = font_grid.metrics.cell_height, - }, - ); + try self.setCellSize(.{ + .width = font_grid.metrics.cell_width, + .height = font_grid.metrics.cell_height, + }); // Notify our render thread of the new font stack. The renderer // MUST accept the new font grid and deref the old. From 39733ac3e509a374e55278463ebc29b5cedc9f09 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 27 Sep 2024 15:49:02 -0500 Subject: [PATCH 110/139] nix: work around ziglang/zig#20976 and fix nix package builds Because Zig does not fetch recursive dependencies when you run `zig build --fetch` (see https://github.com/ziglang/zig/issues/20976) we need to do some extra work to fetch everything that we actually need to build without Internet access (such as when building a Nix package). An example of this happening: ``` error: builder for '/nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv' failed with exit code 1; la/build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:7:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure > .url = "git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e", > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ > /build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:16:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure > .url = "git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b", > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ > For full logs, run 'nix log /nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv'. ``` To update this script, add any failing URLs with a line like this: ``` zig fetch ``` Periodically old URLs may need to be cleaned out. Hopefully when the Zig issue is fixed this script can be eliminated in favor of a plain `zig build --fetch`. --- fetch-zig-cache.sh | 45 +++++++++++++++++++++++ nix/build-support/check-zig-cache-hash.sh | 13 +++++-- nix/package.nix | 3 +- nix/zigCacheHash.nix | 2 +- 4 files changed, 57 insertions(+), 6 deletions(-) create mode 100644 fetch-zig-cache.sh diff --git a/fetch-zig-cache.sh b/fetch-zig-cache.sh new file mode 100644 index 000000000..01b1e2569 --- /dev/null +++ b/fetch-zig-cache.sh @@ -0,0 +1,45 @@ +#!/bin/sh + +set -e + +# Because Zig does not fetch recursive dependencies when you run `zig build +# --fetch` (see https://github.com/ziglang/zig/issues/20976) we need to do some +# extra work to fetch everything that we actually need to build without Internet +# access (such as when building a Nix package). +# +# An example of this happening: +# +# error: builder for '/nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv' failed with exit code 1; +# la/build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:7:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure +# > .url = "git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e", +# > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# > /build/tmp.xgHOheUF7V/p/12208cfdda4d5fdbc81b0c44b82e4d6dba2d4a86bff644a153e026fdfc80f8469133/build.zig.zon:16:20: error: unable to discover remote git server capabilities: TemporaryNameServerFailure +# > .url = "git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b", +# > ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +# > +# For full logs, run 'nix log /nix/store/cx8qcwrhjmjxik2547fw99v5j6np5san-ghostty-0.1.0.drv'. +# +# To update this script, add any failing URLs with a line like this: +# +# zig fetch +# +# Periodically old URLs may need to be cleaned out. +# +# Hopefully when the Zig issue is fixed this script can be eliminated in favor +# of a plain `zig build --fetch`. + +if [ -z ${ZIG_GLOBAL_CACHE_DIR+x} ] +then + echo "must set ZIG_GLOBAL_CACHE_DIR!" + exit 1 +fi + +if [ -z ${ZIG_LOCAL_CACHE_DIR+x} ] +then + echo "must set ZIG_LOCAL_CACHE_DIR!" + exit 1 +fi + +zig build --fetch +zig fetch git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e +zig fetch git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b diff --git a/nix/build-support/check-zig-cache-hash.sh b/nix/build-support/check-zig-cache-hash.sh index 2fd1ead5b..4ff88e800 100755 --- a/nix/build-support/check-zig-cache-hash.sh +++ b/nix/build-support/check-zig-cache-hash.sh @@ -25,16 +25,21 @@ elif [ "$1" != "--update" ]; then exit 1 fi -TMP_CACHE_DIR="$(mktemp --directory --suffix nix-zig-cache)" +ZIG_GLOBAL_CACHE_DIR="$(mktemp --directory --suffix nix-zig-cache)" +ZIG_LOCAL_CACHE_DIR="${ZIG_GLOBAL_CACHE_DIR}" +export ZIG_GLOBAL_CACHE_DIR +export ZIG_LOCAL_CACHE_DIR + # This is not 100% necessary in CI but is helpful when running locally to keep # a local workstation clean. -trap 'rm -rf "${TMP_CACHE_DIR}"' EXIT +trap 'rm -rf "${ZIG_GLOBAL_CACHE_DIR}"' EXIT # Run Zig and download the cache to the temporary directory. -zig build --fetch --global-cache-dir "${TMP_CACHE_DIR}" + +sh ./fetch-zig-cache.sh # Now, calculate the hash. -ZIG_CACHE_HASH="sha256-$(nix-hash --type sha256 --to-base64 "$(nix-hash --type sha256 "${TMP_CACHE_DIR}")")" +ZIG_CACHE_HASH="sha256-$(nix-hash --type sha256 --to-base64 "$(nix-hash --type sha256 "${ZIG_GLOBAL_CACHE_DIR}")")" if [ "${OLD_CACHE_HASH}" == "${ZIG_CACHE_HASH}" ]; then echo -e "\nOK: Zig cache store hash unchanged." diff --git a/nix/package.nix b/nix/package.nix index b8d69eef9..d6c294b86 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -56,6 +56,7 @@ ../vendor ../build.zig ../build.zig.zon + ../fetch-zig-cache.sh ] ); }; @@ -90,7 +91,7 @@ buildPhase = '' runHook preBuild - zig build --fetch + sh ./fetch-zig-cache.sh runHook postBuild ''; diff --git a/nix/zigCacheHash.nix b/nix/zigCacheHash.nix index 92e656264..f11d67db1 100644 --- a/nix/zigCacheHash.nix +++ b/nix/zigCacheHash.nix @@ -1,3 +1,3 @@ # This file is auto-generated! check build-support/check-zig-cache-hash.sh for # more details. -"sha256-qFt9sC3GekfU940Gd9oV9Gcbs5MdxVMojIMbkDo3m2A=" +"sha256-JsAEfg1jp20aGz9YXG/QEp4MS5K5J5U7zFS2Orw2K/s=" From 6d68db3bdc7743a4c51403bfb9ba111f9edd0900 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 14:18:33 -0700 Subject: [PATCH 111/139] core: only extend selection on mouse if click interval is exceeded Previously, once we had one click registered, shift+click would always go into selection extend mode. This is not the behavior we want, since we want shift+double/triple click to work in alternate screens. This commit changes the behavior so that we only extend the selection after the multi-click interval has passed. I see a lot of opportunity to improve this whole callback much more but I don't want to risk introducing new bugs since this is a hard to test area, so I'm going to leave it for now. --- src/Surface.zig | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index 298b7eabd..e8bbb885f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -2476,15 +2476,33 @@ pub fn mouseButtonCallback( if (mods.shift and self.mouse.left_click_count > 0 and !shift_capture) - { + extend_selection: { // We split this conditional out on its own because this is the // only one that requires a renderer mutex grab which is VERY // expensive because it could block all our threads. - if (self.hasSelection()) { - const pos = try self.rt_surface.getCursorPos(); - try self.cursorPosCallback(pos, null); - return true; + if (!self.hasSelection()) break :extend_selection; + + // If we are within the interval that the click would register + // an increment then we do not extend the selection. + if (std.time.Instant.now()) |now| { + const since = now.since(self.mouse.left_click_time); + if (since <= self.config.mouse_interval) { + // Click interval very short, we may be increasing + // click counts so we don't extend the selection. + break :extend_selection; + } + } else |err| { + // This is a weird behavior, I think either behavior is actually + // fine. This failure should be exceptionally rare anyways. + // My thinking here is that we can't be sure if we should extend + // the selection or not so we just don't. + log.warn("failed to get time, not extending selection err={}", .{err}); + break :extend_selection; } + + const pos = try self.rt_surface.getCursorPos(); + try self.cursorPosCallback(pos, null); + return true; } } From a9b04037b4adbddf33d41292aab4d9b7122d7e2f Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 27 Sep 2024 19:19:51 -0500 Subject: [PATCH 112/139] nix: don't require the ZIG_LOCAL_CACHE_DIR to be set NixOS does not set this during Nix package builds. Probably Nix's caching got in the way of detecting this on my system. --- fetch-zig-cache.sh | 6 ------ nix/build-support/check-zig-cache-hash.sh | 2 -- 2 files changed, 8 deletions(-) diff --git a/fetch-zig-cache.sh b/fetch-zig-cache.sh index 01b1e2569..56b94e35d 100644 --- a/fetch-zig-cache.sh +++ b/fetch-zig-cache.sh @@ -34,12 +34,6 @@ then exit 1 fi -if [ -z ${ZIG_LOCAL_CACHE_DIR+x} ] -then - echo "must set ZIG_LOCAL_CACHE_DIR!" - exit 1 -fi - zig build --fetch zig fetch git+https://github.com/zigimg/zigimg#3a667bdb3d7f0955a5a51c8468eac83210c1439e zig fetch git+https://github.com/mitchellh/libxev#f6a672a78436d8efee1aa847a43a900ad773618b diff --git a/nix/build-support/check-zig-cache-hash.sh b/nix/build-support/check-zig-cache-hash.sh index 4ff88e800..ad51af2e4 100755 --- a/nix/build-support/check-zig-cache-hash.sh +++ b/nix/build-support/check-zig-cache-hash.sh @@ -26,9 +26,7 @@ elif [ "$1" != "--update" ]; then fi ZIG_GLOBAL_CACHE_DIR="$(mktemp --directory --suffix nix-zig-cache)" -ZIG_LOCAL_CACHE_DIR="${ZIG_GLOBAL_CACHE_DIR}" export ZIG_GLOBAL_CACHE_DIR -export ZIG_LOCAL_CACHE_DIR # This is not 100% necessary in CI but is helpful when running locally to keep # a local workstation clean. From 576453cfde3a83498d4bed9080da0d366ea3203f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 18:33:50 -0700 Subject: [PATCH 113/139] apprt/gtk: set null url if url is empty Fixes #2306 --- src/apprt/gtk/App.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 294954bd4..94fae8015 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -622,7 +622,10 @@ fn setMouseOverLink( ) void { switch (target) { .app => {}, - .surface => |v| v.rt_surface.mouseOverLink(value.url), + .surface => |v| v.rt_surface.mouseOverLink(if (value.url.len > 0) + value.url + else + null), } } From 93b2fe60f828539adb3877a904bc20672d5ca597 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 14:44:57 -0700 Subject: [PATCH 114/139] macos: start work on SlideTerminal, slides in window from top --- macos/Ghostty.xcodeproj/project.pbxproj | 20 ++++++ macos/Sources/App/macOS/AppDelegate.swift | 7 ++- .../Features/SlideTerminal/SlideTerminal.xib | 31 ++++++++++ .../SlideTerminalController.swift | 62 +++++++++++++++++++ .../SlideTerminal/SlideTerminalWindow.swift | 39 ++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminal.xib create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminalController.swift create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 9bff35757..f05c8e74c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,6 +62,9 @@ A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; + A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; + A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; + A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -133,6 +136,9 @@ A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; + A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; + A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; + A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -204,6 +210,7 @@ A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, + A5CBD05A2CA0C5910017A1AE /* SlideTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, @@ -381,6 +388,16 @@ path = "Global Keybinds"; sourceTree = ""; }; + A5CBD05A2CA0C5910017A1AE /* SlideTerminal */ = { + isa = PBXGroup; + children = ( + A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */, + A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */, + A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */, + ); + path = SlideTerminal; + sourceTree = ""; + }; A5CEAFDA29B8005900646FDA /* SplitView */ = { isa = PBXGroup; children = ( @@ -506,6 +523,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, + A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -533,6 +551,8 @@ A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, + A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */, + A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index fc24345a8..5e520df00 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -156,6 +156,8 @@ class AppDelegate: NSObject, center.delegate = self } + var foo: SlideTerminalController? = nil + func applicationDidBecomeActive(_ notification: Notification) { guard !applicationHasBecomeActive else { return } applicationHasBecomeActive = true @@ -165,8 +167,11 @@ class AppDelegate: NSObject, // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state if terminalManager.windows.count == 0 { - terminalManager.newWindow() + //terminalManager.newWindow() } + + foo = SlideTerminalController(window: nil) + foo?.showWindow(self) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminal.xib b/macos/Sources/Features/SlideTerminal/SlideTerminal.xib new file mode 100644 index 000000000..4bb068a7e --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminal.xib @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift new file mode 100644 index 000000000..4bd183508 --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -0,0 +1,62 @@ +import Foundation +import Cocoa +import SwiftUI +import GhosttyKit + +/// Controller for the slide-style terminal. +class SlideTerminalController: NSWindowController { + override var windowNibName: NSNib.Name? { "SlideTerminal" } + + override func windowDidLoad() { + guard let window = self.window else { return } + + // Make the window full width + let screenFrame = NSScreen.main?.frame ?? .zero + window.setFrame(NSRect( + x: 0, + y: 0, + width: screenFrame.size.width, + height: window.frame.size.height + ), display: false) + + slideWindowIn(window: window) + } + + private func slideWindowIn(window: NSWindow) { + guard let screen = NSScreen.main else { return } + + // Determine our final position. Our final position is exactly + // pinned against the top menu bar. + let windowFrame = window.frame + let finalY = screen.visibleFrame.maxY - windowFrame.height + + // Move our window off screen to the top + window.setFrameOrigin(.init( + x: windowFrame.origin.x, + y: screen.frame.maxY)) + + // Set the window invisible + window.alphaValue = 0 + + // Move it to the visible position since animation requires this + window.makeKeyAndOrderFront(nil) + + // Run the animation that moves our window into the proper place and makes + // it visible. + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = .init(name: .easeIn) + + let animator = window.animator() + animator.setFrame(.init( + origin: .init(x: windowFrame.origin.x, y: finalY), + size: windowFrame.size + ), display: true) + animator.alphaValue = 1 + } + } +} + +enum SlideTerminalLocation { + case top +} diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift new file mode 100644 index 000000000..f2eac6b4d --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift @@ -0,0 +1,39 @@ +import Cocoa + +class SlideTerminalWindow: NSWindow { + // Both of these must be true for windows without decorations to be able to + // still become key/main and receive events. + override var canBecomeKey: Bool { return true } + override var canBecomeMain: Bool { return true } + + override func awakeFromNib() { + super.awakeFromNib() + + // Note: almost all of this stuff can be done in the nib/xib directly + // but I prefer to do it programmatically because the properties we + // care about are less hidden. + + // Remove the title completely. This will make the window square. One + // downside is it also hides the cursor indications of resize but the + // window remains resizable. + self.styleMask.remove(.titled) + + // We need to set our window level to a high value. In testing, only + // popUpMenu and above do what we want. This gets it above the menu bar + // and lets us render off screen. + self.level = .popUpMenu + + self.isMovableByWindowBackground = true + self.isMovable = true + + self.collectionBehavior = [ + // We want this to be part of every space because it is a singleton. + .canJoinAllSpaces, + + // We don't want to be part of command-tilde + .ignoresCycle, + + // We never support fullscreen + .fullScreenNone] + } +} From bdd0070ffdb0816991c1f7c6d9e66d2504295702 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 20:52:04 -0700 Subject: [PATCH 115/139] macos: render a terminal in the slide window --- macos/Ghostty.xcodeproj/project.pbxproj | 4 + macos/Sources/App/macOS/AppDelegate.swift | 2 +- .../SlideTerminalController.swift | 94 ++++++++++++------- .../SlideTerminal/SlideTerminalPosition.swift | 41 ++++++++ .../SlideTerminal/SlideTerminalWindow.swift | 3 - .../Features/Terminal/TerminalView.swift | 1 + 6 files changed, 108 insertions(+), 37 deletions(-) create mode 100644 macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index f05c8e74c..450a994a7 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -65,6 +65,7 @@ A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; + A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -139,6 +140,7 @@ A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; + A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalPosition.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -393,6 +395,7 @@ children = ( A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */, + A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */, A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */, ); path = SlideTerminal; @@ -548,6 +551,7 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, + A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 5e520df00..1611541d1 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -170,7 +170,7 @@ class AppDelegate: NSObject, //terminalManager.newWindow() } - foo = SlideTerminalController(window: nil) + foo = SlideTerminalController(ghostty, baseConfig: nil) foo?.showWindow(self) } diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 4bd183508..3db695eab 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -4,39 +4,77 @@ import SwiftUI import GhosttyKit /// Controller for the slide-style terminal. -class SlideTerminalController: NSWindowController { +class SlideTerminalController: NSWindowController, TerminalViewDelegate, TerminalViewModel { override var windowNibName: NSNib.Name? { "SlideTerminal" } + /// The app instance that this terminal view will represent. + let ghostty: Ghostty.App + + /// The position fo the slide terminal. + let position: SlideTerminalPosition + + /// The surface tree for this window. + @Published var surfaceTree: Ghostty.SplitNode? = nil + + init(_ ghostty: Ghostty.App, + position: SlideTerminalPosition = .top, + baseConfig base: Ghostty.SurfaceConfiguration? = nil, + surfaceTree tree: Ghostty.SplitNode? = nil + ) { + self.ghostty = ghostty + self.position = position + + super.init(window: nil) + + // Initialize our initial surface. + guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } + self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + // MARK: NSWindowController + override func windowDidLoad() { guard let window = self.window else { return } - // Make the window full width - let screenFrame = NSScreen.main?.frame ?? .zero - window.setFrame(NSRect( - x: 0, - y: 0, - width: screenFrame.size.width, - height: window.frame.size.height - ), display: false) + // The slide window is not restorable (yet!). "Yet" because in theory we can + // make this restorable, but it isn't currently implemented. + window.isRestorable = false - slideWindowIn(window: window) + // Setup our content + window.contentView = NSHostingView(rootView: TerminalView( + ghostty: self.ghostty, + viewModel: self, + delegate: self + )) + + // Animate the window in + slideWindowIn(window: window, from: position) } - private func slideWindowIn(window: NSWindow) { + //MARK: TerminalViewDelegate + + func cellSizeDidChange(to: NSSize) { + guard ghostty.config.windowStepResize else { return } + self.window?.contentResizeIncrements = to + } + + func surfaceTreeDidChange() { + if (surfaceTree == nil) { + self.window?.close() + } + } + + // MARK: Slide Logic + + private func slideWindowIn(window: NSWindow, from position: SlideTerminalPosition) { guard let screen = NSScreen.main else { return } - // Determine our final position. Our final position is exactly - // pinned against the top menu bar. - let windowFrame = window.frame - let finalY = screen.visibleFrame.maxY - windowFrame.height - // Move our window off screen to the top - window.setFrameOrigin(.init( - x: windowFrame.origin.x, - y: screen.frame.maxY)) - - // Set the window invisible - window.alphaValue = 0 + position.setInitial(in: window, on: screen) // Move it to the visible position since animation requires this window.makeKeyAndOrderFront(nil) @@ -46,17 +84,7 @@ class SlideTerminalController: NSWindowController { NSAnimationContext.runAnimationGroup { context in context.duration = 0.3 context.timingFunction = .init(name: .easeIn) - - let animator = window.animator() - animator.setFrame(.init( - origin: .init(x: windowFrame.origin.x, y: finalY), - size: windowFrame.size - ), display: true) - animator.alphaValue = 1 + position.setFinal(in: window.animator(), on: screen) } } } - -enum SlideTerminalLocation { - case top -} diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift new file mode 100644 index 000000000..89c521a47 --- /dev/null +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -0,0 +1,41 @@ +import Cocoa + +enum SlideTerminalPosition { + case top + + /// Set the initial state for a window for animating out of this position. + func setInitial(in window: NSWindow, on screen: NSScreen) { + // We always start invisible + window.alphaValue = 0 + + // Position depends + switch (self) { + case .top: + window.setFrame(.init( + origin: .init( + x: 0, + y: screen.frame.maxY), + size: .init( + width: screen.frame.width, + height: window.frame.height) + ), display: false) + } + } + + /// Set the final state for a window in this position. + func setFinal(in window: NSWindow, on screen: NSScreen) { + // We always end visible + window.alphaValue = 1 + + // Position depends + switch (self) { + case .top: + window.setFrame(.init( + origin: .init( + x: window.frame.origin.x, + y: screen.visibleFrame.maxY - window.frame.height), + size: window.frame.size + ), display: true) + } + } +} diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift index f2eac6b4d..c170b3a8b 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift @@ -23,9 +23,6 @@ class SlideTerminalWindow: NSWindow { // and lets us render off screen. self.level = .popUpMenu - self.isMovableByWindowBackground = true - self.isMovable = true - self.collectionBehavior = [ // We want this to be part of every space because it is a singleton. .canJoinAllSpaces, diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 248c09056..64ce37885 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -18,6 +18,7 @@ protocol TerminalViewDelegate: AnyObject { /// not called initially. func surfaceTreeDidChange() + /// This is called when a split is zoomed. func zoomStateDidChange(to: Bool) } From 63456d28a53bc28cd6e883e4f3695389042a3586 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 21:28:57 -0700 Subject: [PATCH 116/139] macos: make sliding logic a bit more extensible --- .../SlideTerminalController.swift | 35 +++++++++++++++++-- .../SlideTerminal/SlideTerminalPosition.swift | 24 +++++++++---- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 3db695eab..c117ae0f1 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -4,7 +4,7 @@ import SwiftUI import GhosttyKit /// Controller for the slide-style terminal. -class SlideTerminalController: NSWindowController, TerminalViewDelegate, TerminalViewModel { +class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel { override var windowNibName: NSNib.Name? { "SlideTerminal" } /// The app instance that this terminal view will represent. @@ -40,6 +40,10 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina override func windowDidLoad() { guard let window = self.window else { return } + // The controller is the window delegate so we can detect events such as + // window close so we can animate out. + window.delegate = self + // The slide window is not restorable (yet!). "Yet" because in theory we can // make this restorable, but it isn't currently implemented. window.isRestorable = false @@ -52,7 +56,13 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina )) // Animate the window in - slideWindowIn(window: window, from: position) + slideIn() + } + + // MARK: NSWindowDelegate + + func windowDidResignKey(_ notification: Notification) { + slideOut() } //MARK: TerminalViewDelegate @@ -68,7 +78,17 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina } } - // MARK: Slide Logic + // MARK: Slide Methods + + func slideIn() { + guard let window = self.window else { return } + slideWindowIn(window: window, from: position) + } + + func slideOut() { + guard let window = self.window else { return } + slideWindowOut(window: window, to: position) + } private func slideWindowIn(window: NSWindow, from position: SlideTerminalPosition) { guard let screen = NSScreen.main else { return } @@ -87,4 +107,13 @@ class SlideTerminalController: NSWindowController, TerminalViewDelegate, Termina position.setFinal(in: window.animator(), on: screen) } } + + private func slideWindowOut(window: NSWindow, to position: SlideTerminalPosition) { + guard let screen = NSScreen.main else { return } + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.3 + context.timingFunction = .init(name: .easeIn) + position.setInitial(in: window.animator(), on: screen) + } + } } diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift index 89c521a47..3ef7d7dcc 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -12,9 +12,7 @@ enum SlideTerminalPosition { switch (self) { case .top: window.setFrame(.init( - origin: .init( - x: 0, - y: screen.frame.maxY), + origin: initialOrigin(for: window, on: screen), size: .init( width: screen.frame.width, height: window.frame.height) @@ -31,11 +29,25 @@ enum SlideTerminalPosition { switch (self) { case .top: window.setFrame(.init( - origin: .init( - x: window.frame.origin.x, - y: screen.visibleFrame.maxY - window.frame.height), + origin: finalOrigin(for: window, on: screen), size: window.frame.size ), display: true) } } + + /// The initial point origin for this position. + func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch (self) { + case .top: + return .init(x: 0, y: screen.frame.maxY) + } + } + + /// The final point origin for this position. + func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { + switch (self) { + case .top: + return .init(x: window.frame.origin.x, y: screen.visibleFrame.maxY - window.frame.height) + } + } } From d18e1c879bcc68a4c4db68f4cebaa16193b26bdd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 21:35:07 -0700 Subject: [PATCH 117/139] macos: restrict resizing based on sliding terminal position --- .../SlideTerminal/SlideTerminalController.swift | 7 ++++++- .../SlideTerminal/SlideTerminalPosition.swift | 11 +++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index c117ae0f1..12f785843 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -10,7 +10,7 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie /// The app instance that this terminal view will represent. let ghostty: Ghostty.App - /// The position fo the slide terminal. + /// The position for the slide terminal. let position: SlideTerminalPosition /// The surface tree for this window. @@ -65,6 +65,11 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie slideOut() } + func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { + guard let screen = NSScreen.main else { return frameSize } + return position.restrictFrameSize(frameSize, on: screen) + } + //MARK: TerminalViewDelegate func cellSizeDidChange(to: NSSize) { diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift index 3ef7d7dcc..72f8d9483 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -35,6 +35,17 @@ enum SlideTerminalPosition { } } + /// Restrict the frame size during resizing. + func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize { + var finalSize = size + switch (self) { + case .top: + finalSize.width = screen.frame.width + } + + return finalSize + } + /// The initial point origin for this position. func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { From cadb960ef9b99b8c65af87eaed5fcdf6203dbf69 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 21:57:17 -0700 Subject: [PATCH 118/139] core: slide terminal keybinding action --- macos/Sources/App/macOS/AppDelegate.swift | 21 +++++++++++++------ macos/Sources/App/macOS/MainMenu.xib | 8 +++++++ .../SlideTerminalController.swift | 9 ++++++++ src/Surface.zig | 6 ++++++ src/input/Binding.zig | 15 +++++++++++++ 5 files changed, 53 insertions(+), 6 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 1611541d1..47302f302 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -49,6 +49,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuIncreaseFontSize: NSMenuItem? @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? + @IBOutlet private var menuSlideTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuEqualizeSplits: NSMenuItem? @@ -73,6 +74,9 @@ class AppDelegate: NSObject, /// Manages our terminal windows. let terminalManager: TerminalManager + /// Our slide terminal. This starts out uninitialized and only initializes if used. + private var slideController: SlideTerminalController? = nil + /// Manages updates let updaterController: SPUStandardUpdaterController let updaterDelegate: UpdaterDelegate = UpdaterDelegate() @@ -156,8 +160,6 @@ class AppDelegate: NSObject, center.delegate = self } - var foo: SlideTerminalController? = nil - func applicationDidBecomeActive(_ notification: Notification) { guard !applicationHasBecomeActive else { return } applicationHasBecomeActive = true @@ -167,11 +169,8 @@ class AppDelegate: NSObject, // - if we're opening a URL since `application(_:openFile:)` is called before this. // - if we're restoring from persisted state if terminalManager.windows.count == 0 { - //terminalManager.newWindow() + terminalManager.newWindow() } - - foo = SlideTerminalController(ghostty, baseConfig: nil) - foo?.showWindow(self) } func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { @@ -315,6 +314,7 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(action: "toggle_slide_terminal", menuItem: self.menuSlideTerminal) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) @@ -550,4 +550,13 @@ class AppDelegate: NSObject, @IBAction func toggleSecureInput(_ sender: Any) { setSecureInput(.toggle) } + + @IBAction func toggleSlideTerminal(_ sender: Any) { + if slideController == nil { + slideController = SlideTerminalController(ghostty, baseConfig: nil) + } + + guard let slideController = self.slideController else { return } + slideController.slideToggle() + } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index beb411987..f19f9d1ed 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -42,6 +42,7 @@ + @@ -216,6 +217,13 @@ + + + + + + + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 12f785843..d1c4efc63 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -85,6 +85,15 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie // MARK: Slide Methods + func slideToggle() { + guard let window = self.window else { return } + if (window.alphaValue > 0) { + slideOut() + } else { + slideIn() + } + } + func slideIn() { guard let window = self.window else { return } slideWindowIn(window: window, from: position) diff --git a/src/Surface.zig b/src/Surface.zig index e8bbb885f..007f561e0 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3856,6 +3856,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), + .toggle_slide_terminal => { + if (@hasDecl(apprt.Surface, "toggleSlideTerminal")) { + self.rt_surface.toggleSlideTerminal(); + } else log.warn("runtime doesn't implement toggleSlideTerminal", .{}); + }, + .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index f9921a87e..73fb6aa47 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -363,6 +363,17 @@ pub const Action = union(enum) { /// This only works on macOS, since this is a system API on macOS. toggle_secure_input: void, + /// Toggle the "slide" terminal. The slide terminal is a terminal that + /// slides in from some screen edge, usually the top. This is useful for + /// quick access to a terminal without having to open a new window or tab. + /// + /// The slide terminal is a singleton; only one instance can exist at a + /// time. + /// + /// See the various configurations for the slide terminal in the + /// configuration file to customize its behavior. + toggle_slide_terminal: void, + /// Quit ghostty. quit: void, @@ -382,6 +393,10 @@ pub const Action = union(enum) { /// crash: CrashThread, + pub const SlideTerminalPosition = enum { + top, + }; + pub const CrashThread = enum { main, io, From bdc2c02f23376c1f9532614959ca866a336fdc31 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sun, 22 Sep 2024 22:08:13 -0700 Subject: [PATCH 119/139] macos: when sliding out the terminal, cycle focus --- .../SlideTerminalController.swift | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index d1c4efc63..7e0ddebdc 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -124,10 +124,41 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie private func slideWindowOut(window: NSWindow, to position: SlideTerminalPosition) { guard let screen = NSScreen.main else { return } - NSAnimationContext.runAnimationGroup { context in + + // Keep track of if we were the key window. If we were the key window then we + // want to move focus to the next window so that focus is preserved somewhere + // in the app. + let wasKey = window.isKeyWindow + + NSAnimationContext.runAnimationGroup({ context in context.duration = 0.3 context.timingFunction = .init(name: .easeIn) position.setInitial(in: window.animator(), on: screen) + }, completionHandler: { + guard wasKey else { return } + self.focusNextWindow() + }) + } + + private func focusNextWindow() { + // We only want to consider windows that are visible + let windows = NSApp.windows.filter { $0.isVisible } + + // If we have no windows there is nothing to focus. + guard !windows.isEmpty else { return } + + // Find the current key window (the window that is currently focused) + if let keyWindow = NSApp.keyWindow, + let currentIndex = windows.firstIndex(of: keyWindow) { + // Calculate the index of the next window (cycle through the list) + let nextIndex = (currentIndex + 1) % windows.count + let nextWindow = windows[nextIndex] + + // Make the next window key and bring it to the front + nextWindow.makeKeyAndOrderFront(nil) + } else { + // If there's no key window, focus the first available window + windows.first?.makeKeyAndOrderFront(nil) } } } From 7806366eec8d631d97c42d05210bad39a8c8eaaf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 25 Sep 2024 09:48:47 -0700 Subject: [PATCH 120/139] core: fix up toggle_slide_terminal action for rebase --- include/ghostty.h | 1 + src/App.zig | 1 + src/Surface.zig | 6 ------ src/apprt/action.zig | 4 ++++ src/apprt/glfw.zig | 1 + src/apprt/gtk/App.zig | 1 + src/input/Binding.zig | 1 + 7 files changed, 9 insertions(+), 6 deletions(-) diff --git a/include/ghostty.h b/include/ghostty.h index b5dd1609b..38affd16e 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -507,6 +507,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, + GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_RESIZE_SPLIT, diff --git a/src/App.zig b/src/App.zig index 2e8ac3cf6..369fc4288 100644 --- a/src/App.zig +++ b/src/App.zig @@ -324,6 +324,7 @@ pub fn performAction( .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try self.reloadConfig(rt_app), .close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}), + .toggle_slide_terminal => try rt_app.performAction(.app, .toggle_slide_terminal, {}), } } diff --git a/src/Surface.zig b/src/Surface.zig index 007f561e0..e8bbb885f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -3856,12 +3856,6 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .toggle, ), - .toggle_slide_terminal => { - if (@hasDecl(apprt.Surface, "toggleSlideTerminal")) { - self.rt_surface.toggleSlideTerminal(); - } else log.warn("runtime doesn't implement toggleSlideTerminal", .{}); - }, - .select_all => { const sel = self.io.terminal.screen.selectAll(); if (sel) |s| { diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 9ed89b5a3..6fd15ec9c 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -93,6 +93,9 @@ pub const Action = union(Key) { /// Toggle whether window directions are shown. toggle_window_decorations, + /// Toggle the slide terminal in or out. + toggle_slide_terminal, + /// Jump to a specific tab. Must handle the scenario that the tab /// value is invalid. goto_tab: GotoTab, @@ -176,6 +179,7 @@ pub const Action = union(Key) { toggle_fullscreen, toggle_tab_overview, toggle_window_decorations, + toggle_slide_terminal, goto_tab, goto_split, resize_split, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index fb31f7c2b..a64ed0afc 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -196,6 +196,7 @@ pub const App = struct { .close_all_windows, .toggle_tab_overview, .toggle_window_decorations, + .toggle_slide_terminal, .goto_tab, .inspector, .render_inspector, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index 94fae8015..dc535868e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -379,6 +379,7 @@ pub fn performAction( // Unimplemented .close_all_windows, .toggle_split_zoom, + .toggle_slide_terminal, .size_limit, .cell_size, .secure_input, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 73fb6aa47..986b9e7c8 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -578,6 +578,7 @@ pub const Action = union(enum) { .reload_config, .close_all_windows, .quit, + .toggle_slide_terminal, => .app, // These are app but can be special-cased in a surface context. From 99e5e594914a23adbf1815b47b04289769f85482 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 27 Sep 2024 13:39:25 -0700 Subject: [PATCH 121/139] macos: hook up the action for the slide terminal --- macos/Sources/Ghostty/Ghostty.App.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 5b2efad3e..f1e35cb99 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -482,6 +482,9 @@ extension Ghostty { case GHOSTTY_ACTION_RENDERER_HEALTH: rendererHealth(app, target: target, v: action.action.renderer_health) + case GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL: + toggleSlideTerminal(app, target: target) + case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough case GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW: @@ -830,6 +833,14 @@ extension Ghostty { } } + private static func toggleSlideTerminal( + _ app: ghostty_app_t, + target: ghostty_target_s + ) { + guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } + appDelegate.toggleSlideTerminal(self) + } + private static func setTitle( _ app: ghostty_app_t, target: ghostty_target_s, From 21a7e40510e1b03f56eea7ed871c81c799bc55f2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 27 Sep 2024 20:40:03 -0500 Subject: [PATCH 122/139] nix: move fetch-zig-cache script into nix/build-support --- nix/build-support/check-zig-cache-hash.sh | 2 +- fetch-zig-cache.sh => nix/build-support/fetch-zig-cache.sh | 0 nix/package.nix | 4 ++-- 3 files changed, 3 insertions(+), 3 deletions(-) rename fetch-zig-cache.sh => nix/build-support/fetch-zig-cache.sh (100%) diff --git a/nix/build-support/check-zig-cache-hash.sh b/nix/build-support/check-zig-cache-hash.sh index ad51af2e4..49ea29ffb 100755 --- a/nix/build-support/check-zig-cache-hash.sh +++ b/nix/build-support/check-zig-cache-hash.sh @@ -34,7 +34,7 @@ trap 'rm -rf "${ZIG_GLOBAL_CACHE_DIR}"' EXIT # Run Zig and download the cache to the temporary directory. -sh ./fetch-zig-cache.sh +sh ./nix/build-support/fetch-zig-cache.sh # Now, calculate the hash. ZIG_CACHE_HASH="sha256-$(nix-hash --type sha256 --to-base64 "$(nix-hash --type sha256 "${ZIG_GLOBAL_CACHE_DIR}")")" diff --git a/fetch-zig-cache.sh b/nix/build-support/fetch-zig-cache.sh similarity index 100% rename from fetch-zig-cache.sh rename to nix/build-support/fetch-zig-cache.sh diff --git a/nix/package.nix b/nix/package.nix index d6c294b86..fccaf1c92 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -56,7 +56,7 @@ ../vendor ../build.zig ../build.zig.zon - ../fetch-zig-cache.sh + ./build-support/fetch-zig-cache.sh ] ); }; @@ -91,7 +91,7 @@ buildPhase = '' runHook preBuild - sh ./fetch-zig-cache.sh + sh ./nix/build-support/fetch-zig-cache.sh runHook postBuild ''; From 93643d1741bd3eec1835d415318a87c7f2121162 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Fri, 27 Sep 2024 22:22:22 -0500 Subject: [PATCH 123/139] sgr: add support for legacy double underline SGR 21 is defined to be a double underline. This behavior is common among many terminals, notably xterm. References: https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ --- src/terminal/sgr.zig | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/terminal/sgr.zig b/src/terminal/sgr.zig index b23bd1514..67a4c05ea 100644 --- a/src/terminal/sgr.zig +++ b/src/terminal/sgr.zig @@ -165,6 +165,8 @@ pub const Parser = struct { 9 => return Attribute{ .strikethrough = {} }, + 21 => return Attribute{ .underline = .double }, + 22 => return Attribute{ .reset_bold = {} }, 23 => return Attribute{ .reset_italic = {} }, From cadca8ca450321acfebaa4a33520e5fccaa7aca2 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Fri, 27 Sep 2024 23:18:42 -0500 Subject: [PATCH 124/139] nix: remove tracy from the devshell --- nix/devShell.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/nix/devShell.nix b/nix/devShell.nix index b4e109513..6a973d17a 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -14,7 +14,6 @@ python3, qemu, scdoc, - tracy, valgrind, #, vulkan-loader # unused vttest, @@ -100,7 +99,6 @@ in # Testing parallel python3 - tracy vttest hyperfine From 9f543ceac2eb2347f07ee581ecc3797d89672cf1 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 28 Sep 2024 12:12:00 -0500 Subject: [PATCH 125/139] cli: fix integer overflow in `+list-themes` if window is too narrow Reproduction is to resize the window to it's minimum width and then run `ghostty +list-themes`. Ghostty will crash because Zig for loops don't like having a range where the end is smaller than the start. --- src/cli/list_themes.zig | 78 ++++++++++++++++++++++------------------- 1 file changed, 42 insertions(+), 36 deletions(-) diff --git a/src/cli/list_themes.zig b/src/cli/list_themes.zig index ed375dff2..48919116d 100644 --- a/src/cli/list_themes.zig +++ b/src/cli/list_themes.zig @@ -1068,19 +1068,21 @@ const Preview = struct { .col_offset = 2, }, ); - for (10..child.width) |col| { - _ = try child.print( - &.{ - .{ - .text = "─", - .style = color238, + if (child.width > 10) { + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, }, - }, - .{ - .row_offset = 1, - .col_offset = col, - }, - ); + .{ + .row_offset = 1, + .col_offset = col, + }, + ); + } } } _ = try child.print( @@ -1118,19 +1120,21 @@ const Preview = struct { .col_offset = 2, }, ); - for (10..child.width) |col| { - _ = try child.print( - &.{ - .{ - .text = "─", - .style = color238, + if (child.width > 10) { + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, }, - }, - .{ - .row_offset = 3, - .col_offset = col, - }, - ); + .{ + .row_offset = 3, + .col_offset = col, + }, + ); + } } } _ = try child.print( @@ -1383,19 +1387,21 @@ const Preview = struct { .col_offset = 2, }, ); - for (10..child.width) |col| { - _ = try child.print( - &.{ - .{ - .text = "─", - .style = color238, + if (child.width > 10) { + for (10..child.width) |col| { + _ = try child.print( + &.{ + .{ + .text = "─", + .style = color238, + }, }, - }, - .{ - .row_offset = 21, - .col_offset = col, - }, - ); + .{ + .row_offset = 21, + .col_offset = col, + }, + ); + } } } _ = try child.print( From 50fb7331af8f869819b4cbde7a4e8f5451e4e171 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 10:38:35 -0700 Subject: [PATCH 126/139] macos: base class for terminal controller --- macos/Ghostty.xcodeproj/project.pbxproj | 24 +- .../SlideTerminalController.swift | 31 +- .../Terminal/BaseTerminalController.swift | 361 ++++++++++++++++++ .../Terminal/TerminalController.swift | 328 ++-------------- .../Features/Terminal/TerminalView.swift | 8 - 5 files changed, 408 insertions(+), 344 deletions(-) create mode 100644 macos/Sources/Features/Terminal/BaseTerminalController.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 450a994a7..ed7b97d07 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -34,6 +34,7 @@ A53D0C952B53B4D800305CE6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A5B30538299BEAAB0047F10C /* Assets.xcassets */; }; A53D0C9B2B543F3B00305CE6 /* Ghostty.App.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */; }; A53D0C9C2B543F7B00305CE6 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; + A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */; }; A55685E029A03A9F004303CE /* AppError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55685DF29A03A9F004303CE /* AppError.swift */; }; A55B7BB829B6F53A0055DE60 /* Package.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BB729B6F53A0055DE60 /* Package.swift */; }; A55B7BBC29B6FC330055DE60 /* SurfaceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */; }; @@ -61,11 +62,11 @@ A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; - A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */; }; + A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; A5CDF1912AAF9A5800513312 /* ConfigurationErrors.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */; }; @@ -109,6 +110,7 @@ A535B9D9299C569B0017E2E4 /* ErrorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = ""; }; A53D0C932B53B43700305CE6 /* iOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSApp.swift; sourceTree = ""; }; A53D0C992B543F3B00305CE6 /* Ghostty.App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.App.swift; sourceTree = ""; }; + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseTerminalController.swift; sourceTree = ""; }; A55685DF29A03A9F004303CE /* AppError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppError.swift; sourceTree = ""; }; A55B7BB729B6F53A0055DE60 /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Package.swift; sourceTree = ""; }; A55B7BBB29B6FC330055DE60 /* SurfaceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView.swift; sourceTree = ""; }; @@ -136,11 +138,11 @@ A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; - A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalPosition.swift; sourceTree = ""; }; + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; A5CDF1902AAF9A5800513312 /* ConfigurationErrors.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = ConfigurationErrors.xib; sourceTree = ""; }; @@ -342,6 +344,7 @@ A51B78462AF4B58B00F3EDB9 /* TerminalWindow.swift */, AEF9CE232B6AD07A0017E195 /* TerminalToolbar.swift */, A535B9D9299C569B0017E2E4 /* ErrorView.swift */, + A54D786B2CA79788001B19B1 /* BaseTerminalController.swift */, ); path = Terminal; sourceTree = ""; @@ -382,14 +385,6 @@ name = Products; sourceTree = ""; }; - A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { - isa = PBXGroup; - children = ( - A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, - ); - path = "Global Keybinds"; - sourceTree = ""; - }; A5CBD05A2CA0C5910017A1AE /* SlideTerminal */ = { isa = PBXGroup; children = ( @@ -401,6 +396,14 @@ path = SlideTerminal; sourceTree = ""; }; + A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { + isa = PBXGroup; + children = ( + A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */, + ); + path = "Global Keybinds"; + sourceTree = ""; + }; A5CEAFDA29B8005900646FDA /* SplitView */ = { isa = PBXGroup; children = ( @@ -547,6 +550,7 @@ files = ( A59630A42AF059BB00D64628 /* Ghostty.SplitNode.swift in Sources */, A514C8D62B54A16400493A16 /* Ghostty.Config.swift in Sources */, + A54D786C2CA7978E001B19B1 /* BaseTerminalController.swift in Sources */, A59FB5CF2AE0DB50009128F3 /* InspectorView.swift in Sources */, A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 7e0ddebdc..4b5ec05d3 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -4,31 +4,19 @@ import SwiftUI import GhosttyKit /// Controller for the slide-style terminal. -class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalViewDelegate, TerminalViewModel { +class SlideTerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "SlideTerminal" } - /// The app instance that this terminal view will represent. - let ghostty: Ghostty.App - /// The position for the slide terminal. let position: SlideTerminalPosition - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil - init(_ ghostty: Ghostty.App, position: SlideTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { - self.ghostty = ghostty self.position = position - - super.init(window: nil) - - // Initialize our initial surface. - guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + super.init(ghostty, baseConfig: base, surfaceTree: tree) } required init?(coder: NSCoder) { @@ -61,7 +49,8 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie // MARK: NSWindowDelegate - func windowDidResignKey(_ notification: Notification) { + override func windowDidResignKey(_ notification: Notification) { + super.windowDidResignKey(notification) slideOut() } @@ -70,15 +59,13 @@ class SlideTerminalController: NSWindowController, NSWindowDelegate, TerminalVie return position.restrictFrameSize(frameSize, on: screen) } - //MARK: TerminalViewDelegate + // MARK: Base Controller Overrides - func cellSizeDidChange(to: NSSize) { - guard ghostty.config.windowStepResize else { return } - self.window?.contentResizeIncrements = to - } + override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + super.surfaceTreeDidChange(from: from, to: to) - func surfaceTreeDidChange() { - if (surfaceTree == nil) { + // If our surface tree is now nil then we close our window. + if (to == nil) { self.window?.close() } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift new file mode 100644 index 000000000..fec30eecb --- /dev/null +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -0,0 +1,361 @@ +import Cocoa +import SwiftUI +import GhosttyKit + +/// A base class for windows that can contain Ghostty windows. This base class implements +/// the bare minimum functionality that every terminal window in Ghostty should implement. +/// +/// Usage: Specify this as the base class of your window controller for the window that contains +/// a terminal. The window controller must also be the window delegate OR the window delegate +/// functions on this base class must be called by your own custom delegate. For the terminal +/// view the TerminalView SwiftUI view must be used and this class is the view model and +/// delegate. +/// +/// Notably, things this class does NOT implement (not exhaustive): +/// +/// - Tabbing, because there are many ways to get tabbed behavior in macOS and we +/// don't want to be opinionated about it. +/// - Fullscreen +/// - Window restoration or save state +/// - Window visual styles (such as titlebar colors) +/// +/// The primary idea of all the behaviors we don't implement here are that subclasses may not +/// want these behaviors. +class BaseTerminalController: NSWindowController, + NSWindowDelegate, + TerminalViewDelegate, + TerminalViewModel, + ClipboardConfirmationViewDelegate +{ + /// The app instance that this terminal view will represent. + let ghostty: Ghostty.App + + /// The currently focused surface. + var focusedSurface: Ghostty.SurfaceView? = nil { + didSet { syncFocusToSurfaceTree() } + } + + /// The surface tree for this window. + @Published var surfaceTree: Ghostty.SplitNode? = nil { + didSet { surfaceTreeDidChange(from: oldValue, to: surfaceTree) } + } + + /// Non-nil when an alert is active so we don't overlap multiple. + private var alert: NSAlert? = nil + + /// The clipboard confirmation window, if shown. + private var clipboardConfirmation: ClipboardConfirmationController? = nil + + required init?(coder: NSCoder) { + fatalError("init(coder:) is not supported for this view") + } + + init(_ ghostty: Ghostty.App, + baseConfig base: Ghostty.SurfaceConfiguration? = nil, + surfaceTree tree: Ghostty.SplitNode? = nil + ) { + self.ghostty = ghostty + + super.init(window: nil) + + // Initialize our initial surface. + guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } + self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + + // Setup our notifications for behaviors + let center = NotificationCenter.default + center.addObserver( + self, + selector: #selector(onConfirmClipboardRequest), + name: Ghostty.Notification.confirmClipboard, + object: nil) + } + + /// Called when the surfaceTree variable changed. + /// + /// Subclasses should call super first. + func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + // If our surface tree becomes nil then ensure all surfaces + // in the old tree have closed. + if (to == nil) { + from?.close() + focusedSurface = nil + } + } + + /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about + /// what surface is focused. This must be called whenever a surface OR window changes focus. + func syncFocusToSurfaceTree() { + guard let tree = self.surfaceTree else { return } + + for leaf in tree { + // Our focus state requires that this window is key and our currently + // focused surface is the surface in this leaf. + let focused: Bool = (window?.isKeyWindow ?? false) && + focusedSurface != nil && + leaf.surface == focusedSurface! + leaf.surface.focusDidChange(focused) + } + } + + // MARK: TerminalViewDelegate + + // Note: this is different from surfaceDidTreeChange(from:,to:) because this is called + // when the currently set value changed in place and the from:to: variant is called + // when the variable was set. + func surfaceTreeDidChange() {} + + func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { + focusedSurface = to + } + + func titleDidChange(to: String) { + guard let window else { return } + + // Set the main window title + window.title = to + } + + func cellSizeDidChange(to: NSSize) { + guard ghostty.config.windowStepResize else { return } + self.window?.contentResizeIncrements = to + } + + func zoomStateDidChange(to: Bool) {} + + // MARK: Clipboard Confirmation + + @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { + guard let target = notification.object as? Ghostty.SurfaceView else { return } + guard target == self.focusedSurface else { return } + guard let surface = target.surface else { return } + + // We need a window + guard let window = self.window else { return } + + // Check whether we use non-native fullscreen + guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } + guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } + guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } + + // If we already have a clipboard confirmation view up, we ignore this request. + // This shouldn't be possible... + guard self.clipboardConfirmation == nil else { + Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) + return + } + + // Show our paste confirmation + self.clipboardConfirmation = ClipboardConfirmationController( + surface: surface, + contents: str, + request: request, + state: state, + delegate: self + ) + window.beginSheet(self.clipboardConfirmation!.window!) + } + + func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { + // End our clipboard confirmation no matter what + guard let cc = self.clipboardConfirmation else { return } + self.clipboardConfirmation = nil + + // Close the sheet + if let ccWindow = cc.window { + window?.endSheet(ccWindow) + } + + switch (request) { + case .osc_52_write: + guard case .confirm = action else { break } + let pb = NSPasteboard.general + pb.declareTypes([.string], owner: nil) + pb.setString(cc.contents, forType: .string) + case .osc_52_read, .paste: + let str: String + switch (action) { + case .cancel: + str = "" + + case .confirm: + str = cc.contents + } + + Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) + } + } + + //MARK: - NSWindowDelegate + + // This is called when performClose is called on a window (NOT when close() + // is called directly). performClose is called primarily when UI elements such + // as the "red X" are pressed. + func windowShouldClose(_ sender: NSWindow) -> Bool { + // We must have a window. Is it even possible not to? + guard let window = self.window else { return true } + + // If we have no surfaces, close. + guard let node = self.surfaceTree else { return true } + + // If we already have an alert, continue with it + guard alert == nil else { return false } + + // If our surfaces don't require confirmation, close. + if (!node.needsConfirmQuit()) { return true } + + // We require confirmation, so show an alert as long as we aren't already. + let alert = NSAlert() + alert.messageText = "Close Terminal?" + alert.informativeText = "The terminal still has a running process. If you close the " + + "terminal the process will be killed." + alert.addButton(withTitle: "Close the Terminal") + alert.addButton(withTitle: "Cancel") + alert.alertStyle = .warning + alert.beginSheetModal(for: window, completionHandler: { response in + self.alert = nil + switch (response) { + case .alertFirstButtonReturn: + window.close() + + default: + break + } + }) + + self.alert = alert + + return false + } + + func windowWillClose(_ notification: Notification) { + // I don't know if this is required anymore. We previously had a ref cycle between + // the view and the window so we had to nil this out to break it but I think this + // may now be resolved. We should verify that no memory leaks and we can remove this. + self.window?.contentView = nil + } + + func windowDidBecomeKey(_ notification: Notification) { + // Becoming/losing key means we have to notify our surface(s) that we have focus + // so things like cursors blink, pty events are sent, etc. + self.syncFocusToSurfaceTree() + } + + func windowDidResignKey(_ notification: Notification) { + // Becoming/losing key means we have to notify our surface(s) that we have focus + // so things like cursors blink, pty events are sent, etc. + self.syncFocusToSurfaceTree() + } + + func windowDidChangeOcclusionState(_ notification: Notification) { + guard let surfaceTree = self.surfaceTree else { return } + let visible = self.window?.occlusionState.contains(.visible) ?? false + for leaf in surfaceTree { + if let surface = leaf.surface.surface { + ghostty_surface_set_occlusion(surface, visible) + } + } + } + + // MARK: First Responder + + @IBAction func close(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.requestClose(surface: surface) + } + + @IBAction func closeWindow(_ sender: Any) { + guard let window = window else { return } + window.performClose(sender) + } + + @IBAction func splitRight(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) + } + + @IBAction func splitDown(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) + } + + @IBAction func splitZoom(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitToggleZoom(surface: surface) + } + + + @IBAction func splitMoveFocusPrevious(_ sender: Any) { + splitMoveFocus(direction: .previous) + } + + @IBAction func splitMoveFocusNext(_ sender: Any) { + splitMoveFocus(direction: .next) + } + + @IBAction func splitMoveFocusAbove(_ sender: Any) { + splitMoveFocus(direction: .top) + } + + @IBAction func splitMoveFocusBelow(_ sender: Any) { + splitMoveFocus(direction: .bottom) + } + + @IBAction func splitMoveFocusLeft(_ sender: Any) { + splitMoveFocus(direction: .left) + } + + @IBAction func splitMoveFocusRight(_ sender: Any) { + splitMoveFocus(direction: .right) + } + + @IBAction func equalizeSplits(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitEqualize(surface: surface) + } + + @IBAction func moveSplitDividerUp(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .up, amount: 10) + } + + @IBAction func moveSplitDividerDown(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .down, amount: 10) + } + + @IBAction func moveSplitDividerLeft(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .left, amount: 10) + } + + @IBAction func moveSplitDividerRight(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitResize(surface: surface, direction: .right, amount: 10) + } + + private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { + guard let surface = focusedSurface?.surface else { return } + ghostty.splitMoveFocus(surface: surface, direction: direction) + } + + @IBAction func increaseFontSize(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.changeFontSize(surface: surface, .increase(1)) + } + + @IBAction func decreaseFontSize(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.changeFontSize(surface: surface, .decrease(1)) + } + + @IBAction func resetFontSize(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.changeFontSize(surface: surface, .reset) + } + + @objc func resetTerminal(_ sender: Any) { + guard let surface = focusedSurface?.surface else { return } + ghostty.resetTerminal(surface: surface) + } +} diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 25bbd9b94..bb8b5665d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -3,45 +3,14 @@ import Cocoa import SwiftUI import GhosttyKit -/// The terminal controller is an NSWindowController that maps 1:1 to a terminal window. -class TerminalController: NSWindowController, NSWindowDelegate, - TerminalViewDelegate, TerminalViewModel, - ClipboardConfirmationViewDelegate +/// A classic, tabbed terminal experience. +class TerminalController: BaseTerminalController { override var windowNibName: NSNib.Name? { "Terminal" } - /// The app instance that this terminal view will represent. - let ghostty: Ghostty.App - - /// The currently focused surface. - var focusedSurface: Ghostty.SurfaceView? = nil { - didSet { - syncFocusToSurfaceTree() - } - } - - /// The surface tree for this window. - @Published var surfaceTree: Ghostty.SplitNode? = nil { - didSet { - // If our surface tree becomes nil then ensure all surfaces - // in the old tree have closed and then close the window. - if (surfaceTree == nil) { - oldValue?.close() - focusedSurface = nil - lastSurfaceDidClose() - } - } - } - /// Fullscreen state management. let fullscreenHandler = FullScreenHandler() - /// True when an alert is active so we don't overlap multiple. - private var alert: NSAlert? = nil - - /// The clipboard confirmation window, if shown. - private var clipboardConfirmation: ClipboardConfirmationController? = nil - /// This is set to true when we care about frame changes. This is a small optimization since /// this controller registers a listener for ALL frame change notifications and this lets us bail /// early if we don't care. @@ -59,8 +28,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, withBaseConfig base: Ghostty.SurfaceConfiguration? = nil, withSurfaceTree tree: Ghostty.SplitNode? = nil ) { - self.ghostty = ghostty - // The window we manage is not restorable if we've specified a command // to execute. We do this because the restored window is meaningless at the // time of writing this: it'd just restore to a shell in the same directory @@ -68,11 +35,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, // restoration. self.restorable = (base?.command ?? "") == "" - super.init(window: nil) - - // Initialize our initial surface. - guard let ghostty_app = ghostty.app else { preconditionFailure("app must be loaded") } - self.surfaceTree = tree ?? .leaf(.init(ghostty_app, baseConfig: base)) + super.init(ghostty, baseConfig: base, surfaceTree: tree) // Setup our notifications for behaviors let center = NotificationCenter.default @@ -86,11 +49,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, selector: #selector(onGotoTab), name: Ghostty.Notification.ghosttyGotoTab, object: nil) - center.addObserver( - self, - selector: #selector(onConfirmClipboardRequest), - name: Ghostty.Notification.confirmClipboard, - object: nil) center.addObserver( self, selector: #selector(onFrameDidChange), @@ -108,6 +66,17 @@ class TerminalController: NSWindowController, NSWindowDelegate, center.removeObserver(self) } + // MARK: Base Controller Overrides + + override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { + super.surfaceTreeDidChange(from: from, to: to) + + // If our surface tree is now nil then we close our window. + if (to == nil) { + self.window?.close() + } + } + //MARK: - Methods func configDidReload() { @@ -230,21 +199,6 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } - /// Update all surfaces with the focus state. This ensures that libghostty has an accurate view about - /// what surface is focused. This must be called whenever a surface OR window changes focus. - private func syncFocusToSurfaceTree() { - guard let tree = self.surfaceTree else { return } - - for leaf in tree { - // Our focus state requires that this window is key and our currently - // focused surface is the surface in this leaf. - let focused: Bool = (window?.isKeyWindow ?? false) && - focusedSurface != nil && - leaf.surface == focusedSurface! - leaf.surface.focusDidChange(focused) - } - } - //MARK: - NSWindowController override func windowWillLoad() { @@ -397,84 +351,21 @@ class TerminalController: NSWindowController, NSWindowDelegate, //MARK: - NSWindowDelegate - // This is called when performClose is called on a window (NOT when close() - // is called directly). performClose is called primarily when UI elements such - // as the "red X" are pressed. - func windowShouldClose(_ sender: NSWindow) -> Bool { - // We must have a window. Is it even possible not to? - guard let window = self.window else { return true } - - // If we have no surfaces, close. - guard let node = self.surfaceTree else { return true } - - // If we already have an alert, continue with it - guard alert == nil else { return false } - - // If our surfaces don't require confirmation, close. - if (!node.needsConfirmQuit()) { return true } - - // We require confirmation, so show an alert as long as we aren't already. - let alert = NSAlert() - alert.messageText = "Close Terminal?" - alert.informativeText = "The terminal still has a running process. If you close the " + - "terminal the process will be killed." - alert.addButton(withTitle: "Close the Terminal") - alert.addButton(withTitle: "Cancel") - alert.alertStyle = .warning - alert.beginSheetModal(for: window, completionHandler: { response in - self.alert = nil - switch (response) { - case .alertFirstButtonReturn: - window.close() - - default: - break - } - }) - - self.alert = alert - - return false - } - - func windowWillClose(_ notification: Notification) { - // I don't know if this is required anymore. We previously had a ref cycle between - // the view and the window so we had to nil this out to break it but I think this - // may now be resolved. We should verify that no memory leaks and we can remove this. - self.window?.contentView = nil - + override func windowWillClose(_ notification: Notification) { + super.windowWillClose(notification) self.relabelTabs() } - func windowDidBecomeKey(_ notification: Notification) { + override func windowDidBecomeKey(_ notification: Notification) { + super.windowDidBecomeKey(notification) self.relabelTabs() self.fixTabBar() - - // Becoming/losing key means we have to notify our surface(s) that we have focus - // so things like cursors blink, pty events are sent, etc. - self.syncFocusToSurfaceTree() - } - - func windowDidResignKey(_ notification: Notification) { - // Becoming/losing key means we have to notify our surface(s) that we have focus - // so things like cursors blink, pty events are sent, etc. - self.syncFocusToSurfaceTree() } func windowDidMove(_ notification: Notification) { self.fixTabBar() } - func windowDidChangeOcclusionState(_ notification: Notification) { - guard let surfaceTree = self.surfaceTree else { return } - let visible = self.window?.occlusionState.contains(.visible) ?? false - for leaf in surfaceTree { - if let surface = leaf.surface.surface { - ghostty_surface_set_occlusion(surface, visible) - } - } - } - // Called when the window will be encoded. We handle the data encoding here in the // window controller. func window(_ window: NSWindow, willEncodeRestorableState state: NSCoder) { @@ -482,7 +373,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, data.encode(with: state) } - //MARK: - First Responder + // MARK: First Responder @IBAction func newWindow(_ sender: Any?) { guard let surface = focusedSurface?.surface else { return } @@ -494,12 +385,7 @@ class TerminalController: NSWindowController, NSWindowDelegate, ghostty.newTab(surface: surface) } - @IBAction func close(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.requestClose(surface: surface) - } - - @IBAction func closeWindow(_ sender: Any) { + @IBAction override func closeWindow(_ sender: Any) { guard let window = window else { return } guard let tabGroup = window.tabGroup else { // No tabs, no tab group, just perform a normal close. @@ -549,117 +435,23 @@ class TerminalController: NSWindowController, NSWindowDelegate, }) } - @IBAction func splitRight(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_RIGHT) - } - - @IBAction func splitDown(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.split(surface: surface, direction: GHOSTTY_SPLIT_DIRECTION_DOWN) - } - - @IBAction func splitZoom(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitToggleZoom(surface: surface) - } - - @IBAction func splitMoveFocusPrevious(_ sender: Any) { - splitMoveFocus(direction: .previous) - } - - @IBAction func splitMoveFocusNext(_ sender: Any) { - splitMoveFocus(direction: .next) - } - - @IBAction func splitMoveFocusAbove(_ sender: Any) { - splitMoveFocus(direction: .top) - } - - @IBAction func splitMoveFocusBelow(_ sender: Any) { - splitMoveFocus(direction: .bottom) - } - - @IBAction func splitMoveFocusLeft(_ sender: Any) { - splitMoveFocus(direction: .left) - } - - @IBAction func splitMoveFocusRight(_ sender: Any) { - splitMoveFocus(direction: .right) - } - - @IBAction func equalizeSplits(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitEqualize(surface: surface) - } - - @IBAction func moveSplitDividerUp(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .up, amount: 10) - } - - @IBAction func moveSplitDividerDown(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .down, amount: 10) - } - - @IBAction func moveSplitDividerLeft(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .left, amount: 10) - } - - @IBAction func moveSplitDividerRight(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitResize(surface: surface, direction: .right, amount: 10) - } - - private func splitMoveFocus(direction: Ghostty.SplitFocusDirection) { - guard let surface = focusedSurface?.surface else { return } - ghostty.splitMoveFocus(surface: surface, direction: direction) - } - @IBAction func toggleGhosttyFullScreen(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleFullscreen(surface: surface) } - @IBAction func increaseFontSize(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.changeFontSize(surface: surface, .increase(1)) - } - - @IBAction func decreaseFontSize(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.changeFontSize(surface: surface, .decrease(1)) - } - - @IBAction func resetFontSize(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.changeFontSize(surface: surface, .reset) - } - @IBAction func toggleTerminalInspector(_ sender: Any) { guard let surface = focusedSurface?.surface else { return } ghostty.toggleTerminalInspector(surface: surface) } - @objc func resetTerminal(_ sender: Any) { - guard let surface = focusedSurface?.surface else { return } - ghostty.resetTerminal(surface: surface) - } - //MARK: - TerminalViewDelegate - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) { - self.focusedSurface = to - } + override func titleDidChange(to: String) { + super.titleDidChange(to: to) - func titleDidChange(to: String) { guard let window = window as? TerminalWindow else { return } - // Set the main window title - window.title = to - // Custom toolbar-based title used when titlebar tabs are enabled. if let toolbar = window.toolbar as? TerminalToolbar { if (window.titlebarTabs || ghostty.config.macosTitlebarStyle == "hidden") { @@ -672,58 +464,17 @@ class TerminalController: NSWindowController, NSWindowDelegate, } } - func cellSizeDidChange(to: NSSize) { - guard ghostty.config.windowStepResize else { return } - self.window?.contentResizeIncrements = to - } - - func lastSurfaceDidClose() { - self.window?.close() - } - - func surfaceTreeDidChange() { + override func surfaceTreeDidChange() { // Whenever our surface tree changes in any way (new split, close split, etc.) // we want to invalidate our state. invalidateRestorableState() } - func zoomStateDidChange(to: Bool) { + override func zoomStateDidChange(to: Bool) { guard let window = window as? TerminalWindow else { return } window.surfaceIsZoomed = to } - //MARK: - Clipboard Confirmation - - func clipboardConfirmationComplete(_ action: ClipboardConfirmationView.Action, _ request: Ghostty.ClipboardRequest) { - // End our clipboard confirmation no matter what - guard let cc = self.clipboardConfirmation else { return } - self.clipboardConfirmation = nil - - // Close the sheet - if let ccWindow = cc.window { - window?.endSheet(ccWindow) - } - - switch (request) { - case .osc_52_write: - guard case .confirm = action else { break } - let pb = NSPasteboard.general - pb.declareTypes([.string], owner: nil) - pb.setString(cc.contents, forType: .string) - case .osc_52_read, .paste: - let str: String - switch (action) { - case .cancel: - str = "" - - case .confirm: - str = cc.contents - } - - Ghostty.App.completeClipboardRequest(cc.surface, data: str, state: cc.state, confirmed: true) - } - } - //MARK: - Notifications @objc private func onGotoTab(notification: SwiftUI.Notification) { @@ -793,35 +544,4 @@ class TerminalController: NSWindowController, NSWindowDelegate, Ghostty.moveFocus(to: focusedSurface) } } - - @objc private func onConfirmClipboardRequest(notification: SwiftUI.Notification) { - guard let target = notification.object as? Ghostty.SurfaceView else { return } - guard target == self.focusedSurface else { return } - guard let surface = target.surface else { return } - - // We need a window - guard let window = self.window else { return } - - // Check whether we use non-native fullscreen - guard let str = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStrKey] as? String else { return } - guard let state = notification.userInfo?[Ghostty.Notification.ConfirmClipboardStateKey] as? UnsafeMutableRawPointer? else { return } - guard let request = notification.userInfo?[Ghostty.Notification.ConfirmClipboardRequestKey] as? Ghostty.ClipboardRequest else { return } - - // If we already have a clipboard confirmation view up, we ignore this request. - // This shouldn't be possible... - guard self.clipboardConfirmation == nil else { - Ghostty.App.completeClipboardRequest(surface, data: "", state: state, confirmed: true) - return - } - - // Show our paste confirmation - self.clipboardConfirmation = ClipboardConfirmationController( - surface: surface, - contents: str, - request: request, - state: state, - delegate: self - ) - window.beginSheet(self.clipboardConfirmation!.window!) - } } diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 64ce37885..ec7d7c229 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -22,14 +22,6 @@ protocol TerminalViewDelegate: AnyObject { func zoomStateDidChange(to: Bool) } -// Default all the functions so they're optional -extension TerminalViewDelegate { - func focusedSurfaceDidChange(to: Ghostty.SurfaceView?) {} - func titleDidChange(to: String) {} - func cellSizeDidChange(to: NSSize) {} - func zoomStateDidChange(to: Bool) {} -} - /// The view model is a required implementation for TerminalView callers. This contains /// the main state between the TerminalView caller and SwiftUI. This abstraction is what /// allows AppKit to own most of the data in SwiftUI. From 1977e220f587a03a70d31d6904ca7faaf3416668 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 10:51:14 -0700 Subject: [PATCH 127/139] macos: slide terminal exit and close window don't kill the window --- .../SlideTerminalController.swift | 20 +++++++++++++++++-- .../Terminal/BaseTerminalController.swift | 4 +++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 4b5ec05d3..5029b22b0 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -64,9 +64,9 @@ class SlideTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is now nil then we close our window. + // If our surface tree is nil then we slide the window out. if (to == nil) { - self.window?.close() + slideOut() } } @@ -83,7 +83,16 @@ class SlideTerminalController: BaseTerminalController { func slideIn() { guard let window = self.window else { return } + + // Animate the window in slideWindowIn(window: window, from: position) + + // If our surface tree is nil then we initialize a new terminal. The surface + // tree can be nil if for example we run "eixt" in the terminal and force a + // slide out. + if (surfaceTree == nil) { + surfaceTree = .leaf(.init(ghostty.app!, baseConfig: nil)) + } } func slideOut() { @@ -148,4 +157,11 @@ class SlideTerminalController: BaseTerminalController { windows.first?.makeKeyAndOrderFront(nil) } } + + // MARK: First Responder + + @IBAction override func closeWindow(_ sender: Any) { + // Instead of closing the window, we slide it out. + slideOut() + } } diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index fec30eecb..4417ce9cc 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -229,10 +229,12 @@ class BaseTerminalController: NSWindowController, } func windowWillClose(_ notification: Notification) { + guard let window else { return } + // I don't know if this is required anymore. We previously had a ref cycle between // the view and the window so we had to nil this out to break it but I think this // may now be resolved. We should verify that no memory leaks and we can remove this. - self.window?.contentView = nil + window.contentView = nil } func windowDidBecomeKey(_ notification: Notification) { From 0a3ca772964f98404e61c5630bdbac7b996c8329 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 28 Sep 2024 12:56:39 -0500 Subject: [PATCH 128/139] nix: remove tracy from flake.nix --- flake.nix | 2 -- 1 file changed, 2 deletions(-) diff --git a/flake.nix b/flake.nix index 43b808393..d87fa6a0d 100644 --- a/flake.nix +++ b/flake.nix @@ -30,8 +30,6 @@ pkgs-unstable = nixpkgs-unstable.legacyPackages.${system}; in { devShell.${system} = pkgs-stable.callPackage ./nix/devShell.nix { - inherit (pkgs-unstable) tracy; - zig = zig.packages.${system}."0.13.0"; wraptest = pkgs-stable.callPackage ./nix/wraptest.nix {}; }; From e3b340c6d3c4715936de6a587ba1775fd5ab50d5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 15:08:14 -0700 Subject: [PATCH 129/139] macos: set initial terminal dimensions --- .../SlideTerminal/SlideTerminalController.swift | 3 +++ .../SlideTerminal/SlideTerminalPosition.swift | 14 ++++++++++++++ .../SlideTerminal/SlideTerminalWindow.swift | 3 +++ 3 files changed, 20 insertions(+) diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift index 5029b22b0..07d7d42ed 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift @@ -36,6 +36,9 @@ class SlideTerminalController: BaseTerminalController { // make this restorable, but it isn't currently implemented. window.isRestorable = false + // Setup our initial size based on our configured position + position.setLoaded(window) + // Setup our content window.contentView = NSHostingView(rootView: TerminalView( ghostty: self.ghostty, diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift index 72f8d9483..d65f02038 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift @@ -3,6 +3,20 @@ import Cocoa enum SlideTerminalPosition { case top + /// Set the loaded state for a window. + func setLoaded(_ window: NSWindow) { + guard let screen = window.screen ?? NSScreen.main else { return } + switch (self) { + case .top: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: screen.frame.width, + height: screen.frame.height / 4) + ), display: false) + } + } + /// Set the initial state for a window for animating out of this position. func setInitial(in window: NSWindow, on screen: NSScreen) { // We always start invisible diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift index c170b3a8b..fe3426d2b 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift +++ b/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift @@ -23,6 +23,9 @@ class SlideTerminalWindow: NSWindow { // and lets us render off screen. self.level = .popUpMenu + // This plus the level above was what was needed for the animation to work, + // because it gets the window off screen properly. Plus we add some fields + // we just want the behavior of. self.collectionBehavior = [ // We want this to be part of every space because it is a singleton. .canJoinAllSpaces, From 1570ef01a78072ad34f3fab160ed85d180c46465 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 15:20:24 -0700 Subject: [PATCH 130/139] rename slide to quick terminal --- include/ghostty.h | 2 +- macos/Ghostty.xcodeproj/project.pbxproj | 38 +++++++------- macos/Sources/App/macOS/AppDelegate.swift | 18 +++---- macos/Sources/App/macOS/MainMenu.xib | 6 +-- .../QuickTerminal.xib} | 4 +- .../QuickTerminalController.swift} | 50 +++++++++---------- .../QuickTerminalPosition.swift} | 2 +- .../QuickTerminalWindow.swift} | 2 +- macos/Sources/Ghostty/Ghostty.App.swift | 8 +-- src/App.zig | 2 +- src/apprt/action.zig | 6 +-- src/apprt/glfw.zig | 2 +- src/apprt/gtk/App.zig | 2 +- src/input/Binding.zig | 27 ++++++---- 14 files changed, 89 insertions(+), 80 deletions(-) rename macos/Sources/Features/{SlideTerminal/SlideTerminal.xib => QuickTerminal/QuickTerminal.xib} (94%) rename macos/Sources/Features/{SlideTerminal/SlideTerminalController.swift => QuickTerminal/QuickTerminalController.swift} (81%) rename macos/Sources/Features/{SlideTerminal/SlideTerminalPosition.swift => QuickTerminal/QuickTerminalPosition.swift} (98%) rename macos/Sources/Features/{SlideTerminal/SlideTerminalWindow.swift => QuickTerminal/QuickTerminalWindow.swift} (97%) diff --git a/include/ghostty.h b/include/ghostty.h index 38affd16e..e66ce08ea 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -507,7 +507,7 @@ typedef enum { GHOSTTY_ACTION_TOGGLE_FULLSCREEN, GHOSTTY_ACTION_TOGGLE_TAB_OVERVIEW, GHOSTTY_ACTION_TOGGLE_WINDOW_DECORATIONS, - GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL, + GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_RESIZE_SPLIT, diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index ed7b97d07..295de738c 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -62,10 +62,10 @@ A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */; }; A5CBD0582C9F30960017A1AE /* Cursor.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0572C9F30860017A1AE /* Cursor.swift */; }; A5CBD0592C9F37B10017A1AE /* Backport.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CEAFFE29C2410700646FDA /* Backport.swift */; }; - A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */; }; - A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */; }; - A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */; }; - A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */; }; + A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */ = {isa = PBXBuildFile; fileRef = A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */; }; + A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */; }; + A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */; }; + A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */; }; A5CBD06B2CA322430017A1AE /* GlobalEventTap.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */; }; A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */; }; A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5CC36142C9CDA03004D6760 /* View+Extension.swift */; }; @@ -138,10 +138,10 @@ A5B3053D299BEAAB0047F10C /* Ghostty.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Ghostty.entitlements; sourceTree = ""; }; A5CBD0552C9E65A50017A1AE /* DraggableWindowView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraggableWindowView.swift; sourceTree = ""; }; A5CBD0572C9F30860017A1AE /* Cursor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cursor.swift; sourceTree = ""; }; - A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = SlideTerminal.xib; sourceTree = ""; }; - A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalController.swift; sourceTree = ""; }; - A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalWindow.swift; sourceTree = ""; }; - A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideTerminalPosition.swift; sourceTree = ""; }; + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = QuickTerminal.xib; sourceTree = ""; }; + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalController.swift; sourceTree = ""; }; + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalWindow.swift; sourceTree = ""; }; + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalPosition.swift; sourceTree = ""; }; A5CBD06A2CA322320017A1AE /* GlobalEventTap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalEventTap.swift; sourceTree = ""; }; A5CC36122C9CD729004D6760 /* SecureInputOverlay.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureInputOverlay.swift; sourceTree = ""; }; A5CC36142C9CDA03004D6760 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; @@ -214,7 +214,7 @@ A5CBD0672CA2704E0017A1AE /* Global Keybinds */, A56D58872ACDE6BE00508D2C /* Services */, A59630982AEE1C4400D64628 /* Terminal */, - A5CBD05A2CA0C5910017A1AE /* SlideTerminal */, + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */, A5E112912AF73E4D00C6E0C2 /* ClipboardConfirmation */, A57D79252C9C8782001D522E /* Secure Input */, A534263E2A7DCC5800EBB7A2 /* Settings */, @@ -385,15 +385,15 @@ name = Products; sourceTree = ""; }; - A5CBD05A2CA0C5910017A1AE /* SlideTerminal */ = { + A5CBD05A2CA0C5910017A1AE /* QuickTerminal */ = { isa = PBXGroup; children = ( - A5CBD05B2CA0C5C70017A1AE /* SlideTerminal.xib */, - A5CBD05D2CA0C5E70017A1AE /* SlideTerminalController.swift */, - A5CBD0632CA122E70017A1AE /* SlideTerminalPosition.swift */, - A5CBD05F2CA0C9080017A1AE /* SlideTerminalWindow.swift */, + A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, + A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, + A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, + A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, ); - path = SlideTerminal; + path = QuickTerminal; sourceTree = ""; }; A5CBD0672CA2704E0017A1AE /* Global Keybinds */ = { @@ -529,7 +529,7 @@ A5985CE62C33060F00C57AD3 /* man in Resources */, A5A1F8852A489D6800D1E8BC /* terminfo in Resources */, 552964E62B34A9B400030505 /* vim in Resources */, - A5CBD05C2CA0C5C70017A1AE /* SlideTerminal.xib in Resources */, + A5CBD05C2CA0C5C70017A1AE /* QuickTerminal.xib in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -555,12 +555,12 @@ A5D0AF3D2B37804400D21823 /* CodableBridge.swift in Sources */, A5D0AF3B2B36A1DE00D21823 /* TerminalRestorable.swift in Sources */, C1F26EA72B738B9900404083 /* NSView+Extension.swift in Sources */, - A5CBD0642CA122E70017A1AE /* SlideTerminalPosition.swift in Sources */, + A5CBD0642CA122E70017A1AE /* QuickTerminalPosition.swift in Sources */, A596309C2AEE1C9E00D64628 /* TerminalController.swift in Sources */, A5CC36152C9CDA06004D6760 /* View+Extension.swift in Sources */, A56D58892ACDE6CA00508D2C /* ServiceProvider.swift in Sources */, - A5CBD0602CA0C90A0017A1AE /* SlideTerminalWindow.swift in Sources */, - A5CBD05E2CA0C5EC0017A1AE /* SlideTerminalController.swift in Sources */, + A5CBD0602CA0C90A0017A1AE /* QuickTerminalWindow.swift in Sources */, + A5CBD05E2CA0C5EC0017A1AE /* QuickTerminalController.swift in Sources */, A51BFC222B2FB6B400E92F16 /* AboutView.swift in Sources */, A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */, A5CBD0562C9E65B80017A1AE /* DraggableWindowView.swift in Sources */, diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 47302f302..3686f7fb8 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -49,7 +49,7 @@ class AppDelegate: NSObject, @IBOutlet private var menuIncreaseFontSize: NSMenuItem? @IBOutlet private var menuDecreaseFontSize: NSMenuItem? @IBOutlet private var menuResetFontSize: NSMenuItem? - @IBOutlet private var menuSlideTerminal: NSMenuItem? + @IBOutlet private var menuQuickTerminal: NSMenuItem? @IBOutlet private var menuTerminalInspector: NSMenuItem? @IBOutlet private var menuEqualizeSplits: NSMenuItem? @@ -74,8 +74,8 @@ class AppDelegate: NSObject, /// Manages our terminal windows. let terminalManager: TerminalManager - /// Our slide terminal. This starts out uninitialized and only initializes if used. - private var slideController: SlideTerminalController? = nil + /// Our quick terminal. This starts out uninitialized and only initializes if used. + private var quickController: QuickTerminalController? = nil /// Manages updates let updaterController: SPUStandardUpdaterController @@ -314,7 +314,7 @@ class AppDelegate: NSObject, syncMenuShortcut(action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) syncMenuShortcut(action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) syncMenuShortcut(action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(action: "toggle_slide_terminal", menuItem: self.menuSlideTerminal) + syncMenuShortcut(action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) syncMenuShortcut(action: "inspector:toggle", menuItem: self.menuTerminalInspector) syncMenuShortcut(action: "toggle_secure_input", menuItem: self.menuSecureInput) @@ -551,12 +551,12 @@ class AppDelegate: NSObject, setSecureInput(.toggle) } - @IBAction func toggleSlideTerminal(_ sender: Any) { - if slideController == nil { - slideController = SlideTerminalController(ghostty, baseConfig: nil) + @IBAction func toggleQuickTerminal(_ sender: Any) { + if quickController == nil { + quickController = QuickTerminalController(ghostty, baseConfig: nil) } - guard let slideController = self.slideController else { return } - slideController.slideToggle() + guard let quickController = self.quickController else { return } + quickController.toggle() } } diff --git a/macos/Sources/App/macOS/MainMenu.xib b/macos/Sources/App/macOS/MainMenu.xib index f19f9d1ed..63aae4c60 100644 --- a/macos/Sources/App/macOS/MainMenu.xib +++ b/macos/Sources/App/macOS/MainMenu.xib @@ -32,6 +32,7 @@ + @@ -42,7 +43,6 @@ - @@ -217,10 +217,10 @@ - + - + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminal.xib b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib similarity index 94% rename from macos/Sources/Features/SlideTerminal/SlideTerminal.xib rename to macos/Sources/Features/QuickTerminal/QuickTerminal.xib index 4bb068a7e..b2a99cbf5 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminal.xib +++ b/macos/Sources/Features/QuickTerminal/QuickTerminal.xib @@ -6,14 +6,14 @@ - + - + diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift similarity index 81% rename from macos/Sources/Features/SlideTerminal/SlideTerminalController.swift rename to macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 07d7d42ed..5025e725c 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -3,15 +3,15 @@ import Cocoa import SwiftUI import GhosttyKit -/// Controller for the slide-style terminal. -class SlideTerminalController: BaseTerminalController { - override var windowNibName: NSNib.Name? { "SlideTerminal" } +/// Controller for the "quick" terminal. +class QuickTerminalController: BaseTerminalController { + override var windowNibName: NSNib.Name? { "QuickTerminal" } - /// The position for the slide terminal. - let position: SlideTerminalPosition + /// The position for the quick terminal. + let position: QuickTerminalPosition init(_ ghostty: Ghostty.App, - position: SlideTerminalPosition = .top, + position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, surfaceTree tree: Ghostty.SplitNode? = nil ) { @@ -32,7 +32,7 @@ class SlideTerminalController: BaseTerminalController { // window close so we can animate out. window.delegate = self - // The slide window is not restorable (yet!). "Yet" because in theory we can + // The quick window is not restorable (yet!). "Yet" because in theory we can // make this restorable, but it isn't currently implemented. window.isRestorable = false @@ -47,14 +47,14 @@ class SlideTerminalController: BaseTerminalController { )) // Animate the window in - slideIn() + animateIn() } // MARK: NSWindowDelegate override func windowDidResignKey(_ notification: Notification) { super.windowDidResignKey(notification) - slideOut() + animateOut() } func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { @@ -67,43 +67,43 @@ class SlideTerminalController: BaseTerminalController { override func surfaceTreeDidChange(from: Ghostty.SplitNode?, to: Ghostty.SplitNode?) { super.surfaceTreeDidChange(from: from, to: to) - // If our surface tree is nil then we slide the window out. + // If our surface tree is nil then we animate the window out. if (to == nil) { - slideOut() + animateOut() } } - // MARK: Slide Methods + // MARK: Methods - func slideToggle() { + func toggle() { guard let window = self.window else { return } if (window.alphaValue > 0) { - slideOut() + animateOut() } else { - slideIn() + animateIn() } } - func slideIn() { + func animateIn() { guard let window = self.window else { return } // Animate the window in - slideWindowIn(window: window, from: position) + animateWindowIn(window: window, from: position) // If our surface tree is nil then we initialize a new terminal. The surface - // tree can be nil if for example we run "eixt" in the terminal and force a - // slide out. + // tree can be nil if for example we run "eixt" in the terminal and force + // animate out. if (surfaceTree == nil) { surfaceTree = .leaf(.init(ghostty.app!, baseConfig: nil)) } } - func slideOut() { + func animateOut() { guard let window = self.window else { return } - slideWindowOut(window: window, to: position) + animateWindowOut(window: window, to: position) } - private func slideWindowIn(window: NSWindow, from position: SlideTerminalPosition) { + private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { guard let screen = NSScreen.main else { return } // Move our window off screen to the top @@ -121,7 +121,7 @@ class SlideTerminalController: BaseTerminalController { } } - private func slideWindowOut(window: NSWindow, to position: SlideTerminalPosition) { + private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { guard let screen = NSScreen.main else { return } // Keep track of if we were the key window. If we were the key window then we @@ -164,7 +164,7 @@ class SlideTerminalController: BaseTerminalController { // MARK: First Responder @IBAction override func closeWindow(_ sender: Any) { - // Instead of closing the window, we slide it out. - slideOut() + // Instead of closing the window, we animate it out. + animateOut() } } diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift similarity index 98% rename from macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift rename to macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index d65f02038..c7509c465 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -1,6 +1,6 @@ import Cocoa -enum SlideTerminalPosition { +enum QuickTerminalPosition { case top /// Set the loaded state for a window. diff --git a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift similarity index 97% rename from macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift rename to macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift index fe3426d2b..2d9d1df7c 100644 --- a/macos/Sources/Features/SlideTerminal/SlideTerminalWindow.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalWindow.swift @@ -1,6 +1,6 @@ import Cocoa -class SlideTerminalWindow: NSWindow { +class QuickTerminalWindow: NSWindow { // Both of these must be true for windows without decorations to be able to // still become key/main and receive events. override var canBecomeKey: Bool { return true } diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index f1e35cb99..05c01a75e 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -482,8 +482,8 @@ extension Ghostty { case GHOSTTY_ACTION_RENDERER_HEALTH: rendererHealth(app, target: target, v: action.action.renderer_health) - case GHOSTTY_ACTION_TOGGLE_SLIDE_TERMINAL: - toggleSlideTerminal(app, target: target) + case GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL: + toggleQuickTerminal(app, target: target) case GHOSTTY_ACTION_CLOSE_ALL_WINDOWS: fallthrough @@ -833,12 +833,12 @@ extension Ghostty { } } - private static func toggleSlideTerminal( + private static func toggleQuickTerminal( _ app: ghostty_app_t, target: ghostty_target_s ) { guard let appDelegate = NSApplication.shared.delegate as? AppDelegate else { return } - appDelegate.toggleSlideTerminal(self) + appDelegate.toggleQuickTerminal(self) } private static func setTitle( diff --git a/src/App.zig b/src/App.zig index 369fc4288..5922528ab 100644 --- a/src/App.zig +++ b/src/App.zig @@ -324,7 +324,7 @@ pub fn performAction( .open_config => try rt_app.performAction(.app, .open_config, {}), .reload_config => try self.reloadConfig(rt_app), .close_all_windows => try rt_app.performAction(.app, .close_all_windows, {}), - .toggle_slide_terminal => try rt_app.performAction(.app, .toggle_slide_terminal, {}), + .toggle_quick_terminal => try rt_app.performAction(.app, .toggle_quick_terminal, {}), } } diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 6fd15ec9c..2f7616bc4 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -93,8 +93,8 @@ pub const Action = union(Key) { /// Toggle whether window directions are shown. toggle_window_decorations, - /// Toggle the slide terminal in or out. - toggle_slide_terminal, + /// Toggle the quick terminal in or out. + toggle_quick_terminal, /// Jump to a specific tab. Must handle the scenario that the tab /// value is invalid. @@ -179,7 +179,7 @@ pub const Action = union(Key) { toggle_fullscreen, toggle_tab_overview, toggle_window_decorations, - toggle_slide_terminal, + toggle_quick_terminal, goto_tab, goto_split, resize_split, diff --git a/src/apprt/glfw.zig b/src/apprt/glfw.zig index a64ed0afc..87314c0e1 100644 --- a/src/apprt/glfw.zig +++ b/src/apprt/glfw.zig @@ -196,7 +196,7 @@ pub const App = struct { .close_all_windows, .toggle_tab_overview, .toggle_window_decorations, - .toggle_slide_terminal, + .toggle_quick_terminal, .goto_tab, .inspector, .render_inspector, diff --git a/src/apprt/gtk/App.zig b/src/apprt/gtk/App.zig index dc535868e..9bbfad94e 100644 --- a/src/apprt/gtk/App.zig +++ b/src/apprt/gtk/App.zig @@ -379,7 +379,7 @@ pub fn performAction( // Unimplemented .close_all_windows, .toggle_split_zoom, - .toggle_slide_terminal, + .toggle_quick_terminal, .size_limit, .cell_size, .secure_input, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 986b9e7c8..36d87ae3e 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -363,16 +363,25 @@ pub const Action = union(enum) { /// This only works on macOS, since this is a system API on macOS. toggle_secure_input: void, - /// Toggle the "slide" terminal. The slide terminal is a terminal that - /// slides in from some screen edge, usually the top. This is useful for - /// quick access to a terminal without having to open a new window or tab. + /// Toggle the "quick" terminal. The quick terminal is a terminal that + /// appears on demand from a keybinding, often sliding in from a screen + /// edge such as the top. This is useful for quick access to a terminal + /// without having to open a new window or tab. /// - /// The slide terminal is a singleton; only one instance can exist at a - /// time. + /// When the quick terminal loses focus, it disappears. The terminal state + /// is preserved between appearances, so you can always press the keybinding + /// to bring it back up. /// - /// See the various configurations for the slide terminal in the + /// Ths quick terminal has some limitations: + /// + /// - It is a singleton; only one instance can exist at a time. + /// - It does not support tabs. + /// - It will not be restored when the application is restarted + /// (for systems that support window restoration). + /// + /// See the various configurations for the quick terminal in the /// configuration file to customize its behavior. - toggle_slide_terminal: void, + toggle_quick_terminal: void, /// Quit ghostty. quit: void, @@ -393,7 +402,7 @@ pub const Action = union(enum) { /// crash: CrashThread, - pub const SlideTerminalPosition = enum { + pub const QuickTerminalPosition = enum { top, }; @@ -578,7 +587,7 @@ pub const Action = union(enum) { .reload_config, .close_all_windows, .quit, - .toggle_slide_terminal, + .toggle_quick_terminal, => .app, // These are app but can be special-cased in a surface context. From 13eb8ac6e20a6e4b3d6cdde5eea8303b9eee2b0c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 15:29:47 -0700 Subject: [PATCH 131/139] macos: ability to interrupt animation, track it in menu --- macos/Sources/App/macOS/AppDelegate.swift | 2 ++ .../QuickTerminal/QuickTerminalController.swift | 15 +++++++++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index 3686f7fb8..ad4c9bbda 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -558,5 +558,7 @@ class AppDelegate: NSObject, guard let quickController = self.quickController else { return } quickController.toggle() + + self.menuQuickTerminal?.state = if (quickController.visible) { .on } else { .off } } } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 5025e725c..979af1fca 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -10,6 +10,9 @@ class QuickTerminalController: BaseTerminalController { /// The position for the quick terminal. let position: QuickTerminalPosition + /// The current state of the quick terminal + private(set) var visible: Bool = false + init(_ ghostty: Ghostty.App, position: QuickTerminalPosition = .top, baseConfig base: Ghostty.SurfaceConfiguration? = nil, @@ -76,8 +79,7 @@ class QuickTerminalController: BaseTerminalController { // MARK: Methods func toggle() { - guard let window = self.window else { return } - if (window.alphaValue > 0) { + if (visible) { animateOut() } else { animateIn() @@ -87,6 +89,10 @@ class QuickTerminalController: BaseTerminalController { func animateIn() { guard let window = self.window else { return } + // Set our visibility state + guard !visible else { return } + visible = true + // Animate the window in animateWindowIn(window: window, from: position) @@ -100,6 +106,11 @@ class QuickTerminalController: BaseTerminalController { func animateOut() { guard let window = self.window else { return } + + // Set our visibility state + guard visible else { return } + visible = false + animateWindowOut(window: window, to: position) } From 6cbced70780bc06e7e9d06c2651c1cd3591529d5 Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sat, 28 Sep 2024 20:05:56 -0500 Subject: [PATCH 132/139] nix: use stdenv from unstable to build the package --- flake.nix | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flake.nix b/flake.nix index d87fa6a0d..01acca063 100644 --- a/flake.nix +++ b/flake.nix @@ -36,7 +36,7 @@ packages.${system} = let mkArgs = optimize: { - inherit (pkgs-unstable) zig_0_13; + inherit (pkgs-unstable) zig_0_13 stdenv; inherit optimize; revision = self.shortRev or self.dirtyShortRev or "dirty"; From 11d5ec7dc1187179f3dc2d20bbcb472d43b2785a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 18:42:13 -0700 Subject: [PATCH 133/139] config: support quick terminal position --- macos/Sources/App/macOS/AppDelegate.swift | 5 +- .../QuickTerminal/QuickTerminalPosition.swift | 62 +++++++++++++------ macos/Sources/Ghostty/Ghostty.Config.swift | 10 +++ src/config/Config.zig | 15 +++++ src/input/Binding.zig | 4 -- 5 files changed, 72 insertions(+), 24 deletions(-) diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index ad4c9bbda..5980b8d66 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -553,7 +553,10 @@ class AppDelegate: NSObject, @IBAction func toggleQuickTerminal(_ sender: Any) { if quickController == nil { - quickController = QuickTerminalController(ghostty, baseConfig: nil) + quickController = QuickTerminalController( + ghostty, + position: ghostty.config.quickTerminalPosition + ) } guard let quickController = self.quickController else { return } diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index c7509c465..559d7ef88 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -1,19 +1,30 @@ import Cocoa -enum QuickTerminalPosition { +enum QuickTerminalPosition : String { case top + case bottom + case left + case right /// Set the loaded state for a window. func setLoaded(_ window: NSWindow) { guard let screen = window.screen ?? NSScreen.main else { return } switch (self) { - case .top: + case .top, .bottom: window.setFrame(.init( origin: window.frame.origin, size: .init( width: screen.frame.width, height: screen.frame.height / 4) ), display: false) + + case .left, .right: + window.setFrame(.init( + origin: window.frame.origin, + size: .init( + width: screen.frame.width / 4, + height: screen.frame.height) + ), display: false) } } @@ -23,15 +34,10 @@ enum QuickTerminalPosition { window.alphaValue = 0 // Position depends - switch (self) { - case .top: - window.setFrame(.init( - origin: initialOrigin(for: window, on: screen), - size: .init( - width: screen.frame.width, - height: window.frame.height) - ), display: false) - } + window.setFrame(.init( + origin: initialOrigin(for: window, on: screen), + size: window.frame.size + ), display: false) } /// Set the final state for a window in this position. @@ -40,21 +46,21 @@ enum QuickTerminalPosition { window.alphaValue = 1 // Position depends - switch (self) { - case .top: - window.setFrame(.init( - origin: finalOrigin(for: window, on: screen), - size: window.frame.size - ), display: true) - } + window.setFrame(.init( + origin: finalOrigin(for: window, on: screen), + size: window.frame.size + ), display: true) } /// Restrict the frame size during resizing. func restrictFrameSize(_ size: NSSize, on screen: NSScreen) -> NSSize { var finalSize = size switch (self) { - case .top: + case .top, .bottom: finalSize.width = screen.frame.width + + case .left, .right: + finalSize.height = screen.frame.height } return finalSize @@ -65,6 +71,15 @@ enum QuickTerminalPosition { switch (self) { case .top: return .init(x: 0, y: screen.frame.maxY) + + case .bottom: + return .init(x: 0, y: -window.frame.height) + + case .left: + return .init(x: -window.frame.width, y: 0) + + case .right: + return .init(x: screen.frame.maxX, y: 0) } } @@ -73,6 +88,15 @@ enum QuickTerminalPosition { switch (self) { case .top: return .init(x: window.frame.origin.x, y: screen.visibleFrame.maxY - window.frame.height) + + case .bottom: + return .init(x: window.frame.origin.x, y: 0) + + case .left: + return .init(x: 0, y: window.frame.origin.y) + + case .right: + return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) } } } diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 7ecd45cc4..441172ea0 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -332,6 +332,16 @@ extension Ghostty { return Color(newColor) } + var quickTerminalPosition: QuickTerminalPosition { + guard let config = self.config else { return .top } + var v: UnsafePointer? = nil + let key = "quick-terminal-position" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .top } + guard let ptr = v else { return .top } + let str = String(cString: ptr) + return QuickTerminalPosition(rawValue: str) ?? .top + } + var resizeOverlay: ResizeOverlay { guard let config = self.config else { return .after_first } var v: UnsafePointer? = nil diff --git a/src/config/Config.zig b/src/config/Config.zig index efa741307..6a0818095 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1220,6 +1220,13 @@ keybind: Keybinds = .{}, /// window is ever created. Only implemented on Linux. @"initial-window": bool = true, +/// The position of the "quick" terminal window. To learn more about the +/// quick terminal, see the documentation for the `toggle_quick_terminal` +/// binding action. +/// +/// Changing this configuration requires restarting Ghostty completely. +@"quick-terminal-position": QuickTerminalPosition = .top, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -4401,6 +4408,14 @@ pub const ResizeOverlayPosition = enum { @"bottom-right", }; +/// See quick-terminal-position +pub const QuickTerminalPosition = enum { + top, + bottom, + left, + right, +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 36d87ae3e..bef2e2209 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -402,10 +402,6 @@ pub const Action = union(enum) { /// crash: CrashThread, - pub const QuickTerminalPosition = enum { - top, - }; - pub const CrashThread = enum { main, io, From 61dd395251ef57c1bd58254e1a6244e172443a4d Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 18:46:45 -0700 Subject: [PATCH 134/139] macos: show alert if new tab is attempted from quick term --- .../QuickTerminal/QuickTerminalController.swift | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 979af1fca..698be98bc 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -57,6 +57,11 @@ class QuickTerminalController: BaseTerminalController { override func windowDidResignKey(_ notification: Notification) { super.windowDidResignKey(notification) + + // We don't animate out if there is a modal sheet being shown currently. + // This lets us show alerts without causing the window to disappear. + guard window?.attachedSheet == nil else { return } + animateOut() } @@ -178,4 +183,14 @@ class QuickTerminalController: BaseTerminalController { // Instead of closing the window, we animate it out. animateOut() } + + @IBAction func newTab(_ sender: Any?) { + guard let window else { return } + let alert = NSAlert() + alert.messageText = "Cannot Create New Tab" + alert.informativeText = "Tabs aren't supported in the Quick Terminal." + alert.addButton(withTitle: "OK") + alert.alertStyle = .warning + alert.beginSheetModal(for: window) + } } From 1f3c3dde1017a5d37a462fe078c8c5749ca1ee55 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 18:49:52 -0700 Subject: [PATCH 135/139] input: note fullscreen isn't supported by quick terminal --- src/config/Config.zig | 7 +++++++ src/input/Binding.zig | 3 ++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 6a0818095..c950cf807 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1224,6 +1224,13 @@ keybind: Keybinds = .{}, /// quick terminal, see the documentation for the `toggle_quick_terminal` /// binding action. /// +/// Valid values are: +/// +/// * `top` - Terminal appears at the top of the screen. +/// * `bottom` - Terminal appears at the bottom of the screen. +/// * `left` - Terminal appears at the left of the screen. +/// * `right` - Terminal appears at the right of the screen. +/// /// Changing this configuration requires restarting Ghostty completely. @"quick-terminal-position": QuickTerminalPosition = .top, diff --git a/src/input/Binding.zig b/src/input/Binding.zig index bef2e2209..5df3ae8e4 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -372,10 +372,11 @@ pub const Action = union(enum) { /// is preserved between appearances, so you can always press the keybinding /// to bring it back up. /// - /// Ths quick terminal has some limitations: + /// The quick terminal has some limitations: /// /// - It is a singleton; only one instance can exist at a time. /// - It does not support tabs. + /// - It does not support fullscreen. /// - It will not be restored when the application is restarted /// (for systems that support window restoration). /// From 1d727320b47d9d6884d006c8e6bc42e079dcf3d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 19:11:04 -0700 Subject: [PATCH 136/139] macos: if initializing new surface tree, move focus to it --- .../QuickTerminal/QuickTerminalController.swift | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index 698be98bc..e28bda0fd 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -105,7 +105,19 @@ class QuickTerminalController: BaseTerminalController { // tree can be nil if for example we run "eixt" in the terminal and force // animate out. if (surfaceTree == nil) { - surfaceTree = .leaf(.init(ghostty.app!, baseConfig: nil)) + let leaf: Ghostty.SplitNode.Leaf = .init(ghostty.app!, baseConfig: nil) + surfaceTree = .leaf(leaf) + focusedSurface = leaf.surface + + // We need to grab first responder but it takes a few loop cycles + // before the view is attached to the window so we do it async. + DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(250)) { + // We should probably retry here but I was never able to trigger this. + // If this happens though its a crash so let's avoid it. + guard let leafWindow = leaf.surface.window, + leafWindow == window else { return } + window.makeFirstResponder(leaf.surface) + } } } From 76a2041cbf4b160c1aee51a5454aea7a5f11f53e Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 19:11:34 -0700 Subject: [PATCH 137/139] macos: make quick terminal animation 0.2 instead of 0.3 --- .../Features/QuickTerminal/QuickTerminalController.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index e28bda0fd..a185eabfe 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -143,7 +143,7 @@ class QuickTerminalController: BaseTerminalController { // Run the animation that moves our window into the proper place and makes // it visible. NSAnimationContext.runAnimationGroup { context in - context.duration = 0.3 + context.duration = 0.2 context.timingFunction = .init(name: .easeIn) position.setFinal(in: window.animator(), on: screen) } @@ -158,7 +158,7 @@ class QuickTerminalController: BaseTerminalController { let wasKey = window.isKeyWindow NSAnimationContext.runAnimationGroup({ context in - context.duration = 0.3 + context.duration = 0.2 context.timingFunction = .init(name: .easeIn) position.setInitial(in: window.animator(), on: screen) }, completionHandler: { From bcdbb5899b45ecfde351321845a0059da08b2825 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 19:14:14 -0700 Subject: [PATCH 138/139] macos: only define quick terminal configs for AppKit --- macos/Sources/Ghostty/Ghostty.Config.swift | 2 ++ 1 file changed, 2 insertions(+) diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 441172ea0..936ac9821 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -332,6 +332,7 @@ extension Ghostty { return Color(newColor) } + #if canImport(AppKit) var quickTerminalPosition: QuickTerminalPosition { guard let config = self.config else { return .top } var v: UnsafePointer? = nil @@ -341,6 +342,7 @@ extension Ghostty { let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top } + #endif var resizeOverlay: ResizeOverlay { guard let config = self.config else { return .after_first } From 4f9d49b380b957fbfa559d5f09349e85cdbc6b1f Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Sat, 28 Sep 2024 20:21:47 -0700 Subject: [PATCH 139/139] macos: handle multiple monitors properly --- macos/Ghostty.xcodeproj/project.pbxproj | 4 ++ .../QuickTerminalController.swift | 9 +++-- .../QuickTerminal/QuickTerminalPosition.swift | 14 +++---- .../QuickTerminal/QuickTerminalScreen.swift | 37 +++++++++++++++++++ macos/Sources/Ghostty/Ghostty.Config.swift | 10 +++++ src/config/Config.zig | 27 ++++++++++++++ 6 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 295de738c..e3ad5adf3 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -21,6 +21,7 @@ A51BFC272B30F1B800E92F16 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = A51BFC262B30F1B800E92F16 /* Sparkle */; }; A51BFC2B2B30F6BE00E92F16 /* UpdateDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */; }; A5278A9B2AA05B2600CD3039 /* Ghostty.Input.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */; }; + A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */; }; A5333E1C2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; }; A5333E1D2B5A1CE3008AEFF7 /* CrossKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */; }; A5333E202B5A2111008AEFF7 /* SurfaceView_UIKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */; }; @@ -103,6 +104,7 @@ A51BFC282B30F26D00E92F16 /* GhosttyDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = GhosttyDebug.entitlements; sourceTree = ""; }; A51BFC2A2B30F6BE00E92F16 /* UpdateDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateDelegate.swift; sourceTree = ""; }; A5278A9A2AA05B2600CD3039 /* Ghostty.Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Ghostty.Input.swift; sourceTree = ""; }; + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuickTerminalScreen.swift; sourceTree = ""; }; A5333E152B59DE8E008AEFF7 /* SurfaceView_UIKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_UIKit.swift; sourceTree = ""; }; A5333E1B2B5A1CE3008AEFF7 /* CrossKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CrossKit.swift; sourceTree = ""; }; A5333E212B5A2128008AEFF7 /* SurfaceView_AppKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurfaceView_AppKit.swift; sourceTree = ""; }; @@ -391,6 +393,7 @@ A5CBD05B2CA0C5C70017A1AE /* QuickTerminal.xib */, A5CBD05D2CA0C5E70017A1AE /* QuickTerminalController.swift */, A5CBD0632CA122E70017A1AE /* QuickTerminalPosition.swift */, + A52FFF562CA90481000C6A5B /* QuickTerminalScreen.swift */, A5CBD05F2CA0C9080017A1AE /* QuickTerminalWindow.swift */, ); path = QuickTerminal; @@ -588,6 +591,7 @@ A5CDF1932AAF9E0800513312 /* ConfigurationErrorsController.swift in Sources */, A59FB5D12AE0DEA7009128F3 /* MetalView.swift in Sources */, A55685E029A03A9F004303CE /* AppError.swift in Sources */, + A52FFF572CA90484000C6A5B /* QuickTerminalScreen.swift in Sources */, A5CC36132C9CD72D004D6760 /* SecureInputOverlay.swift in Sources */, A535B9DA299C569B0017E2E4 /* ErrorView.swift in Sources */, A51BFC202B2FB64F00E92F16 /* AboutController.swift in Sources */, diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift index a185eabfe..f5d899e76 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalController.swift @@ -66,7 +66,9 @@ class QuickTerminalController: BaseTerminalController { } func windowWillResize(_ sender: NSWindow, to frameSize: NSSize) -> NSSize { - guard let screen = NSScreen.main else { return frameSize } + // We use the actual screen the window is on for this, since it should + // be on the proper screen. + guard let screen = window?.screen ?? NSScreen.main else { return frameSize } return position.restrictFrameSize(frameSize, on: screen) } @@ -132,7 +134,7 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowIn(window: NSWindow, from position: QuickTerminalPosition) { - guard let screen = NSScreen.main else { return } + guard let screen = ghostty.config.quickTerminalScreen.screen else { return } // Move our window off screen to the top position.setInitial(in: window, on: screen) @@ -150,7 +152,8 @@ class QuickTerminalController: BaseTerminalController { } private func animateWindowOut(window: NSWindow, to position: QuickTerminalPosition) { - guard let screen = NSScreen.main else { return } + // We always animate out to whatever screen the window is actually on. + guard let screen = window.screen ?? NSScreen.main else { return } // Keep track of if we were the key window. If we were the key window then we // want to move focus to the next window so that focus is preserved somewhere diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift index 559d7ef88..51b450700 100644 --- a/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalPosition.swift @@ -36,7 +36,7 @@ enum QuickTerminalPosition : String { // Position depends window.setFrame(.init( origin: initialOrigin(for: window, on: screen), - size: window.frame.size + size: restrictFrameSize(window.frame.size, on: screen) ), display: false) } @@ -48,7 +48,7 @@ enum QuickTerminalPosition : String { // Position depends window.setFrame(.init( origin: finalOrigin(for: window, on: screen), - size: window.frame.size + size: restrictFrameSize(window.frame.size, on: screen) ), display: true) } @@ -70,10 +70,10 @@ enum QuickTerminalPosition : String { func initialOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { case .top: - return .init(x: 0, y: screen.frame.maxY) + return .init(x: screen.frame.minX, y: screen.frame.maxY) case .bottom: - return .init(x: 0, y: -window.frame.height) + return .init(x: screen.frame.minX, y: -window.frame.height) case .left: return .init(x: -window.frame.width, y: 0) @@ -87,13 +87,13 @@ enum QuickTerminalPosition : String { func finalOrigin(for window: NSWindow, on screen: NSScreen) -> CGPoint { switch (self) { case .top: - return .init(x: window.frame.origin.x, y: screen.visibleFrame.maxY - window.frame.height) + return .init(x: screen.frame.minX, y: screen.visibleFrame.maxY - window.frame.height) case .bottom: - return .init(x: window.frame.origin.x, y: 0) + return .init(x: screen.frame.minX, y: screen.frame.minY) case .left: - return .init(x: 0, y: window.frame.origin.y) + return .init(x: screen.frame.minX, y: window.frame.origin.y) case .right: return .init(x: screen.visibleFrame.maxX - window.frame.width, y: window.frame.origin.y) diff --git a/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift new file mode 100644 index 000000000..cd07a6f12 --- /dev/null +++ b/macos/Sources/Features/QuickTerminal/QuickTerminalScreen.swift @@ -0,0 +1,37 @@ +import Cocoa + +enum QuickTerminalScreen { + case main + case mouse + case menuBar + + init?(fromGhosttyConfig string: String) { + switch (string) { + case "main": + self = .main + + case "mouse": + self = .mouse + + case "macos-menu-bar": + self = .menuBar + + default: + return nil + } + } + + var screen: NSScreen? { + switch (self) { + case .main: + return NSScreen.main + + case .mouse: + let mouseLoc = NSEvent.mouseLocation + return NSScreen.screens.first(where: { $0.frame.contains(mouseLoc) }) + + case .menuBar: + return NSScreen.screens.first + } + } +} diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 936ac9821..76f85d2a3 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -342,6 +342,16 @@ extension Ghostty { let str = String(cString: ptr) return QuickTerminalPosition(rawValue: str) ?? .top } + + var quickTerminalScreen: QuickTerminalScreen { + guard let config = self.config else { return .main } + var v: UnsafePointer? = nil + let key = "quick-terminal-screen" + guard ghostty_config_get(config, &v, key, UInt(key.count)) else { return .main } + guard let ptr = v else { return .main } + let str = String(cString: ptr) + return QuickTerminalScreen(fromGhosttyConfig: str) ?? .main + } #endif var resizeOverlay: ResizeOverlay { diff --git a/src/config/Config.zig b/src/config/Config.zig index c950cf807..0f5e9b81b 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1234,6 +1234,26 @@ keybind: Keybinds = .{}, /// Changing this configuration requires restarting Ghostty completely. @"quick-terminal-position": QuickTerminalPosition = .top, +/// The screen where the quick terminal should show up. +/// +/// Valid values are: +/// +/// * `main` - The screen that the operating system recommends as the main +/// screen. On macOS, this is the screen that is currently receiving +/// keyboard input. This screen is defined by the operating system and +/// not chosen by Ghostty. +/// +/// * `mouse` - The screen that the mouse is currently hovered over. +/// +/// * `macos-menu-bar` - The screen that contains the macOS menu bar as +/// set in the display settings on macOS. This is a bit confusing because +/// every screen on macOS has a menu bar, but this is the screen that +/// contains the primary menu bar. +/// +/// The default value is `main` because this is the recommended screen +/// by the operating system. +@"quick-terminal-screen": QuickTerminalScreen = .main, + /// Whether to enable shell integration auto-injection or not. Shell integration /// greatly enhances the terminal experience by enabling a number of features: /// @@ -4423,6 +4443,13 @@ pub const QuickTerminalPosition = enum { right, }; +/// See quick-terminal-screen +pub const QuickTerminalScreen = enum { + main, + mouse, + @"macos-menu-bar", +}; + /// See grapheme-width-method pub const GraphemeWidthMethod = enum { legacy,