macOS: fix title bar clipping custom font (#14217)

Fixes https://github.com/ghostty-org/ghostty/issues/14135.

<img width="1398" height="652" alt="image"
src="https://github.com/user-attachments/assets/6901a05e-9d23-4e25-89a7-c16c1694a0f9"
/>


> The #9168 fix is no longer needed, since the frame is now higher than
the actual glyph.

The frame change observation only affects those who have a custom window
title font set in their config. I asked Claude to run some main thread
benchmarking compared to `main`; it will gain some delays for rapid
title changes and window resizing. The additional cost is brought by the
frequent frame updates which are done by AppKit. But that's necessary
for updating the title to the correct style.

> I tried to do some diffing and removing duplicates, but it will add
too many changes too, and I didn't think it's worth doing so.

The amount looks ok to me.

### `window-title-font-family = PT Mono`

| Phase | Metric | base | branch | Δ | ratio |
|---|---|---:|---:|---:|---:|
| Idle, 3 s | main-thread CPU | 2.8 ms | 2.8 ms | -0.0 | 1.00 |
|  | process CPU | 11.3 ms | 11.2 ms | -0.1 | 0.99 |
| Paced title updates, 150 × 100 ms | main-thread CPU | 878.5 ms |
**946.7 ms** | **+68.3** | **1.08** |
|  | process CPU | 1219.9 ms | 1335.0 ms | +115.1 | 1.09 |
|  | wall | 17.24 s | 17.34 s | +0.1 | 1.01 |
| Title burst, 5000 back-to-back | main-thread CPU | 180.7 ms | 181.4 ms
| +0.7 | 1.00 |
|  | process CPU | 254.2 ms | 254.8 ms | +0.6 | 1.00 |
|  | wall | 2.31 s | 2.32 s | +0.0 | 1.00 |
| `toggle_maximize` × 16 (animated resize) | main-thread CPU | 1947.8 ms
| **2178.0 ms** | **+230.2** | **1.12** |
|  | process CPU | 4166.8 ms | 4403.2 ms | +236.4 | 1.06 |
|  | wall | 16.68 s | 16.72 s | +0.0 | 1.00 |
| Native fullscreen enter/exit × 2 | main-thread CPU | 196.8 ms | 195.9
ms | -0.9 | 1.00 |
|  | process CPU | 360.8 ms | 361.5 ms | +0.7 | 1.00 |
|  | wall | 8.47 s | 8.47 s | +0.0 | 1.00 |

### AI Disclosure

Asked Claude to generate the harness to run the benchmark and review my
changes. I did the changes myself.
This commit is contained in:
Mitchell Hashimoto
2026-09-14 12:05:47 -07:00
committed by GitHub
2 changed files with 85 additions and 24 deletions

View File

@@ -1278,6 +1278,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
// Whenever we resize save our last position and size for the next start.
LastWindowPosition.shared.save(window)
if let window = self.window as? TerminalWindow {
// Expand the title frame to new width.
// This is needed because when the new window size becomes bigger,
// window's title will be clipped again.
window.syncWindowTitleAppearance()
}
}
func windowDidBecomeMain(_ notification: Notification) {

View File

@@ -1,4 +1,5 @@
import AppKit
import Combine
import SwiftUI
import GhosttyKit
@@ -277,12 +278,6 @@ class TerminalWindow: NSWindow {
/// added.
static let tabBarIdentifier: NSUserInterfaceItemIdentifier = .init("_ghosttyTabBar")
var hasMoreThanOneTabs: Bool {
/// accessing ``tabGroup?.windows`` here
/// will cause other edge cases, be careful
(tabbedWindows?.count ?? 0) > 1
}
func isTabBar(_ childViewController: NSTitlebarAccessoryViewController) -> Bool {
if childViewController.identifier == nil {
// The good case
@@ -396,12 +391,9 @@ class TerminalWindow: NSWindow {
// Whenever we change the window title we must also update our
// tab title if we're using custom fonts.
tab.attributedTitle = attributedTitle
/// We also needs to update this here, just in case
/// the value is not what we want
///
/// Check ``titlebarFont`` down below
/// to see why we need to check `hasMoreThanOneTabs` here
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
guard title != oldValue else { return }
syncWindowTitleAppearance()
}
}
@@ -409,25 +401,28 @@ class TerminalWindow: NSWindow {
var titlebarFont: NSFont? {
didSet {
let font = titlebarFont ?? NSFont.titleBarFont(ofSize: NSFont.systemFontSize)
titlebarTextField?.font = font
/// We check `hasMoreThanOneTabs` here because the system
/// may copy this setting to the tabs text field at some point(e.g. entering/exiting fullscreen),
/// which can cause the title to be vertically misaligned (shifted downward).
///
/// This behaviour is the opposite of what happens in the title bars text field, which is quite odd...
titlebarTextField?.usesSingleLineMode = !hasMoreThanOneTabs
tab.attributedTitle = attributedTitle
// We need to call this every time the font is set,
// after entering or exiting fullscreen, or other cases,
// AppKit will reset the font.
syncWindowTitleAppearance(font: font)
}
}
// Find the NSTextField responsible for displaying the titlebar's title.
private var titlebarTextField: NSTextField? {
titlebarContainer?
// Find the array of NSTextField responsible for displaying the titlebar's title.
// In fullscreen mode, there'll be two of them.
private var titlebarTextFields: [NSTextField] {
(titlebarContainer?
.firstDescendant(withClassName: "NSTitlebarView")?
.firstDescendant(withClassName: "NSTextField") as? NSTextField
.descendants(withClassName: "NSTextField")
.compactMap { $0 as? NSTextField } ?? [])
.filter({ $0.superview?.className == "NSTitlebarView" })
}
// Cancellables for the frame change of the text fields in the titlebar.
private var titlebarTextFieldFrameCancellables = Set<AnyCancellable>()
// Return a styled representation of our title property.
var attributedTitle: NSAttributedString? {
guard let titlebarFont = titlebarFont else { return nil }
@@ -463,6 +458,65 @@ class TerminalWindow: NSWindow {
// MARK: Positioning And Styling
/// Update titlebarTextField's size and font
func syncWindowTitleAppearance(font: NSFont? = nil) {
titlebarTextFields.forEach { field in
if let font {
field.font = font
}
}
titlebarTextFieldFrameCancellables.removeAll()
// macOS 15 doesn't seem to need to adjust the frame.
//
// When using custom font, we always expand the frame to
// show the text properly.
//
// AppKit will relayout the frame when the font changes, that's
// why we need this hack in the first place.
guard #available(macOS 26.0, *), titlebarFont != nil else {
return
}
titlebarTextFields.forEach { field in
setWindowTitleFrameSize(field)
}
// We need to observe changes to make the frame correct in some cases:
//
// 1. Entering fullscreen mode.
// > Updating the frame when it's hidden doesn't seem to work.
// 2. Exiting fullscreen mode.
// 3. Resizing the window.
// 3. Maybe more...
titlebarTextFields.forEach { field in
field.postsFrameChangedNotifications = true
NotificationCenter.default
.publisher(for: NSView.frameDidChangeNotification, object: field)
.compactMap { $0.object as? NSTextField }
.sink { [weak self] in
self?.setWindowTitleFrameSize($0)
}
.store(in: &titlebarTextFieldFrameCancellables)
}
}
private func setWindowTitleFrameSize(_ textfield: NSTextField) {
guard let superview = textfield.superview else { return }
// Button group size estimate: 92.
// Make the available space a little bit smaller.
let fittingSize = textfield.sizeThatFits(superview.bounds.insetBy(dx: 92/2 + 6, dy: 0).size)
// We make the frame a little bit higher so fixed-width fonts can
// align center vertically as well.
let properSize = CGSize(width: fittingSize.width, height: fittingSize.height * 1.1)
guard textfield.frame.size != properSize else { return }
textfield.frame.size = properSize
}
/// This is called by the controller when there is a need to reset the window appearance.
func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// If our window is not visible, then we do nothing. Some things such as blurring