mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-19 12:08:07 +00:00
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:
@@ -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) {
|
||||
|
||||
@@ -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 tab’s 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 bar’s 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
|
||||
|
||||
Reference in New Issue
Block a user