macOS: add inline tab title editing

This commit is contained in:
MiUPa
2026-02-23 11:18:16 +09:00
committed by Mitchell Hashimoto
parent daa2a9d0d5
commit feee4443da
3 changed files with 286 additions and 15 deletions

View File

@@ -1265,6 +1265,17 @@ class BaseTerminalController: NSWindowController,
}
@IBAction func changeTabTitle(_ sender: Any) {
if let targetWindow = window {
let inlineHostWindow =
targetWindow.tabbedWindows?
.first(where: { $0.tabBarView != nil }) as? TerminalWindow
?? (targetWindow as? TerminalWindow)
if let inlineHostWindow, inlineHostWindow.beginInlineTabTitleEdit(for: targetWindow) {
return
}
}
promptTabTitle()
}

View File

@@ -4,7 +4,7 @@ import GhosttyKit
/// The base class for all standalone, "normal" terminal windows. This sets the basic
/// style and configuration of the window based on the app configuration.
class TerminalWindow: NSWindow {
class TerminalWindow: NSWindow, NSTextFieldDelegate {
/// Posted when a terminal window awakes from nib.
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
@@ -37,6 +37,12 @@ class TerminalWindow: NSWindow {
/// Sets up our tab context menu
private var tabMenuObserver: NSObjectProtocol?
/// Active inline editor for renaming a tab title.
private weak var inlineTabTitleEditor: NSTextField?
private weak var inlineTabTitleEditorController: BaseTerminalController?
private var inlineTabTitleHiddenLabels: [(label: NSTextField, wasHidden: Bool)] = []
private var inlineTabTitleButtonState: (button: NSButton, title: String, attributedTitle: NSAttributedString?)?
/// Whether this window supports the update accessory. If this is false, then views within this
/// window should determine how to show update notifications.
var supportsUpdateAccessory: Bool {
@@ -183,6 +189,7 @@ class TerminalWindow: NSWindow {
}
override func close() {
finishInlineTabTitleEdit(commit: true)
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
super.close()
}
@@ -219,6 +226,10 @@ class TerminalWindow: NSWindow {
guard event.type == .leftMouseDown, event.clickCount == 2 else { return false }
let locationInScreen = convertPoint(toScreen: event.locationInWindow)
if beginInlineTabTitleEdit(atScreenPoint: locationInScreen) {
return true
}
guard let tabIndex = tabIndex(atScreenPoint: locationInScreen) else { return false }
guard let targetWindow = tabbedWindows?[safe: tabIndex] else { return false }
@@ -228,6 +239,246 @@ class TerminalWindow: NSWindow {
return true
}
@discardableResult
func beginInlineTabTitleEdit(for targetWindow: NSWindow) -> Bool {
guard let tabbedWindows,
let tabIndex = tabbedWindows.firstIndex(of: targetWindow),
let tabButton = tabButtonsInVisualOrder()[safe: tabIndex],
let targetController = targetWindow.windowController as? BaseTerminalController
else { return false }
return beginInlineTabTitleEdit(
tabButton: tabButton,
targetWindow: targetWindow,
targetController: targetController
)
}
private func beginInlineTabTitleEdit(atScreenPoint screenPoint: NSPoint) -> Bool {
guard let hit = tabButtonHit(atScreenPoint: screenPoint),
let targetWindow = tabbedWindows?[safe: hit.index],
let targetController = targetWindow.windowController as? BaseTerminalController
else { return false }
return beginInlineTabTitleEdit(
tabButton: hit.tabButton,
targetWindow: targetWindow,
targetController: targetController
)
}
private func beginInlineTabTitleEdit(
tabButton: NSView,
targetWindow: NSWindow,
targetController: BaseTerminalController
) -> Bool {
finishInlineTabTitleEdit(commit: true)
let titleLabels = tabButton
.descendants(withClassName: "NSTextField")
.compactMap { $0 as? NSTextField }
let editedTitle = targetController.titleOverride ?? 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 = .byTruncatingTail
if let sourceLabel {
applyTextStyle(to: editor, from: sourceLabel, title: editedTitle)
}
tabButton.addSubview(editor)
inlineTabTitleEditor = editor
inlineTabTitleEditorController = targetController
inlineTabTitleHiddenLabels = titleLabels.map { ($0, $0.isHidden) }
for label in titleLabels {
label.isHidden = true
}
if let tabButton = tabButton as? NSButton {
inlineTabTitleButtonState = (tabButton, tabButton.title, tabButton.attributedTitle)
tabButton.title = ""
tabButton.attributedTitle = NSAttributedString(string: "")
} else {
inlineTabTitleButtonState = nil
}
DispatchQueue.main.async { [weak self, weak editor] in
guard let self, let editor else { return }
self.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
}
private func tabTitleEditorFrame(for tabButton: NSView, sourceLabel: NSTextField?) -> NSRect {
let bounds = tabButton.bounds
let sideInset = min(24, max(10, bounds.width * 0.12))
var frame = bounds.insetBy(dx: sideInset, dy: 0)
if let sourceLabel {
let labelFrame = tabButton.convert(sourceLabel.bounds, from: sourceLabel)
let horizontalPadding: CGFloat = 6
frame.origin.x = max(sideInset, labelFrame.minX - horizontalPadding)
frame.size.width = min(
labelFrame.width + (horizontalPadding * 2),
bounds.width - (sideInset * 2)
)
frame.origin.y = labelFrame.minY
frame.size.height = labelFrame.height
}
return frame.integral
}
private func sourceTabTitleLabel(from labels: [NSTextField], matching title: String) -> NSTextField? {
let expected = title.trimmingCharacters(in: .whitespacesAndNewlines)
if !expected.isEmpty {
if let exactVisible = labels.first(where: {
!$0.isHidden &&
$0.alphaValue > 0.01 &&
$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected
}) {
return exactVisible
}
if let exactAny = labels.first(where: {
$0.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) == expected
}) {
return exactAny
}
}
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 })
}
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
}
}
private func finishInlineTabTitleEdit(commit: Bool) {
guard let editor = inlineTabTitleEditor else { return }
let editedTitle = editor.stringValue
let targetController = inlineTabTitleEditorController
editor.delegate = nil
inlineTabTitleEditor = nil
inlineTabTitleEditorController = nil
if let currentEditor = editor.currentEditor(), firstResponder === currentEditor {
makeFirstResponder(nil)
} else if firstResponder === editor {
makeFirstResponder(nil)
}
editor.removeFromSuperview()
for (label, wasHidden) in inlineTabTitleHiddenLabels {
label.isHidden = wasHidden
}
inlineTabTitleHiddenLabels.removeAll()
if let buttonState = inlineTabTitleButtonState {
buttonState.button.title = buttonState.title
buttonState.button.attributedTitle = buttonState.attributedTitle ?? NSAttributedString(string: buttonState.title)
}
inlineTabTitleButtonState = nil
guard commit, let targetController else { return }
targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle
}
@objc private func renameTabFromContextMenu(_ sender: NSMenuItem) {
let targetWindow = sender.representedObject as? NSWindow ?? self
if beginInlineTabTitleEdit(for: targetWindow) {
return
}
guard let targetController = targetWindow.windowController as? BaseTerminalController else { return }
targetController.promptTabTitle()
}
// MARK: NSTextFieldDelegate
func control(_ control: NSControl, textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
guard control === inlineTabTitleEditor else { return false }
if commandSelector == #selector(NSResponder.insertNewline(_:)) {
finishInlineTabTitleEdit(commit: true)
return true
}
if commandSelector == #selector(NSResponder.cancelOperation(_:)) {
finishInlineTabTitleEdit(commit: false)
return true
}
return false
}
func controlTextDidEndEditing(_ obj: Notification) {
guard let inlineTabTitleEditor,
let finishedEditor = obj.object as? NSTextField,
finishedEditor === inlineTabTitleEditor
else { return }
finishInlineTabTitleEdit(commit: true)
}
override func mergeAllWindows(_ sender: Any?) {
super.mergeAllWindows(sender)
@@ -752,10 +1003,11 @@ extension TerminalWindow {
separator.identifier = Self.tabColorSeparatorIdentifier
menu.addItem(separator)
// Change Title...
let changeTitleItem = NSMenuItem(title: "Change Title...", action: #selector(BaseTerminalController.changeTabTitle(_:)), keyEquivalent: "")
// Rename Tab...
let changeTitleItem = NSMenuItem(title: "Rename Tab...", action: #selector(TerminalWindow.renameTabFromContextMenu(_:)), keyEquivalent: "")
changeTitleItem.identifier = Self.changeTitleMenuItemIdentifier
changeTitleItem.target = target
changeTitleItem.target = self
changeTitleItem.representedObject = target?.window
changeTitleItem.setImageIfDesired(systemSymbolName: "pencil.line")
menu.addItem(changeTitleItem)

View File

@@ -58,25 +58,33 @@ extension NSWindow {
titlebarView?.firstDescendant(withClassName: "NSTabBar")
}
/// Returns the index of the tab button at the given screen point, if any.
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
/// Returns tab button views in visual order from left to right.
func tabButtonsInVisualOrder() -> [NSView] {
guard let tabBarView else { return [] }
return tabBarView
.descendants(withClassName: "NSTabButton")
.sorted { $0.frame.minX < $1.frame.minX }
}
/// Returns the visual tab index and matching tab button at the given screen point.
func tabButtonHit(atScreenPoint screenPoint: NSPoint) -> (index: Int, tabButton: NSView)? {
guard let tabBarView else { return nil }
let locationInWindow = convertPoint(fromScreen: screenPoint)
let locationInTabBar = tabBarView.convert(locationInWindow, from: nil)
guard tabBarView.bounds.contains(locationInTabBar) else { return nil }
// Find all tab buttons and sort by x position to get visual order.
// The view hierarchy order doesn't match the visual tab order.
let tabItemViews = tabBarView.descendants(withClassName: "NSTabButton")
.sorted { $0.frame.origin.x < $1.frame.origin.x }
for (index, tabItemView) in tabItemViews.enumerated() {
let locationInTab = tabItemView.convert(locationInWindow, from: nil)
if tabItemView.bounds.contains(locationInTab) {
return index
for (index, tabButton) in tabButtonsInVisualOrder().enumerated() {
let locationInTabButton = tabButton.convert(locationInWindow, from: nil)
if tabButton.bounds.contains(locationInTabButton) {
return (index, tabButton)
}
}
return nil
}
/// Returns the index of the tab button at the given screen point, if any.
func tabIndex(atScreenPoint screenPoint: NSPoint) -> Int? {
tabButtonHit(atScreenPoint: screenPoint)?.index
}
}