Merge branch 'ghostty-org:main' into celltext-refactor

pull/11000/head
NateSmyth 2026-02-24 13:01:46 -05:00 committed by GitHub
commit ccb76a3dd3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1296 additions and 184 deletions

5
.github/VOUCHED.td vendored
View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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 }}

View File

@ -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"

View File

@ -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 {};
};
};
};

3
macos/AGENTS.md Normal file
View File

@ -0,0 +1,3 @@
# macOS Ghostty Application
- Use `swiftlint` for formatting and linting Swift code.

View File

@ -190,6 +190,7 @@
Helpers/Private/CGS.swift,
Helpers/Private/Dock.swift,
Helpers/TabGroupCloseCoordinator.swift,
Helpers/TabTitleEditor.swift,
Helpers/VibrantLayer.m,
Helpers/Weak.swift,
);

View File

@ -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()
}

View File

@ -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)
}

View File

@ -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()
}
}

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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),

View File

@ -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
}
}

View File

@ -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)
}
}

View File

@ -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";

View File

@ -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";

27
nix/pkgs/wcwidth.nix Normal file
View File

@ -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 = [];
};
}

View File

@ -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"

View File

@ -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"

View File

@ -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

View File

@ -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"),

View File

@ -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

View File

@ -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);

View File

@ -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);

View File

@ -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,

View File

@ -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]);

View File

@ -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:

View File

@ -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),

View File

@ -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);

View File

@ -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());

View File

@ -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,
});

View File

@ -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),
};