diff --git a/.github/workflows/milestone.yml b/.github/workflows/milestone.yml index 33a074159e..05d1f83c86 100644 --- a/.github/workflows/milestone.yml +++ b/.github/workflows/milestone.yml @@ -16,7 +16,7 @@ jobs: steps: - name: Set Milestone for PR uses: hustcer/milestone-action@ebed8d5daafd855a600d7e665c1b130f06d24130 # v3.1 - if: github.event.pull_request.merged == true + if: github.event.pull_request.merged == true && !contains(github.event.pull_request.title, 'VOUCHED') && !startsWith(github.event.pull_request.title, 'ci:') with: action: bind-pr # `bind-pr` is the default action github-token: ${{ secrets.GITHUB_TOKEN }} diff --git a/AGENTS.md b/AGENTS.md index 6df10698c6..8c21c85f53 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,6 +33,7 @@ A file for [guiding coding agents](https://agents.md/). - Don’t rebase a branch after it’s merged. - Sync upstream into `upstream-main` via `--ff-only`, then merge `upstream-main` into `main`. +- **NEVER squash upstream commits.** Always use a real merge (`git merge`, not `git merge --squash`) when merging `upstream-main` into `main`. Squashing severs the git ancestry chain, which permanently inflates GitHub’s "behind" count and forces every future merge to re-resolve conflicts that git would otherwise auto-resolve. This has bitten us before and must not happen again. - Avoid force-pushing `main`; if needed, push a `legacy/...` backup ref first. ## Ghostree Project Context diff --git a/flake.lock b/flake.lock index 6f12f66b92..b8e6d92634 100644 --- a/flake.lock +++ b/flake.lock @@ -16,24 +16,6 @@ "type": "github" } }, - "flake-utils": { - "inputs": { - "systems": "systems" - }, - "locked": { - "lastModified": 1731533236, - "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", - "owner": "numtide", - "repo": "flake-utils", - "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", - "type": "github" - }, - "original": { - "owner": "numtide", - "repo": "flake-utils", - "type": "github" - } - }, "home-manager": { "inputs": { "nixpkgs": [ @@ -70,7 +52,6 @@ "root": { "inputs": { "flake-compat": "flake-compat", - "flake-utils": "flake-utils", "home-manager": "home-manager", "nixpkgs": "nixpkgs", "zig": "zig", @@ -78,6 +59,7 @@ } }, "systems": { + "flake": false, "locked": { "lastModified": 1681028828, "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", @@ -97,19 +79,17 @@ "flake-compat": [ "flake-compat" ], - "flake-utils": [ - "flake-utils" - ], "nixpkgs": [ "nixpkgs" - ] + ], + "systems": "systems" }, "locked": { - "lastModified": 1763295135, - "narHash": "sha256-sGv/NHCmEnJivguGwB5w8LRmVqr1P72OjS+NzcJsssE=", + "lastModified": 1773145353, + "narHash": "sha256-dE8zx8WA54TRmFFQBvA48x/sXGDTP7YaDmY6nNKMAYw=", "owner": "mitchellh", "repo": "zig-overlay", - "rev": "64f8b42cfc615b2cf99144adf2b7728c7847c72a", + "rev": "8666155d83bf792956a7c40915508e6d4b2b8716", "type": "github" }, "original": { diff --git a/flake.nix b/flake.nix index e063f2d70d..61ca39ab1a 100644 --- a/flake.nix +++ b/flake.nix @@ -10,7 +10,6 @@ # Gnome 49/Gtk 4.20. # nixpkgs.url = "https://channels.nixos.org/nixpkgs-unstable/nixexprs.tar.xz"; - flake-utils.url = "github:numtide/flake-utils"; # Used for shell.nix flake-compat = { @@ -22,7 +21,6 @@ url = "github:mitchellh/zig-overlay"; inputs = { nixpkgs.follows = "nixpkgs"; - flake-utils.follows = "flake-utils"; flake-compat.follows = "flake-compat"; }; }; diff --git a/include/ghostty.h b/include/ghostty.h index 19a200f100..40ff55c9b3 100644 --- a/include/ghostty.h +++ b/include/ghostty.h @@ -889,6 +889,7 @@ typedef enum { GHOSTTY_ACTION_RENDER_INSPECTOR, GHOSTTY_ACTION_DESKTOP_NOTIFICATION, GHOSTTY_ACTION_SET_TITLE, + GHOSTTY_ACTION_SET_TAB_TITLE, GHOSTTY_ACTION_PROMPT_TITLE, GHOSTTY_ACTION_PWD, GHOSTTY_ACTION_MOUSE_SHAPE, @@ -937,6 +938,7 @@ typedef union { ghostty_action_inspector_e inspector; ghostty_action_desktop_notification_s desktop_notification; ghostty_action_set_title_s set_title; + ghostty_action_set_title_s set_tab_title; ghostty_action_prompt_title_e prompt_title; ghostty_action_pwd_s pwd; ghostty_action_mouse_shape_e mouse_shape; @@ -968,7 +970,7 @@ typedef struct { } ghostty_action_s; typedef void (*ghostty_runtime_wakeup_cb)(void*); -typedef void (*ghostty_runtime_read_clipboard_cb)(void*, +typedef bool (*ghostty_runtime_read_clipboard_cb)(void*, ghostty_clipboard_e, void*); typedef void (*ghostty_runtime_confirm_read_clipboard_cb)( diff --git a/macos/Ghostty-Info.plist b/macos/Ghostty-Info.plist index c160144c62..2ec30cf86c 100644 --- a/macos/Ghostty-Info.plist +++ b/macos/Ghostty-Info.plist @@ -4,6 +4,8 @@ CFBundleName Ghostree + NSAutoFillRequiresTextContentTypeForOneTimeCodeOnMac + NSDockTilePlugIn DockTilePlugin.plugin CFBundleDocumentTypes diff --git a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift index 41993247ab..ca3f566778 100644 --- a/macos/GhosttyUITests/GhosttyCustomConfigCase.swift +++ b/macos/GhosttyUITests/GhosttyCustomConfigCase.swift @@ -27,6 +27,8 @@ class GhosttyCustomConfigCase: XCTestCase { true } + static let defaultsSuiteName: String = "GHOSTTY_UI_TESTS" + var configFile: URL? override func setUpWithError() throws { continueAfterFailure = false @@ -47,13 +49,14 @@ class GhosttyCustomConfigCase: XCTestCase { try newConfig.write(to: configFile!, atomically: true, encoding: .utf8) } - func ghosttyApplication() throws -> XCUIApplication { + func ghosttyApplication(defaultsSuite: String = GhosttyCustomConfigCase.defaultsSuiteName) throws -> XCUIApplication { let app = XCUIApplication() app.launchArguments.append(contentsOf: ["-ApplePersistenceIgnoreState", "YES"]) guard let configFile else { return app } app.launchEnvironment["GHOSTTY_CONFIG_PATH"] = configFile.path + app.launchEnvironment["GHOSTTY_USER_DEFAULTS_SUITE"] = defaultsSuite return app } } diff --git a/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift new file mode 100644 index 0000000000..399c2531a4 --- /dev/null +++ b/macos/GhosttyUITests/GhosttyWindowPositionUITests.swift @@ -0,0 +1,331 @@ +// +// GhosttyWindowPositionUITests.swift +// GhosttyUITests +// +// Created by Claude on 2026-03-11. +// + +import XCTest + +final class GhosttyWindowPositionUITests: GhosttyCustomConfigCase { + override static var runsForEachTargetApplicationUIConfiguration: Bool { false } + + // MARK: - Cascading + + @MainActor func testWindowCascading() async throws { + try updateConfig( + """ + window-width = 30 + window-height = 10 + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + +// app.menuBarItems["Window"].firstMatch.click() +// app.menuItems["_zoomTopLeft:"].firstMatch.click() +// +// // wait for the animation to finish +// try await Task.sleep(for: .seconds(0.5)) + + let window = app.windows.firstMatch + let windowFrame = window.frame +// XCTAssertEqual(windowFrame.minX, 0, "Window should be on the left") + + app.typeKey("n", modifierFlags: [.command]) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + XCTAssertNotEqual(windowFrame, windowFrame2, "New window should have moved") + + XCTAssertEqual(windowFrame2.minX, windowFrame.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame2.minY, windowFrame.minY + 30, accuracy: 5, "New window should be on the bottom right") + + app.typeKey("n", modifierFlags: [.command]) + + let window3 = app.windows.firstMatch + XCTAssertTrue(window3.waitForExistence(timeout: 5), "New window should appear") + let windowFrame3 = window3.frame + XCTAssertNotEqual(windowFrame2, windowFrame3, "New window should have moved") + + XCTAssertEqual(windowFrame3.minX, windowFrame2.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame3.minY, windowFrame2.minY + 30, accuracy: 5, "New window should be on the bottom right") + + app.typeKey("n", modifierFlags: [.command]) + + let window4 = app.windows.firstMatch + XCTAssertTrue(window4.waitForExistence(timeout: 5), "New window should appear") + let windowFrame4 = window4.frame + XCTAssertNotEqual(windowFrame3, windowFrame4, "New window should have moved") + + XCTAssertEqual(windowFrame4.minX, windowFrame3.minX + 30, accuracy: 5, "New window should be on the right") + + XCTAssertEqual(windowFrame4.minY, windowFrame3.minY + 30, accuracy: 5, "New window should be on the bottom right") + } + + @MainActor func testDragSplitWindowPosition() async throws { + try updateConfig( + """ + window-width = 40 + window-height = 20 + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") + + // remove fixed size + try updateConfig( + """ + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + + app.typeKey("d", modifierFlags: [.command]) + + let rightSplit = app.groups["Right pane"] + let rightFrame = rightSplit.frame + + let sourcePos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width / 2, dy: 3)) + + let targetPos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width + 100, dy: 0)) + + sourcePos.click(forDuration: 0.2, thenDragTo: targetPos) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + + try await Task.sleep(for: .seconds(0.5)) + + XCTAssertEqual(windowFrame2.minX, rightFrame.maxX + 100, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.minY, rightFrame.minY, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.width, rightFrame.width, accuracy: 5, "New window should use size from config") + XCTAssertEqual(windowFrame2.height, rightFrame.height, accuracy: 5, "New window should use size from config") + } + + @MainActor func testDragSplitWindowPositionWithFixedSize() async throws { + try updateConfig( + """ + window-width = 40 + window-height = 20 + title = "GhosttyWindowPositionUITests" + macos-titlebar-style = hidden + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + + app.launch() // window in the center + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "New window should appear") + let windowFrame = window.frame + + app.typeKey("d", modifierFlags: [.command]) + + let rightSplit = app.groups["Right pane"] + let rightFrame = rightSplit.frame + + let sourcePos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width / 2, dy: 3)) + + let targetPos = rightSplit.coordinate(withNormalizedOffset: .zero) + .withOffset(.init(dx: rightFrame.size.width + 100, dy: 0)) + + sourcePos.click(forDuration: 0.2, thenDragTo: targetPos) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + let windowFrame2 = window2.frame + + try await Task.sleep(for: .seconds(0.5)) + + XCTAssertEqual(windowFrame2.minX, rightFrame.maxX + 100, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.minY, rightFrame.minY, accuracy: 5, "New window should be target position") + XCTAssertEqual(windowFrame2.width, windowFrame.width, accuracy: 5, "New window should use size from config") + // We're still using right frame, because of the debug banner + XCTAssertEqual(windowFrame2.height, rightFrame.height, accuracy: 5, "New window should use size from config") + } + + // MARK: - Restore round-trip per titlebar style + + @MainActor func testRestoredNative() throws { try runRestoreTest(titlebarStyle: "native") } + @MainActor func testRestoredHidden() throws { try runRestoreTest(titlebarStyle: "hidden") } + @MainActor func testRestoredTransparent() throws { try runRestoreTest(titlebarStyle: "transparent") } + @MainActor func testRestoredTabs() throws { try runRestoreTest(titlebarStyle: "tabs") } + + // MARK: - Config overrides cached position/size + + @MainActor + func testConfigOverridesCachedPositionAndSize() async throws { + // Launch maximized so the cached frame is fullscreen-sized. + try updateConfig( + """ + maximize = true + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let maximizedFrame = window.frame + + // Now update the config with a small explicit size and position, + // reload, and open a new window. It should respect the config, not the cache. + try updateConfig( + """ + window-position-x = 50 + window-position-y = 50 + window-width = 30 + window-height = 30 + title = "GhosttyWindowPositionUITests" + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) + app.typeKey("n", modifierFlags: [.command]) + + XCTAssertEqual(app.windows.count, 2, "Should have 2 windows") + let newWindow = app.windows.element(boundBy: 0) + let newFrame = newWindow.frame + + // The new window should be smaller than the maximized one. + XCTAssertLessThan(newFrame.size.width, maximizedFrame.size.width, + "30 columns should be narrower than maximized") + XCTAssertLessThan(newFrame.size.height, maximizedFrame.size.height, + "30 rows should be shorter than maximized") + + app.terminate() + } + + // MARK: - Size-only config change preserves position + + @MainActor + func testSizeOnlyConfigPreservesPosition() async throws { + // Launch maximized so the window has a known position (top-left of visible frame). + try updateConfig( + """ + maximize = true + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let initialFrame = window.frame + + // Reload with only size changed, close current window, open new one. + // Position should be restored from cache. + try updateConfig( + """ + window-width = 30 + window-height = 30 + title = "GhosttyWindowPositionUITests" + """ + ) + app.typeKey(",", modifierFlags: [.command, .shift]) + try await Task.sleep(for: .seconds(0.5)) + app.typeKey("w", modifierFlags: [.command]) + app.typeKey("n", modifierFlags: [.command]) + + let newWindow = app.windows.firstMatch + XCTAssertTrue(newWindow.waitForExistence(timeout: 5), "New window should appear") + + let newFrame = newWindow.frame + + // Position should be preserved from the cached value. + // Compare x and maxY since the window is anchored at the top-left + // but AppKit uses bottom-up coordinates (origin.y changes with height). + XCTAssertEqual(newFrame.origin.x, initialFrame.origin.x, accuracy: 2, + "x position should not change with size-only config") + XCTAssertEqual(newFrame.maxY, initialFrame.maxY, accuracy: 2, + "top edge (maxY) should not change with size-only config") + + app.terminate() + } + + // MARK: - Shared round-trip helper + + /// Opens a new window, records its frame, closes it, opens another, + /// and verifies the frame is restored consistently. + private func runRestoreTest(titlebarStyle: String) throws { + try updateConfig( + """ + macos-titlebar-style = \(titlebarStyle) + title = "GhosttyWindowPositionUITests" + """ + ) + + let app = try ghosttyApplication() + // Suppress Restoration + app.launchArguments += ["-NSQuitAlwaysKeepsWindows", "NO"] + // Clean run + app.launchEnvironment["GHOSTTY_CLEAR_USER_DEFAULTS"] = "YES" + app.launch() + + let window = app.windows.firstMatch + XCTAssertTrue(window.waitForExistence(timeout: 5), "Window should appear") + + let firstFrame = window.frame + let screenFrame = NSScreen.main?.frame ?? .zero + + XCTAssertEqual(firstFrame.midX, screenFrame.midX, accuracy: 5.0, "First window should be centered horizontally") + + // Close the window and open a new one — it should restore the same frame. + app.typeKey("w", modifierFlags: [.command]) + app.typeKey("n", modifierFlags: [.command]) + + let window2 = app.windows.firstMatch + XCTAssertTrue(window2.waitForExistence(timeout: 5), "New window should appear") + + let restoredFrame = window2.frame + + XCTAssertEqual(restoredFrame.origin.x, firstFrame.origin.x, accuracy: 2, + "[\(titlebarStyle)] x position should be restored") + XCTAssertEqual(restoredFrame.origin.y, firstFrame.origin.y, accuracy: 2, + "[\(titlebarStyle)] y position should be restored") + XCTAssertEqual(restoredFrame.size.width, firstFrame.size.width, accuracy: 2, + "[\(titlebarStyle)] width should be restored") + XCTAssertEqual(restoredFrame.size.height, firstFrame.size.height, accuracy: 2, + "[\(titlebarStyle)] height should be restored") + + app.terminate() + } +} diff --git a/macos/Sources/App/macOS/AppDelegate.swift b/macos/Sources/App/macOS/AppDelegate.swift index dc87bacc13..e5d0972566 100644 --- a/macos/Sources/App/macOS/AppDelegate.swift +++ b/macos/Sources/App/macOS/AppDelegate.swift @@ -164,6 +164,13 @@ class AppDelegate: NSObject, /// The custom app icon image that is currently in use. @Published private(set) var appIcon: NSImage? + /// Ghostty menu items indexed by their normalized shortcut. This avoids traversing + /// the entire menu tree on every key equivalent event. + /// + /// We store a weak reference so this cache can never be the owner of menu items. + /// If multiple items map to the same shortcut, the most recent one wins. + private var menuItemsByShortcut: [MenuShortcutKey: Weak] = [:] + override init() { #if DEBUG ghostty = Ghostty.App(configPath: ProcessInfo.processInfo.environment["GHOSTTY_CONFIG_PATH"]) @@ -178,7 +185,15 @@ class AppDelegate: NSObject, // MARK: - NSApplicationDelegate func applicationWillFinishLaunching(_ notification: Notification) { - UserDefaults.standard.register(defaults: [ + #if DEBUG + if + let suite = UserDefaults.ghosttySuite, + let clear = ProcessInfo.processInfo.environment["GHOSTTY_CLEAR_USER_DEFAULTS"], + (clear as NSString).boolValue { + UserDefaults.ghostty.removePersistentDomain(forName: suite) + } + #endif + UserDefaults.ghostty.register(defaults: [ // Disable the automatic full screen menu item because we handle // it manually. "NSFullScreenMenuItemEverywhere": false, @@ -200,7 +215,7 @@ class AppDelegate: NSObject, func applicationDidFinishLaunching(_ notification: Notification) { // System settings overrides - UserDefaults.standard.register(defaults: [ + UserDefaults.ghostty.register(defaults: [ // Disable this so that repeated key events make it through to our terminal views. "ApplePressAndHoldEnabled": false, ]) @@ -209,7 +224,7 @@ class AppDelegate: NSObject, applicationLaunchTime = ProcessInfo.processInfo.systemUptime // Check if secure input was enabled when we last quit. - if UserDefaults.standard.bool(forKey: "SecureInput") != SecureInput.shared.enabled { + if UserDefaults.ghostty.bool(forKey: "SecureInput") != SecureInput.shared.enabled { toggleSecureInput(self) } @@ -362,6 +377,7 @@ class AppDelegate: NSObject, // Recalculate dock badge from the full current state. self.syncDockBadge() + // First launch stuff if !applicationHasBecomeActive { applicationHasBecomeActive = true @@ -548,11 +564,6 @@ class AppDelegate: NSObject, return true } - /// This is called for the dock right-click menu. - func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { - return dockMenu - } - /// Setup signal handlers private func setupSignals() { // Register a signal handler for config reloading. It appears that all @@ -581,134 +592,6 @@ class AppDelegate: NSObject, signals.append(sigusr2) } - /// Setup all the images for our menu items. - private func setupMenuImages() { - // Note: This COULD Be done all in the xib file, but I find it easier to - // modify this stuff as code. - self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle") - self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down") - self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "doc.text") - self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90") - self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") - self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") - self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") - self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") - self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") - self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") - self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") - self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") - self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill") - self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") - self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") - self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") - self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") - self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") - self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") - self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") - self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") - self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill") - self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") - self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") - self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") - self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") - self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") - self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") - self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") - self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") - self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") - self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") - self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") - self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") - self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") - self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") - self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") - self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") - } - - /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. - private func syncMenuShortcuts(_ config: Ghostty.Config) { - guard ghostty.readiness == .ready else { return } - - syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) - syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) - syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) - syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) - - syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) - syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) - syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) - syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) - syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) - syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) - syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) - syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft) - syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) - syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) - - syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) - syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) - syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) - syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) - syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) - syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) - syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) - syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) - syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) - syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) - syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) - - syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) - syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) - syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) - syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) - syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) - syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) - syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) - syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) - syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) - syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) - syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) - syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) - syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize) - - syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) - syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) - syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) - syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) - syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) - syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) - syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) - syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) - syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) - syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) - - syncMenuShortcut(config, 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 - // to work but it won't be reflected in the menu item. - // - // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) - - // Dock menu - reloadDockMenu() - } - - /// Syncs a single menu shortcut for the given action. The action string is the same - /// action string used for the Ghostty configuration. - private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { - guard let menu = menuItem else { return } - guard let shortcut = config.keyboardShortcut(for: action) else { - // No shortcut, clear the menu item - menu.keyEquivalent = "" - menu.keyEquivalentModifierMask = [] - return - } - - menu.keyEquivalent = shortcut.key.character.description - menu.keyEquivalentModifierMask = .init(swiftUIFlags: shortcut.modifiers) - } - // MARK: Notifications and Events /// This handles events from the NSEvent.addLocalEventMonitor. We use this so we can get @@ -918,10 +801,10 @@ class AppDelegate: NSObject, // configuration. This is the only way to carefully control whether macOS invokes the // state restoration system. switch config.windowSaveState { - case "never": UserDefaults.standard.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") - case "always": UserDefaults.standard.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") + case "never": UserDefaults.ghostty.setValue(false, forKey: "NSQuitAlwaysKeepsWindows") + case "always": UserDefaults.ghostty.setValue(true, forKey: "NSQuitAlwaysKeepsWindows") case "default": fallthrough - default: UserDefaults.standard.removeObject(forKey: "NSQuitAlwaysKeepsWindows") + default: UserDefaults.ghostty.removeObject(forKey: "NSQuitAlwaysKeepsWindows") } // Sync our auto-update settings. If SUEnableAutomaticChecks (in our Info.plist) is @@ -1006,9 +889,9 @@ class AppDelegate: NSObject, private func updateAppIcon(from config: Ghostty.Config) { // Since this is called after `DockTilePlugin` has been running, // clean it up here to trigger a correct update of the current config. - UserDefaults.standard.removeObject(forKey: "CustomGhosttyIcon") + UserDefaults.ghostty.removeObject(forKey: "CustomGhosttyIcon") DispatchQueue.global().async { - UserDefaults.standard.appIcon = AppIcon(config: config) + UserDefaults.ghostty.appIcon = AppIcon(config: config) DistributedNotificationCenter.default() .postNotificationName(.ghosttyIconDidChange, object: nil, userInfo: nil, deliverImmediately: true) } @@ -1083,17 +966,6 @@ class AppDelegate: NSObject, return nil } - // MARK: - Dock Menu - - private func reloadDockMenu() { - let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") - let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") - - dockMenu.removeAllItems() - dockMenu.addItem(newWindow) - dockMenu.addItem(newTab) - } - // MARK: - Global State func setSecureInput(_ mode: Ghostty.SetSecureInput) { @@ -1109,7 +981,7 @@ class AppDelegate: NSObject, input.global.toggle() } self.menuSecureInput?.state = if input.global { .on } else { .off } - UserDefaults.standard.set(input.global, forKey: "SecureInput") + UserDefaults.ghostty.set(input.global, forKey: "SecureInput") } // MARK: - IB Actions @@ -1300,6 +1172,233 @@ class AppDelegate: NSObject, } } +// MARK: Menu + +extension AppDelegate { + /// This is called for the dock right-click menu. + func applicationDockMenu(_ sender: NSApplication) -> NSMenu? { + return dockMenu + } + + private func reloadDockMenu() { + let newWindow = NSMenuItem(title: "New Window", action: #selector(newWindow), keyEquivalent: "") + let newTab = NSMenuItem(title: "New Tab", action: #selector(newTab), keyEquivalent: "") + + dockMenu.removeAllItems() + dockMenu.addItem(newWindow) + dockMenu.addItem(newTab) + } + + /// Setup all the images for our menu items. + private func setupMenuImages() { + // Note: This COULD Be done all in the xib file, but I find it easier to + // modify this stuff as code. + self.menuAbout?.setImageIfDesired(systemSymbolName: "info.circle") + self.menuCheckForUpdates?.setImageIfDesired(systemSymbolName: "square.and.arrow.down") + self.menuOpenConfig?.setImageIfDesired(systemSymbolName: "gear") + self.menuReloadConfig?.setImageIfDesired(systemSymbolName: "arrow.trianglehead.2.clockwise.rotate.90") + self.menuSecureInput?.setImageIfDesired(systemSymbolName: "lock.display") + self.menuNewWindow?.setImageIfDesired(systemSymbolName: "macwindow.badge.plus") + self.menuNewTab?.setImageIfDesired(systemSymbolName: "macwindow") + self.menuSplitRight?.setImageIfDesired(systemSymbolName: "rectangle.righthalf.inset.filled") + self.menuSplitLeft?.setImageIfDesired(systemSymbolName: "rectangle.leadinghalf.inset.filled") + self.menuSplitUp?.setImageIfDesired(systemSymbolName: "rectangle.tophalf.inset.filled") + self.menuSplitDown?.setImageIfDesired(systemSymbolName: "rectangle.bottomhalf.inset.filled") + self.menuClose?.setImageIfDesired(systemSymbolName: "xmark") + self.menuPasteSelection?.setImageIfDesired(systemSymbolName: "doc.on.clipboard.fill") + self.menuIncreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.larger") + self.menuResetFontSize?.setImageIfDesired(systemSymbolName: "textformat.size") + self.menuDecreaseFontSize?.setImageIfDesired(systemSymbolName: "textformat.size.smaller") + self.menuCommandPalette?.setImageIfDesired(systemSymbolName: "filemenu.and.selection") + self.menuQuickTerminal?.setImageIfDesired(systemSymbolName: "apple.terminal") + self.menuChangeTabTitle?.setImageIfDesired(systemSymbolName: "pencil.line") + self.menuTerminalInspector?.setImageIfDesired(systemSymbolName: "scope") + self.menuReadonly?.setImageIfDesired(systemSymbolName: "eye.fill") + self.menuSetAsDefaultTerminal?.setImageIfDesired(systemSymbolName: "star.fill") + self.menuToggleFullScreen?.setImageIfDesired(systemSymbolName: "square.arrowtriangle.4.outward") + self.menuToggleVisibility?.setImageIfDesired(systemSymbolName: "eye") + self.menuZoomSplit?.setImageIfDesired(systemSymbolName: "arrow.up.left.and.arrow.down.right") + self.menuPreviousSplit?.setImageIfDesired(systemSymbolName: "chevron.backward.2") + self.menuNextSplit?.setImageIfDesired(systemSymbolName: "chevron.forward.2") + self.menuEqualizeSplits?.setImageIfDesired(systemSymbolName: "inset.filled.topleft.topright.bottomleft.bottomright.rectangle") + self.menuSelectSplitLeft?.setImageIfDesired(systemSymbolName: "arrow.left") + self.menuSelectSplitRight?.setImageIfDesired(systemSymbolName: "arrow.right") + self.menuSelectSplitAbove?.setImageIfDesired(systemSymbolName: "arrow.up") + self.menuSelectSplitBelow?.setImageIfDesired(systemSymbolName: "arrow.down") + self.menuMoveSplitDividerUp?.setImageIfDesired(systemSymbolName: "arrow.up.to.line") + self.menuMoveSplitDividerDown?.setImageIfDesired(systemSymbolName: "arrow.down.to.line") + self.menuMoveSplitDividerLeft?.setImageIfDesired(systemSymbolName: "arrow.left.to.line") + self.menuMoveSplitDividerRight?.setImageIfDesired(systemSymbolName: "arrow.right.to.line") + self.menuFloatOnTop?.setImageIfDesired(systemSymbolName: "square.filled.on.square") + self.menuFindParent?.setImageIfDesired(systemSymbolName: "text.page.badge.magnifyingglass") + } + + /// Sync all of our menu item keyboard shortcuts with the Ghostty configuration. + private func syncMenuShortcuts(_ config: Ghostty.Config) { + guard ghostty.readiness == .ready else { return } + + // Reset our shortcut index since we're about to rebuild all menu bindings. + menuItemsByShortcut.removeAll(keepingCapacity: true) + + syncMenuShortcut(config, action: "check_for_updates", menuItem: self.menuCheckForUpdates) + syncMenuShortcut(config, action: "open_config", menuItem: self.menuOpenConfig) + syncMenuShortcut(config, action: "reload_config", menuItem: self.menuReloadConfig) + syncMenuShortcut(config, action: "quit", menuItem: self.menuQuit) + + syncMenuShortcut(config, action: "new_window", menuItem: self.menuNewWindow) + syncMenuShortcut(config, action: "new_tab", menuItem: self.menuNewTab) + syncMenuShortcut(config, action: "close_surface", menuItem: self.menuClose) + syncMenuShortcut(config, action: "close_tab", menuItem: self.menuCloseTab) + syncMenuShortcut(config, action: "close_window", menuItem: self.menuCloseWindow) + syncMenuShortcut(config, action: "close_all_windows", menuItem: self.menuCloseAllWindows) + syncMenuShortcut(config, action: "new_split:right", menuItem: self.menuSplitRight) + syncMenuShortcut(config, action: "new_split:left", menuItem: self.menuSplitLeft) + syncMenuShortcut(config, action: "new_split:down", menuItem: self.menuSplitDown) + syncMenuShortcut(config, action: "new_split:up", menuItem: self.menuSplitUp) + + syncMenuShortcut(config, action: "undo", menuItem: self.menuUndo) + syncMenuShortcut(config, action: "redo", menuItem: self.menuRedo) + syncMenuShortcut(config, action: "copy_to_clipboard", menuItem: self.menuCopy) + syncMenuShortcut(config, action: "paste_from_clipboard", menuItem: self.menuPaste) + syncMenuShortcut(config, action: "paste_from_selection", menuItem: self.menuPasteSelection) + syncMenuShortcut(config, action: "select_all", menuItem: self.menuSelectAll) + syncMenuShortcut(config, action: "start_search", menuItem: self.menuFind) + syncMenuShortcut(config, action: "search_selection", menuItem: self.menuSelectionForFind) + syncMenuShortcut(config, action: "scroll_to_selection", menuItem: self.menuScrollToSelection) + syncMenuShortcut(config, action: "search:next", menuItem: self.menuFindNext) + syncMenuShortcut(config, action: "search:previous", menuItem: self.menuFindPrevious) + + syncMenuShortcut(config, action: "toggle_split_zoom", menuItem: self.menuZoomSplit) + syncMenuShortcut(config, action: "goto_split:previous", menuItem: self.menuPreviousSplit) + syncMenuShortcut(config, action: "goto_split:next", menuItem: self.menuNextSplit) + syncMenuShortcut(config, action: "goto_split:up", menuItem: self.menuSelectSplitAbove) + syncMenuShortcut(config, action: "goto_split:down", menuItem: self.menuSelectSplitBelow) + syncMenuShortcut(config, action: "goto_split:left", menuItem: self.menuSelectSplitLeft) + syncMenuShortcut(config, action: "goto_split:right", menuItem: self.menuSelectSplitRight) + syncMenuShortcut(config, action: "resize_split:up,10", menuItem: self.menuMoveSplitDividerUp) + syncMenuShortcut(config, action: "resize_split:down,10", menuItem: self.menuMoveSplitDividerDown) + syncMenuShortcut(config, action: "resize_split:right,10", menuItem: self.menuMoveSplitDividerRight) + syncMenuShortcut(config, action: "resize_split:left,10", menuItem: self.menuMoveSplitDividerLeft) + syncMenuShortcut(config, action: "equalize_splits", menuItem: self.menuEqualizeSplits) + syncMenuShortcut(config, action: "reset_window_size", menuItem: self.menuReturnToDefaultSize) + + syncMenuShortcut(config, action: "increase_font_size:1", menuItem: self.menuIncreaseFontSize) + syncMenuShortcut(config, action: "decrease_font_size:1", menuItem: self.menuDecreaseFontSize) + syncMenuShortcut(config, action: "reset_font_size", menuItem: self.menuResetFontSize) + syncMenuShortcut(config, action: "prompt_surface_title", menuItem: self.menuChangeTitle) + syncMenuShortcut(config, action: "prompt_tab_title", menuItem: self.menuChangeTabTitle) + syncMenuShortcut(config, action: "toggle_quick_terminal", menuItem: self.menuQuickTerminal) + syncMenuShortcut(config, action: "toggle_visibility", menuItem: self.menuToggleVisibility) + syncMenuShortcut(config, action: "toggle_window_float_on_top", menuItem: self.menuFloatOnTop) + syncMenuShortcut(config, action: "inspector:toggle", menuItem: self.menuTerminalInspector) + syncMenuShortcut(config, action: "toggle_command_palette", menuItem: self.menuCommandPalette) + + syncMenuShortcut(config, 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 + // to work but it won't be reflected in the menu item. + // + // syncMenuShortcut(config, action: "toggle_fullscreen", menuItem: self.menuToggleFullScreen) + + // Dock menu + reloadDockMenu() + } + + /// Syncs a single menu shortcut for the given action. The action string is the same + /// action string used for the Ghostty configuration. + private func syncMenuShortcut(_ config: Ghostty.Config, action: String, menuItem: NSMenuItem?) { + guard let menu = menuItem else { return } + + guard let shortcut = config.keyboardShortcut(for: action) else { + // No shortcut, clear the menu item + menu.keyEquivalent = "" + menu.keyEquivalentModifierMask = [] + return + } + + let keyEquivalent = shortcut.key.character.description + let modifierMask = NSEvent.ModifierFlags(swiftUIFlags: shortcut.modifiers) + menu.keyEquivalent = keyEquivalent + menu.keyEquivalentModifierMask = modifierMask + + // Build a direct lookup for key-equivalent dispatch so we don't need to + // linearly walk the full menu hierarchy at event time. + guard let key = MenuShortcutKey( + keyEquivalent: keyEquivalent, + modifiers: modifierMask + ) else { + return + } + + // Later registrations intentionally override earlier ones for the same key. + menuItemsByShortcut[key] = .init(menu) + } + + /// Attempts to perform a menu key equivalent only for menu items that represent + /// Ghostty keybind actions. This is important because it lets our surface dispatch + /// bindings through the menu so they flash but also lets our surface override macOS built-ins + /// like Cmd+H. + func performGhosttyBindingMenuKeyEquivalent(with event: NSEvent) -> Bool { + // Convert this event into the same normalized lookup key we use when + // syncing menu shortcuts from configuration. + guard let key = MenuShortcutKey(event: event) else { + return false + } + + // If we don't have an entry for this key combo, no Ghostty-owned + // menu shortcut exists for this event. + guard let weakItem = menuItemsByShortcut[key] else { + return false + } + + // Weak references can be nil if a menu item was deallocated after sync. + guard let item = weakItem.value else { + menuItemsByShortcut.removeValue(forKey: key) + return false + } + + guard let parentMenu = item.menu else { + return false + } + + // Keep enablement state fresh in case menu validation hasn't run yet. + parentMenu.update() + guard item.isEnabled else { + return false + } + + let index = parentMenu.index(of: item) + guard index >= 0 else { + return false + } + + parentMenu.performActionForItem(at: index) + return true + } + + /// Hashable key for a menu shortcut match, normalized for quick lookup. + private struct MenuShortcutKey: Hashable { + private static let shortcutModifiers: NSEvent.ModifierFlags = [.shift, .control, .option, .command] + + private let keyEquivalent: String + private let modifiersRawValue: UInt + + init?(keyEquivalent: String, modifiers: NSEvent.ModifierFlags) { + let normalized = keyEquivalent.lowercased() + guard !normalized.isEmpty else { return nil } + + self.keyEquivalent = normalized + self.modifiersRawValue = modifiers.intersection(Self.shortcutModifiers).rawValue + } + + init?(event: NSEvent) { + guard let keyEquivalent = event.charactersIgnoringModifiers else { return nil } + self.init(keyEquivalent: keyEquivalent, modifiers: event.modifierFlags) + } + } +} + // MARK: Floating Windows extension AppDelegate { @@ -1320,7 +1419,7 @@ extension AppDelegate { } @IBAction func useAsDefault(_ sender: NSMenuItem) { - let ud = UserDefaults.standard + let ud = UserDefaults.ghostty let key = TerminalWindow.defaultLevelKey if menuFloatOnTop?.state == .on { ud.set(NSWindow.Level.floating, forKey: key) diff --git a/macos/Sources/Features/Terminal/TerminalController.swift b/macos/Sources/Features/Terminal/TerminalController.swift index 91d52ab721..966705f44d 100644 --- a/macos/Sources/Features/Terminal/TerminalController.swift +++ b/macos/Sources/Features/Terminal/TerminalController.swift @@ -185,10 +185,10 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } let nib = switch config.macosTitlebarStyle { - case "native": "Terminal" - case "hidden": "TerminalHiddenTitlebar" - case "transparent": "TerminalTransparentTitlebar" - case "tabs": + case .native: "Terminal" + case .hidden: "TerminalHiddenTitlebar" + case .transparent: "TerminalTransparentTitlebar" + case .tabs: #if compiler(>=6.2) if #available(macOS 26.0, *) { "TerminalTabsTitlebarTahoe" @@ -198,7 +198,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr #else "TerminalTabsTitlebarVentura" #endif - default: defaultValue } return nib @@ -553,7 +552,9 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr if all.count > 1 { lastCascadePoint = window.cascadeTopLeft(from: lastCascadePoint) } else { - lastCascadePoint = window.cascadeTopLeft(from: NSPoint(x: window.frame.minX, y: window.frame.maxY)) + // We assume the window frame is already correct at this point, + // so we pass .zero to let cascade use the current frame position. + lastCascadePoint = window.cascadeTopLeft(from: .zero) } } @@ -608,6 +609,8 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // take effect. Our best theory is there is some next-event-loop-tick logic // that Cocoa is doing that we need to be after. DispatchQueue.main.async { + c.showWindow(self) + // Only cascade if we aren't fullscreen. if let window = c.window { if !window.styleMask.contains(.fullScreen) { @@ -616,8 +619,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } - c.showWindow(self) - // All new_window actions force our app to be active, so that the new // window is focused and visible. NSApp.activate(ignoringOtherApps: true) @@ -670,6 +671,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr let treeSize: CGSize? = tree.root?.viewBounds() DispatchQueue.main.async { + c.showWindow(self) if let window = c.window { // If we have a tree size, resize the window's content to match if let treeSize, treeSize.width > 0, treeSize.height > 0 { @@ -687,8 +689,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr } } } - - c.showWindow(self) } // Setup our undo @@ -2018,6 +2018,34 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr return false } + /// Setup correct window frame before showing the window + override func showWindow(_ sender: Any?) { + guard let terminalWindow = window as? TerminalWindow else { return } + + // Set the initial window position. This must happen after the window + // is fully set up (content view, toolbar, default size) so that + // decorations added by subclass awakeFromNib (e.g. toolbar for tabs + // style) don't change the frame after the position is restored. + let originChanged = terminalWindow.setInitialWindowPosition( + x: derivedConfig.windowPositionX, + y: derivedConfig.windowPositionY, + ) + let restored = LastWindowPosition.shared.restore( + terminalWindow, + origin: !originChanged, + size: defaultSize == nil, + ) + + // If nothing is changed for the frame, + // we should center the window + if !originChanged, !restored { + // This doesn't work in `windowDidLoad` somehow + terminalWindow.center() + } + + super.showWindow(sender) + } + // Shows the "+" button in the tab bar, responds to that click. override func newWindowForTab(_ sender: Any?) { // Trigger the ghostty core event logic for a new tab. @@ -2097,9 +2125,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr self.fixTabBar() // Whenever we move save our last position for the next start. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) } override func windowDidResize(_ notification: Notification) { @@ -2115,9 +2141,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr // Whenever we get focused, use that as our last window position for // restart. This differs from Terminal.app but matches iTerm2 behavior // and I think its sensible. - if let window { - LastWindowPosition.shared.save(window) - } + LastWindowPosition.shared.save(window) // Remember our last main Self.lastMain = self @@ -2538,7 +2562,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr struct DerivedConfig { let backgroundColor: Color let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let maximize: Bool let windowPositionX: Int16? let windowPositionY: Int16? @@ -2546,7 +2570,7 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr init() { self.backgroundColor = Color(NSColor.windowBackgroundColor) self.macosWindowButtons = .visible - self.macosTitlebarStyle = "system" + self.macosTitlebarStyle = .default self.maximize = false self.windowPositionX = nil self.windowPositionY = nil diff --git a/macos/Sources/Features/Terminal/TerminalView.swift b/macos/Sources/Features/Terminal/TerminalView.swift index 398f964728..5921837b50 100644 --- a/macos/Sources/Features/Terminal/TerminalView.swift +++ b/macos/Sources/Features/Terminal/TerminalView.swift @@ -105,7 +105,7 @@ struct TerminalView: View { idealHeight: lastFocusedSurface?.value?.initialSize?.height) } // 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 : []) + .ignoresSafeArea(.container, edges: ghostty.config.macosTitlebarStyle == .hidden ? .top : []) if let surfaceView = lastFocusedSurface?.value { TerminalCommandPaletteView( diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index fcd71438d9..afc774f013 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -33,11 +33,24 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } - /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` - /// work in ``TerminalController/windowDidLoad()``, - /// we override this to provide the correct size. + /// The initial content size to use as a fallback before the SwiftUI + /// view hierarchy has completed layout (i.e. before @FocusedValue + /// propagates `lastFocusedSurface`). Once the hosting view reports + /// a valid intrinsic size, this fallback is no longer used. + var initialContentSize: NSSize? + + override var intrinsicContentSize: NSSize { - terminalView.intrinsicContentSize + let hostingSize = terminalView.intrinsicContentSize + // The hosting view returns a valid size once SwiftUI has laid out + // with the correct idealWidth/idealHeight. Before that (when + // @FocusedValue hasn't propagated), it returns a tiny default. + // Fall back to initialContentSize in that case. + if let initialContentSize, + hostingSize.width < initialContentSize.width || hostingSize.height < initialContentSize.height { + return initialContentSize + } + return hostingSize } private func setup() { diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 974956493f..351fb894e1 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -128,11 +128,10 @@ class TerminalWindow: NSWindow { // If window decorations are disabled, remove our title if !config.windowDecorations { styleMask.remove(.titled) } - // Set our window positioning to coordinates if config value exists, otherwise - // fallback to original centering behavior - setInitialWindowPosition( - x: config.windowPositionX, - y: config.windowPositionY) + // NOTE: setInitialWindowPosition is NOT called here because subclass + // awakeFromNib may add decorations (e.g. toolbar for tabs style) that + // change the frame. It is called from TerminalController.windowDidLoad + // after the window is fully set up. // If our traffic buttons should be hidden, then hide them if config.macosWindowButtons == .hidden { @@ -215,7 +214,7 @@ class TerminalWindow: NSWindow { tab.accessoryView = stackView // Get our saved level - level = UserDefaults.standard.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal + level = UserDefaults.ghostty.value(forKey: Self.defaultLevelKey) as? NSWindow.Level ?? .normal } // Both of these must be true for windows without decorations to be able to @@ -530,7 +529,7 @@ class TerminalWindow: NSWindow { let fontName: String let fontSize: CGFloat let isKeyWindow: Bool - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let tabCount: Int let toolbarIdentifier: ObjectIdentifier? } @@ -538,7 +537,7 @@ class TerminalWindow: NSWindow { override var title: String { didSet { // Only manage tab titles for custom tab styles. - if derivedConfig.macosTitlebarStyle == "tabs" { + if derivedConfig.macosTitlebarStyle == .tabs { tab.title = title tab.attributedTitle = attributedTitle } @@ -594,7 +593,7 @@ class TerminalWindow: NSWindow { } lastTitlebarFontState = state - if derivedConfig.macosTitlebarStyle != "tabs", + if derivedConfig.macosTitlebarStyle != .tabs, tabCount > 1 { updateWorktrunkToolbarTitle() return @@ -603,7 +602,7 @@ class TerminalWindow: NSWindow { titlebarTextField.font = enforcedTitlebarFont titlebarTextField.usesSingleLineMode = true titlebarTextField.attributedStringValue = attributedTitle ?? NSAttributedString(string: title) - if derivedConfig.macosTitlebarStyle == "tabs" { + if derivedConfig.macosTitlebarStyle == .tabs { tab.title = title tab.attributedTitle = attributedTitle } @@ -794,20 +793,15 @@ class TerminalWindow: NSWindow { terminalController?.updateColorSchemeForSurfaceTree() } - private func setInitialWindowPosition(x: Int16?, y: Int16?) { + func setInitialWindowPosition(x: Int16?, y: Int16?) -> Bool { // If we don't have an X/Y then we try to use the previously saved window pos. guard let x = x, let y = y else { - if !LastWindowPosition.shared.restore(self) { - center() - } - - return + return false } // Prefer the screen our window is being placed on otherwise our primary screen. guard let screen = screen ?? NSScreen.screens.first else { - center() - return + return false } // Convert top-left coordinates to bottom-left origin using our utility extension @@ -823,6 +817,7 @@ class TerminalWindow: NSWindow { safeOrigin.y = min(max(safeOrigin.y, vf.minY), vf.maxY - frame.height) setFrameOrigin(safeOrigin) + return true } private func hideWindowButtons() { @@ -845,7 +840,7 @@ class TerminalWindow: NSWindow { let backgroundColor: NSColor let backgroundOpacity: Double let macosWindowButtons: Ghostty.MacOSWindowButtons - let macosTitlebarStyle: String + let macosTitlebarStyle: Ghostty.Config.MacOSTitlebarStyle let windowCornerRadius: CGFloat init() { @@ -854,7 +849,7 @@ class TerminalWindow: NSWindow { self.backgroundOpacity = 1 self.macosWindowButtons = .visible self.backgroundBlur = .disabled - self.macosTitlebarStyle = "transparent" + self.macosTitlebarStyle = .default self.windowCornerRadius = 16 } @@ -870,7 +865,7 @@ class TerminalWindow: NSWindow { // Native, transparent, and hidden styles use 16pt radius // Tabs style uses 20pt radius switch config.macosTitlebarStyle { - case "tabs": + case .tabs: self.windowCornerRadius = 20 default: self.windowCornerRadius = 16 @@ -1100,4 +1095,13 @@ extension TerminalWindow: TabTitleEditorDelegate { guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } targetController.promptTabTitle() } + + func tabTitleEditor(_ editor: TabTitleEditor, didFinishEditing targetWindow: NSWindow) { + // After inline editing, the first responder is the window itself. + // Restore focus to the terminal surface so keyboard input works. + guard let controller = windowController as? BaseTerminalController, + let focusedSurface = controller.focusedSurface + else { return } + makeFirstResponder(focusedSurface) + } } diff --git a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift index 26c6756384..3083d45d2f 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TransparentTitlebarTerminalWindow.swift @@ -96,8 +96,8 @@ class TransparentTitlebarTerminalWindow: TerminalWindow { // For glass background styles, use a transparent titlebar to let the glass effect show through // Only apply this for transparent and tabs titlebar styles let isGlassStyle = derivedConfig.backgroundBlur.isGlassStyle - let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == "transparent" || - derivedConfig.macosTitlebarStyle == "tabs" + let isTransparentTitlebar = derivedConfig.macosTitlebarStyle == .transparent || + derivedConfig.macosTitlebarStyle == .tabs let windowTheme = surfaceConfig.windowTheme.trimmingCharacters(in: .whitespacesAndNewlines) let usesTerminalBackgroundForWindow = windowTheme == "auto" || windowTheme == "ghostty" diff --git a/macos/Sources/Ghostty/Ghostty.App.swift b/macos/Sources/Ghostty/Ghostty.App.swift index 82b3ad35c2..2f0644b938 100644 --- a/macos/Sources/Ghostty/Ghostty.App.swift +++ b/macos/Sources/Ghostty/Ghostty.App.swift @@ -269,7 +269,9 @@ extension Ghostty { _ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer? - ) {} + ) -> Bool { + return false + } static func confirmReadClipboard( _ userdata: UnsafeMutableRawPointer?, @@ -321,20 +323,23 @@ extension Ghostty { ]) } - static func readClipboard(_ userdata: UnsafeMutableRawPointer?, location: ghostty_clipboard_e, state: UnsafeMutableRawPointer?) { - // If we don't even have a surface, something went terrible wrong so we have - // to leak "state". + static func readClipboard( + _ userdata: UnsafeMutableRawPointer?, + location: ghostty_clipboard_e, + state: UnsafeMutableRawPointer? + ) -> Bool { let surfaceView = self.surfaceUserdata(from: userdata) - guard let surface = surfaceView.surface else { return } + guard let surface = surfaceView.surface else { return false } // Get our pasteboard - guard let pasteboard = NSPasteboard.ghostty(location) else { - return completeClipboardRequest(surface, data: "", state: state) - } + guard let pasteboard = NSPasteboard.ghostty(location) else { return false } + + // Return false if there is no text-like clipboard content so + // performable paste bindings can pass through to the terminal. + guard let str = pasteboard.getOpinionatedStringContents() else { return false } - // Get our string - let str = pasteboard.getOpinionatedStringContents() ?? "" completeClipboardRequest(surface, data: str, state: state) + return true } static func confirmReadClipboard( @@ -534,6 +539,9 @@ extension Ghostty { case GHOSTTY_ACTION_SET_TITLE: setTitle(app, target: target, v: action.action.set_title) + case GHOSTTY_ACTION_SET_TAB_TITLE: + return setTabTitle(app, target: target, v: action.action.set_tab_title) + case GHOSTTY_ACTION_PROMPT_TITLE: return promptTitle(app, target: target, v: action.action.prompt_title) @@ -1597,6 +1605,33 @@ extension Ghostty { } } + private static func setTabTitle( + _ app: ghostty_app_t, + target: ghostty_target_s, + v: ghostty_action_set_title_s + ) -> Bool { + switch target.tag { + case GHOSTTY_TARGET_APP: + Ghostty.logger.warning("set tab title does nothing with an app target") + return false + + case GHOSTTY_TARGET_SURFACE: + guard let title = String(cString: v.title!, encoding: .utf8) else { return false } + let titleOverride = title.isEmpty ? nil : title + guard let surface = target.target.surface else { return false } + guard let surfaceView = self.surfaceView(from: surface) else { return false } + guard let window = surfaceView.window, + let controller = window.windowController as? BaseTerminalController + else { return false } + controller.titleOverride = titleOverride + return true + + default: + assertionFailure() + return false + } + } + private static func copyTitleToClipboard( _ app: ghostty_app_t, target: ghostty_target_s) -> Bool { @@ -1934,6 +1969,15 @@ extension Ghostty { case GHOSTTY_TARGET_SURFACE: guard let surface = target.target.surface else { return } guard let surfaceView = self.surfaceView(from: surface) else { return } + guard let config = (NSApplication.shared.delegate as? AppDelegate)?.ghostty.config else { return } + + guard config.progressStyle else { + Ghostty.logger.debug("progress_report action blocked by config") + DispatchQueue.main.async { + surfaceView.progressReport = nil + } + return + } let progressReport = Ghostty.Action.ProgressReport(c: v) DispatchQueue.main.async { diff --git a/macos/Sources/Ghostty/Ghostty.Config.swift b/macos/Sources/Ghostty/Ghostty.Config.swift index 239f458e33..160894b18c 100644 --- a/macos/Sources/Ghostty/Ghostty.Config.swift +++ b/macos/Sources/Ghostty/Ghostty.Config.swift @@ -53,7 +53,7 @@ extension Ghostty { /// - Parameters: /// - path: An optional preferred config file path. Pass `nil` to load the default configuration files. /// - finalize: Whether to finalize the configuration to populate default values. - static private func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { + static func loadConfig(at path: String?, finalize: Bool) -> ghostty_config_t? { // Initialize the global configuration. guard let cfg = ghostty_config_new() else { logger.critical("ghostty_config_new failed") @@ -354,14 +354,14 @@ extension Ghostty { return MacOSWindowButtons(rawValue: str) ?? defaultValue } - var macosTitlebarStyle: String { - let defaultValue = "transparent" + var macosTitlebarStyle: MacOSTitlebarStyle { + let defaultValue = MacOSTitlebarStyle.transparent guard let config = self.config else { return defaultValue } var v: UnsafePointer? let key = "macos-titlebar-style" guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return defaultValue } guard let ptr = v else { return defaultValue } - return String(cString: ptr) + return MacOSTitlebarStyle(rawValue: String(cString: ptr)) ?? defaultValue } var macosTitlebarProxyIcon: MacOSTitlebarProxyIcon { @@ -725,6 +725,14 @@ extension Ghostty { let buffer = UnsafeBufferPointer(start: v.commands, count: v.len) return buffer.map { Ghostty.Command(cValue: $0) } } + + var progressStyle: Bool { + guard let config = self.config else { return true } + var v = true + let key = "progress-style" + _ = ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) + return v + } } } @@ -906,4 +914,9 @@ extension Ghostty.Config { static let bell = NotifyOnCommandFinishAction(rawValue: 1 << 0) static let notify = NotifyOnCommandFinishAction(rawValue: 1 << 1) } + + enum MacOSTitlebarStyle: String { + static let `default` = MacOSTitlebarStyle.transparent + case native, transparent, tabs, hidden + } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index a8555e938a..c5ab84124b 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,37 +1,81 @@ import SwiftUI extension Ghostty { - /// A grab handle overlay at the top of the surface for dragging the window. + /// A grab handle overlay at the top of the surface for dragging a surface. struct SurfaceGrabHandle: View { + // Size of the actual drag handle; the hover reveal region is larger. + private static let handleSize = CGSize(width: 80, height: 12) + + // Reveal the handle anywhere within the top % of the pane height. + private static let hoverHeightFactor: CGFloat = 0.2 + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false + private var handleVisible: Bool { + // Handle should always be visible in non-fullscreen + guard let window = surfaceView.window else { return true } + guard window.styleMask.contains(.fullScreen) else { return true } + + // If fullscreen, only show the handle if we have splits + guard let controller = window.windowController as? BaseTerminalController else { return false } + return controller.surfaceTree.isSplit + } + private var ellipsisVisible: Bool { - surfaceView.mouseOverSurface && surfaceView.cursorVisible + // If the cursor isn't visible, never show the handle + guard surfaceView.cursorVisible else { return false } + // If we're hovering or actively dragging, always visible + if isHovering || isDragging { return true } + + // Require our mouse location to be within the top area of the + // surface. + guard let mouseLocation = surfaceView.mouseLocationInSurface else { return false } + return Self.isInHoverRegion(mouseLocation, in: surfaceView.bounds) } var body: some View { - ZStack { - SurfaceDragSource( - surfaceView: surfaceView, - isDragging: $isDragging, - isHovering: $isHovering - ) - .frame(width: 80, height: 12) - .contentShape(Rectangle()) - - if ellipsisVisible { - Image(systemName: "ellipsis") - .font(.system(size: 10, weight: .semibold)) - .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) - .offset(y: -3) - .allowsHitTesting(false) - .transition(.opacity) + if handleVisible { + ZStack { + SurfaceDragSource( + surfaceView: surfaceView, + isDragging: $isDragging, + isHovering: $isHovering + ) + .frame(width: Self.handleSize.width, height: Self.handleSize.height) + .contentShape(Rectangle()) + + if ellipsisVisible { + Image(systemName: "ellipsis") + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.primary.opacity(isHovering ? 0.8 : 0.3)) + .offset(y: -3) + .allowsHitTesting(false) + .transition(.opacity) + } } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } + + /// The full-width hover band that reveals the drag handle. + private static func hoverRect(in bounds: CGRect) -> CGRect { + guard !bounds.isEmpty else { return .zero } + + let hoverHeight = min(bounds.height, max(handleSize.height, bounds.height * hoverHeightFactor)) + return CGRect( + x: bounds.minX, + y: bounds.maxY - hoverHeight, + width: bounds.width, + height: hoverHeight + ) + } + + /// Returns true when the pointer is inside the top hover band. + private static func isInHoverRegion(_ point: CGPoint, in bounds: CGRect) -> Bool { + hoverRect(in: bounds).contains(point) } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index ca4163c6dc..d7a41f4ef1 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -119,6 +119,10 @@ extension Ghostty { // Whether the mouse is currently over this surface @Published private(set) var mouseOverSurface: Bool = false + // The last known mouse location in the surface's local coordinate space, + // used by overlays such as the split drag handle reveal region. + @Published private(set) var mouseLocationInSurface: CGPoint? + // Whether the cursor is currently visible (not hidden by typing, etc.) @Published private(set) var cursorVisible: Bool = true @@ -438,6 +442,15 @@ extension Ghostty { guard let surface = self.surface else { return } guard self.focused != focused else { return } self.focused = focused + + // If we lost our focus then remove the mouse event suppression so + // our mouse release event leaving the surface can properly be + // sent to stop things like mouse selection. + if !focused { + suppressNextLeftMouseUp = false + } + + // Notify libghostty ghostty_surface_set_focus(surface, focused) // Update our secure input state if we are a password input @@ -648,9 +661,15 @@ extension Ghostty { let location = convert(event.locationInWindow, from: nil) guard hitTest(location) == self else { return event } + // We always assume that we're resetting our mouse suppression + // unless we see the specific scenario below to set it. + suppressNextLeftMouseUp = false + // If we're already the first responder then no focus transfer is // happening, so the click should continue as normal. - guard window.firstResponder !== self else { return event } + guard window.firstResponder !== self else { + return event + } // If our window/app is already focused, then this click is only // being used to transfer split focus. Consume it so it does not @@ -937,13 +956,15 @@ extension Ghostty { mouseOverSurface = true super.mouseEntered(with: event) + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + guard let surfaceModel else { return } // On mouse enter we need to reset our cursor position. This is // super important because we set it to -1/-1 on mouseExit and // lots of mouse logic (i.e. whether to send mouse reports) depend // on the position being in the viewport if it is. - let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, @@ -954,6 +975,7 @@ extension Ghostty { override func mouseExited(with event: NSEvent) { mouseOverSurface = false + mouseLocationInSurface = nil guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit @@ -973,10 +995,12 @@ extension Ghostty { } override func mouseMoved(with event: NSEvent) { + let pos = self.convert(event.locationInWindow, from: nil) + mouseLocationInSurface = pos + guard let surfaceModel else { return } // Convert window position to view position. Note (0, 0) is bottom left. - let pos = self.convert(event.locationInWindow, from: nil) let mouseEvent = Ghostty.Input.MousePosEvent( x: pos.x, y: frame.height - pos.y, @@ -1046,7 +1070,7 @@ extension Ghostty { // If the user has force click enabled then we do a quick look. There // is no public API for this as far as I can tell. - guard UserDefaults.standard.bool(forKey: "com.apple.trackpad.forceClick") else { return } + guard UserDefaults.ghostty.bool(forKey: "com.apple.trackpad.forceClick") else { return } quickLook(with: event) } @@ -1241,7 +1265,8 @@ extension Ghostty { keyTables.isEmpty, bindingFlags.isDisjoint(with: [.all, .performable]), bindingFlags.contains(.consumed) { - if let menu = NSApp.mainMenu, menu.performKeyEquivalent(with: event) { + if let appDelegate = NSApp.delegate as? AppDelegate, + appDelegate.performGhosttyBindingMenuKeyEquivalent(with: event) { return true } } diff --git a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift index ca338f1022..84553ed346 100644 --- a/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSScreen+Extension.swift @@ -18,7 +18,7 @@ extension NSScreen { // AND present on this screen. var hasDock: Bool { // If the dock autohides then we don't have a dock ever. - if let dockAutohide = UserDefaults.standard.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { + if let dockAutohide = UserDefaults.ghostty.persistentDomain(forName: "com.apple.dock")?["autohide"] as? Bool { if dockAutohide { return false } } diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 3c5cbd23ae..46758a42db 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -85,13 +85,17 @@ extension NSWindow { /// Returns the visual tab index and matching tab button at the given screen point. func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? { - guard let tabBarView else { return nil } - let locationInWindow = convertPoint(fromScreen: screenPoint) - let locationInTabBar = tabBarView.convert(locationInWindow, from: nil) + guard let tabBarView, let tabBarWindow = tabBarView.window else { return nil } + + // In fullscreen, AppKit can host the titlebar and tab bar in a separate + // NSToolbarFullScreenWindow. Hit testing has to use that window's base + // coordinate space or content clicks can be misinterpreted as tab clicks. + let locationInTabBarWindow = tabBarWindow.convertPoint(fromScreen: screenPoint) + let locationInTabBar = tabBarView.convert(locationInTabBarWindow, from: nil) guard tabBarView.bounds.contains(locationInTabBar) else { return nil } for (index, tabButton) in tabButtonsInVisualOrder().enumerated() { - let locationInTabButton = tabButton.convert(locationInWindow, from: nil) + let locationInTabButton = tabButton.convert(locationInTabBarWindow, from: nil) if tabButton.bounds.contains(locationInTabButton) { return (index, tabButton) } diff --git a/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift new file mode 100644 index 0000000000..7cd0e12edc --- /dev/null +++ b/macos/Sources/Helpers/Extensions/UserDefaults+Extension.swift @@ -0,0 +1,15 @@ +import Foundation + +extension UserDefaults { + static var ghosttySuite: String? { + #if DEBUG + ProcessInfo.processInfo.environment["GHOSTTY_USER_DEFAULTS_SUITE"] + #else + nil + #endif + } + + static var ghostty: UserDefaults { + ghosttySuite.flatMap(UserDefaults.init(suiteName:)) ?? .standard + } +} diff --git a/macos/Sources/Helpers/LastWindowPosition.swift b/macos/Sources/Helpers/LastWindowPosition.swift index 5a9ce1d2c8..c7989b6faf 100644 --- a/macos/Sources/Helpers/LastWindowPosition.swift +++ b/macos/Sources/Helpers/LastWindowPosition.swift @@ -6,14 +6,33 @@ class LastWindowPosition { private let positionKey = "NSWindowLastPosition" - func save(_ window: NSWindow) { + @discardableResult + func save(_ window: NSWindow?) -> Bool { + // We should only save the frame if the window is visible. + // This avoids overriding the previously saved one + // with the wrong one when window decorations change while creating, + // e.g. adding a toolbar affects the window's frame. + guard let window, window.isVisible else { return false } let frame = window.frame let rect = [frame.origin.x, frame.origin.y, frame.size.width, frame.size.height] - UserDefaults.standard.set(rect, forKey: positionKey) + UserDefaults.ghostty.set(rect, forKey: positionKey) + return true } - func restore(_ window: NSWindow) -> Bool { - guard let values = UserDefaults.standard.array(forKey: positionKey) as? [Double], + /// Restores a previously saved window frame (or parts of it) onto the given window. + /// + /// - Parameters: + /// - window: The window whose frame should be updated. + /// - restoreOrigin: Whether to restore the saved position. Pass `false` when the + /// config specifies an explicit `window-position-x`/`window-position-y`. + /// - restoreSize: Whether to restore the saved size. Pass `false` when the config + /// specifies an explicit `window-width`/`window-height`. + /// - Returns: `true` if the frame was modified, `false` if there was nothing to restore. + @discardableResult + func restore(_ window: NSWindow, origin restoreOrigin: Bool = true, size restoreSize: Bool = true) -> Bool { + guard restoreOrigin || restoreSize else { return false } + + guard let values = UserDefaults.ghostty.array(forKey: positionKey) as? [Double], values.count >= 2 else { return false } let lastPosition = CGPoint(x: values[0], y: values[1]) @@ -22,14 +41,22 @@ class LastWindowPosition { let visibleFrame = screen.visibleFrame var newFrame = window.frame - newFrame.origin = lastPosition + if restoreOrigin { + newFrame.origin = lastPosition + } - if values.count >= 4 { + if restoreSize, values.count >= 4 { newFrame.size.width = min(values[2], visibleFrame.width) newFrame.size.height = min(values[3], visibleFrame.height) } - if !visibleFrame.contains(newFrame.origin) { + // If the new frame is not constrained to the visible screen, + // we need to shift it a little bit before AppKit does this for us, + // so that we can save the correct size beforehand. + // This fixes restoration while running UI tests, + // where config is modified without switching apps, + // which will not trigger `windowDidBecomeMain`. + if restoreOrigin, !visibleFrame.contains(newFrame) { newFrame.origin.x = max(visibleFrame.minX, min(visibleFrame.maxX - newFrame.width, newFrame.origin.x)) newFrame.origin.y = max(visibleFrame.minY, min(visibleFrame.maxY - newFrame.height, newFrame.origin.y)) } diff --git a/macos/Sources/Helpers/PermissionRequest.swift b/macos/Sources/Helpers/PermissionRequest.swift index 29d1ab6d3f..0308a02042 100644 --- a/macos/Sources/Helpers/PermissionRequest.swift +++ b/macos/Sources/Helpers/PermissionRequest.swift @@ -126,7 +126,7 @@ class PermissionRequest { /// - Parameter key: The UserDefaults key to check /// - Returns: The cached decision, or nil if no valid cached decision exists private static func getStoredResult(for key: String) -> Bool? { - let userDefaults = UserDefaults.standard + let userDefaults = UserDefaults.ghostty guard let data = userDefaults.data(forKey: key), let storedPermission = try? NSKeyedUnarchiver.unarchivedObject( ofClass: StoredPermission.self, from: data) else { @@ -151,7 +151,7 @@ class PermissionRequest { let expiryDate = Date().addingTimeInterval(duration.timeInterval) let storedPermission = StoredPermission(result: result, expiry: expiryDate) if let data = try? NSKeyedArchiver.archivedData(withRootObject: storedPermission, requiringSecureCoding: true) { - let userDefaults = UserDefaults.standard + let userDefaults = UserDefaults.ghostty userDefaults.set(data, forKey: key) } } diff --git a/macos/Sources/Helpers/TabTitleEditor.swift b/macos/Sources/Helpers/TabTitleEditor.swift index 0a1efae324..4be2c5306f 100644 --- a/macos/Sources/Helpers/TabTitleEditor.swift +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -26,6 +26,12 @@ protocol TabTitleEditorDelegate: AnyObject { _ editor: TabTitleEditor, performFallbackRenameFor targetWindow: NSWindow ) + + /// Called after inline editing finishes (whether committed or cancelled). + /// Use this to restore focus to the appropriate responder. + func tabTitleEditor( + _ editor: TabTitleEditor, + didFinishEditing targetWindow: NSWindow) } /// Handles inline tab title editing for native AppKit window tabs. @@ -34,6 +40,8 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { private weak var hostWindow: NSWindow? /// Delegate that provides and commits title data for target tab windows. private weak var delegate: TabTitleEditorDelegate? + /// Local event monitor so fullscreen titlebar-window clicks can also trigger rename. + private var eventMonitor: Any? /// Active inline editor view, if editing is in progress. private weak var inlineTitleEditor: NSTextField? @@ -46,8 +54,24 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { /// Creates a coordinator bound to a host window and rename delegate. init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) { + super.init() + self.hostWindow = hostWindow self.delegate = delegate + + // This is needed so that fullscreen clicks can register since they won't + // event on the NSWindow. We may want to tighten this up in the future by + // only doing this if we're fullscreen. + self.eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + guard let self else { return event } + return handleMouseDown(event) ? nil : event + } + } + + deinit { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } } /// Handles leftMouseDown events from the host window and begins inline edit if possible. If this @@ -58,8 +82,15 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // If we don't have a host window to look up the click, we do nothing. guard let hostWindow else { return false } + // In native fullscreen, AppKit can route titlebar clicks through a detached + // NSToolbarFullScreenWindow. Only allow clicks from the host window or its + // fullscreen tab bar window so rename handling stays scoped to this tab strip. + let sourceWindow = event.window ?? hostWindow + guard sourceWindow === hostWindow || sourceWindow === hostWindow.tabBarView?.window + else { return false } + // Find the tab window that is being clicked. - let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) + let locationInScreen = sourceWindow.convertPoint(toScreen: event.locationInWindow) guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true @@ -165,9 +196,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { // Focus after insertion so AppKit has created the field editor for this text field. DispatchQueue.main.async { [weak hostWindow, weak editor] in - guard let hostWindow, let editor else { return } + guard let editor else { return } + let responderWindow = editor.window ?? hostWindow + guard let responderWindow else { return } editor.isHidden = false - hostWindow.makeFirstResponder(editor) + responderWindow.makeFirstResponder(editor) if let fieldEditor = editor.currentEditor() as? NSTextView, let editorFont = editor.font { fieldEditor.font = editorFont @@ -198,11 +231,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { inlineTitleTargetWindow = nil // Make sure the window grabs focus again - if let hostWindow { - if let currentEditor = editor.currentEditor(), hostWindow.firstResponder === currentEditor { - hostWindow.makeFirstResponder(nil) - } else if hostWindow.firstResponder === editor { - hostWindow.makeFirstResponder(nil) + if let responderWindow = editor.window ?? hostWindow { + if let currentEditor = editor.currentEditor(), responderWindow.firstResponder === currentEditor { + responderWindow.makeFirstResponder(nil) + } else if responderWindow.firstResponder === editor { + responderWindow.makeFirstResponder(nil) } } @@ -212,8 +245,14 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate { previousTabState = nil // Delegate owns title persistence semantics (including empty-title handling). - guard commit, let targetWindow else { return } - delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + guard let targetWindow else { return } + + if commit { + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + } + + // Notify delegate that editing is done so it can restore focus. + delegate?.tabTitleEditor(self, didFinishEditing: targetWindow) } /// Chooses an editor frame that aligns with the tab title within the tab button. diff --git a/macos/Tests/Ghostty/ConfigTests.swift b/macos/Tests/Ghostty/ConfigTests.swift new file mode 100644 index 0000000000..b9c9d6a4a0 --- /dev/null +++ b/macos/Tests/Ghostty/ConfigTests.swift @@ -0,0 +1,244 @@ +import Testing +@testable import Ghostty +@testable import GhosttyKit +import SwiftUI + +@Suite +struct ConfigTests { + // MARK: - Boolean Properties + + @Test func initialWindowDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.initialWindow == true) + } + + @Test func initialWindowSetToFalse() throws { + let config = try TemporaryConfig("initial-window = false") + #expect(config.initialWindow == false) + } + + @Test func quitAfterLastWindowClosedDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.shouldQuitAfterLastWindowClosed == false) + } + + @Test func quitAfterLastWindowClosedSetToTrue() throws { + let config = try TemporaryConfig("quit-after-last-window-closed = true") + #expect(config.shouldQuitAfterLastWindowClosed == true) + } + + @Test func windowStepResizeDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.windowStepResize == false) + } + + @Test func focusFollowsMouseDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.focusFollowsMouse == false) + } + + @Test func focusFollowsMouseSetToTrue() throws { + let config = try TemporaryConfig("focus-follows-mouse = true") + #expect(config.focusFollowsMouse == true) + } + + @Test func windowDecorationsDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.windowDecorations == true) + } + + @Test func windowDecorationsNone() throws { + let config = try TemporaryConfig("window-decoration = none") + #expect(config.windowDecorations == false) + } + + @Test func macosWindowShadowDefaultsToTrue() throws { + let config = try TemporaryConfig("") + #expect(config.macosWindowShadow == true) + } + + @Test func maximizeDefaultsToFalse() throws { + let config = try TemporaryConfig("") + #expect(config.maximize == false) + } + + @Test func maximizeSetToTrue() throws { + let config = try TemporaryConfig("maximize = true") + #expect(config.maximize == true) + } + + // MARK: - String / Optional String Properties + + @Test func titleDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.title == nil) + } + + @Test func titleSetToCustomValue() throws { + let config = try TemporaryConfig("title = My Terminal") + #expect(config.title == "My Terminal") + } + + @Test func windowTitleFontFamilyDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.windowTitleFontFamily == nil) + } + + @Test func windowTitleFontFamilySetToValue() throws { + let config = try TemporaryConfig("window-title-font-family = Menlo") + #expect(config.windowTitleFontFamily == "Menlo") + } + + // MARK: - Enum Properties + + @Test func macosTitlebarStyleDefaultsToTransparent() throws { + let config = try TemporaryConfig("") + #expect(config.macosTitlebarStyle == .transparent) + } + + @Test(arguments: [ + ("native", Ghostty.Config.MacOSTitlebarStyle.native), + ("transparent", Ghostty.Config.MacOSTitlebarStyle.transparent), + ("tabs", Ghostty.Config.MacOSTitlebarStyle.tabs), + ("hidden", Ghostty.Config.MacOSTitlebarStyle.hidden), + ]) + func macosTitlebarStyleValues(raw: String, expected: Ghostty.Config.MacOSTitlebarStyle) throws { + let config = try TemporaryConfig("macos-titlebar-style = \(raw)") + #expect(config.macosTitlebarStyle == expected) + } + + @Test func resizeOverlayDefaultsToAfterFirst() throws { + let config = try TemporaryConfig("") + #expect(config.resizeOverlay == .after_first) + } + + @Test(arguments: [ + ("always", Ghostty.Config.ResizeOverlay.always), + ("never", Ghostty.Config.ResizeOverlay.never), + ("after-first", Ghostty.Config.ResizeOverlay.after_first), + ]) + func resizeOverlayValues(raw: String, expected: Ghostty.Config.ResizeOverlay) throws { + let config = try TemporaryConfig("resize-overlay = \(raw)") + #expect(config.resizeOverlay == expected) + } + + @Test func resizeOverlayPositionDefaultsToCenter() throws { + let config = try TemporaryConfig("") + #expect(config.resizeOverlayPosition == .center) + } + + @Test func macosIconDefaultsToOfficial() throws { + let config = try TemporaryConfig("") + #expect(config.macosIcon == .official) + } + + @Test func macosIconFrameDefaultsToAluminum() throws { + let config = try TemporaryConfig("") + #expect(config.macosIconFrame == .aluminum) + } + + @Test func macosWindowButtonsDefaultsToVisible() throws { + let config = try TemporaryConfig("") + #expect(config.macosWindowButtons == .visible) + } + + @Test func scrollbarDefaultsToSystem() throws { + let config = try TemporaryConfig("") + #expect(config.scrollbar == .system) + } + + @Test func scrollbarSetToNever() throws { + let config = try TemporaryConfig("scrollbar = never") + #expect(config.scrollbar == .never) + } + + // MARK: - Numeric Properties + + @Test func backgroundOpacityDefaultsToOne() throws { + let config = try TemporaryConfig("") + #expect(config.backgroundOpacity == 1.0) + } + + @Test func backgroundOpacitySetToCustom() throws { + let config = try TemporaryConfig("background-opacity = 0.5") + #expect(config.backgroundOpacity == 0.5) + } + + @Test func windowPositionDefaultsToNil() throws { + let config = try TemporaryConfig("") + #expect(config.windowPositionX == nil) + #expect(config.windowPositionY == nil) + } + + // MARK: - Config Loading + + @Test func loadedIsTrueForValidConfig() throws { + let config = try TemporaryConfig("") + #expect(config.loaded == true) + } + + @Test func unfinalizedConfigIsLoaded() throws { + let config = try TemporaryConfig("", finalize: false) + #expect(config.loaded == true) + } + + @Test func defaultConfigIsLoaded() throws { + let config = try TemporaryConfig("") + #expect(config.optionalAutoUpdateChannel != nil) // release or tip + let config1 = try TemporaryConfig("", finalize: false) + #expect(config1.optionalAutoUpdateChannel == nil) + } + + @Test func errorsEmptyForValidConfig() throws { + let config = try TemporaryConfig("") + #expect(config.errors.isEmpty) + } + + @Test func errorsReportedForInvalidConfig() throws { + let config = try TemporaryConfig("not-a-real-key = value") + #expect(!config.errors.isEmpty) + } + + // MARK: - Multiple Config Lines + + @Test func multipleConfigValues() throws { + let config = try TemporaryConfig(""" + initial-window = false + quit-after-last-window-closed = true + maximize = true + focus-follows-mouse = true + """) + #expect(config.initialWindow == false) + #expect(config.shouldQuitAfterLastWindowClosed == true) + #expect(config.maximize == true) + #expect(config.focusFollowsMouse == true) + } +} + +/// Create a temporary config file and delete it when this is deallocated +class TemporaryConfig: Ghostty.Config { + let temporaryFile: URL + + init(_ configText: String, finalize: Bool = true) throws { + let temporaryFile = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + .appendingPathExtension("ghostty") + try configText.write(to: temporaryFile, atomically: true, encoding: .utf8) + self.temporaryFile = temporaryFile + super.init(config: Self.loadConfig(at: temporaryFile.path(), finalize: finalize)) + } + + var optionalAutoUpdateChannel: Ghostty.AutoUpdateChannel? { + guard let config = self.config else { return nil } + var v: UnsafePointer? + let key = "auto-update-channel" + guard ghostty_config_get(config, &v, key, UInt(key.lengthOfBytes(using: .utf8))) else { return nil } + guard let ptr = v else { return nil } + let str = String(cString: ptr) + return Ghostty.AutoUpdateChannel(rawValue: str) + } + + deinit { + try? FileManager.default.removeItem(at: temporaryFile) + } +} diff --git a/src/Surface.zig b/src/Surface.zig index a3691b53e7..4d66622e3f 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -639,7 +639,7 @@ pub fn init( .shell_integration = config.@"shell-integration", .shell_integration_features = config.@"shell-integration-features", .cursor_blink = config.@"cursor-style-blink", - .working_directory = config.@"working-directory", + .working_directory = if (config.@"working-directory") |wd| wd.value() else null, .resources_dir = global_state.resources_dir.host(), .term = config.term, .rt_pre_exec_info = .init(config), @@ -5482,6 +5482,26 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool .tab, ), + .set_surface_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_title, + .{ .title = title }, + ); + }, + + .set_tab_title => |v| { + const title = try self.alloc.dupeZ(u8, v); + defer self.alloc.free(title); + return try self.rt_app.performAction( + .{ .surface = self }, + .set_tab_title, + .{ .title = title }, + ); + }, + .clear_screen => { // This is a duplicate of some of the logic in termio.clearScreen // but we need to do this here so we can know the answer before diff --git a/src/apprt/action.zig b/src/apprt/action.zig index 55e80a7006..f6865af83d 100644 --- a/src/apprt/action.zig +++ b/src/apprt/action.zig @@ -201,6 +201,9 @@ pub const Action = union(Key) { /// Set the title of the target to the requested value. set_title: SetTitle, + /// Set the tab title override for the target's tab. + set_tab_title: SetTitle, + /// Set the title of the target to a prompted value. It is up to /// the apprt to prompt. The value specifies whether to prompt for the /// surface title or the tab title. @@ -375,6 +378,7 @@ pub const Action = union(Key) { render_inspector, desktop_notification, set_title, + set_tab_title, prompt_title, pwd, mouse_shape, diff --git a/src/apprt/embedded.zig b/src/apprt/embedded.zig index 54d5472c62..0d5a4f8da2 100644 --- a/src/apprt/embedded.zig +++ b/src/apprt/embedded.zig @@ -50,10 +50,11 @@ pub const App = struct { /// Callback called to handle an action. action: *const fn (*App, apprt.Target.C, apprt.Action.C) callconv(.c) bool, - /// 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. - read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) void, + /// Read the clipboard value. Returns true if the clipboard request + /// was started and complete_clipboard_request may be called with the + /// given state pointer. Returns false if the clipboard request couldn't + /// be started (such as when no text is available for a paste request). + read_clipboard: *const fn (SurfaceUD, c_int, *apprt.ClipboardRequest) callconv(.c) bool, /// This may be called after a read clipboard call to request /// confirmation that the clipboard value is safe to read. The embedder @@ -512,7 +513,15 @@ pub const Surface = struct { break :wd; } - config.@"working-directory" = wd; + var wd_val: configpkg.WorkingDirectory = .{ .path = wd }; + if (wd_val.finalize(config.arenaAlloc())) |_| { + config.@"working-directory" = wd_val; + } else |err| { + log.warn( + "error finalizing working directory config dir={s} err={}", + .{ wd_val.path, err }, + ); + } } } @@ -672,14 +681,16 @@ pub const Surface = struct { errdefer alloc.destroy(state_ptr); state_ptr.* = state; - self.app.opts.read_clipboard( + const started = self.app.opts.read_clipboard( self.userdata, @intCast(@intFromEnum(clipboard_type)), state_ptr, ); + if (!started) { + alloc.destroy(state_ptr); + return false; + } - // Embedded apprt can't synchronously check clipboard content types, - // so we always return true to indicate the request was started. return true; } diff --git a/src/apprt/gtk/class/application.zig b/src/apprt/gtk/class/application.zig index c3ff51e0f7..039e853aa4 100644 --- a/src/apprt/gtk/class/application.zig +++ b/src/apprt/gtk/class/application.zig @@ -740,6 +740,7 @@ pub const Application = extern struct { .scrollbar => Action.scrollbar(target, value), .set_title => Action.setTitle(target, value), + .set_tab_title => return Action.setTabTitle(target, value), .show_child_exited => return Action.showChildExited(target, value), @@ -2545,6 +2546,30 @@ const Action = struct { } } + pub fn setTabTitle( + target: apprt.Target, + value: apprt.action.SetTitle, + ) bool { + switch (target) { + .app => { + log.warn("set_tab_title to app is unexpected", .{}); + return false; + }, + .surface => |core| { + const surface = core.rt_surface.surface; + const tab = ext.getAncestor( + Tab, + surface.as(gtk.Widget), + ) orelse { + log.warn("surface is not in a tab, ignoring set_tab_title", .{}); + return false; + }; + tab.setTitleOverride(if (value.title.len == 0) null else value.title); + return true; + }, + } + } + pub fn showChildExited( target: apprt.Target, value: apprt.surface.Message.ChildExited, diff --git a/src/apprt/gtk/class/surface.zig b/src/apprt/gtk/class/surface.zig index 8ce9ac1d18..4b31c43d5e 100644 --- a/src/apprt/gtk/class/surface.zig +++ b/src/apprt/gtk/class/surface.zig @@ -1013,6 +1013,14 @@ pub const Surface = extern struct { priv.progress_bar_timer = null; } + if (priv.config) |config| { + if (!config.get().@"progress-style") { + log.debug("progress_report action blocked by config", .{}); + priv.progress_bar_overlay.as(gtk.Widget).setVisible(@intFromBool(false)); + return; + } + } + const progress_bar = priv.progress_bar_overlay; switch (value.state) { // Remove the progress bar @@ -3381,12 +3389,20 @@ pub const Surface = extern struct { config.command = try c.clone(config._arena.?.allocator()); } if (priv.overrides.working_directory) |wd| { - config.@"working-directory" = try config._arena.?.allocator().dupeZ(u8, wd); + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, wd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; } // Properties that can impact surface init if (priv.font_size_request) |size| config.@"font-size" = size.points; - if (priv.pwd) |pwd| config.@"working-directory" = pwd; + if (priv.pwd) |pwd| { + const config_alloc = config.arenaAlloc(); + var wd_val: configpkg.WorkingDirectory = .{ .path = try config_alloc.dupe(u8, pwd) }; + try wd_val.finalize(config_alloc); + config.@"working-directory" = wd_val; + } // Initialize the surface surface.init( diff --git a/src/apprt/surface.zig b/src/apprt/surface.zig index 5c25281c8d..3cb0016fad 100644 --- a/src/apprt/surface.zig +++ b/src/apprt/surface.zig @@ -188,7 +188,7 @@ pub fn newConfig( if (prev) |p| { if (shouldInheritWorkingDirectory(context, config)) { if (try p.pwd(alloc)) |pwd| { - copy.@"working-directory" = pwd; + copy.@"working-directory" = .{ .path = pwd }; } } } diff --git a/src/cli/new_window.zig b/src/cli/new_window.zig index 2b54917892..43435e383d 100644 --- a/src/cli/new_window.zig +++ b/src/cli/new_window.zig @@ -198,7 +198,8 @@ fn runArgs( const cwd: std.fs.Dir = std.fs.cwd(); var buf: [std.fs.max_path_bytes]u8 = undefined; const wd = try cwd.realpath(".", &buf); - try opts._arguments.append(alloc, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); + // This should be inserted at the beginning of the list, just in case `-e` was used. + try opts._arguments.insert(alloc, 0, try std.fmt.allocPrintSentinel(alloc, "--working-directory={s}", .{wd}, 0)); } var arena = ArenaAllocator.init(alloc_gpa); diff --git a/src/config.zig b/src/config.zig index 0bf61a47fb..314fb49eee 100644 --- a/src/config.zig +++ b/src/config.zig @@ -44,6 +44,7 @@ pub const WindowPaddingColor = Config.WindowPaddingColor; pub const BackgroundImagePosition = Config.BackgroundImagePosition; pub const BackgroundImageFit = Config.BackgroundImageFit; pub const LinkPreviews = Config.LinkPreviews; +pub const WorkingDirectory = Config.WorkingDirectory; // Alternate APIs pub const CApi = @import("config/CApi.zig"); diff --git a/src/config/Config.zig b/src/config/Config.zig index 1b5dcb4ca9..f61990a708 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -749,7 +749,7 @@ foreground: Color = .{ .r = 0xFF, .g = 0xFF, .b = 0xFF }, /// The null character (U+0000) is always treated as a boundary and does not /// need to be included in this configuration. /// -/// Default: ` \t'"│`|:;,()[]{}<>$` +/// Default: `` \t'"│`|:;,()[]{}<>$ `` /// /// To add or remove specific characters, you can set this to a custom value. /// For example, to treat semicolons as part of words: @@ -1398,8 +1398,6 @@ input: RepeatableReadableIO = .{}, /// * `never` - Never show a scrollbar. You can still scroll using the mouse, /// keybind actions, etc. but you will not have a visual UI widget showing /// a scrollbar. -/// -/// This only applies to macOS currently. GTK doesn't yet support scrollbars. scrollbar: Scrollbar = .system, /// Match a regular expression against the terminal text and associate clicking @@ -1526,13 +1524,14 @@ class: ?[:0]const u8 = null, /// `open`, then it defaults to `home`. On Linux with GTK, if Ghostty can detect /// it was launched from a desktop launcher, then it defaults to `home`. /// -/// The value of this must be an absolute value or one of the special values -/// below: +/// The value of this must be an absolute path, a path prefixed with `~/` +/// (the tilde will be expanded to the user's home directory), or +/// one of the special values below: /// /// * `home` - The home directory of the executing user. /// /// * `inherit` - The working directory of the launching process. -@"working-directory": ?[]const u8 = null, +@"working-directory": ?WorkingDirectory = null, /// Key bindings. The format is `trigger=action`. Duplicate triggers will /// overwrite previously set values. The list of actions is available in @@ -3647,6 +3646,11 @@ else /// notifications using certain escape sequences such as OSC 9 or OSC 777. @"desktop-notifications": bool = true, +/// If `true` (default), applications running in the terminal can show +/// graphical progress bars using the ConEmu OSC 9;4 escape sequence. +/// If `false`, progress bar sequences are silently ignored. +@"progress-style": bool = true, + /// Modifies the color used for bold text in the terminal. /// /// This can be set to a specific color, using the same format as @@ -4014,10 +4018,28 @@ pub fn loadDefaultFiles(self: *Config, alloc: Allocator) !void { const app_support_path = try file_load.preferredAppSupportPath(alloc); defer alloc.free(app_support_path); const app_support_loaded: bool = loaded: { - const legacy_app_support_action = self.loadOptionalFile(alloc, legacy_app_support_path); - const app_support_action = self.loadOptionalFile(alloc, app_support_path); + const legacy_app_support_action = self.loadOptionalFile( + alloc, + legacy_app_support_path, + ); + + // The app support path and legacy may be the same, since we + // use the `preferred` call above. If its the same, avoid + // a double-load. + const app_support_action: OptionalFileAction = if (!std.mem.eql( + u8, + legacy_app_support_path, + app_support_path, + )) self.loadOptionalFile( + alloc, + app_support_path, + ) else .not_found; + if (app_support_action != .not_found and legacy_app_support_action != .not_found) { - log.warn("both config files `{s}` and `{s}` exist.", .{ legacy_app_support_path, app_support_path }); + log.warn( + "both config files `{s}` and `{s}` exist.", + .{ legacy_app_support_path, app_support_path }, + ); log.warn("loading them both in that order", .{}); break :loaded true; } @@ -4502,23 +4524,18 @@ pub fn finalize(self: *Config) !void { } // The default for the working directory depends on the system. - const wd = self.@"working-directory" orelse if (probable_cli) - // From the CLI, we want to inherit where we were launched from. - "inherit" + var wd: WorkingDirectory = self.@"working-directory" orelse if (probable_cli) + .inherit else - // Otherwise we typically just want the home directory because - // our pwd is probably a runtime state dir or root or something - // (launchers and desktop environments typically do this). - "home"; + .home; // If we are missing either a command or home directory, we need // to look up defaults which is kind of expensive. We only do this // on desktop. - const wd_home = std.mem.eql(u8, "home", wd); if ((comptime !builtin.target.cpu.arch.isWasm()) and (comptime !builtin.is_test)) { - if (self.command == null or wd_home) command: { + if (self.command == null or wd == .home) command: { // First look up the command using the SHELL env var if needed. // We don't do this in flatpak because SHELL in Flatpak is always // set to /bin/sh. @@ -4540,7 +4557,7 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = copy }; // If we don't need the working directory, then we can exit now. - if (!wd_home) break :command; + if (wd != .home) break :command; } else |_| {} } @@ -4551,10 +4568,12 @@ pub fn finalize(self: *Config) !void { self.command = .{ .shell = "cmd.exe" }; } - if (wd_home) { + if (wd == .home) { var buf: [std.fs.max_path_bytes]u8 = undefined; if (try internal_os.home(&buf)) |home| { - self.@"working-directory" = try alloc.dupe(u8, home); + wd = .{ .path = try alloc.dupe(u8, home) }; + } else { + wd = .inherit; } } }, @@ -4569,10 +4588,12 @@ pub fn finalize(self: *Config) !void { } } - if (wd_home) { + if (wd == .home) { if (pw.home) |home| { log.info("default working directory src=passwd value={s}", .{home}); - self.@"working-directory" = home; + wd = .{ .path = home }; + } else { + wd = .inherit; } } @@ -4583,6 +4604,8 @@ pub fn finalize(self: *Config) !void { } } } + try wd.finalize(alloc); + self.@"working-directory" = wd; // Apprt-specific defaults switch (build_config.app_runtime) { @@ -4601,10 +4624,6 @@ pub fn finalize(self: *Config) !void { }, } - // If we have the special value "inherit" then set it to null which - // does the same. In the future we should change to a tagged union. - if (std.mem.eql(u8, wd, "inherit")) self.@"working-directory" = null; - // Default our click interval if (self.@"click-repeat-interval" == 0 and (comptime !builtin.is_test)) @@ -5228,6 +5247,127 @@ pub const LinkPreviews = enum { osc8, }; +/// See working-directory +pub const WorkingDirectory = union(enum) { + const Self = @This(); + + /// Resolve to the current user's home directory during config finalize. + home, + + /// Inherit the working directory from the launching process. + inherit, + + /// Use an explicit working directory path. This may be not be + /// expanded until finalize is called. + path: []const u8, + + pub fn parseCLI(self: *Self, alloc: Allocator, input_: ?[]const u8) !void { + var input = input_ orelse return error.ValueRequired; + input = std.mem.trim(u8, input, &std.ascii.whitespace); + if (input.len == 0) return error.ValueRequired; + + // Match path.zig behavior for quoted values. + if (input.len >= 2 and input[0] == '"' and input[input.len - 1] == '"') { + input = input[1 .. input.len - 1]; + } + + if (std.mem.eql(u8, input, "home")) { + self.* = .home; + return; + } + + if (std.mem.eql(u8, input, "inherit")) { + self.* = .inherit; + return; + } + + self.* = .{ .path = try alloc.dupe(u8, input) }; + } + + /// Expand tilde paths in .path values. + pub fn finalize(self: *Self, alloc: Allocator) Allocator.Error!void { + const path = switch (self.*) { + .path => |path| path, + else => return, + }; + + if (!std.mem.startsWith(u8, path, "~/")) return; + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expanded = internal_os.expandHome(path, &buf) catch |err| { + log.warn( + "error expanding home directory for working-directory path={s}: {}", + .{ path, err }, + ); + return; + }; + + if (std.mem.eql(u8, expanded, path)) return; + self.* = .{ .path = try alloc.dupe(u8, expanded) }; + } + + pub fn value(self: Self) ?[]const u8 { + return switch (self) { + .path => |path| path, + .home, .inherit => null, + }; + } + + pub fn clone(self: Self, alloc: Allocator) Allocator.Error!Self { + return switch (self) { + .path => |path| .{ .path = try alloc.dupe(u8, path) }, + else => self, + }; + } + + pub fn formatEntry(self: Self, formatter: formatterpkg.EntryFormatter) !void { + switch (self) { + .home, .inherit => try formatter.formatEntry([]const u8, @tagName(self)), + .path => |path| try formatter.formatEntry([]const u8, path), + } + } + + test "WorkingDirectory parseCLI" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + var wd: Self = .inherit; + + try wd.parseCLI(alloc, "inherit"); + try testing.expectEqual(.inherit, wd); + + try wd.parseCLI(alloc, "home"); + try testing.expectEqual(.home, wd); + + try wd.parseCLI(alloc, "~/projects/ghostty"); + try testing.expectEqualStrings("~/projects/ghostty", wd.path); + + try wd.parseCLI(alloc, "\"/tmp path\""); + try testing.expectEqualStrings("/tmp path", wd.path); + } + + test "WorkingDirectory finalize" { + const testing = std.testing; + var arena = ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const alloc = arena.allocator(); + + { + var wd: Self = .{ .path = "~/projects/ghostty" }; + try wd.finalize(alloc); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, wd.value().?); + } + } +}; + /// Color represents a color using RGB. /// /// This is a packed struct so that the C API to read color values just @@ -6315,10 +6455,11 @@ pub const Keybinds = struct { .{ .copy_to_clipboard = .mixed }, .{ .performable = true }, ); - try self.set.put( + try self.set.putFlags( alloc, .{ .key = .{ .unicode = 'v' }, .mods = mods }, - .{ .paste_from_clipboard = {} }, + .paste_from_clipboard, + .{ .performable = true }, ); } @@ -10291,6 +10432,26 @@ test "clone preserves conditional set" { try testing.expect(clone1._conditional_set.contains(.theme)); } +test "working-directory expands tilde" { + const testing = std.testing; + const alloc = testing.allocator; + + var cfg = try Config.default(alloc); + defer cfg.deinit(); + var it: TestIterator = .{ .data = &.{ + "--working-directory=~/projects/ghostty", + } }; + try cfg.loadIter(alloc, &it); + try cfg.finalize(); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const expected = internal_os.expandHome( + "~/projects/ghostty", + &buf, + ) catch "~/projects/ghostty"; + try testing.expectEqualStrings(expected, cfg.@"working-directory".?.value().?); +} + test "changed" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index b1126dd4e7..30e1d0544e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1078,67 +1078,70 @@ test "shape Devanagari string" { try testing.expect(try it.next(alloc) == null); } +// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed +// locally. Disabling this test until it can be fixed. test "shape Tai Tham vowels (position differs from advance)" { - // Note that while this test was necessary for CoreText, the old logic was - // working for HarfBuzz. Still we keep it to ensure it has the correct - // behavior. - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, which is a system font on macOS, we just skip the - // test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ - buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 2), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - - // The first glyph renders in the next cell. We expect the x_offset - // to equal the cell width. However, with FreeType the cell_width is - // computed from ASCII glyphs, and Noto Sans Tai Tham only has the - // space character in ASCII (with a 3px advance), so the cell_width - // metric doesn't match the actual Tai Tham glyph positioning. - const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); - try testing.expectEqual(expected_x_offset, cells[0].x_offset); - try testing.expectEqual(@as(i16, 0), cells[1].x_offset); - } - try testing.expectEqual(@as(usize, 1), count); + return error.SkipZigTest; + // // Note that while this test was necessary for CoreText, the old logic was + // // working for HarfBuzz. Still we keep it to ensure it has the correct + // // behavior. + // const testing = std.testing; + // const alloc = testing.allocator; + + // // We need a font that supports Tai Tham for this to work, if we can't find + // // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // // test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Tai Tham", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); + + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; + // buf_idx += try std.unicode.utf8Encode(0x1a2F, buf[buf_idx..]); // ᨯ + // buf_idx += try std.unicode.utf8Encode(0x1a70, buf[buf_idx..]); // ᩰ + + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); + + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); + + // var s = t.vtStream(); + // defer s.deinit(); + // try s.nextSlice(buf[0..buf_idx]); + + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); + + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; + + // const cells = try shaper.shape(run); + // try testing.expectEqual(@as(usize, 2), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + + // // The first glyph renders in the next cell. We expect the x_offset + // // to equal the cell width. However, with FreeType the cell_width is + // // computed from ASCII glyphs, and Noto Sans Tai Tham only has the + // // space character in ASCII (with a 3px advance), so the cell_width + // // metric doesn't match the actual Tai Tham glyph positioning. + // const expected_x_offset: i16 = if (comptime font.options.backend.hasFreetype()) 7 else @intCast(run.grid.metrics.cell_width); + // try testing.expectEqual(expected_x_offset, cells[0].x_offset); + // try testing.expectEqual(@as(i16, 0), cells[1].x_offset); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Tibetan characters" { @@ -1194,125 +1197,131 @@ test "shape Tibetan characters" { try testing.expectEqual(@as(usize, 1), count); } +// This test fails on Linux if you have the "Noto Sans Tai Tham" font installed +// locally. Disabling this test until it can be fixed. test "shape Tai Tham letters (run_offset.y differs from zero)" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Tai Tham for this to work, if we can't find - // Noto Sans Tai Tham, which is a system font on macOS, we just skip the - // test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Tai Tham", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA - buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme - - // The U glyph renders at a y below zero - try testing.expectEqual(@as(i16, -3), cells[2].y_offset); - } - try testing.expectEqual(@as(usize, 1), count); + return error.SkipZigTest; + // const testing = std.testing; + // const alloc = testing.allocator; + + // // We need a font that supports Tai Tham for this to work, if we can't find + // // Noto Sans Tai Tham, which is a system font on macOS, we just skip the + // // test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Tai Tham", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); + + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; + + // // First grapheme cluster: + // buf_idx += try std.unicode.utf8Encode(0x1a49, buf[buf_idx..]); // HA + // buf_idx += try std.unicode.utf8Encode(0x1a60, buf[buf_idx..]); // SAKOT + // // Second grapheme cluster, combining with the first in a ligature: + // buf_idx += try std.unicode.utf8Encode(0x1a3f, buf[buf_idx..]); // YA + // buf_idx += try std.unicode.utf8Encode(0x1a69, buf[buf_idx..]); // U + + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); + + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); + + // var s = t.vtStream(); + // defer s.deinit(); + // try s.nextSlice(buf[0..buf_idx]); + + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); + + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; + + // const cells = try shaper.shape(run); + // try testing.expectEqual(@as(usize, 3), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + // try testing.expectEqual(@as(u16, 0), cells[2].x); // U from second grapheme + + // // The U glyph renders at a y below zero + // try testing.expectEqual(@as(i16, -3), cells[2].y_offset); + // } + // try testing.expectEqual(@as(usize, 1), count); } +// This test fails on Linux if you have the "Noto Sans Javanese" font installed +// locally. Disabling this test until it can be fixed. test "shape Javanese ligatures" { - const testing = std.testing; - const alloc = testing.allocator; - - // We need a font that supports Javanese for this to work, if we can't find - // Noto Sans Javanese Regular, which is a system font on macOS, we just - // skip the test. - var testdata = testShaperWithDiscoveredFont( - alloc, - "Noto Sans Javanese", - ) catch return error.SkipZigTest; - defer testdata.deinit(); - - var buf: [32]u8 = undefined; - var buf_idx: usize = 0; - - // First grapheme cluster: - buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA - buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON - // Second grapheme cluster, combining with the first in a ligature: - buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA - buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU - - // Make a screen with some data - var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); - defer t.deinit(alloc); - - // Enable grapheme clustering - t.modes.set(.grapheme_cluster, true); - - var s = t.vtStream(); - defer s.deinit(); - try s.nextSlice(buf[0..buf_idx]); - - var state: terminal.RenderState = .empty; - defer state.deinit(alloc); - try state.update(alloc, &t); - - // Get our run iterator - var shaper = &testdata.shaper; - var it = shaper.runIterator(.{ - .grid = testdata.grid, - .cells = state.row_data.get(0).cells.slice(), - }); - var count: usize = 0; - while (try it.next(alloc)) |run| { - count += 1; - - const cells = try shaper.shape(run); - const cell_width = run.grid.metrics.cell_width; - try testing.expectEqual(@as(usize, 3), cells.len); - try testing.expectEqual(@as(u16, 0), cells[0].x); - try testing.expectEqual(@as(u16, 0), cells[1].x); - try testing.expectEqual(@as(u16, 0), cells[2].x); - - // The vowel sign SUKU renders with correct x_offset - try testing.expect(cells[2].x_offset > 3 * cell_width); - } - try testing.expectEqual(@as(usize, 1), count); + return error.SkipZigTest; + // const testing = std.testing; + // const alloc = testing.allocator; + + // // We need a font that supports Javanese for this to work, if we can't find + // // Noto Sans Javanese Regular, which is a system font on macOS, we just + // // skip the test. + // var testdata = testShaperWithDiscoveredFont( + // alloc, + // "Noto Sans Javanese", + // ) catch return error.SkipZigTest; + // defer testdata.deinit(); + + // var buf: [32]u8 = undefined; + // var buf_idx: usize = 0; + + // // First grapheme cluster: + // buf_idx += try std.unicode.utf8Encode(0xa9a4, buf[buf_idx..]); // NA + // buf_idx += try std.unicode.utf8Encode(0xa9c0, buf[buf_idx..]); // PANGKON + // // Second grapheme cluster, combining with the first in a ligature: + // buf_idx += try std.unicode.utf8Encode(0xa9b2, buf[buf_idx..]); // HA + // buf_idx += try std.unicode.utf8Encode(0xa9b8, buf[buf_idx..]); // Vowel sign SUKU + + // // Make a screen with some data + // var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); + // defer t.deinit(alloc); + + // // Enable grapheme clustering + // t.modes.set(.grapheme_cluster, true); + + // var s = t.vtStream(); + // defer s.deinit(); + // try s.nextSlice(buf[0..buf_idx]); + + // var state: terminal.RenderState = .empty; + // defer state.deinit(alloc); + // try state.update(alloc, &t); + + // // Get our run iterator + // var shaper = &testdata.shaper; + // var it = shaper.runIterator(.{ + // .grid = testdata.grid, + // .cells = state.row_data.get(0).cells.slice(), + // }); + // var count: usize = 0; + // while (try it.next(alloc)) |run| { + // count += 1; + + // const cells = try shaper.shape(run); + // const cell_width = run.grid.metrics.cell_width; + // try testing.expectEqual(@as(usize, 3), cells.len); + // try testing.expectEqual(@as(u16, 0), cells[0].x); + // try testing.expectEqual(@as(u16, 0), cells[1].x); + // try testing.expectEqual(@as(u16, 0), cells[2].x); + + // // The vowel sign SUKU renders with correct x_offset + // try testing.expect(cells[2].x_offset > 3 * cell_width); + // } + // try testing.expectEqual(@as(usize, 1), count); } test "shape Chakma vowel sign with ligature (vowel sign renders first)" { diff --git a/src/input/Binding.zig b/src/input/Binding.zig index 286c8f2edc..62a4e39acf 100644 --- a/src/input/Binding.zig +++ b/src/input/Binding.zig @@ -577,6 +577,16 @@ pub const Action = union(enum) { /// and persists across focus changes within the tab. prompt_tab_title, + /// Set the title for the current focused surface. + /// + /// If the title is empty, the surface title is reset to an empty title. + set_surface_title: []const u8, + + /// Set the title for the current focused tab. + /// + /// If the title is empty, the tab title override is cleared. + set_tab_title: []const u8, + /// Create a new split in the specified direction. /// /// Valid arguments: @@ -1324,6 +1334,8 @@ pub const Action = union(enum) { .set_font_size, .prompt_surface_title, .prompt_tab_title, + .set_surface_title, + .set_tab_title, .clear_screen, .select_all, .scroll_to_top, @@ -3292,6 +3304,16 @@ test "parse: action with string" { try testing.expect(binding.action == .esc); try testing.expectEqualStrings("A", binding.action.esc); } + { + const binding = try parseSingle("a=set_surface_title:surface"); + try testing.expect(binding.action == .set_surface_title); + try testing.expectEqualStrings("surface", binding.action.set_surface_title); + } + { + const binding = try parseSingle("a=set_tab_title:tab"); + try testing.expect(binding.action == .set_tab_title); + try testing.expectEqualStrings("tab", binding.action.set_tab_title); + } } test "parse: action with enum" { @@ -4557,6 +4579,18 @@ test "action: format" { try testing.expectEqualStrings("text:\\xf0\\x9f\\x91\\xbb", buf.written()); } +test "action: format set title" { + const testing = std.testing; + const alloc = testing.allocator; + + const a: Action = .{ .set_tab_title = "foo bar" }; + + var buf: std.Io.Writer.Allocating = .init(alloc); + defer buf.deinit(); + try a.format(&buf.writer); + try testing.expectEqualStrings("set_tab_title:foo bar", buf.written()); +} + test "set: appendChain with no parent returns error" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/input/command.zig b/src/input/command.zig index f50e6840b0..ac048eec08 100644 --- a/src/input/command.zig +++ b/src/input/command.zig @@ -689,6 +689,8 @@ fn actionCommands(action: Action.Key) []const Command { .esc, .cursor_key, .set_font_size, + .set_surface_title, + .set_tab_title, .search, .scroll_to_row, .scroll_page_fractional, diff --git a/src/shell-integration/bash/ghostty.bash b/src/shell-integration/bash/ghostty.bash index badc3503b3..ef1997f9e3 100644 --- a/src/shell-integration/bash/ghostty.bash +++ b/src/shell-integration/bash/ghostty.bash @@ -216,20 +216,22 @@ function __ghostty_precmd() { _GHOSTTY_SAVE_PS1="$PS1" _GHOSTTY_SAVE_PS2="$PS2" - # Marks. We need to do fresh line (A) at the beginning of the prompt - # since if the cursor is not at the beginning of a line, the terminal - # will emit a newline. - PS1='\[\e]133;A;redraw=last;cl=line;aid='"$BASHPID"'\a\]'$PS1'\[\e]133;B\a\]' - PS2='\[\e]133;A;k=s\a\]'$PS2'\[\e]133;B\a\]' - - # Bash doesn't redraw the leading lines in a multiline prompt so - # we mark the start of each line (after each newline) as a secondary - # prompt. This correctly handles multiline prompts by setting the first - # to primary and the subsequent lines to secondary. - if [[ "${PS1}" == *"\n"* || "${PS1}" == *$'\n'* ]]; then - builtin local __ghostty_mark=$'\\[\\e]133;A;k=s\\a\\]' - PS1="${PS1//$'\n'/$'\n'$__ghostty_mark}" - PS1="${PS1//\\n/\\n$__ghostty_mark}" + # Use 133;P (not 133;A) inside PS1 to avoid fresh-line behavior on + # readline redraws (e.g., vi mode switches, Ctrl-L). The initial + # 133;A with fresh-line is emitted once via printf below. + PS1='\[\e]133;P;k=i\a\]'$PS1'\[\e]133;B\a\]' + PS2='\[\e]133;P;k=s\a\]'$PS2'\[\e]133;B\a\]' + + # Bash doesn't redraw the leading lines in a multiline prompt so we mark + # the start of each line (after each newline) as a secondary prompt. This + # correctly handles multiline prompts by setting the first to primary and + # the subsequent lines to secondary. + # + # We only replace the \n prompt escape, not literal newlines ($'\n'), + # because literal newlines may appear inside $(...) command substitutions + # where inserting escape sequences would break shell syntax. + if [[ "$PS1" == *"\n"* ]]; then + PS1="${PS1//\\n/\\n$'\\[\\e]133;P;k=s\\a\\]'}" fi # Cursor @@ -252,6 +254,9 @@ function __ghostty_precmd() { builtin printf "\e]133;D;%s;aid=%s\a" "$ret" "$BASHPID" fi + # Fresh line and start of prompt. + builtin printf "\e]133;A;redraw=last;cl=line;aid=%s\a" "$BASHPID" + # unfortunately bash provides no hooks to detect cwd changes # in particular this means cwd reporting will not happen for a # command like cd /test && cat. PS0 is evaluated before cd is run. diff --git a/src/shell-integration/zsh/ghostty-integration b/src/shell-integration/zsh/ghostty-integration index f3c9c1cb53..c22f6755eb 100644 --- a/src/shell-integration/zsh/ghostty-integration +++ b/src/shell-integration/zsh/ghostty-integration @@ -148,32 +148,59 @@ _ghostty_deferred_init() { # SIGCHLD if notify is set. Themes that update prompt # asynchronously from a `zle -F` handler might still remove our # marks. Oh well. + + # Restore PS1/PS2 to their pre-mark state if nothing else has + # modified them since we last added marks. This avoids exposing + # PS1 with our marks to other hooks (which can break themes like + # Pure that use pattern matching to strip/rebuild the prompt). + # If PS1 was modified (by a theme, async update, etc.), we + # keep the modified version, prioritizing the theme's changes. + builtin local ps1_changed=0 + if [[ -n ${_ghostty_saved_ps1+x} ]]; then + if [[ $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + elif [[ $PS1 != $_ghostty_saved_ps1 ]]; then + ps1_changed=1 + fi + fi + + # Save the clean PS1/PS2 before we add marks. + _ghostty_saved_ps1=$PS1 + _ghostty_saved_ps2=$PS2 + + # Add our marks. Since we always start from a clean PS1 + # (either restored above or freshly set by a theme), we can + # unconditionally add mark1 and markB. builtin local mark2=$'%{\e]133;A;k=s\a%}' builtin local markB=$'%{\e]133;B\a%}' - # Add marks conditionally to avoid a situation where we have - # several marks in place. These conditions can have false - # positives and false negatives though. - # - # - False positive (with prompt_percent): PS1="%(?.$mark1.)" - # - False negative (with prompt_subst): PS1='$mark1' - [[ $PS1 == *$mark1* ]] || PS1=${mark1}${PS1} - [[ $PS1 == *$markB* ]] || PS1=${PS1}${markB} + PS1=${mark1}${PS1}${markB} + # Handle multiline prompts by marking newline-separated # continuation lines with k=s (mark2). We skip the newline # immediately after mark1 to avoid introducing a double # newline due to OSC 133;A's fresh-line behavior. - if [[ $PS1 == ${mark1}$'\n'* ]]; then - builtin local rest=${PS1#${mark1}$'\n'} - if [[ $rest == *$'\n'* ]]; then - PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + # + # We skip this when PS1 changed because injecting marks into + # newlines can break pattern matching in themes that + # strip/rebuild the prompt dynamically (e.g., Pure). + if (( ! ps1_changed )) && [[ $PS1 == *$'\n'* ]]; then + if [[ $PS1 == ${mark1}$'\n'* ]]; then + builtin local rest=${PS1#${mark1}$'\n'} + if [[ $rest == *$'\n'* ]]; then + PS1=${mark1}$'\n'${rest//$'\n'/$'\n'${mark2}} + fi + else + PS1=${PS1//$'\n'/$'\n'${mark2}} fi - elif [[ $PS1 == *$'\n'* ]]; then - PS1=${PS1//$'\n'/$'\n'${mark2}} fi # PS2 mark is needed when clearing the prompt on resize - [[ $PS2 == *$mark2* ]] || PS2=${mark2}${PS2} - [[ $PS2 == *$markB* ]] || PS2=${PS2}${markB} + PS2=${mark2}${PS2}${markB} + + # Save the marked PS1 so we can detect modifications + # by other hooks in the next cycle. + _ghostty_marked_ps1=$PS1 (( _ghostty_state = 2 )) else # If our precmd hook is not the last, we cannot rely on prompt @@ -205,17 +232,14 @@ _ghostty_deferred_init() { _ghostty_preexec() { builtin emulate -L zsh -o no_warn_create_global -o no_aliases - # This can potentially break user prompt. Oh well. The robustness of - # this code can be improved in the case prompt_subst is set because - # it'll allow us distinguish (not perfectly but close enough) between - # our own prompt, user prompt, and our own prompt with user additions on - # top. We cannot force prompt_subst on the user though, so we would - # still need this code for the no_prompt_subst case. - PS1=${PS1//$'%{\e]133;A;cl=line\a%}'} - PS1=${PS1//$'%{\e]133;A;k=s\a%}'} - PS1=${PS1//$'%{\e]133;B\a%}'} - PS2=${PS2//$'%{\e]133;A;k=s\a%}'} - PS2=${PS2//$'%{\e]133;B\a%}'} + # Restore the original PS1/PS2 if nothing else has modified them + # since our precmd added marks. This ensures other preexec hooks + # see a clean PS1 without our marks. If PS1 was modified (e.g., + # by an async theme update), we leave it alone. + if [[ -n ${_ghostty_saved_ps1+x} && $PS1 == $_ghostty_marked_ps1 ]]; then + PS1=$_ghostty_saved_ps1 + PS2=$_ghostty_saved_ps2 + fi # This will work incorrectly in the presence of a preexec hook that # prints. For example, if MichaelAquilina/zsh-you-should-use installs @@ -436,7 +460,7 @@ _ghostty_deferred_init() { builtin typeset -ag precmd_functions if (( $+functions[_ghostty_precmd] )); then - precmd_functions=(${precmd_functions:/_ghostty_deferred_init/_ghostty_precmd}) + precmd_functions=(${precmd_functions:#_ghostty_deferred_init} _ghostty_precmd) _ghostty_precmd else precmd_functions=(${precmd_functions:#_ghostty_deferred_init}) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index b6d53beee1..6e39428dbd 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2682,11 +2682,22 @@ fn scrollPrompt(self: *PageList, delta: isize) void { // delta so that we don't land back on our current viewport. const start_pin = start: { const tl = self.getTopLeft(.viewport); - const adjusted: ?Pin = if (delta > 0) - tl.down(1) - else - tl.up(1); - break :start adjusted orelse return; + + // If we're moving up we can just move the viewport up because + // promptIterator handles jumpting to the start of prompts. + if (delta <= 0) break :start tl.up(1) orelse return; + + // If we're moving down and we're presently at some kind of + // prompt, we need to skip all the continuation lines because + // promptIterator can't know if we're cutoff or continuing. + var adjusted: Pin = tl.down(1) orelse return; + if (tl.rowAndCell().row.semantic_prompt != .none) skip: { + while (adjusted.rowAndCell().row.semantic_prompt == .prompt_continuation) { + adjusted = adjusted.down(1) orelse break :skip; + } + } + + break :start adjusted; }; // Go through prompts delta times @@ -6866,6 +6877,55 @@ test "Screen: jump back one prompt" { } } +test "Screen: jump forward prompt skips multiline continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 5, 3, null); + defer s.deinit(); + try s.growRows(7); + + // Multiline prompt on rows 1-3. + { + const p = s.pin(.{ .screen = .{ .y = 1 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt; + } + { + const p = s.pin(.{ .screen = .{ .y = 2 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt_continuation; + } + { + const p = s.pin(.{ .screen = .{ .y = 3 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt_continuation; + } + + // Next prompt after command output. + { + const p = s.pin(.{ .screen = .{ .y = 6 } }).?; + p.rowAndCell().row.semantic_prompt = .prompt; + } + + // Starting at the first prompt line should jump to the next prompt, + // not to continuation lines. + s.scroll(.{ .row = 1 }); + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); + + // Starting in the middle of continuation lines should also jump to + // the next prompt. + s.scroll(.{ .row = 2 }); + s.scroll(.{ .delta_prompt = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = 6, + } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?); +} + test "PageList grow fit in capacity" { const testing = std.testing; const alloc = testing.allocator;