macos: extract inline title editing to standalone file
parent
879d7cf337
commit
b6a9d54e98
|
|
@ -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, NSTextFieldDelegate {
|
||||
class TerminalWindow: NSWindow {
|
||||
/// Posted when a terminal window awakes from nib.
|
||||
static let terminalDidAwake = Notification.Name("TerminalWindowDidAwake")
|
||||
|
||||
|
|
@ -37,12 +37,11 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate {
|
|||
/// 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?)?
|
||||
private var pendingInlineTabTitleEditWorkItem: DispatchWorkItem?
|
||||
/// Coordinates inline tab title editing for this host window.
|
||||
private lazy var inlineTabTitleEditingCoordinator = InlineTabTitleEditingCoordinator(
|
||||
hostWindow: self,
|
||||
delegate: self
|
||||
)
|
||||
|
||||
/// Whether this window supports the update accessory. If this is false, then views within this
|
||||
/// window should determine how to show update notifications.
|
||||
|
|
@ -182,7 +181,7 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate {
|
|||
override var canBecomeMain: Bool { return true }
|
||||
|
||||
override func sendEvent(_ event: NSEvent) {
|
||||
if promptTabTitleForDoubleClick(event) {
|
||||
if inlineTabTitleEditingCoordinator.handleDoubleClick(event) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -190,7 +189,7 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate {
|
|||
}
|
||||
|
||||
override func close() {
|
||||
finishInlineTabTitleEdit(commit: true)
|
||||
inlineTabTitleEditingCoordinator.finishEditing(commit: true)
|
||||
NotificationCenter.default.post(name: Self.terminalWillCloseNotification, object: self)
|
||||
super.close()
|
||||
}
|
||||
|
|
@ -223,228 +222,9 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate {
|
|||
viewModel.isMainWindow = false
|
||||
}
|
||||
|
||||
private func promptTabTitleForDoubleClick(_ event: NSEvent) -> Bool {
|
||||
guard event.type == .leftMouseDown, event.clickCount == 2 else { return false }
|
||||
|
||||
let locationInScreen = convertPoint(toScreen: event.locationInWindow)
|
||||
guard let tabIndex = tabIndex(atScreenPoint: locationInScreen),
|
||||
let targetWindow = tabbedWindows?[safe: tabIndex],
|
||||
let targetController = targetWindow.windowController as? BaseTerminalController
|
||||
else { return false }
|
||||
|
||||
pendingInlineTabTitleEditWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self, weak targetWindow, weak targetController] in
|
||||
guard let self else { return }
|
||||
if let targetWindow, self.beginInlineTabTitleEdit(for: targetWindow) {
|
||||
return
|
||||
}
|
||||
|
||||
targetController?.promptTabTitle()
|
||||
}
|
||||
pendingInlineTabTitleEditWorkItem = workItem
|
||||
DispatchQueue.main.async(execute: workItem)
|
||||
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(
|
||||
tabButton: NSView,
|
||||
targetWindow: NSWindow,
|
||||
targetController: BaseTerminalController
|
||||
) -> Bool {
|
||||
pendingInlineTabTitleEditWorkItem?.cancel()
|
||||
pendingInlineTabTitleEditWorkItem = nil
|
||||
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 = .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)
|
||||
}
|
||||
editor.isHidden = true
|
||||
|
||||
inlineTabTitleEditor = editor
|
||||
inlineTabTitleEditorController = targetController
|
||||
CATransaction.begin()
|
||||
CATransaction.setDisableActions(true)
|
||||
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
|
||||
}
|
||||
tabButton.layoutSubtreeIfNeeded()
|
||||
tabButton.displayIfNeeded()
|
||||
tabButton.addSubview(editor)
|
||||
CATransaction.commit()
|
||||
|
||||
DispatchQueue.main.async { [weak self, weak editor] in
|
||||
guard let self, let editor else { return }
|
||||
editor.isHidden = false
|
||||
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 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
|
||||
}
|
||||
|
||||
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) {
|
||||
pendingInlineTabTitleEditWorkItem?.cancel()
|
||||
pendingInlineTabTitleEditWorkItem = nil
|
||||
|
||||
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
|
||||
inlineTabTitleEditingCoordinator.beginEditing(for: targetWindow)
|
||||
}
|
||||
|
||||
@objc private func renameTabFromContextMenu(_ sender: NSMenuItem) {
|
||||
|
|
@ -457,33 +237,6 @@ class TerminalWindow: NSWindow, NSTextFieldDelegate {
|
|||
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)
|
||||
|
||||
|
|
@ -1038,3 +791,42 @@ private func makeTabColorPaletteView(
|
|||
hostingView.frame.size = hostingView.intrinsicContentSize
|
||||
return hostingView
|
||||
}
|
||||
|
||||
// MARK: - Inline Tab Title Editing
|
||||
|
||||
extension TerminalWindow: InlineTabTitleEditingCoordinatorDelegate {
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
canRenameTabFor targetWindow: NSWindow
|
||||
) -> Bool {
|
||||
targetWindow.windowController is BaseTerminalController
|
||||
}
|
||||
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
titleFor targetWindow: NSWindow
|
||||
) -> String {
|
||||
guard let targetController = targetWindow.windowController as? BaseTerminalController else {
|
||||
return targetWindow.title
|
||||
}
|
||||
|
||||
return targetController.titleOverride ?? targetWindow.title
|
||||
}
|
||||
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
didCommitTitle editedTitle: String,
|
||||
for targetWindow: NSWindow
|
||||
) {
|
||||
guard let targetController = targetWindow.windowController as? BaseTerminalController else { return }
|
||||
targetController.titleOverride = editedTitle.isEmpty ? nil : editedTitle
|
||||
}
|
||||
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
performFallbackRenameFor targetWindow: NSWindow
|
||||
) {
|
||||
guard let targetController = targetWindow.windowController as? BaseTerminalController else { return }
|
||||
targetController.promptTabTitle()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,336 @@
|
|||
import AppKit
|
||||
|
||||
/// Delegate used by ``InlineTabTitleEditingCoordinator`` to resolve tab-specific behavior.
|
||||
protocol InlineTabTitleEditingCoordinatorDelegate: AnyObject {
|
||||
/// Returns whether inline rename should be allowed for the given tab window.
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
canRenameTabFor targetWindow: NSWindow
|
||||
) -> Bool
|
||||
|
||||
/// Returns the current title value to seed into the inline editor.
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
titleFor targetWindow: NSWindow
|
||||
) -> String
|
||||
|
||||
/// Called when inline editing commits a title for a target tab window.
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
didCommitTitle editedTitle: String,
|
||||
for targetWindow: NSWindow
|
||||
)
|
||||
|
||||
/// Called when inline editing could not start and the host should show a fallback flow.
|
||||
func inlineTabTitleEditingCoordinator(
|
||||
_ coordinator: InlineTabTitleEditingCoordinator,
|
||||
performFallbackRenameFor targetWindow: NSWindow
|
||||
)
|
||||
}
|
||||
|
||||
/// Handles inline tab title editing for native AppKit window tabs.
|
||||
final class InlineTabTitleEditingCoordinator: 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: InlineTabTitleEditingCoordinatorDelegate?
|
||||
|
||||
/// 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: InlineTabTitleEditingCoordinatorDelegate) {
|
||||
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?.inlineTabTitleEditingCoordinator(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?.inlineTabTitleEditingCoordinator(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?.inlineTabTitleEditingCoordinator(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?.inlineTabTitleEditingCoordinator(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?.inlineTabTitleEditingCoordinator(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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue