369 lines
18 KiB
Swift
369 lines
18 KiB
Swift
import AppKit
|
|
import SwiftUI
|
|
|
|
/// Programmatic handle into the bridged rename field, owned by the grid view (`@State`, so it
|
|
/// survives re-renders). SwiftUI-side hooks that must end or start editing — the panel's
|
|
/// blank-space tap, `closeFolder`, the in-folder `onDragBegan` (grid cells never take first
|
|
/// responder, so blur alone can't end the session), and the context-menu rename — route
|
|
/// through it into the live coordinator.
|
|
@MainActor
|
|
final class LaunchpadFolderRenameController {
|
|
weak var coordinator: LaunchpadFolderRenameField.Coordinator?
|
|
|
|
var isEditing: Bool { coordinator?.isEditing ?? false }
|
|
|
|
/// Commit (or cancel) the in-flight rename, if any. Safe to call unconditionally.
|
|
func endEditing(commit: Bool) {
|
|
coordinator?.endEditingNow(commit: commit)
|
|
}
|
|
}
|
|
|
|
/// The folder panel's title: a real `NSTextField` bridged into SwiftUI, ALWAYS rendered —
|
|
/// it reads as a static title until clicked, then edits inline (macOS-native Launchpad
|
|
/// behaviour, design §2.2). One constant NSView sidesteps the Text↔TextField identity swap
|
|
/// this ZStack-over-AppKit hierarchy is known to mishandle (see the `folderShown` note), and
|
|
/// editing only moves first responder WITHIN the overlay window — no resign-active/key, so
|
|
/// the dismiss observers need no new exemptions. Mirrors the `LaunchpadSearchField` bridge:
|
|
/// Coordinator + `doCommandBySelector` + `hasMarkedText()` for IME safety.
|
|
struct LaunchpadFolderRenameField: NSViewRepresentable {
|
|
var folderID: String
|
|
var name: String
|
|
var placeholder: String
|
|
/// The big-title text colour, derived from the label-colour preset (design 2026-06-13).
|
|
/// Defaults to `.labelColor` — the historical hardcoded title colour — so an unset caller
|
|
/// renders exactly as before.
|
|
var titleColor: NSColor = .labelColor
|
|
/// The big-title font, derived in `LaunchpadGridMetrics.resolve(_:)` (folderTitleFontSize/
|
|
/// folderTitleWeight). The default reproduces the historical `.title2`/semibold title, so an
|
|
/// unset caller is byte-compatible.
|
|
var titleFont: NSFont = .systemFont(
|
|
ofSize: NSFont.preferredFont(forTextStyle: .title2).pointSize,
|
|
weight: .semibold
|
|
)
|
|
/// Folder id whose field should grab focus + select-all (context-menu rename / the
|
|
/// post-creation auto-open). Consumed via `onFocusRequestHandled`.
|
|
var focusRequestID: String?
|
|
var controller: LaunchpadFolderRenameController?
|
|
/// Refused entry into editing (e.g. mid-carry): the field stays a static title.
|
|
var editGate: @MainActor () -> Bool = { true }
|
|
var onCommit: (String) -> Void
|
|
var onFocusRequestHandled: () -> Void = {}
|
|
|
|
/// True when `responder` is a field editor currently editing a rename field — the Esc
|
|
/// key monitor then lets the event through so `cancelOperation` cancels the rename
|
|
/// instead of closing the launcher (design §2.4; the marked-text IME exemption stays
|
|
/// first). Static + responder-typed so the routing rule is unit-testable.
|
|
@MainActor
|
|
static func shouldRouteEsc(to responder: NSResponder?) -> Bool {
|
|
guard let editor = responder as? NSTextView else { return false }
|
|
// The field editor's delegate IS the control being edited (AppKit wiring). Bridge via
|
|
// AnyObject: NSTextField's NSTextViewDelegate conformance is ObjC-dynamic, so a direct
|
|
// `is` reads as an unrelated-type cast to the Swift type checker.
|
|
return (editor.delegate as AnyObject?) is LaunchpadRenameTextField
|
|
}
|
|
|
|
/// Placeholder text with a centered paragraph style — a plain `placeholderString` ignores
|
|
/// the field's `.center` alignment and draws leading-aligned.
|
|
private static func centeredPlaceholder(_ text: String, font: NSFont?) -> NSAttributedString {
|
|
let style = NSMutableParagraphStyle()
|
|
style.alignment = .center
|
|
var attributes: [NSAttributedString.Key: Any] = [
|
|
.paragraphStyle: style,
|
|
.foregroundColor: NSColor.placeholderTextColor,
|
|
]
|
|
if let font { attributes[.font] = font }
|
|
return NSAttributedString(string: text, attributes: attributes)
|
|
}
|
|
|
|
func makeNSView(context: Context) -> LaunchpadRenameTextField {
|
|
let field = LaunchpadRenameTextField()
|
|
field.delegate = context.coordinator
|
|
field.isBordered = false
|
|
field.isBezeled = false
|
|
field.drawsBackground = false
|
|
field.focusRingType = .none
|
|
field.alignment = .center
|
|
// Label-style injection (design 2026-06-13): colour from the preset, font derived from
|
|
// the folder-title metrics. The defaults reproduce the historical `.labelColor` +
|
|
// `.title2`/semibold title. `updateNSView` MUST re-apply both — this Representable reuses
|
|
// the same field instance, so a preset change after reopen would not refresh otherwise.
|
|
field.textColor = titleColor
|
|
field.font = titleFont
|
|
field.maximumNumberOfLines = 1
|
|
field.usesSingleLineMode = true
|
|
field.lineBreakMode = .byTruncatingTail
|
|
// NOT scrollable: a scrollable NSTextFieldCell draws from the leading edge and ignores
|
|
// `.center` alignment, so the title rendered left. Single-line + truncating tail already
|
|
// prevent wrapping, so dropping it keeps the title centered (iOS folder-title parity).
|
|
field.stringValue = name
|
|
// Centered placeholder: a plain `placeholderString` draws leading-aligned regardless of
|
|
// the field's `.center`, so the empty-title prompt rendered left. An attributed string
|
|
// with a centered paragraph style honors the alignment.
|
|
field.placeholderAttributedString = Self.centeredPlaceholder(placeholder, font: field.font)
|
|
field.editGate = { [weak coordinator = context.coordinator] in
|
|
coordinator?.parent.editGate() ?? true
|
|
}
|
|
field.onFocusIn = { [weak coordinator = context.coordinator, weak field] in
|
|
coordinator?.beginEditing(original: field?.stringValue ?? "")
|
|
}
|
|
context.coordinator.field = field
|
|
controller?.coordinator = context.coordinator
|
|
return field
|
|
}
|
|
|
|
func updateNSView(_ field: LaunchpadRenameTextField, context: Context) {
|
|
let coordinator = context.coordinator
|
|
coordinator.parent = self
|
|
coordinator.field = field
|
|
controller?.coordinator = coordinator
|
|
// Symmetric with `makeNSView`: the Representable reuses the same field across reopens, so
|
|
// re-apply the label style here or a preset change would not take until the panel is
|
|
// recreated. Gate behind a value-changed check (like the `folderID`/`stringValue` guards
|
|
// below) so a re-render mid-rename — `controlTextDidChange` saves every keystroke, which
|
|
// re-renders the parent and fires this on each key — does not churn the live field editor's
|
|
// layout by reassigning an already-equal font/colour. Set the font BEFORE rebuilding the
|
|
// placeholder so it follows (centeredPlaceholder reads `field.font`).
|
|
if field.textColor != titleColor {
|
|
field.textColor = titleColor
|
|
}
|
|
if field.font != titleFont {
|
|
field.font = titleFont
|
|
field.placeholderAttributedString = Self.centeredPlaceholder(placeholder, font: field.font)
|
|
}
|
|
if coordinator.folderID != folderID {
|
|
// The panel stayed mounted across a close→reopen of a different folder (the 0.34s
|
|
// unmount grace): any leftover session belongs to the OLD folder — `closeFolder`
|
|
// already committed it, so just drop the state and show the new name.
|
|
coordinator.abandonSession()
|
|
coordinator.folderID = folderID
|
|
field.stringValue = name
|
|
} else if !coordinator.isEditing, field.stringValue != name {
|
|
field.stringValue = name // reflect store changes; never stomp live typing
|
|
}
|
|
if let request = focusRequestID, request == folderID {
|
|
coordinator.requestProgrammaticFocus()
|
|
}
|
|
}
|
|
|
|
/// Unmount backstop, SECOND line of defence: an unresolved session commits so no typed
|
|
/// name is ever lost. The primary paths commit earlier and on the event stack — scrim
|
|
/// close / typing-to-search via `closeFolder`, whole-window teardown via the overlay
|
|
/// controller's `close()` resigning first responder (SwiftUI does NOT contract dismantle
|
|
/// for an NSHostingView deallocated with its window, only for diffing removals). This
|
|
/// catches what remains, e.g. the folder dissolving underneath the open panel. Data only —
|
|
/// the window may be going away, so no refocus.
|
|
static func dismantleNSView(_ field: LaunchpadRenameTextField, coordinator: Coordinator) {
|
|
coordinator.endEditingNow(commit: true, refocus: false)
|
|
coordinator.detach()
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator(self) }
|
|
|
|
/// Forwards AppKit text-control events into the pure `LaunchpadRenameEditSession` and
|
|
/// applies its resolutions. The handle* methods take plain values so unit tests drive
|
|
/// the full table without a window or field editor.
|
|
@MainActor
|
|
final class Coordinator: NSObject, NSTextFieldDelegate {
|
|
var parent: LaunchpadFolderRenameField
|
|
weak var field: LaunchpadRenameTextField?
|
|
var folderID: String
|
|
private(set) var session: LaunchpadRenameEditSession?
|
|
/// Last applied resolution — test instrumentation for the forwarding table.
|
|
private(set) var lastResolution: LaunchpadRenameEditSession.Resolution?
|
|
private var focusRequestInFlight = false
|
|
/// True once a keystroke has been saved to the store in real time this session, so an Esc
|
|
/// cancel knows it must roll the store back to the original name. Cleared on each begin.
|
|
private var hasLiveCommitted = false
|
|
|
|
init(_ parent: LaunchpadFolderRenameField) {
|
|
self.parent = parent
|
|
self.folderID = parent.folderID
|
|
}
|
|
|
|
var isEditing: Bool { session.map { !$0.hasResolved } ?? false }
|
|
|
|
// MARK: Session events (unit-testable surface)
|
|
|
|
func beginEditing(original: String) {
|
|
guard !isEditing else { return }
|
|
session = .begin(originalName: original)
|
|
hasLiveCommitted = false
|
|
}
|
|
|
|
func handleTextChange(_ text: String) {
|
|
session?.textChanged(text)
|
|
}
|
|
|
|
/// `doCommandBySelector` routing. During IME composition the command belongs to the
|
|
/// candidate window — return false so Return confirms / Esc cancels the composition,
|
|
/// never resolving the rename (same discipline as the search field).
|
|
func handleCommand(_ selector: Selector, hasMarkedText: Bool) -> Bool {
|
|
guard !hasMarkedText, session != nil else { return false }
|
|
switch selector {
|
|
case #selector(NSResponder.insertNewline(_:)):
|
|
apply(resolve { $0.commit() })
|
|
return true
|
|
case #selector(NSResponder.cancelOperation(_:)):
|
|
apply(resolve { $0.cancel() })
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Native end-editing (blur, refocus after Return/Esc, teardown). The session's latch
|
|
/// makes the post-Return/post-Esc occurrence inert; a plain blur commits here.
|
|
func handleEndEditing() {
|
|
apply(resolve { $0.endEditing() })
|
|
session = nil
|
|
}
|
|
|
|
/// SwiftUI-side hooks (blank tap / closeFolder / in-folder drag began).
|
|
func endEditingNow(commit: Bool, refocus: Bool = true) {
|
|
guard isEditing else { return }
|
|
apply(resolve { commit ? $0.commit() : $0.cancel() }, refocus: refocus)
|
|
}
|
|
|
|
func detach() {
|
|
session = nil
|
|
field = nil
|
|
}
|
|
|
|
func abandonSession() {
|
|
session = nil
|
|
}
|
|
|
|
// MARK: Programmatic focus (context-menu rename / post-creation auto-open)
|
|
|
|
/// Focus + select-all once the field is actually in a window. The panel may be mounting
|
|
/// in this very SwiftUI transaction, so retry across a few runloop turns before giving
|
|
/// up; the request is consumed (cleared) either way.
|
|
func requestProgrammaticFocus() {
|
|
guard !focusRequestInFlight else { return }
|
|
focusRequestInFlight = true
|
|
attemptProgrammaticFocus(retries: 8)
|
|
}
|
|
|
|
private func attemptProgrammaticFocus(retries: Int) {
|
|
DispatchQueue.main.async { [weak self] in
|
|
guard let self else { return }
|
|
if let field = self.field, let window = field.window {
|
|
self.focusRequestInFlight = false
|
|
self.parent.onFocusRequestHandled()
|
|
guard self.parent.editGate() else { return } // mid-carry: stay a title
|
|
window.makeFirstResponder(field)
|
|
field.selectText(nil) // programmatic entry selects all (§2.3)
|
|
// `selectText` re-establishes the field editor and resets its alignment to the
|
|
// natural (leading) default, stomping the `.center` that becomeFirstResponder /
|
|
// controlTextDidBeginEditing applied — so the auto-opened (post-creation) and
|
|
// context-menu-rename titles rendered LEFT-aligned while the click-entered and
|
|
// static titles centered. Re-apply center AFTER selectText, where the editor is
|
|
// guaranteed live. (Runtime-verified: without this, a new folder's title sits
|
|
// ~one-cell left of the panel centre.)
|
|
(field.currentEditor() as? NSTextView)?.alignment = .center
|
|
} else if retries > 0 {
|
|
self.attemptProgrammaticFocus(retries: retries - 1)
|
|
} else {
|
|
self.focusRequestInFlight = false
|
|
self.parent.onFocusRequestHandled()
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: Resolution plumbing
|
|
|
|
private func resolve(
|
|
_ event: (inout LaunchpadRenameEditSession) -> LaunchpadRenameEditSession.Resolution?
|
|
) -> LaunchpadRenameEditSession.Resolution? {
|
|
guard var current = session else { return nil }
|
|
let resolution = event(¤t)
|
|
session = current // keep the latched session until end-editing clears it
|
|
return resolution
|
|
}
|
|
|
|
private func apply(_ resolution: LaunchpadRenameEditSession.Resolution?, refocus: Bool = true) {
|
|
guard let resolution else { return }
|
|
lastResolution = resolution
|
|
switch resolution {
|
|
case .commit(let text):
|
|
parent.onCommit(text)
|
|
case .cancel(let restore):
|
|
field?.stringValue = restore
|
|
// Only roll the store back if real-time save actually wrote mid-edit text this
|
|
// session; otherwise a cancel must stay silent (no spurious commit).
|
|
if hasLiveCommitted {
|
|
parent.onCommit(restore)
|
|
}
|
|
}
|
|
// Hand the keyboard back to the search field (Return keeps the folder open, §2.3);
|
|
// the resulting native end-editing is latched into a no-op by the session.
|
|
if refocus { refocusSearchField() }
|
|
}
|
|
|
|
private func refocusSearchField() {
|
|
guard let window = field?.window,
|
|
let search = Self.searchField(in: window.contentView) else { return }
|
|
window.makeFirstResponder(search)
|
|
}
|
|
|
|
private static func searchField(in view: NSView?) -> NSSearchField? {
|
|
guard let view else { return nil }
|
|
if let field = view as? NSSearchField { return field }
|
|
for subview in view.subviews {
|
|
if let found = searchField(in: subview) { return found }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// MARK: NSTextFieldDelegate
|
|
|
|
func controlTextDidBeginEditing(_ obj: Notification) {
|
|
// Center the shared field editor here, where it is guaranteed attached — doing it in
|
|
// becomeFirstResponder is too early (currentEditor() may still be nil), which left the
|
|
// caret/placeholder leading-aligned while editing despite the field's .center.
|
|
(field?.currentEditor() as? NSTextView)?.alignment = .center
|
|
}
|
|
|
|
func controlTextDidChange(_ obj: Notification) {
|
|
guard let field else { return }
|
|
handleTextChange(field.stringValue)
|
|
// Real-time save: persist every keystroke so the name can never be lost to a focus
|
|
// race or an out-of-order close (the auto-open rename and the carry-release refocus
|
|
// can land in the same runloop). `renameFolder` trims, falls back on empty, and
|
|
// no-op-guards unchanged names, so high-frequency calls are cheap and safe. An Esc
|
|
// cancel rolls the store back to the original name (see `apply`).
|
|
if isEditing {
|
|
parent.onCommit(field.stringValue)
|
|
hasLiveCommitted = true
|
|
}
|
|
}
|
|
|
|
func controlTextDidEndEditing(_ obj: Notification) {
|
|
handleEndEditing()
|
|
}
|
|
|
|
func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
|
|
handleCommand(selector, hasMarkedText: textView.hasMarkedText())
|
|
}
|
|
}
|
|
}
|
|
|
|
/// NSTextField subclass so the Esc monitor can recognise the rename field editor by delegate
|
|
/// type, and so entry into editing can be gated (mid-carry the title must stay inert).
|
|
final class LaunchpadRenameTextField: NSTextField {
|
|
var editGate: @MainActor () -> Bool = { true }
|
|
var onFocusIn: @MainActor () -> Void = {}
|
|
|
|
override func becomeFirstResponder() -> Bool {
|
|
guard editGate() else { return false }
|
|
let became = super.becomeFirstResponder()
|
|
if became {
|
|
// The shared field editor does not inherit the field's `.center` alignment, so typed
|
|
// text would left-align while editing; force it center to match the static title.
|
|
(currentEditor() as? NSTextView)?.alignment = .center
|
|
onFocusIn()
|
|
}
|
|
return became
|
|
}
|
|
}
|