mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-14 03:25:50 +00:00
macOS: Refine tab title editing (#11150)
- Pass through mouse down event to `TabTitleEditor` if needed - Pass through right mouse down event to `TabTitleEditor` if needed - Hide close button when editing tab title Refactor: - Use a separated struct to hide and restore tab states https://github.com/user-attachments/assets/e69838f5-e199-437c-b53b-a491e9d5b752
This commit is contained in:
@@ -38,7 +38,7 @@ class TerminalWindow: NSWindow {
|
||||
private var tabMenuObserver: NSObjectProtocol?
|
||||
|
||||
/// Handles inline tab title editing for this host window.
|
||||
private lazy var tabTitleEditor = TabTitleEditor(
|
||||
private(set) lazy var tabTitleEditor = TabTitleEditor(
|
||||
hostWindow: self,
|
||||
delegate: self
|
||||
)
|
||||
@@ -181,7 +181,7 @@ class TerminalWindow: NSWindow {
|
||||
override var canBecomeMain: Bool { return true }
|
||||
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
if tabTitleEditor.handleDoubleClick(event) {
|
||||
if tabTitleEditor.handleMouseDown(event) {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -90,6 +90,10 @@ class TitlebarTabsTahoeTerminalWindow: TransparentTitlebarTerminalWindow, NSTool
|
||||
return
|
||||
}
|
||||
|
||||
guard !tabTitleEditor.handleRightMouseDown(event) else {
|
||||
return
|
||||
}
|
||||
|
||||
let locationInTabBar = tabBarView.convert(event.locationInWindow, from: nil)
|
||||
guard tabBarView.bounds.contains(locationInTabBar) else {
|
||||
super.sendEvent(event)
|
||||
|
||||
@@ -39,10 +39,8 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
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?)?
|
||||
/// 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?
|
||||
|
||||
@@ -52,11 +50,10 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
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 }
|
||||
/// 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 }
|
||||
@@ -68,6 +65,14 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
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
|
||||
@@ -85,6 +90,18 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
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
|
||||
@@ -104,12 +121,11 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
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 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 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 }
|
||||
|
||||
@@ -136,21 +152,12 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
|
||||
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)
|
||||
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
|
||||
}
|
||||
tabState.hide()
|
||||
|
||||
tabButton.layoutSubtreeIfNeeded()
|
||||
tabButton.displayIfNeeded()
|
||||
tabButton.addSubview(editor)
|
||||
@@ -201,17 +208,8 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
|
||||
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
|
||||
previousTabState?.restore()
|
||||
previousTabState = nil
|
||||
|
||||
// Delegate owns title persistence semantics (including empty-title handling).
|
||||
guard commit, let targetWindow else { return }
|
||||
@@ -336,3 +334,65 @@ final class TabTitleEditor: NSObject, NSTextFieldDelegate {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user