mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-18 05:20:29 +00:00
macOS: add inline tab title editing
This commit is contained in:
committed by
Mitchell Hashimoto
parent
daa2a9d0d5
commit
feee4443da
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user