diff --git a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift index 6ff1fe553..93982dc54 100644 --- a/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift +++ b/macos/Sources/Features/Terminal/Window Styles/TerminalWindow.swift @@ -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() + } +} diff --git a/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift b/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift new file mode 100644 index 000000000..1cd10fb23 --- /dev/null +++ b/macos/Sources/Helpers/InlineTabTitleEditingCoordinator.swift @@ -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) + } +}