Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

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(&current)
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
}
}