mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 11:35:48 +00:00
337 lines
14 KiB
Swift
337 lines
14 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
|
|
)
|
|
}
|
|
|
|
/// 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)
|
|
}
|
|
}
|