Merge branch 'ghostty-org:main' into celltext-refactor
commit
ccb76a3dd3
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
};
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# macOS Ghostty Application
|
||||
|
||||
- Use `swiftlint` for formatting and linting Swift code.
|
||||
|
|
@ -190,6 +190,7 @@
|
|||
Helpers/Private/CGS.swift,
|
||||
Helpers/Private/Dock.swift,
|
||||
Helpers/TabGroupCloseCoordinator.swift,
|
||||
Helpers/TabTitleEditor.swift,
|
||||
Helpers/VibrantLayer.m,
|
||||
Helpers/Weak.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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,8 @@ import SwiftUI
|
|||
class TerminalViewContainer<ViewModel: TerminalViewModel>: 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<ViewModel: TerminalViewModel>: 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<ViewModel: TerminalViewModel>: 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<ViewModel: TerminalViewModel>: 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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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 = [];
|
||||
};
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 <geesinknico@gmail.com>\n"
|
||||
"Language-Team: Dutch <vertaling@vrijschrift.org>\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"
|
||||
|
|
|
|||
|
|
@ -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 <me@pan93.com>\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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue