mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 13:30:29 +00:00
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>
411 lines
16 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|