diff --git a/.github/VOUCHED.td b/.github/VOUCHED.td index 9228a26d5..179b5f222 100644 --- a/.github/VOUCHED.td +++ b/.github/VOUCHED.td @@ -19,6 +19,7 @@ # discussion by the author. Maintainers can denounce users by commenting # "!denounce" or "!denounce [username]" on a discussion. 00-kat +aalhendi abudvytis aindriu80 alanmoyano @@ -37,6 +38,7 @@ brentschroeter charliie-dev chernetskyi craziestowl +curtismoncoq d-dudas daiimus damyanbogoev @@ -70,6 +72,7 @@ khipp kirwiisp kjvdven kloneets +koranir kristina8888 kristofersoler laxystem @@ -84,6 +87,7 @@ mikailmm misairuzame mitchellh miupa +mrmage mtak natesmyth neo773 @@ -99,7 +103,6 @@ piedrahitac pluiedev pouwerkerk priyans-hu -prsweet qwerasd205 reo101 rgehan diff --git a/.github/workflows/release-tag.yml b/.github/workflows/release-tag.yml index d9f73197d..16cfd9dba 100644 --- a/.github/workflows/release-tag.yml +++ b/.github/workflows/release-tag.yml @@ -130,10 +130,20 @@ jobs: GHOSTTY_VERSION: ${{ needs.setup.outputs.version }} GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + - uses: DeterminateSystems/nix-installer-action@main with: determinate: true @@ -174,7 +184,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # Add all our metadata to Info.plist so we can reference it later. - name: Update Info.plist diff --git a/.github/workflows/release-tip.yml b/.github/workflows/release-tip.yml index 3eb4296f7..bccb3b0ed 100644 --- a/.github/workflows/release-tip.yml +++ b/.github/workflows/release-tip.yml @@ -37,6 +37,11 @@ jobs: with: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + path: | + /nix - uses: cachix/install-nix-action@2126ae7fc54c9df00dd18f7f18754393182c73cd # v31.9.1 with: nix_path: nixpkgs=channel:nixos-unstable @@ -219,6 +224,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -226,6 +233,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -263,7 +278,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -462,6 +479,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -469,6 +488,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -506,7 +533,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. @@ -646,6 +675,8 @@ jobs: GHOSTTY_BUILD: ${{ needs.setup.outputs.build }} GHOSTTY_COMMIT: ${{ needs.setup.outputs.commit }} GHOSTTY_COMMIT_LONG: ${{ needs.setup.outputs.commit_long }} + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -653,6 +684,14 @@ jobs: # Important so that build number generation works fetch-depth: 0 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -690,7 +729,9 @@ jobs: - name: Build Ghostty.app run: | cd macos - xcodebuild -target Ghostty -configuration Release + xcodebuild -target Ghostty -configuration Release \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # We inject the "build number" as simply the number of commits since HEAD. # This will be a monotonically always increasing build number that we use. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9058b395..ff085ef81 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -330,10 +330,21 @@ jobs: target: [aarch64-macos, x86_64-macos, aarch64-ios] runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -368,7 +379,7 @@ jobs: uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - name: Setup Cache - uses: namespacelabs/nscloud-cache-action@4d61c33d0b4333a518e975a0c4de7633d28713bb # v1.4.1 + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 with: path: | /nix @@ -590,10 +601,21 @@ jobs: build-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -622,21 +644,38 @@ jobs: # codesigning. IMPORTANT: this must NOT run in a Nix environment. # Nix breaks xcodebuild so this has to be run outside. - name: Build Ghostty.app - run: cd macos && xcodebuild -target Ghostty + run: | + cd macos + xcodebuild -target Ghostty \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES # Build the iOS target without code signing just to verify it works. - name: Build Ghostty iOS run: | cd macos - xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" + xcodebuild -target Ghostty-iOS "CODE_SIGNING_ALLOWED=NO" \ + COMPILATION_CACHE_CAS_PATH=/Users/runner/Library/Developer/Xcode/DerivedData/CompilationCache.noindex \ + COMPILATION_CACHE_KEEP_CAS_DIRECTORY=YES build-macos-freetype: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -899,10 +938,21 @@ jobs: test-macos: runs-on: namespace-profile-ghostty-macos-tahoe needs: test + env: + ZIG_LOCAL_CACHE_DIR: /Users/runner/zig/local-cache + ZIG_GLOBAL_CACHE_DIR: /Users/runner/zig/global-cache steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -1057,6 +1107,14 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Setup Cache + uses: namespacelabs/nscloud-cache-action@a90bb5d4b27522ce881c6e98eebd7d7e6d1653f9 # v1.4.2 + with: + cache: | + xcode + path: | + /Users/runner/zig + # TODO(tahoe): https://github.com/NixOS/nix/issues/13342 - uses: DeterminateSystems/nix-installer-action@main with: @@ -1260,7 +1318,7 @@ jobs: needs: [test, build-dist] steps: - name: Install and configure Namespace CLI - uses: namespacelabs/nscloud-setup@d1c625762f7c926a54bd39252efff0705fd11c64 # v0.0.10 + uses: namespacelabs/nscloud-setup@f378676225212387f1283f4da878712af2c4cd60 # v0.0.11 - name: Configure Namespace powered Buildx uses: namespacelabs/nscloud-setup-buildx-action@f5814dcf37a16cce0624d5bec2ab879654294aa0 # v0.0.22 diff --git a/.github/workflows/vouch-check-issue.yml b/.github/workflows/vouch-check-issue.yml index 60c56fe8f..3fa3bb542 100644 --- a/.github/workflows/vouch-check-issue.yml +++ b/.github/workflows/vouch-check-issue.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/check-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: issue-number: ${{ github.event.issue.number }} auto-close: true diff --git a/.github/workflows/vouch-check-pr.yml b/.github/workflows/vouch-check-pr.yml index aaf9176b3..0efb6208c 100644 --- a/.github/workflows/vouch-check-pr.yml +++ b/.github/workflows/vouch-check-pr.yml @@ -14,7 +14,7 @@ jobs: app-id: ${{ secrets.VOUCH_APP_ID }} private-key: ${{ secrets.VOUCH_APP_PRIVATE_KEY }} - - uses: mitchellh/vouch/action/check-pr@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/check-pr@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: pr-number: ${{ github.event.pull_request.number }} auto-close: true diff --git a/.github/workflows/vouch-manage-by-discussion.yml b/.github/workflows/vouch-manage-by-discussion.yml index 93e7a1343..cf7c092e2 100644 --- a/.github/workflows/vouch-manage-by-discussion.yml +++ b/.github/workflows/vouch-manage-by-discussion.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-discussion@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/manage-by-discussion@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: discussion-number: ${{ github.event.discussion.number }} comment-node-id: ${{ github.event.comment.node_id }} diff --git a/.github/workflows/vouch-manage-by-issue.yml b/.github/workflows/vouch-manage-by-issue.yml index acea8f4fd..6f85520bd 100644 --- a/.github/workflows/vouch-manage-by-issue.yml +++ b/.github/workflows/vouch-manage-by-issue.yml @@ -22,7 +22,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/manage-by-issue@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/manage-by-issue@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: repo: ${{ github.repository }} issue-id: ${{ github.event.issue.number }} diff --git a/.github/workflows/vouch-sync-codeowners.yml b/.github/workflows/vouch-sync-codeowners.yml index fe1977a66..fac06a372 100644 --- a/.github/workflows/vouch-sync-codeowners.yml +++ b/.github/workflows/vouch-sync-codeowners.yml @@ -23,7 +23,7 @@ jobs: with: token: ${{ steps.app-token.outputs.token }} - - uses: mitchellh/vouch/action/sync-codeowners@daa39f90448eb4054ad334d7f53959a7efb5005b # v1.4.0 + - uses: mitchellh/vouch/action/sync-codeowners@c6d80ead49839655b61b422700b7a3bc9d0804a9 # v1.4.2 with: repo: ${{ github.repository }} pull-request: "true" diff --git a/flake.nix b/flake.nix index d892dbd2f..e063f2d70 100644 --- a/flake.nix +++ b/flake.nix @@ -80,6 +80,7 @@ packageOverrides = pyfinal: pyprev: { blessed = pyfinal.callPackage ./nix/pkgs/blessed.nix {}; ucs-detect = pyfinal.callPackage ./nix/pkgs/ucs-detect.nix {}; + wcwidth = pyfinal.callPackage ./nix/pkgs/wcwidth.nix {}; }; }; }; diff --git a/macos/AGENTS.md b/macos/AGENTS.md new file mode 100644 index 000000000..6321808b8 --- /dev/null +++ b/macos/AGENTS.md @@ -0,0 +1,3 @@ +# macOS Ghostty Application + +- Use `swiftlint` for formatting and linting Swift code. diff --git a/macos/Ghostty.xcodeproj/project.pbxproj b/macos/Ghostty.xcodeproj/project.pbxproj index 49d8132e8..d34dfa257 100644 --- a/macos/Ghostty.xcodeproj/project.pbxproj +++ b/macos/Ghostty.xcodeproj/project.pbxproj @@ -190,6 +190,7 @@ Helpers/Private/CGS.swift, Helpers/Private/Dock.swift, Helpers/TabGroupCloseCoordinator.swift, + Helpers/TabTitleEditor.swift, Helpers/VibrantLayer.m, Helpers/Weak.swift, ); diff --git a/macos/Sources/Features/Terminal/BaseTerminalController.swift b/macos/Sources/Features/Terminal/BaseTerminalController.swift index 1cff80c52..9f65d35ce 100644 --- a/macos/Sources/Features/Terminal/BaseTerminalController.swift +++ b/macos/Sources/Features/Terminal/BaseTerminalController.swift @@ -1265,6 +1265,17 @@ class BaseTerminalController: NSWindowController, } @IBAction func changeTabTitle(_ sender: Any) { + if let targetWindow = window { + let inlineHostWindow = + targetWindow.tabbedWindows? + .first(where: { $0.tabBarView != nil }) as? TerminalWindow + ?? (targetWindow as? TerminalWindow) + + if let inlineHostWindow, inlineHostWindow.beginInlineTabTitleEdit(for: targetWindow) { + return + } + } + promptTabTitle() } diff --git a/macos/Sources/Features/Terminal/TerminalViewContainer.swift b/macos/Sources/Features/Terminal/TerminalViewContainer.swift index c65dca1d2..ef4aff5b9 100644 --- a/macos/Sources/Features/Terminal/TerminalViewContainer.swift +++ b/macos/Sources/Features/Terminal/TerminalViewContainer.swift @@ -6,9 +6,8 @@ import SwiftUI class TerminalViewContainer: NSView { private let terminalView: NSView - /// Glass effect view for liquid glass background when transparency is enabled + /// Combined glass effect and inactive tint overlay view private var glassEffectView: NSView? - private var glassTopConstraint: NSLayoutConstraint? private var derivedConfig: DerivedConfig init(ghostty: Ghostty.App, viewModel: ViewModel, delegate: (any TerminalViewDelegate)? = nil) { @@ -27,6 +26,10 @@ class TerminalViewContainer: NSView { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + /// To make ``TerminalController/DefaultSize/contentIntrinsicSize`` /// work in ``TerminalController/windowDidLoad()``, /// we override this to provide the correct size. @@ -50,6 +53,20 @@ class TerminalViewContainer: NSView { name: .ghosttyConfigDidChange, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidBecomeKey(_:)), + name: NSWindow.didBecomeKeyNotification, + object: nil + ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(windowDidResignKey(_:)), + name: NSWindow.didResignKeyNotification, + object: nil + ) } override func viewDidMoveToWindow() { @@ -72,36 +89,139 @@ class TerminalViewContainer: NSView { derivedConfig = newValue DispatchQueue.main.async(execute: updateGlassEffectIfNeeded) } + + @objc private func windowDidBecomeKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + updateGlassTintOverlay(isKeyWindow: true) + } + + @objc private func windowDidResignKey(_ notification: Notification) { + guard let window = notification.object as? NSWindow, + window == self.window else { return } + updateGlassTintOverlay(isKeyWindow: false) + } } // MARK: Glass +/// An `NSView` that contains a liquid glass background effect and +/// an inactive-window tint overlay. +#if compiler(>=6.2) +@available(macOS 26.0, *) +private class TerminalGlassView: NSView { + private let glassEffectView: NSGlassEffectView + private var glassTopConstraint: NSLayoutConstraint? + private let tintOverlay: NSView + private var tintTopConstraint: NSLayoutConstraint? + + init(topOffset: CGFloat) { + self.glassEffectView = NSGlassEffectView() + self.tintOverlay = NSView() + super.init(frame: .zero) + + translatesAutoresizingMaskIntoConstraints = false + + // Glass effect view fills this view. + glassEffectView.translatesAutoresizingMaskIntoConstraints = false + addSubview(glassEffectView) + glassTopConstraint = glassEffectView.topAnchor.constraint( + equalTo: topAnchor, + constant: topOffset + ) + if let glassTopConstraint { + NSLayoutConstraint.activate([ + glassTopConstraint, + glassEffectView.leadingAnchor.constraint(equalTo: leadingAnchor), + glassEffectView.bottomAnchor.constraint(equalTo: bottomAnchor), + glassEffectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + + // Tint overlay sits above the glass effect. + tintOverlay.translatesAutoresizingMaskIntoConstraints = false + tintOverlay.wantsLayer = true + tintOverlay.alphaValue = 0 + addSubview(tintOverlay, positioned: .above, relativeTo: glassEffectView) + tintTopConstraint = tintOverlay.topAnchor.constraint( + equalTo: topAnchor, + constant: topOffset + ) + if let tintTopConstraint { + NSLayoutConstraint.activate([ + tintTopConstraint, + tintOverlay.leadingAnchor.constraint(equalTo: leadingAnchor), + tintOverlay.bottomAnchor.constraint(equalTo: bottomAnchor), + tintOverlay.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Configures the glass effect style, tint color, corner radius, and + /// updates the inactive tint overlay based on window key status. + func configure( + style: NSGlassEffectView.Style, + backgroundColor: NSColor, + backgroundOpacity: Double, + cornerRadius: CGFloat?, + isKeyWindow: Bool + ) { + glassEffectView.style = style + glassEffectView.tintColor = backgroundColor.withAlphaComponent(backgroundOpacity) + if let cornerRadius { + glassEffectView.cornerRadius = cornerRadius + } + updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) + } + + /// Updates the top inset offset for both the glass effect and tint overlay. + /// Call this when the safe area insets change (e.g., during layout). + func updateTopInset(_ offset: CGFloat) { + glassTopConstraint?.constant = offset + tintTopConstraint?.constant = offset + } + + /// Updates the tint overlay visibility based on window key status. + func updateKeyStatus(_ isKeyWindow: Bool, backgroundColor: NSColor) { + let tint = tintProperties(for: backgroundColor) + tintOverlay.layer?.backgroundColor = tint.color.cgColor + tintOverlay.alphaValue = isKeyWindow ? 0 : tint.opacity + } + + /// Computes a saturation-boosted tint color and opacity for the inactive overlay. + private func tintProperties(for color: NSColor) -> (color: NSColor, opacity: CGFloat) { + let isLight = color.isLightColor + let vibrant = color.adjustingSaturation(by: 1.2) + let overlayOpacity: CGFloat = isLight ? 0.35 : 0.85 + return (vibrant, overlayOpacity) + } +} +#endif // compiler(>=6.2) + private extension TerminalViewContainer { #if compiler(>=6.2) @available(macOS 26.0, *) - func addGlassEffectViewIfNeeded() -> NSGlassEffectView? { - if let existed = glassEffectView as? NSGlassEffectView { + func addGlassEffectViewIfNeeded() -> TerminalGlassView? { + if let existed = glassEffectView as? TerminalGlassView { updateGlassEffectTopInsetIfNeeded() return existed } guard let themeFrameView = window?.contentView?.superview else { return nil } - let effectView = NSGlassEffectView() + let effectView = TerminalGlassView(topOffset: -themeFrameView.safeAreaInsets.top) addSubview(effectView, positioned: .below, relativeTo: terminalView) - effectView.translatesAutoresizingMaskIntoConstraints = false - glassTopConstraint = effectView.topAnchor.constraint( - equalTo: topAnchor, - constant: -themeFrameView.safeAreaInsets.top - ) - if let glassTopConstraint { - NSLayoutConstraint.activate([ - glassTopConstraint, - effectView.leadingAnchor.constraint(equalTo: leadingAnchor), - effectView.bottomAnchor.constraint(equalTo: bottomAnchor), - effectView.trailingAnchor.constraint(equalTo: trailingAnchor), - ]) - } + NSLayoutConstraint.activate([ + effectView.topAnchor.constraint(equalTo: topAnchor), + effectView.leadingAnchor.constraint(equalTo: leadingAnchor), + effectView.bottomAnchor.constraint(equalTo: bottomAnchor), + effectView.trailingAnchor.constraint(equalTo: trailingAnchor), + ]) glassEffectView = effectView return effectView } @@ -112,26 +232,35 @@ private extension TerminalViewContainer { guard #available(macOS 26.0, *), derivedConfig.backgroundBlur.isGlassStyle else { glassEffectView?.removeFromSuperview() glassEffectView = nil - glassTopConstraint = nil return } guard let effectView = addGlassEffectViewIfNeeded() else { return } + + let style: NSGlassEffectView.Style switch derivedConfig.backgroundBlur { case .macosGlassRegular: - effectView.style = NSGlassEffectView.Style.regular + style = NSGlassEffectView.Style.regular case .macosGlassClear: - effectView.style = NSGlassEffectView.Style.clear + style = NSGlassEffectView.Style.clear default: - break + style = NSGlassEffectView.Style.regular } let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) - effectView.tintColor = backgroundColor - .withAlphaComponent(derivedConfig.backgroundOpacity) - if let window, window.responds(to: Selector(("_cornerRadius"))), let cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat { - effectView.cornerRadius = cornerRadius + + var cornerRadius: CGFloat? + if let window, window.responds(to: Selector(("_cornerRadius"))) { + cornerRadius = window.value(forKey: "_cornerRadius") as? CGFloat } + + effectView.configure( + style: style, + backgroundColor: backgroundColor, + backgroundOpacity: derivedConfig.backgroundOpacity, + cornerRadius: cornerRadius, + isKeyWindow: window?.isKeyWindow ?? true + ) #endif // compiler(>=6.2) } @@ -142,7 +271,16 @@ private extension TerminalViewContainer { } guard glassEffectView != nil else { return } guard let themeFrameView = window?.contentView?.superview else { return } - glassTopConstraint?.constant = -themeFrameView.safeAreaInsets.top + (glassEffectView as? TerminalGlassView)?.updateTopInset(-themeFrameView.safeAreaInsets.top) +#endif // compiler(>=6.2) + } + + func updateGlassTintOverlay(isKeyWindow: Bool) { +#if compiler(>=6.2) + guard #available(macOS 26.0, *) else { return } + guard glassEffectView != nil else { return } + let backgroundColor = (window as? TerminalWindow)?.preferredBackgroundColor ?? NSColor(derivedConfig.backgroundColor) + (glassEffectView as? TerminalGlassView)?.updateKeyStatus(isKeyWindow, backgroundColor: backgroundColor) #endif // compiler(>=6.2) } diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index cde8d2747..62835e286 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -37,6 +37,12 @@ class TerminalWindow: NSWindow { /// Sets up our tab context menu private var tabMenuObserver: NSObjectProtocol? + /// Handles inline tab title editing for this host window. + private lazy var tabTitleEditor = TabTitleEditor( + hostWindow: self, + delegate: self + ) + /// Whether this window supports the update accessory. If this is false, then views within this /// window should determine how to show update notifications. var supportsUpdateAccessory: Bool { @@ -174,7 +180,16 @@ class TerminalWindow: NSWindow { override var canBecomeKey: Bool { return true } override var canBecomeMain: Bool { return true } + override func sendEvent(_ event: NSEvent) { + if tabTitleEditor.handleDoubleClick(event) { + return + } + + super.sendEvent(event) + } + override func close() { + tabTitleEditor.finishEditing(commit: true) NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self) super.close() } @@ -207,6 +222,21 @@ class TerminalWindow: NSWindow { viewModel.isMainWindow = false } + @discardableResult + func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool { + tabTitleEditor.beginEditing(for: targetWindow) + } + + @objc private func renameTabFromContextMenu(_ sender: NSMenuItem) { + let targetWindow = sender.representedObject as? NSWindow ?? self + if beginInlineTabTitleEdit(for: targetWindow) { + return + } + + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.promptTabTitle() + } + override func mergeAllWindows(_ sender: Any?) { super.mergeAllWindows(sender) @@ -731,10 +761,11 @@ extension TerminalWindow { separator.identifier = Self.tabColorSeparatorIdentifier menu.addItem(separator) - // Change Title... - let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "") + // Rename Tab... + let changeTitleItem = NSMenuItem(title: "Rename Tab...", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "") changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier - changeTitleItem.target = target + changeTitleItem.target = self + changeTitleItem.representedObject = target?.window changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line") menu.addItem(changeTitleItem) @@ -760,3 +791,42 @@ private func makeTabColorPaletteView( hostingView.frame.size = hostingView.intrinsicContentSize return hostingView } + +// MARK: - Inline Tab Title Editing + +extension TerminalWindow: TabTitleEditorDelegate { + func tabTitleEditor( + _ editor: TabTitleEditor, + canRenameTabFor targetWindow: NSWindow + ) -> Bool { + targetWindow.windowController is BaseTerminalController + } + + func tabTitleEditor( + _ editor: TabTitleEditor, + titleFor targetWindow: NSWindow + ) -> String { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { + return targetWindow.title + } + + return targetController.titleOverride ?? targetWindow.title + } + + func tabTitleEditor( + _ editor: TabTitleEditor, + didCommitTitle editedTitle: String, + for targetWindow: NSWindow + ) { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle + } + + func tabTitleEditor( + _ editor: TabTitleEditor, + performFallbackRenameFor targetWindow: NSWindow + ) { + guard let targetController = targetWindow.windowController as? BaseTerminalController else { return } + targetController.promptTabTitle() + } +} diff --git a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift index ff751df10..a8555e938 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceGrabHandle.swift @@ -1,41 +1,37 @@ -import AppKit import SwiftUI extension Ghostty { /// A grab handle overlay at the top of the surface for dragging the window. - /// Only appears when hovering in the top region of the surface. struct SurfaceGrabHandle: View { - private let handleHeight: CGFloat = 10 - - let surfaceView: SurfaceView + @ObservedObject var surfaceView: SurfaceView @State private var isHovering: Bool = false @State private var isDragging: Bool = false - var body: some View { - VStack(spacing: 0) { - Rectangle() - .fill(Color.primary.opacity(isHovering || isDragging ? 0.15 : 0)) - .frame(height: handleHeight) - .overlay(alignment: .center) { - if isHovering || isDragging { - Image(systemName: "ellipsis") - .font(.system(size: 14, weight: .semibold)) - .foregroundColor(.primary.opacity(0.5)) - } - } - .contentShape(Rectangle()) - .overlay { - SurfaceDragSource( - surfaceView: surfaceView, - isDragging: $isDragging, - isHovering: $isHovering - ) - } + private var ellipsisVisible: Bool { + surfaceView.mouseOverSurface && surfaceView.cursorVisible + } - Spacer() + 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) + } } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } } } diff --git a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift index 6b3bfbfb4..e45480a20 100644 --- a/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift +++ b/macos/Sources/Ghostty/Surface View/SurfaceView_AppKit.swift @@ -116,6 +116,12 @@ extension Ghostty { // Whether the pointer should be visible or not @Published private(set) var pointerStyle: CursorStyle = .horizontalText + // Whether the mouse is currently over this surface + @Published private(set) var mouseOverSurface: Bool = false + + // Whether the cursor is currently visible (not hidden by typing, etc.) + @Published private(set) var cursorVisible: Bool = true + /// The configuration derived from the Ghostty config so we don't need to rely on references. @Published private(set) var derivedConfig: DerivedConfig @@ -533,6 +539,7 @@ extension Ghostty { } func setCursorVisibility(_ visible: Bool) { + cursorVisible = visible // Technically this action could be called anytime we want to // change the mouse visibility but at the time of writing this // mouse-hide-while-typing is the only use case so this is the @@ -910,6 +917,7 @@ extension Ghostty { } override func mouseEntered(with event: NSEvent) { + mouseOverSurface = true super.mouseEntered(with: event) guard let surfaceModel else { return } @@ -928,6 +936,7 @@ extension Ghostty { } override func mouseExited(with event: NSEvent) { + mouseOverSurface = false guard let surfaceModel else { return } // If the mouse is being dragged then we don't have to emit diff --git a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift index 63cf02ed4..ed2177325 100644 --- a/macos/Sources/Helpers/Extensions/NSColor+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSColor+Extension.swift @@ -24,6 +24,14 @@ extension NSColor { appleColorList?.allKeys.map { $0.lowercased() } ?? [] } + /// Returns a new color with its saturation multiplied by the given factor, clamped to [0, 1]. + func adjustingSaturation(by factor: CGFloat) -> NSColor { + var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0 + let hsbColor = self.usingColorSpace(.sRGB) ?? self + hsbColor.getHue(&h, saturation: &s, brightness: &b, alpha: &a) + return NSColor(hue: h, saturation: min(max(s * factor, 0), 1), brightness: b, alpha: a) + } + /// Calculates the perceptual distance to another color in RGB space. func distance(to other: NSColor) -> Double { guard let a = self.usingColorSpace(.sRGB), diff --git a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift index 0fa330f1b..ee941e3ac 100644 --- a/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift +++ b/macos/Sources/Helpers/Extensions/NSWindow+Extension.swift @@ -58,25 +58,33 @@ extension NSWindow { titlebarView?.firstDescendant(withClassName: "NSTabBar") } - /// Returns the index of the tab button at the given screen point, if any. - func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + /// Returns tab button views in visual order from left to right. + func tabButtonsInVisualOrder() -> [NSView] { + guard let tabBarView else { return [] } + return tabBarView + .descendants(withClassName: "NSTabButton") + .sorted { $0.frame.minX < $1.frame.minX } + } + + /// 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 tabBarView.bounds.contains(locationInTabBar) else { return nil } - // Find all tab buttons and sort by x position to get visual order. - // The view hierarchy order doesn't match the visual tab order. - let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton") - .sorted { $0.frame.origin.x < $1.frame.origin.x } - - for (index, tabItemView) in tabItemViews.enumerated() { - let locationInTab = tabItemView.convert(locationInWindow, from: nil) - if tabItemView.bounds.contains(locationInTab) { - return index + for (index, tabButton) in tabButtonsInVisualOrder().enumerated() { + let locationInTabButton = tabButton.convert(locationInWindow, from: nil) + if tabButton.bounds.contains(locationInTabButton) { + return (index, tabButton) } } return nil } + + /// Returns the index of the tab button at the given screen point, if any. + func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? { + tabButtonHit(atScreenPoint: screenPoint)?.index + } } diff --git a/macos/Sources/Helpers/TabTitleEditor.swift b/macos/Sources/Helpers/TabTitleEditor.swift new file mode 100644 index 000000000..c1784112e --- /dev/null +++ b/macos/Sources/Helpers/TabTitleEditor.swift @@ -0,0 +1,336 @@ +import AppKit + +/// Delegate used by ``TabTitleEditor`` to resolve tab-specific behavior. +protocol TabTitleEditorDelegate: AnyObject { + /// Returns whether inline rename should be allowed for the given tab window. + func tabTitleEditor( + _ editor: TabTitleEditor, + canRenameTabFor targetWindow: NSWindow + ) -> Bool + + /// Returns the current title value to seed into the inline editor. + func tabTitleEditor( + _ editor: TabTitleEditor, + titleFor targetWindow: NSWindow + ) -> String + + /// Called when inline editing commits a title for a target tab window. + func tabTitleEditor( + _ editor: TabTitleEditor, + didCommitTitle editedTitle: String, + for targetWindow: NSWindow + ) + + /// Called when inline editing could not start and the host should show a fallback flow. + func tabTitleEditor( + _ editor: TabTitleEditor, + performFallbackRenameFor targetWindow: NSWindow + ) +} + +/// Handles inline tab title editing for native AppKit window tabs. +final class TabTitleEditor: NSObject, NSTextFieldDelegate { + /// Host window containing the tab bar where editing occurs. + private weak var hostWindow: NSWindow? + /// Delegate that provides and commits title data for target tab windows. + private weak var delegate: TabTitleEditorDelegate? + + /// Active inline editor view, if editing is in progress. + private weak var inlineTitleEditor: NSTextField? + /// Tab window currently being edited. + private weak var inlineTitleTargetWindow: NSWindow? + /// Original hidden state for title labels that are temporarily hidden while editing. + private var hiddenLabels: [(label: NSTextField, wasHidden: Bool)] = [] + /// Original button title state restored once editing finishes. + private var buttonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)? + /// Deferred begin-editing work used to avoid visual flicker on double-click. + private var pendingEditWorkItem: DispatchWorkItem? + + /// Creates a coordinator bound to a host window and rename delegate. + init(hostWindow: NSWindow, delegate: TabTitleEditorDelegate) { + self.hostWindow = hostWindow + self.delegate = delegate + } + + /// Handles double-click events from the host window and begins inline edit if possible. If this + /// returns true then the double click was handled by the coordinator. + func handleDoubleClick(_ event: NSEvent) -> Bool { + // We only want double-clicks + guard event.type == .leftMouseDown, event.clickCount == 2 else { return false } + + // If we don't have a host window to look up the click, we do nothing. + guard let hostWindow else { return false } + + // Find the tab window that is being clicked. + let locationInScreen = hostWindow.convertPoint(toScreen: event.locationInWindow) + guard let tabIndex = hostWindow.tabIndex(atScreenPoint: locationInScreen), + let targetWindow = hostWindow.tabbedWindows?[safe: tabIndex], + delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true + else { return false } + + // We need to start editing in a separate event loop tick, so set that up. + pendingEditWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self, weak targetWindow] in + guard let self, let targetWindow else { return } + if self.beginEditing(for: targetWindow) { + return + } + + // Inline editing failed, so trigger fallback rename whatever it is. + self.delegate?.tabTitleEditor(self, performFallbackRenameFor: targetWindow) + } + + pendingEditWorkItem = workItem + DispatchQueue.main.async(execute: workItem) + return true + } + + /// Begins editing the given target tab window title. Returns true if we're able to start the + /// inline edit. + @discardableResult + func beginEditing(for targetWindow: NSWindow) -> Bool { + // Resolve the visual tab button for the target tab window. We rely on visual order + // since native tab view hierarchy order does not necessarily match what is on screen. + guard let hostWindow, + let tabbedWindows = hostWindow.tabbedWindows, + let tabIndex = tabbedWindows.firstIndex(of: targetWindow), + let tabButton = hostWindow.tabButtonsInVisualOrder()[safe: tabIndex], + delegate?.tabTitleEditor(self, canRenameTabFor: targetWindow) == true + else { return false } + + // If we have a pending edit, we need to cancel it because we got + // called to start edit explicitly. + pendingEditWorkItem?.cancel() + pendingEditWorkItem = nil + finishEditing(commit: true) + + // Build the editor using title text and style derived from the tab's existing label. + let titleLabels = tabButton + .descendants(withClassName: "NSTextField") + .compactMap { $0 as? NSTextField } + let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title + let sourceLabel = sourceTabTitleLabel(from: titleLabels, matching: editedTitle) + let editorFrame = tabTitleEditorFrame(for: tabButton, sourceLabel: sourceLabel) + guard editorFrame.width >= 20, editorFrame.height >= 14 else { return false } + + let editor = NSTextField(frame: editorFrame) + editor.delegate = self + editor.stringValue = editedTitle + editor.alignment = sourceLabel?.alignment ?? .center + editor.isBordered = false + editor.isBezeled = false + editor.drawsBackground = false + editor.focusRingType = .none + editor.lineBreakMode = .byClipping + if let editorCell = editor.cell as? NSTextFieldCell { + editorCell.wraps = false + editorCell.usesSingleLineMode = true + editorCell.isScrollable = true + } + if let sourceLabel { + applyTextStyle(to: editor, from: sourceLabel, title: editedTitle) + } + + // Hide it until the tab button has finished layout so we can avoid flicker. + editor.isHidden = true + + inlineTitleEditor = editor + inlineTitleTargetWindow = targetWindow + + // Temporarily hide native title label views while editing so only the text field is visible. + CATransaction.begin() + CATransaction.setDisableActions(true) + hiddenLabels = titleLabels.map { ($0, $0.isHidden) } + for label in titleLabels { + label.isHidden = true + } + if let tabButton = tabButton as? NSButton { + buttonState = (tabButton, tabButton.title, tabButton.attributedTitle) + tabButton.title = "" + tabButton.attributedTitle = NSAttributedString(string: "") + } else { + buttonState = nil + } + tabButton.layoutSubtreeIfNeeded() + tabButton.displayIfNeeded() + tabButton.addSubview(editor) + CATransaction.commit() + + // 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 } + editor.isHidden = false + hostWindow.makeFirstResponder(editor) + if let fieldEditor = editor.currentEditor() as? NSTextView, + let editorFont = editor.font { + fieldEditor.font = editorFont + var typingAttributes = fieldEditor.typingAttributes + typingAttributes[.font] = editorFont + fieldEditor.typingAttributes = typingAttributes + } + editor.currentEditor()?.selectAll(nil) + } + + return true + } + + /// Finishes any in-flight inline edit and optionally commits the edited title. + func finishEditing(commit: Bool) { + // If we're pending starting a new edit, cancel it. + pendingEditWorkItem?.cancel() + pendingEditWorkItem = nil + + // To finish editing we need a current editor. + guard let editor = inlineTitleEditor else { return } + let editedTitle = editor.stringValue + let targetWindow = inlineTitleTargetWindow + + // Clear coordinator references first so re-entrant paths don't see stale state. + editor.delegate = nil + inlineTitleEditor = nil + 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) + } + } + + editor.removeFromSuperview() + + // Restore original tab title presentation. + for (label, wasHidden) in hiddenLabels { + label.isHidden = wasHidden + } + hiddenLabels.removeAll() + + if let buttonState { + buttonState.button.title = buttonState.title + buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title) + } + self.buttonState = nil + + // Delegate owns title persistence semantics (including empty-title handling). + guard commit, let targetWindow else { return } + delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow) + } + + /// Chooses an editor frame that aligns with the tab title within the tab button. + private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect { + let bounds = tabButton.bounds + let horizontalInset: CGFloat = 6 + var frame = bounds.insetBy(dx: horizontalInset, dy: 0) + + if let sourceLabel { + let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel) + frame.origin.y = labelFrame.minY + frame.size.height = labelFrame.height + } + + return frame.integral + } + + /// Selects the best title label candidate from private tab button subviews. + private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? { + let expected = title.trimmingCharacters(in: .whitespacesAndNewlines) + if !expected.isEmpty { + // Prefer a visible exact title match when we can find one. + if let exactVisible = labels.first(where: { + !$0.isHidden && + $0.alphaValue > 0.01 && + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactVisible + } + + // Fall back to any exact match, including hidden labels. + if let exactAny = labels.first(where: { + $0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected + }) { + return exactAny + } + } + + // Otherwise heuristically choose the largest visible, centered label first. + let visibleNonEmpty = labels.filter { + !$0.isHidden && + $0.alphaValue > 0.01 && + !$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + } + + if let centeredVisible = visibleNonEmpty + .filter({ $0.alignment == .center }) + .max(by: { $0.bounds.width < $1.bounds.width }) { + return centeredVisible + } + + if let visible = visibleNonEmpty.max(by: { $0.bounds.width < $1.bounds.width }) { + return visible + } + + return labels.max(by: { $0.bounds.width < $1.bounds.width }) + } + + /// Copies text styling from the source tab label onto the inline editor. + private func applyTextStyle(to editor: NSTextField, from label: NSTextField, title: String) { + var attributes: [NSAttributedString.Key: Any] = [:] + if label.attributedStringValue.length > 0 { + attributes = label.attributedStringValue.attributes(at: 0, effectiveRange: nil) + } + + if attributes[.font] == nil, let font = label.font { + attributes[.font] = font + } + + if attributes[.foregroundColor] == nil { + attributes[.foregroundColor] = label.textColor + } + + if let font = attributes[.font] as? NSFont { + editor.font = font + } + + if let textColor = attributes[.foregroundColor] as? NSColor { + editor.textColor = textColor + } + + if !attributes.isEmpty { + editor.attributedStringValue = NSAttributedString(string: title, attributes: attributes) + } else { + editor.stringValue = title + } + } + + // MARK: NSTextFieldDelegate + + func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + guard control === inlineTitleEditor else { return false } + + // Enter commits and exits inline edit. + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + finishEditing(commit: true) + return true + } + + // Escape cancels and restores the previous tab title. + if commandSelector == #selector(NSResponder.cancelOperation(_:)) { + finishEditing(commit: false) + return true + } + + return false + } + + func controlTextDidEndEditing(_ obj: Notification) { + guard let inlineTitleEditor, + let finishedEditor = obj.object as? NSTextField, + finishedEditor === inlineTitleEditor + else { return } + + // Blur/end-edit commits, matching standard NSTextField behavior. + finishEditing(commit: true) + } +} diff --git a/nix/pkgs/blessed.nix b/nix/pkgs/blessed.nix index 8b6728f43..a015e70b6 100644 --- a/nix/pkgs/blessed.nix +++ b/nix/pkgs/blessed.nix @@ -1,22 +1,24 @@ { lib, buildPythonPackage, - fetchPypi, + fetchFromGitHub, pythonOlder, flit-core, six, wcwidth, }: -buildPythonPackage rec { +buildPythonPackage { pname = "blessed"; - version = "1.23.0"; + version = "unstable-2026-02-23"; pyproject = true; - disabled = pythonOlder "3.7"; + disabled = pythonOlder "3.8"; - src = fetchPypi { - inherit pname version; - hash = "sha256-VlkaMpZvcE9hMfFACvQVHZ6PX0FEEzpcoDQBl2Pe53s="; + src = fetchFromGitHub { + owner = "jquast"; + repo = "blessed"; + rev = "master"; + hash = "sha256-ROd/O9pfqnF5DHXqoz+tkl1jQJSZad3Ta1h+oC3+gvY="; }; build-system = [flit-core]; @@ -27,6 +29,7 @@ buildPythonPackage rec { ]; doCheck = false; + dontCheckRuntimeDeps = true; meta = with lib; { homepage = "https://github.com/jquast/blessed"; diff --git a/nix/pkgs/ucs-detect.nix b/nix/pkgs/ucs-detect.nix index 07ec6c2fc..73721b62a 100644 --- a/nix/pkgs/ucs-detect.nix +++ b/nix/pkgs/ucs-detect.nix @@ -1,36 +1,42 @@ { lib, buildPythonPackage, - fetchPypi, + fetchFromGitHub, pythonOlder, - setuptools, + hatchling, # Dependencies blessed, wcwidth, pyyaml, + prettytable, + requests, }: -buildPythonPackage rec { +buildPythonPackage { pname = "ucs-detect"; - version = "1.0.8"; + version = "unstable-2026-02-23"; pyproject = true; disabled = pythonOlder "3.8"; - src = fetchPypi { - inherit version; - pname = "ucs_detect"; - hash = "sha256-ihB+tZCd6ykdeXYxc6V1Q6xALQ+xdCW5yqSL7oppqJc="; + src = fetchFromGitHub { + owner = "jquast"; + repo = "ucs-detect"; + rev = "master"; + hash = "sha256-x7BD14n1/mP9bzjM6DPqc5R1Fk/HLLycl4o41KV+xAE="; }; dependencies = [ blessed wcwidth pyyaml + prettytable + requests ]; - nativeBuildInputs = [setuptools]; + nativeBuildInputs = [hatchling]; doCheck = false; + dontCheckRuntimeDeps = true; meta = with lib; { description = "Measures number of Terminal column cells of wide-character codes"; diff --git a/nix/pkgs/wcwidth.nix b/nix/pkgs/wcwidth.nix new file mode 100644 index 000000000..4bbd1373b --- /dev/null +++ b/nix/pkgs/wcwidth.nix @@ -0,0 +1,27 @@ +{ + lib, + buildPythonPackage, + fetchPypi, + hatchling, +}: +buildPythonPackage rec { + pname = "wcwidth"; + version = "0.6.0"; + pyproject = true; + + src = fetchPypi { + inherit pname version; + hash = "sha256-zcTkJi1u+aGlfgGDhMvrEgjYq7xkF2An4sJFXIExMVk="; + }; + + build-system = [hatchling]; + + doCheck = false; + + meta = with lib; { + description = "Measures the displayed width of unicode strings in a terminal"; + homepage = "https://github.com/jquast/wcwidth"; + license = licenses.mit; + maintainers = []; + }; +} diff --git a/po/mk_MK.UTF-8.po b/po/mk_MK.UTF-8.po index 539283271..0307fff95 100644 --- a/po/mk_MK.UTF-8.po +++ b/po/mk_MK.UTF-8.po @@ -19,7 +19,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Отвори во Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -179,7 +179,7 @@ msgstr "Јазиче" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Промени наслов на јазиче…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -336,7 +336,7 @@ msgstr "Промени наслов на терминал" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Промени наслов на јазиче" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/nl_NL.UTF-8.po b/po/nl_NL.UTF-8.po index 9caee41a4..1d3e1014c 100644 --- a/po/nl_NL.UTF-8.po +++ b/po/nl_NL.UTF-8.po @@ -9,7 +9,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-09 20:39+0100\n" +"PO-Revision-Date: 2026-02-18 20:59+0100\n" "Last-Translator: Nico Geesink \n" "Language-Team: Dutch \n" "Language: nl\n" @@ -20,7 +20,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "Open in Ghostty" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 @@ -180,7 +180,7 @@ msgstr "Tabblad" #: src/apprt/gtk/ui/1.2/surface.blp:325 src/apprt/gtk/ui/1.5/window.blp:224 #: src/apprt/gtk/ui/1.5/window.blp:320 msgid "Change Tab Title…" -msgstr "" +msgstr "Wijzig tabbladtitel…" #: src/apprt/gtk/ui/1.2/surface.blp:330 src/apprt/gtk/ui/1.5/window.blp:57 #: src/apprt/gtk/ui/1.5/window.blp:107 src/apprt/gtk/ui/1.5/window.blp:229 @@ -337,7 +337,7 @@ msgstr "Titel van de terminal wijzigen" #: src/apprt/gtk/class/title_dialog.zig:226 msgid "Change Tab Title" -msgstr "" +msgstr "Wijzig tabbladtitel" #: src/apprt/gtk/class/window.zig:1007 msgid "Reloaded the configuration" diff --git a/po/zh_TW.UTF-8.po b/po/zh_TW.UTF-8.po index 25dacd566..cacdc8acb 100644 --- a/po/zh_TW.UTF-8.po +++ b/po/zh_TW.UTF-8.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: com.mitchellh.ghostty\n" "Report-Msgid-Bugs-To: m@mitchellh.com\n" "POT-Creation-Date: 2026-02-17 23:16+0100\n" -"PO-Revision-Date: 2026-02-10 15:32+0800\n" +"PO-Revision-Date: 2026-02-18 13:58+0800\n" "Last-Translator: Yi-Jyun Pan \n" "Language-Team: Chinese (traditional)\n" "Language: zh_TW\n" @@ -18,7 +18,7 @@ msgstr "" #: dist/linux/ghostty_nautilus.py:53 msgid "Open in Ghostty" -msgstr "" +msgstr "在 Ghostty 中開啟" #: src/apprt/gtk/ui/1.0/clipboard-confirmation-dialog.blp:12 #: src/apprt/gtk/ui/1.4/clipboard-confirmation-dialog.blp:12 diff --git a/src/build/uucode_config.zig b/src/build/uucode_config.zig index d39d4d1e1..2bb0d4508 100644 --- a/src/build/uucode_config.zig +++ b/src/build/uucode_config.zig @@ -100,6 +100,7 @@ pub const tables = [_]config.Table{ }, .fields = &.{ width.field("width"), + wcwidth.field("wcwidth_zero_in_grapheme"), grapheme_break_no_control.field("grapheme_break_no_control"), is_symbol.field("is_symbol"), d.field("is_emoji_vs_base"), diff --git a/src/config/Config.zig b/src/config/Config.zig index 9b0e6cc0f..0a6ca9c9f 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -2946,6 +2946,20 @@ keybind: Keybinds = .{}, /// /// * `vec4 iPreviousCursorColor` - Color of the previous terminal cursor. /// +/// * `vec4 iCurrentCursorStyle` - Style of the terminal cursor +/// +/// Macros simplified use are defined for the various cursor styles: +/// +/// - `CURSORSTYLE_BLOCK` or `0` +/// - `CURSORSTYLE_BLOCK_HOLLOW` or `1` +/// - `CURSORSTYLE_BAR` or `2` +/// - `CURSORSTYLE_UNDERLINE` or `3` +/// - `CURSORSTYLE_LOCK` or `4` +/// +/// * `vec4 iPreviousCursorStyle` - Style of the previous terminal cursor +/// +/// * `vec4 iCursorVisible` - Visibility of the terminal cursor. +/// /// * `float iTimeCursorChange` - Timestamp of terminal cursor change. /// /// When the terminal cursor changes position or color, this is set to diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index ab3c6aaab..5a8a6ccbf 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1895,7 +1895,7 @@ test "shape Bengali ligatures with out of order vowels" { try testing.expectEqual(@as(u16, 0), cells[0].x); try testing.expectEqual(@as(u16, 0), cells[1].x); // See the giant "We need to reset the `cell_offset`" comment, but here - // we should technically have the rest of these be `x` of 1, but that + // we should technically have the rest of these be `x` of 2, but that // would require going back in the stream to adjust past cells, and // we don't take on that complexity. try testing.expectEqual(@as(u16, 0), cells[2].x); diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index 946611e79..b1126dd4e 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -1447,12 +1447,12 @@ test "shape Bengali ligatures with out of order vowels" { // Whereas CoreText puts everything all into the first cell (see the // corresponding test), HarfBuzz splits into two clusters. - try testing.expectEqual(@as(u16, 1), cells[2].x); - try testing.expectEqual(@as(u16, 1), cells[3].x); - try testing.expectEqual(@as(u16, 1), cells[4].x); - try testing.expectEqual(@as(u16, 1), cells[5].x); - try testing.expectEqual(@as(u16, 1), cells[6].x); - try testing.expectEqual(@as(u16, 1), cells[7].x); + try testing.expectEqual(@as(u16, 2), cells[2].x); + try testing.expectEqual(@as(u16, 2), cells[3].x); + try testing.expectEqual(@as(u16, 2), cells[4].x); + try testing.expectEqual(@as(u16, 2), cells[5].x); + try testing.expectEqual(@as(u16, 2), cells[6].x); + try testing.expectEqual(@as(u16, 2), cells[7].x); // The vowel sign E renders before the SSA: try testing.expect(cells[2].x_offset < cells[3].x_offset); diff --git a/src/renderer/cursor.zig b/src/renderer/cursor.zig index cddda9871..33992bc55 100644 --- a/src/renderer/cursor.zig +++ b/src/renderer/cursor.zig @@ -15,7 +15,7 @@ pub const Style = enum { lock, /// Create a cursor style from the terminal style request. - pub fn fromTerminal(term: terminal.CursorStyle) ?Style { + pub fn fromTerminal(term: terminal.CursorStyle) Style { return switch (term) { .bar => .bar, .block => .block, diff --git a/src/renderer/generic.zig b/src/renderer/generic.zig index 5239f7fbb..09ffd7d4e 100644 --- a/src/renderer/generic.zig +++ b/src/renderer/generic.zig @@ -756,6 +756,9 @@ pub fn Renderer(comptime GraphicsAPI: type) type { .previous_cursor = @splat(0), .current_cursor_color = @splat(0), .previous_cursor_color = @splat(0), + .current_cursor_style = 0, + .previous_cursor_style = 0, + .cursor_visible = 0, .cursor_change_time = 0, .time_focus = 0, .focus = 1, // assume focused initially @@ -2011,11 +2014,12 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Only update when terminal state is dirty. if (self.terminal_state.dirty == .false) return; + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; const colors: *const terminal.RenderState.Colors = &self.terminal_state.colors; // 256-color palette for (colors.palette, 0..) |color, i| { - self.custom_shader_uniforms.palette[i] = .{ + uniforms.palette[i] = .{ @as(f32, @floatFromInt(color.r)) / 255.0, @as(f32, @floatFromInt(color.g)) / 255.0, @as(f32, @floatFromInt(color.b)) / 255.0, @@ -2024,7 +2028,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { } // Background color - self.custom_shader_uniforms.background_color = .{ + uniforms.background_color = .{ @as(f32, @floatFromInt(colors.background.r)) / 255.0, @as(f32, @floatFromInt(colors.background.g)) / 255.0, @as(f32, @floatFromInt(colors.background.b)) / 255.0, @@ -2032,7 +2036,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { }; // Foreground color - self.custom_shader_uniforms.foreground_color = .{ + uniforms.foreground_color = .{ @as(f32, @floatFromInt(colors.foreground.r)) / 255.0, @as(f32, @floatFromInt(colors.foreground.g)) / 255.0, @as(f32, @floatFromInt(colors.foreground.b)) / 255.0, @@ -2041,7 +2045,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cursor color if (colors.cursor) |cursor_color| { - self.custom_shader_uniforms.cursor_color = .{ + uniforms.cursor_color = .{ @as(f32, @floatFromInt(cursor_color.r)) / 255.0, @as(f32, @floatFromInt(cursor_color.g)) / 255.0, @as(f32, @floatFromInt(cursor_color.b)) / 255.0, @@ -2055,7 +2059,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Cursor text color if (self.config.cursor_text) |cursor_text| { - self.custom_shader_uniforms.cursor_text = .{ + uniforms.cursor_text = .{ @as(f32, @floatFromInt(cursor_text.color.r)) / 255.0, @as(f32, @floatFromInt(cursor_text.color.g)) / 255.0, @as(f32, @floatFromInt(cursor_text.color.b)) / 255.0, @@ -2065,7 +2069,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Selection background color if (self.config.selection_background) |selection_bg| { - self.custom_shader_uniforms.selection_background_color = .{ + uniforms.selection_background_color = .{ @as(f32, @floatFromInt(selection_bg.color.r)) / 255.0, @as(f32, @floatFromInt(selection_bg.color.g)) / 255.0, @as(f32, @floatFromInt(selection_bg.color.b)) / 255.0, @@ -2075,13 +2079,21 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // Selection foreground color if (self.config.selection_foreground) |selection_fg| { - self.custom_shader_uniforms.selection_foreground_color = .{ + uniforms.selection_foreground_color = .{ @as(f32, @floatFromInt(selection_fg.color.r)) / 255.0, @as(f32, @floatFromInt(selection_fg.color.g)) / 255.0, @as(f32, @floatFromInt(selection_fg.color.b)) / 255.0, 1.0, }; } + + // Cursor visibility + uniforms.cursor_visible = @intFromBool(self.terminal_state.cursor.visible); + + // Cursor style + const cursor_style: renderer.CursorStyle = .fromTerminal(self.terminal_state.cursor.visual_style); + uniforms.previous_cursor_style = uniforms.current_cursor_style; + uniforms.current_cursor_style = @as(i32, @intFromEnum(cursor_style)); } /// Update per-frame custom shader uniforms. @@ -2091,7 +2103,7 @@ pub fn Renderer(comptime GraphicsAPI: type) type { // We only need to do this if we have custom shaders. if (!self.has_custom_shaders) return; - const uniforms = &self.custom_shader_uniforms; + const uniforms: *shadertoy.Uniforms = &self.custom_shader_uniforms; const now = try std.time.Instant.now(); defer self.last_frame_time = now; @@ -2125,7 +2137,6 @@ pub fn Renderer(comptime GraphicsAPI: type) type { 0, }; - // Update custom cursor uniforms, if we have a cursor. if (self.cells.getCursorGlyph()) |cursor| { const cursor_width: f32 = @floatFromInt(cursor.glyph_size[0]); const cursor_height: f32 = @floatFromInt(cursor.glyph_size[1]); diff --git a/src/renderer/shaders/shadertoy_prefix.glsl b/src/renderer/shaders/shadertoy_prefix.glsl index 661bd233d..4b6d091b8 100644 --- a/src/renderer/shaders/shadertoy_prefix.glsl +++ b/src/renderer/shaders/shadertoy_prefix.glsl @@ -15,6 +15,9 @@ layout(binding = 1, std140) uniform Globals { uniform vec4 iPreviousCursor; uniform vec4 iCurrentCursorColor; uniform vec4 iPreviousCursorColor; + uniform int iCurrentCursorStyle; + uniform int iPreviousCursorStyle; + uniform int iCursorVisible; uniform float iTimeCursorChange; uniform float iTimeFocus; uniform int iFocus; @@ -27,6 +30,12 @@ layout(binding = 1, std140) uniform Globals { uniform vec3 iSelectionBackgroundColor; }; +#define CURSORSTYLE_BLOCK 0 +#define CURSORSTYLE_BLOCK_HOLLOW 1 +#define CURSORSTYLE_BAR 2 +#define CURSORSTYLE_UNDERLINE 3 +#define CURSORSTYLE_LOCK 4 + layout(binding = 0) uniform sampler2D iChannel0; // These are unused currently by Ghostty: diff --git a/src/renderer/shadertoy.zig b/src/renderer/shadertoy.zig index 7d0ad4b0a..556c28293 100644 --- a/src/renderer/shadertoy.zig +++ b/src/renderer/shadertoy.zig @@ -24,6 +24,9 @@ pub const Uniforms = extern struct { previous_cursor: [4]f32 align(16), current_cursor_color: [4]f32 align(16), previous_cursor_color: [4]f32 align(16), + current_cursor_style: i32 align(4), + previous_cursor_style: i32 align(4), + cursor_visible: i32 align(4), cursor_change_time: f32 align(4), time_focus: f32 align(4), focus: i32 align(4), diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 248a2c512..ae495f0f3 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -329,12 +329,16 @@ pub fn print(self: *Terminal, c: u21) !void { @branchHint(.unlikely); // We need the previous cell to determine if we're at a grapheme // break or not. If we are NOT, then we are still combining the - // same grapheme. Otherwise, we can stay in this cell. + // same grapheme, and will be appending to prev.cell. Otherwise, we are + // in a new cell. const Prev = struct { cell: *Cell, left: size.CellCountInt }; - const prev: Prev = prev: { + var prev: Prev = prev: { const left: size.CellCountInt = left: { - // If we have wraparound, then we always use the prev col - if (self.modes.get(.wraparound)) break :left 1; + // If we have wraparound, then we use the prev col unless + // there's a pending wrap, in which case we use the current. + if (self.modes.get(.wraparound)) { + break :left @intFromBool(!self.screens.active.cursor.pending_wrap); + } // If we do not have wraparound, the logic is trickier. If // we're not on the last column, then we just use the previous @@ -380,6 +384,8 @@ pub fn print(self: *Terminal, c: u21) !void { // If we can NOT break, this means that "c" is part of a grapheme // with the previous char. if (!grapheme_break) { + var desired_wide: enum { no_change, wide, narrow } = .no_change; + // If this is an emoji variation selector then we need to modify // the cell width accordingly. VS16 makes the character wide and // VS15 makes it narrow. @@ -390,71 +396,132 @@ pub fn print(self: *Terminal, c: u21) !void { if (!prev_props.emoji_vs_base) return; switch (c) { - 0xFE0F => wide: { - if (prev.cell.wide == .wide) break :wide; + 0xFE0F => desired_wide = .wide, + 0xFE0E => desired_wide = .narrow, + else => unreachable, + } + } else if (!unicode.table.get(c).width_zero_in_grapheme) { + // If we have a code point that contributes to the width of a + // grapheme, it necessarily means that we're at least at width + // 2, since the first code point must be at least width 1 to + // start. (Note that Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle + // that yet.) + desired_wide = .wide; + } - // Move our cursor back to the previous. We'll move - // the cursor within this block to the proper location. - self.screens.active.cursorLeft(prev.left); + switch (desired_wide) { + .wide => wide: { + if (prev.cell.wide == .wide) break :wide; - // If we don't have space for the wide char, we need - // to insert spacers and wrap. Then we just print the wide - // char as normal. - if (self.screens.active.cursor.x == right_limit - 1) { - if (!self.modes.get(.wraparound)) return; + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screens.active.cursorLeft(prev.left); + + // If we don't have space for the wide char, we need to + // insert spacers and wrap. We need special handling if the + // previous cell has grapheme data. + if (self.screens.active.cursor.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + + const prev_cp = prev.cell.content.codepoint; + + if (prev.cell.hasGrapheme()) { + // This is like printCell but without clearing the + // grapheme data from the cell, so we can move it + // later. + prev.cell.wide = if (right_limit == self.cols) .spacer_head else .narrow; + prev.cell.content.codepoint = 0; + + try self.printWrap(); + self.printCell(prev_cp, .wide); + + const new_pin = self.screens.active.cursor.page_pin.*; + const new_rac = new_pin.rowAndCell(); + + transfer_graphemes: { + var old_pin = self.screens.active.cursor.page_pin.up(1) orelse break :transfer_graphemes; + old_pin.x = right_limit - 1; + const old_rac = old_pin.rowAndCell(); + + if (new_pin.node == old_pin.node) { + new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + prev.cell.content_tag = .codepoint; + new_rac.cell.content_tag = .codepoint_grapheme; + new_rac.row.grapheme = true; + } else { + const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + for (cps) |cp| { + try self.screens.active.appendGrapheme(new_rac.cell, cp); + } + old_pin.node.data.clearGrapheme(old_rac.cell); + } + + old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + } + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev.cell = new_rac.cell; + } else { self.printCell( 0, if (right_limit == self.cols) .spacer_head else .narrow, ); try self.printWrap(); + self.printCell(prev_cp, .wide); + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev.cell = self.screens.active.cursor.page_cell; } + } else { + prev.cell.wide = .wide; + } - self.printCell(prev.cell.content.codepoint, .wide); + // Write our spacer, since prev.cell is now wide + self.screens.active.cursorRight(1); + self.printCell(0, .spacer_tail); - // Write our spacer + // Move the cursor again so we're beyond our spacer + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; + } else { self.screens.active.cursorRight(1); - self.printCell(0, .spacer_tail); + } + }, - // Move the cursor again so we're beyond our spacer - if (self.screens.active.cursor.x == right_limit - 1) { - self.screens.active.cursor.pending_wrap = true; - } else { - self.screens.active.cursorRight(1); - } - }, + .narrow => narrow: { + // Prev cell is no longer wide + if (prev.cell.wide != .wide) break :narrow; + prev.cell.wide = .narrow; - 0xFE0E => narrow: { - // Prev cell is no longer wide - if (prev.cell.wide != .wide) break :narrow; - prev.cell.wide = .narrow; + // Remove the wide spacer tail + const cell = self.screens.active.cursorCellLeft(prev.left - 1); + cell.wide = .narrow; - // Remove the wide spacer tail - const cell = self.screens.active.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; + // Back track the cursor so that we don't end up with + // an extra space after the character. Since xterm is + // not VS aware, it cannot be used as a reference for + // this behavior; but it does follow the principle of + // least surprise, and also matches the behavior that + // can be observed in Kitty, which is one of the only + // other VS aware terminals. + if (self.screens.active.cursor.x == right_limit - 1) { + // If we're already at the right edge, we stay + // here and set the pending wrap to false since + // when we pend a wrap, we only move our cursor once + // even for wide chars (tests verify). + self.screens.active.cursor.pending_wrap = false; + } else { + // Otherwise, move back. + self.screens.active.cursorLeft(1); + } - // Back track the cursor so that we don't end up with - // an extra space after the character. Since xterm is - // not VS aware, it cannot be used as a reference for - // this behavior; but it does follow the principle of - // least surprise, and also matches the behavior that - // can be observed in Kitty, which is one of the only - // other VS aware terminals. - if (self.screens.active.cursor.x == right_limit - 1) { - // If we're already at the right edge, we stay - // here and set the pending wrap to false since - // when we pend a wrap, we only move our cursor once - // even for wide chars (tests verify). - self.screens.active.cursor.pending_wrap = false; - } else { - // Otherwise, move back. - self.screens.active.cursorLeft(1); - } + break :narrow; + }, - break :narrow; - }, - - else => unreachable, - } + else => {}, } log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{ @@ -3834,19 +3901,23 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { } test "Terminal: VS15 to make narrow character with pending wrap" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 2 }); + var t = try init(testing.allocator, .{ .rows = 5, .cols = 4 }); defer t.deinit(testing.allocator); // Enable grapheme clustering t.modes.set(.grapheme_cluster, true); + try testing.expect(t.modes.get(.wraparound)); + + try t.print(0x1F34B); // Lemon, width=2 try t.print(0x2614); // Umbrella with rain drops, width=2 try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); t.clearDirty(); - // We only move one because we're in a pending wrap state. + // We only move to the end of the line because we're in a pending wrap + // state. try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); try testing.expect(t.screens.active.cursor.pending_wrap); try t.print(0xFE0E); // VS15 to make narrow @@ -3855,17 +3926,17 @@ test "Terminal: VS15 to make narrow character with pending wrap" { // VS15 should clear the pending wrap state try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); - try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); try testing.expect(!t.screens.active.cursor.pending_wrap); { const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); - try testing.expectEqualStrings("☔︎", str); + try testing.expectEqualStrings("🍋☔︎", str); } { - const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); @@ -3873,6 +3944,102 @@ test "Terminal: VS15 to make narrow character with pending wrap" { const cps = list_cell.node.data.lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } + + // VS15 should not affect the previous grapheme + { + const lemon_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?.cell; + try testing.expectEqual(@as(u21, 0x1F34B), lemon_cell.content.codepoint); + try testing.expectEqual(Cell.Wide.wide, lemon_cell.wide); + const spacer_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?.cell; + try testing.expectEqual(@as(u21, 0), spacer_cell.content.codepoint); + try testing.expectEqual(Cell.Wide.spacer_tail, spacer_cell.wide); + } +} + +test "Terminal: VS16 to make wide character on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // '#' cell is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // spacer_tail + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: VS16 to make wide character with pending wrap" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(1); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + // '#' cell is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // spacer_tail + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } } test "Terminal: VS16 to make wide character with mode 2027" { @@ -4013,6 +4180,173 @@ test "Terminal: print invalid VS16 with second char" { } } +test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('o'); + try t.print(0x0300); // combining grave accent + + // We should have 1 cell taken up. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // Devanagari grapheme is wide + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next page" { + const rows = pagepkg.std_capacity.rows; + const cols = pagepkg.std_capacity.cols; + var t = try init(testing.allocator, .{ .rows = rows, .cols = cols }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorDown(rows - 1); + + for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + try t.index(); + } + + t.cursorRight(cols - 1); + + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + + // क्‍ष + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // Previous cell turns into spacer_head + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = cols - 1, .y = rows - 2 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // Devanagari grapheme is wide + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + test "Terminal: print invalid VS16 with second char (combining)" { var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 61507dc75..3e7ca9ac3 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -1556,7 +1556,7 @@ pub const Page = struct { /// WARNING: This will NOT change the content_tag on the cells because /// there are scenarios where we want to move graphemes without changing /// the content tag. Callers beware but assertIntegrity should catch this. - inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { + pub inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { if (build_options.slow_runtime_safety) { assert(src.hasGrapheme()); assert(!dst.hasGrapheme()); diff --git a/src/unicode/props.zig b/src/unicode/props.zig index a6615e56e..2653f0cc6 100644 --- a/src/unicode/props.zig +++ b/src/unicode/props.zig @@ -13,6 +13,10 @@ pub const Properties = packed struct { /// becomes a 2-em dash). width: u2 = 0, + /// Whether the code point does not contribute to the width of a grapheme + /// cluster (not used for single code point cells). + width_zero_in_grapheme: bool = false, + /// Grapheme break property. grapheme_break: uucode.x.types.GraphemeBreakNoControl = .other, @@ -22,6 +26,7 @@ pub const Properties = packed struct { // Needed for lut.Generator pub fn eql(a: Properties, b: Properties) bool { return a.width == b.width and + a.width_zero_in_grapheme == b.width_zero_in_grapheme and a.grapheme_break == b.grapheme_break and a.emoji_vs_base == b.emoji_vs_base; } @@ -34,11 +39,13 @@ pub const Properties = packed struct { try writer.print( \\.{{ \\ .width= {}, + \\ .width_zero_in_grapheme= {}, \\ .grapheme_break= .{s}, \\ .emoji_vs_base= {}, \\}} , .{ self.width, + self.width_zero_in_grapheme, @tagName(self.grapheme_break), self.emoji_vs_base, }); diff --git a/src/unicode/props_uucode.zig b/src/unicode/props_uucode.zig index d876bf4ac..527a757ed 100644 --- a/src/unicode/props_uucode.zig +++ b/src/unicode/props_uucode.zig @@ -8,12 +8,14 @@ const Properties = @import("props.zig").Properties; pub fn get(cp: u21) Properties { if (cp > uucode.config.max_code_point) return .{ .width = 1, + .width_zero_in_grapheme = true, .grapheme_break = .other, .emoji_vs_base = false, }; return .{ .width = uucode.get(.width, cp), + .width_zero_in_grapheme = uucode.get(.wcwidth_zero_in_grapheme, cp), .grapheme_break = uucode.get(.grapheme_break_no_control, cp), .emoji_vs_base = uucode.get(.is_emoji_vs_base, cp), };