Files
ghostty/macos/Sources/Helpers/TabTitleEditor.swift
chronologos 7629130fb4 macOS: restore keyboard focus after inline tab title edit
After finishing an inline tab title edit (via keybind or double-click),
`TabTitleEditor.finishEditing()` calls `makeFirstResponder(nil)` to
clear focus from the text field, leaving the window itself as first
responder. No code path restores focus to the terminal surface, so all
keyboard input is lost until the user clicks into a pane.

Add a `tabTitleEditorDidFinishEditing` delegate callback that fires
after every edit (commit or cancel). TerminalWindow implements it by
calling `makeFirstResponder(focusedSurface)` to hand focus back to the
terminal.

Fixes https://github.com/ghostty-org/ghostty/discussions/11315

Co-Authored-By: Claude <noreply@anthropic.com>
2026-03-10 08:52:00 -07:00

411 lines
16 KiB
Swift

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
)
/// Called after inline editing finishes (whether committed or cancelled).
/// Use this to restore focus to the appropriate responder.
func tabTitleEditor(
_ editor: TabTitleEditor,
didFinishEditing 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 state of the tab bar.
private var previousTabState: TabUIState?
/// 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 leftMouseDown events from the host window and begins inline edit if possible. If this
/// returns true then the event was handled by the coordinator.
func handleMouseDown(_ event: NSEvent) -> Bool {
guard event.type == .leftMouseDown 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 }
guard !isMouseEventWithinEditor(event) else {
// If the click lies within the editor,
// we should forward the event to the editor
inlineTitleEditor?.mouseDown(with: event)
return true
}
// We only want double-clicks to enable editing
guard event.clickCount == 2 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
}
/// Handles rightMouseDown events from the host window.
///
/// If this returns true then the event was handled by the coordinator.
func handleRightMouseDown(_ event: NSEvent) -> Bool {
if isMouseEventWithinEditor(event) {
inlineTitleEditor?.rightMouseDown(with: event)
return true
} else {
return false
}
}
/// 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)
let tabState = TabUIState(tabButton: tabButton)
// Build the editor using title text and style derived from the tab's existing label.
let editedTitle = delegate?.tabTitleEditor(self, titleFor: targetWindow) ?? targetWindow.title
let sourceLabel = sourceTabTitleLabel(from: tabState.labels.map(\.label), 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
previousTabState = tabState
// Temporarily hide native title label views while editing so only the text field is visible.
CATransaction.begin()
CATransaction.setDisableActions(true)
tabState.hide()
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()
previousTabState?.restore()
previousTabState = nil
// Delegate owns title persistence semantics (including empty-title handling).
guard let targetWindow else { return }
if commit {
delegate?.tabTitleEditor(self, didCommitTitle: editedTitle, for: targetWindow)
}
// Notify delegate that editing is done so it can restore focus.
delegate?.tabTitleEditor(self, didFinishEditing: 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)
/// The `labelFrame.minY` value changes unexpectedly after double clicking selected text,
/// I don't know exactly why, but `tabButton.bounds` appears stable enough to calculate the correct position reliably.
frame.origin.y = bounds.midY - labelFrame.height * 0.5
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)
}
}
private extension TabTitleEditor {
func isMouseEventWithinEditor(_ event: NSEvent) -> Bool {
guard let editor = inlineTitleEditor?.currentEditor() else {
return false
}
return editor.convert(editor.bounds, to: nil).contains(event.locationInWindow)
}
}
private extension TabTitleEditor {
struct TabUIState {
/// Original hidden state for title labels that are temporarily hidden while editing.
let labels: [(label: NSTextField, wasHidden: Bool)]
/// Original hidden state for buttons that are temporarily hidden while editing.
let buttons: [(button: NSButton, wasHidden: Bool)]
/// Original button title state restored once editing finishes.
let titleButton: (button: NSButton, title: String, attributedTitle: NSAttributedString?)?
init(tabButton: NSView) {
labels = tabButton
.descendants(withClassName: "NSTextField")
.compactMap { $0 as? NSTextField }
.map { ($0, $0.isHidden) }
buttons = tabButton
.descendants(withClassName: "NSButton")
.compactMap { $0 as? NSButton }
.map { ($0, $0.isHidden) }
if let button = tabButton as? NSButton {
titleButton = (button, button.title, button.attributedTitle)
} else {
titleButton = nil
}
}
func hide() {
for (label, _) in labels {
label.isHidden = true
}
for (btn, _) in buttons {
btn.isHidden = true
}
titleButton?.button.title = ""
titleButton?.button.attributedTitle = NSAttributedString(string: "")
}
func restore() {
for (label, wasHidden) in labels {
label.isHidden = wasHidden
}
for (btn, wasHidden) in buttons {
btn.isHidden = wasHidden
}
if let titleButton {
titleButton.button.title = titleButton.title
if let attributedTitle = titleButton.attributedTitle {
titleButton.button.attributedTitle = attributedTitle
}
}
}
}
}