1142 lines
61 KiB
Swift
1142 lines
61 KiB
Swift
import AppKit
|
||
import MacToolsPluginKit
|
||
import SwiftUI
|
||
|
||
/// The launcher content: a focused search field over a horizontally **paged** app grid
|
||
/// (classic Launchpad layout).
|
||
///
|
||
/// Input model (Codex P0 #2/#3): the `NSSearchField` is first responder, so typing /
|
||
/// IME composition just works while the grid stays visible ("grid-first, type to
|
||
/// search"). Arrow keys move the grid selection and Return launches — routed through
|
||
/// the field's `doCommandBySelector`, which the system does NOT call during IME
|
||
/// composition, so a candidate-selecting Return never launches an app.
|
||
///
|
||
/// Paging (LP4b): macOS SwiftUI has no `PageTabViewStyle`, so pages are laid out in a
|
||
/// single clipped `HStack` and slid by an `offset`. `selectedIndex` is the source of
|
||
/// truth; the visible page is derived from it (`selectedIndex / perPage`), so keyboard
|
||
/// navigation, page dots and drag all stay consistent.
|
||
struct LaunchpadGridView: View {
|
||
@ObservedObject var catalog: LaunchpadAppCatalog
|
||
/// Custom-order layout. Observed so a reorder / reset (which mutates `@Published layout`)
|
||
/// re-evaluates `filtered` and re-renders — the overlay grid does NOT observe
|
||
/// `onStateChange`, so this injection is the only reorder-refresh path (design §5.5 / R1).
|
||
@ObservedObject var layoutStore: LaunchpadLayoutStore
|
||
/// Shared across the root pages and the open folder; owns the finger-bound carry session.
|
||
/// Injected (the overlay controller owns it) so overlay teardown can abort an in-flight carry —
|
||
/// the floating icon is a separate NSWindow that would otherwise outlive the launcher.
|
||
/// `@ObservedObject` so its `@Published` commit token re-renders this view: the commit visuals
|
||
/// are requested from an AppKit mouseUp handler, where a captured-closure `@State` write
|
||
/// doesn't invalidate. The token is VISUAL-only — data lands via the injected storeApplier.
|
||
@ObservedObject var dragCoordinator: LaunchpadDragCoordinator
|
||
/// Fixed column count, or `LaunchpadPreferences.autoColumns` (0) to fit to width.
|
||
var columns: Int = LaunchpadPreferences.autoColumns
|
||
/// Compact (centered panel) vs fullscreen — tightens padding and, since a small
|
||
/// panel dismisses by clicking *outside* it (→ app resigns active), drops the
|
||
/// inside-the-panel click-to-dismiss that fullscreen Launchpad uses.
|
||
var isCompact: Bool = false
|
||
/// Session grid metrics, injected by the overlay controller (single source, design
|
||
/// §1.4): paging math, the AppKit page grids and the folder panel all read THIS —
|
||
/// never a private mirror. No default on purpose (P2): construction must inject the
|
||
/// session snapshot, so a new call site can't silently fall back to 64pt/labels-on.
|
||
var metrics: LaunchpadGridMetrics
|
||
/// Ids hidden from the grid (snapshot at open; the live set below seeds from it).
|
||
var hiddenAppIDs: Set<String> = []
|
||
/// Glass background recipe, snapshotted at `open()` like `windowMode` (design §5.4 —
|
||
/// no live re-binding mid-session). Default = the standard preset's legacy rendering.
|
||
var backgroundRecipe: LaunchpadBackgroundRecipe = .legacyUltraThin
|
||
var localization: PluginLocalization = PluginLocalization(bundle: .main)
|
||
var onActivate: (LaunchpadAppItem) -> Void
|
||
var onReveal: (LaunchpadAppItem) -> Void
|
||
var onHide: (LaunchpadAppItem) -> Void
|
||
var onDismiss: () -> Void
|
||
|
||
@State private var searchText = ""
|
||
@State private var selectedIndex = 0
|
||
@State private var currentPage = 0
|
||
/// Hiding an app removes it from the grid immediately (live), while `onHide`
|
||
/// persists it; seeded from `hiddenAppIDs` on appear.
|
||
@State private var sessionHidden: Set<String> = []
|
||
@State private var columnCount = 7
|
||
@State private var rowCount = 5
|
||
/// Visible order frozen at drag start, so a drop reorders against exactly what the user
|
||
/// dragged — even if an async catalog reload lands mid-drag (design §5.3 / Codex P2).
|
||
@State private var dragOrderSnapshot: [LaunchpadDisplayCell]?
|
||
/// Live horizontal offset while an empty-space drag pages the grid (follow-the-cursor).
|
||
@State private var pageDragTranslation: CGFloat = 0
|
||
/// Debounces the end of a two-finger scroll — its accumulation lives here (shared), NOT in the
|
||
/// per-page AppKit containers, so a sliding grid can't feed two totals into the offset.
|
||
@State private var pageScrollEndWork: DispatchWorkItem?
|
||
/// The open folder's id, or `nil` when no folder overlay is showing. Tapping a folder cell
|
||
/// sets it; the scrim tap / typing / Esc clears it.
|
||
@State private var openFolderID: String?
|
||
/// Drives the open/close zoom explicitly. A `.transition` on the conditionally-inserted panel
|
||
/// never animates reliably in this ZStack-over-AppKit hierarchy (the tuple-derived view has no
|
||
/// stable identity), so the panel is always rendered while open and zoomed via this flag.
|
||
@State private var folderShown = false
|
||
/// Folder id whose rename field should grab focus + select-all once its panel is mounted —
|
||
/// set by the context-menu rename action and the post-creation auto-open (design §2.5/§2.6).
|
||
@State private var pendingRenameFocusID: String?
|
||
/// Stable handle into the bridged rename field, so SwiftUI-side hooks (blank tap, folder
|
||
/// close, in-folder drag start) can end the edit session explicitly (design §2.3/§2.7).
|
||
@State private var renameController = LaunchpadFolderRenameController()
|
||
/// Backs the `filtered` memoization. Reference type so cached cells persist across
|
||
/// body passes; mutating it never invalidates the view (it is a pure cache).
|
||
@State private var filteredCache = FilteredCache()
|
||
/// Full ZStack size, captured via a background GeometryReader. The folder pop-out's
|
||
/// `collapsedOffset` needs the whole container (its centre + the grid's chrome offset),
|
||
/// not the post-chrome grid viewport `pagedGrid` measures. `.zero` until the first layout
|
||
/// pass; the collapsed-offset fallback from the animation design catches that frame.
|
||
@State private var containerSize: CGSize = .zero
|
||
/// Shared chrome constants (search bar, paddings, page-dot reserve) — single-sourced
|
||
/// in `LaunchpadLayoutMath.Chrome` so the settings preview can't drift (design §1.1).
|
||
private var chrome: LaunchpadLayoutMath.Chrome { .standard(isCompact: isCompact) }
|
||
|
||
/// Memoized: the reconcile projection is pure in these inputs, so a body pass
|
||
/// that changes only paging state (`pageDragTranslation`) — i.e. every frame of
|
||
/// a two-finger page swipe — reuses the cached cells instead of rebuilding the
|
||
/// folder projection per frame. The cache key is the FULL input set; a stale key
|
||
/// would surface wrong icons after paging, so every dependency is included and
|
||
/// compared exactly (Array `==` is O(1) on the unchanged COW buffer during paging).
|
||
private var filtered: [LaunchpadDisplayCell] {
|
||
let inputs = FilteredInputs(
|
||
apps: catalog.apps,
|
||
layout: layoutStore.layout,
|
||
sessionHidden: sessionHidden,
|
||
searchText: searchText,
|
||
folderEjectActive: dragCoordinator.folderEjectActive,
|
||
carriedAppID: dragCoordinator.carriedAppID,
|
||
carriedSourceFolderID: dragCoordinator.carriedSourceFolderID
|
||
)
|
||
return filteredCache.resolve(inputs) {
|
||
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
// Search is a flat, folder-agnostic projection: every matching app surfaces as its own
|
||
// `.app` cell, folders dissolved (design §6). Clearing the query returns to the layout.
|
||
guard query.isEmpty else { return searchResults(query: query).map(LaunchpadDisplayCell.app) }
|
||
// Layout state: custom order + folders via the pure reconcile projection. `layout == nil`
|
||
// → alphabetical `.app`-only cells, byte-for-byte the previous behaviour (design §5.2:
|
||
// output set == visible, only order differs).
|
||
let cells = LaunchpadLayoutReconciler
|
||
.reconcile(apps: catalog.apps, layout: layoutStore.layout, hidden: sessionHidden)
|
||
// During a folder carry, hide the carried app from its source folder's thumbnail (display
|
||
// only — the data isn't touched until release, preserving the drop-back-in revert).
|
||
guard dragCoordinator.folderEjectActive,
|
||
let appID = dragCoordinator.carriedAppID,
|
||
let folderID = dragCoordinator.carriedSourceFolderID
|
||
else { return cells }
|
||
return cells.map { cell in
|
||
guard case .folder(let id, let name, let items) = cell, id == folderID else { return cell }
|
||
return .folder(id: id, name: name, items: items.filter { $0.id != appID })
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The root-level apps among the display cells — folders excluded. Used when capturing the
|
||
/// visible order before a reorder (a folder is already a layout node; only loose apps need
|
||
/// folding into the layout).
|
||
private func rootApps(of cells: [LaunchpadDisplayCell]) -> [LaunchpadAppItem] {
|
||
cells.compactMap { if case .app(let item) = $0 { return item }; return nil }
|
||
}
|
||
|
||
/// Flat fuzzy search — ignores layout/folders entirely and never touches persistence
|
||
/// (design §6); clearing the query returns to the reconciled layout for free.
|
||
private func searchResults(query: String) -> [LaunchpadAppItem] {
|
||
let visible = catalog.apps.filter { !sessionHidden.contains($0.id) }
|
||
// Fuzzy (subsequence) match, ranked by relevance; ties keep alphabetical order.
|
||
var scored: [(app: LaunchpadAppItem, score: Int)] = []
|
||
for app in visible {
|
||
if let s = LaunchpadFuzzy.score(name: app.name, query: query) {
|
||
scored.append((app, s))
|
||
}
|
||
}
|
||
scored.sort { lhs, rhs in
|
||
if lhs.score != rhs.score { return lhs.score > rhs.score }
|
||
return lhs.app.name.localizedCaseInsensitiveCompare(rhs.app.name) == .orderedAscending
|
||
}
|
||
return scored.map(\.app)
|
||
}
|
||
|
||
private var perPage: Int { max(1, columnCount * rowCount) }
|
||
|
||
private var pageCount: Int {
|
||
max(1, Int(ceil(Double(filtered.count) / Double(perPage))))
|
||
}
|
||
|
||
/// Main background, resolved from the session's recipe snapshot (never switches
|
||
/// mid-session, so the case change carries no view-identity risk).
|
||
/// - `.legacyUltraThin` (the default "standard" preset) is the pre-change path,
|
||
/// byte-identical: one `.ultraThinMaterial` fill doubling as the dismiss tap layer (G1).
|
||
/// - `.glass` is the parameterised backdrop + a plain dim Rectangle (free — no second
|
||
/// effect view, §5.5 performance line) + a clear tap layer preserving the exact
|
||
/// click-to-dismiss semantics.
|
||
@ViewBuilder
|
||
private var backgroundLayer: some View {
|
||
switch backgroundRecipe {
|
||
case .legacyUltraThin:
|
||
Rectangle()
|
||
.fill(.ultraThinMaterial)
|
||
.ignoresSafeArea()
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { if !isCompact { onDismiss() } }
|
||
case .glass(let material, let dimOpacity, let forcesDark):
|
||
LaunchpadGlassBackdrop(
|
||
material: material,
|
||
blendingMode: .behindWindow,
|
||
forcesDarkAppearance: forcesDark,
|
||
// Compact panel: behind-window blur may ignore the host layer's corner
|
||
// mask (AppKit gotcha, design §5.4 #4) — round the material itself too.
|
||
cornerRadius: isCompact ? LaunchpadCompactPanelMetrics.cornerRadius : 0
|
||
)
|
||
.ignoresSafeArea()
|
||
Rectangle()
|
||
.fill(.black.opacity(dimOpacity))
|
||
.ignoresSafeArea()
|
||
.allowsHitTesting(false)
|
||
Rectangle()
|
||
.fill(.clear)
|
||
.contentShape(Rectangle())
|
||
.ignoresSafeArea()
|
||
.onTapGesture { if !isCompact { onDismiss() } }
|
||
}
|
||
}
|
||
|
||
var body: some View {
|
||
ZStack {
|
||
// Glass backdrop + click-to-dismiss on empty space (design §5.4). Icons and the
|
||
// search field (AppKit NSViews) hit-test first and swallow their own clicks, so
|
||
// only genuine empty space (gaps/margins) reaches the tap layer. In compact mode
|
||
// the panel itself is the content, so inside clicks must NOT dismiss (outside
|
||
// clicks deactivate the app → close). Stays OUTSIDE the paging `.offset` —
|
||
// wrapping it would re-sample the desktop every paging frame (§5.5).
|
||
backgroundLayer
|
||
|
||
VStack(spacing: chrome.stackSpacing) {
|
||
searchBar
|
||
content
|
||
}
|
||
.padding(.top, chrome.topPadding)
|
||
.padding(.bottom, chrome.bottomPadding)
|
||
.padding(.horizontal, chrome.horizontalPadding)
|
||
|
||
// Folder overlay = a frosted scrim + the folder panel. Both are rendered whenever a
|
||
// folder is open and animated via `folderShown` (explicit scale/opacity, not a
|
||
// transition — see the `folderShown` note). The grid below is interaction-disabled
|
||
// while it's up, so a scrim tap reliably closes instead of being eaten by a cell.
|
||
if let folder = openFolder {
|
||
folderScrim
|
||
.opacity(folderShown ? 1 : 0)
|
||
.animation(folderShown ? folderOpenAnimation : folderCloseAnimation, value: folderShown)
|
||
|
||
// iOS-style pop-out: the panel grows FROM the opened folder's grid cell, not from
|
||
// the ZStack centre (animation design 2026-06-13). `collapsedOffset` slides the
|
||
// (centred) panel onto that cell and `collapsedScale` shrinks it to a dot; opening
|
||
// returns to `.zero` / `1`. offset + scale + opacity share ONE `.animation(value:
|
||
// folderShown)` so the three never split into off-beat segments. No `.transition`
|
||
// (the ZStack-over-AppKit tuple view has no stable identity — see `folderShown`).
|
||
folderPanel(folder)
|
||
.scaleEffect(folderShown ? 1 : collapsedScale, anchor: .center)
|
||
.offset(folderShown ? .zero : collapsedOffset)
|
||
.opacity(folderShown ? 1 : 0)
|
||
.animation(folderShown ? folderOpenAnimation : folderCloseAnimation, value: folderShown)
|
||
}
|
||
}
|
||
// Capture the full ZStack size for the folder pop-out's collapsed-offset math. Sits in a
|
||
// background (never affects layout) and reports the container the centred panel lives in.
|
||
.background(
|
||
GeometryReader { proxy in
|
||
Color.clear.onAppear { containerSize = proxy.size }
|
||
.onChange(of: proxy.size) { _, size in containerSize = size }
|
||
}
|
||
)
|
||
.onAppear {
|
||
selectedIndex = 0; currentPage = 0; sessionHidden = hiddenAppIDs
|
||
// The coordinator outlives this view (controller-owned): a previous session torn
|
||
// down with its folder open would otherwise leave the Esc ladder's middle rung
|
||
// armed forever — every later Esc would be swallowed by a folder that no longer
|
||
// exists. Fresh session, fresh mirror.
|
||
dragCoordinator.folderPanelDidChange(open: false)
|
||
}
|
||
// Mid-drag: the app left the folder → zoom the folder closed NOW while the drag continues
|
||
// (kept mounted so the folder cell stays the live mouse-event target until release). Driven
|
||
// by the coordinator's @Published flag so this runs in a tracked SwiftUI transaction.
|
||
.onChange(of: dragCoordinator.folderEjectActive) { _, active in
|
||
if active { withAnimation(folderCloseAnimation) { folderShown = false } }
|
||
}
|
||
// Release: VISUAL channel only. The store mutation already happened synchronously in
|
||
// mouseUp through the coordinator's injected storeApplier (design §1.3) — if this view is
|
||
// torn down before the token is consumed, only the close/re-select visuals are lost.
|
||
.onChange(of: dragCoordinator.commitToken) { _, _ in
|
||
guard let visual = dragCoordinator.pendingVisualCommit else { return }
|
||
if case .folder = visual.origin { // finish unmounting the (already zoomed) folder
|
||
openFolderID = nil
|
||
folderShown = false
|
||
dragCoordinator.folderPanelDidChange(open: false)
|
||
}
|
||
// One animation for re-select + virtual-page collapse: the dot retracting and the
|
||
// viewport falling back to the last real page read as a single motion (§6.2).
|
||
withAnimation(pageSnap) {
|
||
if let landingID = visual.landingID { relocateSelection(to: landingID) }
|
||
currentPage = min(currentPage, pageCount - 1)
|
||
}
|
||
dragOrderSnapshot = nil
|
||
}
|
||
// Post-creation auto-open (design §2.6, R1=B): published at the settle REVEAL — never
|
||
// mid-flight, where mounting the panel would fight the `settlingItemID` park visuals.
|
||
// Declared after the commitToken handler so a folder-origin close lands first. Guards:
|
||
// the board may have changed in the 0.25s flight (new carry adopted the gesture, typing
|
||
// flattened to search, the folder dissolved) — then the reveal is consumed silently.
|
||
.onChange(of: dragCoordinator.folderRevealToken) { _, _ in
|
||
guard let id = dragCoordinator.revealedFolderID,
|
||
dragCoordinator.carrySession == nil,
|
||
isLayoutEditable,
|
||
folderCell(id) != nil
|
||
else { return }
|
||
pendingRenameFocusID = id
|
||
openFolderPanel(id: id)
|
||
}
|
||
// Esc with a folder open (design §2.4 middle rung): the controller's key monitor can't
|
||
// reach SwiftUI state, so it publishes a request and the view runs the real close path
|
||
// (commit a live rename, zoom out). A stale request with no folder open is a no-op
|
||
// (`closeFolder` guards on `openFolderID`).
|
||
.onChange(of: dragCoordinator.folderCloseRequestToken) { _, _ in
|
||
closeFolder()
|
||
}
|
||
// Safety net for the coordinator's folder-visibility mirror: the panel can die without
|
||
// `closeFolder()` ever running (the open folder emptied or dissolved underneath) — re-sync
|
||
// from the derived value so the Esc ladder can never get stuck on the folder rung.
|
||
.onChange(of: openFolder?.id) { _, id in
|
||
if id == nil { dragCoordinator.folderPanelDidChange(open: false) }
|
||
}
|
||
// Declared AFTER the commitToken handler — on a commit, the landing re-selection above
|
||
// must win before this cancel-shaped fallback could run (§6.4, AR-8).
|
||
.onChange(of: dragCoordinator.carryActive) { _, active in
|
||
if active {
|
||
// Entering a carry clears any in-flight two-finger paging residue: the deferred
|
||
// snap work item would otherwise fire mid-carry with a stale translation (AC-6).
|
||
pageScrollEndWork?.cancel()
|
||
pageScrollEndWork = nil
|
||
pageDragTranslation = 0
|
||
} else if dragCoordinator.endReason == .cancelled {
|
||
// A cancelled carry collapses the virtual tail page; clamp back with the snap.
|
||
withAnimation(pageSnap) { currentPage = min(currentPage, pageCount - 1) }
|
||
}
|
||
}
|
||
.onChange(of: searchText) { _, _ in
|
||
// Design §9.1 row 6: typing mid-carry cancels the carry FIRST, before the folder
|
||
// close and the page/selection reset. This keeps `editableAtBegin`'s safety premise
|
||
// self-contained — a release can never classify against the flattened search cells
|
||
// and write the store. (The folder grid's unmount hook is the second insurance: a
|
||
// non-empty query dissolves folders, so the source grid leaves the window in this
|
||
// same commit and would cancel too.)
|
||
if dragCoordinator.carrySession != nil { dragCoordinator.cancelCarry(.searchActivated) }
|
||
selectedIndex = 0; currentPage = 0
|
||
closeFolder() // typing dissolves folders into a flat search (animated close)
|
||
}
|
||
// Edge-dwell page flip (design §4.4): published by the dwell machine from the mouse-
|
||
// tracking loop, applied here in a tracked transaction (never withAnimation inside a
|
||
// mouse handler). Double insurance: a request withdrawn at release/cancel reads nil, and
|
||
// a stale one is ignored unless a carry is actually live.
|
||
.onChange(of: dragCoordinator.flipRequest) { _, request in
|
||
guard let request, dragCoordinator.carrySession?.isCarrying == true else { return }
|
||
goToPage(request.targetPage)
|
||
}
|
||
}
|
||
|
||
private var searchBar: some View {
|
||
LaunchpadSearchField(
|
||
text: $searchText,
|
||
localization: localization,
|
||
onMove: handleMove,
|
||
onLaunch: activateSelection,
|
||
onCancel: handleCancel
|
||
)
|
||
.frame(width: chrome.searchBarWidth, height: chrome.searchBarHeight)
|
||
}
|
||
|
||
@ViewBuilder
|
||
private var content: some View {
|
||
if catalog.isLoading && catalog.apps.isEmpty {
|
||
Spacer()
|
||
ProgressView().controlSize(.large)
|
||
Spacer()
|
||
} else if filtered.isEmpty {
|
||
Spacer()
|
||
VStack(spacing: 8) {
|
||
Image(systemName: "magnifyingglass")
|
||
.font(.system(size: 42, weight: .regular))
|
||
.foregroundStyle(.secondary)
|
||
Text(
|
||
searchText.isEmpty
|
||
? localization.string("grid.empty.noApps", defaultValue: "未找到应用")
|
||
: localization.string("grid.empty.noMatches", defaultValue: "无匹配应用")
|
||
)
|
||
.font(.title3)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
Spacer()
|
||
} else {
|
||
pagedGrid
|
||
}
|
||
}
|
||
|
||
/// Real pages plus one VIRTUAL tail page while a carry is live (design §6.1): an empty,
|
||
/// zero-cost container appended from carry begin, so the page a dwell flips to is already
|
||
/// mounted and registered. A drop on it lands at the global tail (§6.2 — the documented iOS
|
||
/// divergence: no persistent sparse pages); afterwards the page collapses with the snap.
|
||
private var displayPageCount: Int {
|
||
pageCount + (dragCoordinator.carryActive && isLayoutEditable ? 1 : 0)
|
||
}
|
||
|
||
private var pagedGrid: some View {
|
||
GeometryReader { geo in
|
||
let visiblePage = min(currentPage, displayPageCount - 1)
|
||
VStack(spacing: 10) {
|
||
HStack(spacing: 0) {
|
||
ForEach(0..<displayPageCount, id: \.self) { page in
|
||
pageContent(page: page, columns: columnCount)
|
||
.frame(width: geo.size.width)
|
||
}
|
||
}
|
||
.frame(width: geo.size.width, alignment: .leading)
|
||
.offset(x: -CGFloat(visiblePage) * geo.size.width + pageDragTranslation)
|
||
.clipped()
|
||
// OUTSIDE the .offset: this background's frame chain is clean of the paging
|
||
// transform, so it measures the viewport (the visible page slot) in window space.
|
||
.background(LaunchpadViewportRelay(coordinator: dragCoordinator,
|
||
pageCount: pageCount, perPage: perPage))
|
||
// Paging is handled inside the AppKit grid: `onPageDrag` tracks an empty-space
|
||
// drag live (follow-the-cursor) and `onPageSwipe` handles scroll; both share one
|
||
// event-arbitration tree with per-item drag (design §5.1). Page changes animate
|
||
// via `withAnimation` in the handlers (not a value-keyed modifier) so the live
|
||
// drag offset and the snap stay one continuous motion.
|
||
|
||
if displayPageCount > 1 {
|
||
pageIndicator(current: visiblePage)
|
||
}
|
||
}
|
||
.onAppear {
|
||
updateLayout(size: geo.size)
|
||
dragCoordinator.currentPageDidChange(currentPage) // @State resets per open; the coordinator outlives it
|
||
}
|
||
.onChange(of: geo.size) { _, size in updateLayout(size: size) }
|
||
// Single funnel for page changes — every flip path (goToPage, snap, keyboard, hide,
|
||
// search reset, …) mutates this one @State, so the coordinator can't miss one (§3.2).
|
||
.onChange(of: currentPage) { _, page in dragCoordinator.currentPageDidChange(page) }
|
||
}
|
||
}
|
||
|
||
/// One page rendered as an AppKit drag grid (per-item `NSDraggingSource`). Items are
|
||
/// sliced from `filtered`; selection/activation/reorder use the global `filtered` order
|
||
/// so navigation and ordering span the whole list.
|
||
private func pageContent(page: Int, columns: Int) -> some View {
|
||
let start = page * perPage
|
||
let end = min(start + perPage, filtered.count)
|
||
let items = start < end ? Array(filtered[start..<end]) : []
|
||
return LaunchpadDragGrid(
|
||
items: items,
|
||
columns: columns,
|
||
// Page capacity for the make-way overflow: rowCount is what sliced `items` above, so
|
||
// the container's "off the right edge" threshold and the slice agree exactly (a
|
||
// bounds-derived row count could drift from the indicator-reserve arithmetic).
|
||
rows: rowCount,
|
||
selectedID: selectedID,
|
||
isCompact: isCompact,
|
||
interactionEnabled: openFolder == nil, // exactly matches overlay visibility
|
||
// The critical seam (design §1.4): the root pages must run on the SAME
|
||
// injected metrics as the paging math above, or the SwiftUI pager and the
|
||
// AppKit grid disagree and cross-page carry drop points skew.
|
||
metrics: metrics,
|
||
localization: localization,
|
||
iconProvider: { catalog.icon(for: $0) },
|
||
onActivate: activateCell,
|
||
onReveal: onReveal,
|
||
onCopyPath: copyPath,
|
||
onHide: hideApp,
|
||
onMoveToFront: moveAppToFront,
|
||
onMoveToEnd: moveAppToEnd,
|
||
onSelect: selectCell,
|
||
onReorder: handleReorder,
|
||
onMakeFolder: handleMakeFolder,
|
||
onAddToFolder: handleAddToFolder,
|
||
// Context-menu folder actions (design §2.5, R2): rename opens the panel with the
|
||
// title focused + selected; dissolve releases the apps back to the grid, no
|
||
// confirmation (data is never lost — the folder is cheap to rebuild).
|
||
onRenameFolder: { id in
|
||
pendingRenameFocusID = id
|
||
openFolderPanel(id: id)
|
||
},
|
||
onDissolveFolder: { id in
|
||
let firstChild = folderCell(id)?.items.first?.id // before dissolve: id vanishes after
|
||
layoutStore.dissolveFolder(id)
|
||
if let firstChild { relocateSelection(to: firstChild) } // it inherits the folder's slot
|
||
},
|
||
onDragBegan: {
|
||
// A root drag may escalate into a carry the moment it lifts (design §2.1): stage
|
||
// the visible order + editability for the session (the commit resolves against
|
||
// exactly what the user saw, §1.3) and clear paging residue synchronously — a
|
||
// deferred scroll-snap work item must never fire mid-carry (§2.1-3 / AC-6).
|
||
dragOrderSnapshot = filtered
|
||
dragCoordinator.freezeVisibleOrder(filtered, editable: isLayoutEditable)
|
||
pageScrollEndWork?.cancel()
|
||
pageScrollEndWork = nil
|
||
pageDragTranslation = 0
|
||
},
|
||
onPageSwipe: { direction in
|
||
guard dragCoordinator.carrySession == nil else { return } // §9.4 input freeze
|
||
goToPage(min(currentPage, pageCount - 1) + direction)
|
||
},
|
||
onPageDrag: handlePageDrag,
|
||
onPageScroll: handlePageScroll,
|
||
onDismiss: onDismiss,
|
||
allowsCustomOrderActions: isLayoutEditable, // search = read-only projection
|
||
coordinator: dragCoordinator,
|
||
pageIndex: page
|
||
)
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
||
}
|
||
|
||
/// Selected cell's layoutID for the AppKit cell highlight, derived from `selectedIndex`.
|
||
private var selectedID: String? {
|
||
filtered.indices.contains(selectedIndex) ? filtered[selectedIndex].layoutID : nil
|
||
}
|
||
|
||
/// App → launch; folder → open the overlay. Keeps the selection on whatever was activated.
|
||
private func activateCell(_ cell: LaunchpadDisplayCell) {
|
||
if let index = filtered.firstIndex(where: { $0.layoutID == cell.layoutID }) { selectedIndex = index }
|
||
switch cell {
|
||
case .app(let item): onActivate(item)
|
||
case .folder(let id, _, _): openFolderPanel(id: id)
|
||
}
|
||
}
|
||
|
||
/// Mount the folder panel and zoom it in on the next tick (the `folderShown` discipline).
|
||
/// Shared by cell activation, the context-menu rename and the post-creation auto-open.
|
||
private func openFolderPanel(id: String) {
|
||
openFolderID = id
|
||
folderShown = false
|
||
dragCoordinator.folderPanelDidChange(open: true) // arms the Esc ladder's middle rung
|
||
DispatchQueue.main.async { if openFolderID == id { folderShown = true } } // next tick → zoom
|
||
}
|
||
|
||
private func selectCell(_ layoutID: String) {
|
||
if let index = filtered.firstIndex(where: { $0.layoutID == layoutID }) { selectedIndex = index }
|
||
}
|
||
|
||
private func copyPath(_ app: LaunchpadAppItem) {
|
||
// Lightweight clipboard action — no lifecycle effect, so keep the launcher open.
|
||
NSPasteboard.general.clearContents()
|
||
NSPasteboard.general.setString(app.url.path, forType: .string)
|
||
}
|
||
|
||
private func hideApp(_ app: LaunchpadAppItem) {
|
||
sessionHidden.insert(app.id) // drop from this grid immediately
|
||
selectedIndex = 0
|
||
currentPage = 0
|
||
onHide(app) // persist (settings page can restore)
|
||
}
|
||
|
||
/// First successful drop materialises the alphabetical snapshot into a layout, then
|
||
/// applies the relative move; selection follows the dragged app by id to its new global
|
||
/// index (design §5.3 / §5.5). Never writes while searching (search is read-only).
|
||
private func handleReorder(_ draggedID: String, _ target: LaunchpadDropTarget) {
|
||
// Reorder against the order frozen at drag start, not a list a mid-drag reload may
|
||
// have changed (design §5.3 / Codex P2).
|
||
let order = dragOrderSnapshot ?? filtered
|
||
dragOrderSnapshot = nil
|
||
// A drop that changes nothing visible must not flip alphabetical → custom mode (Codex P2).
|
||
guard isLayoutEditable, !target.isNoOp(dragged: draggedID, in: order.map(\.layoutID)) else { return }
|
||
layoutStore.captureVisibleOrder(rootApps(of: order))
|
||
switch target {
|
||
case .before(let id): layoutStore.move(id: draggedID, before: id)
|
||
case .after(let id): layoutStore.move(id: draggedID, after: id)
|
||
}
|
||
relocateSelection(to: draggedID)
|
||
}
|
||
|
||
/// Drag-to-stack: dropping app `draggedID` onto app `targetID` folds both into a new folder
|
||
/// occupying the target's slot (iOS). Materialises against the order frozen at drag start —
|
||
/// the same one the (frozen) AppKit page is showing — so the persisted order matches what the
|
||
/// user saw, exactly like `handleReorder`. The target is always a drag-start cell, so it's in
|
||
/// the snapshot. Selection follows to the *new folder's* id (the target app is now inside it).
|
||
private func handleMakeFolder(_ targetID: String, _ draggedID: String) {
|
||
let order = dragOrderSnapshot ?? filtered
|
||
dragOrderSnapshot = nil
|
||
guard isLayoutEditable, targetID != draggedID else { return }
|
||
layoutStore.captureVisibleOrder(rootApps(of: order))
|
||
let folderID = UUID().uuidString
|
||
layoutStore.makeFolder(target: targetID, dragged: draggedID, name: folderDefaultName, id: folderID)
|
||
relocateSelection(to: folderID)
|
||
// macOS-native: a fresh folder opens with its name selected, ready to type (R1=B).
|
||
// This legacy (coordinator-less) path has no settle flight, so open immediately.
|
||
pendingRenameFocusID = folderID
|
||
openFolderPanel(id: folderID)
|
||
}
|
||
|
||
/// Drag-to-stack onto an existing folder: app joins it.
|
||
private func handleAddToFolder(_ folderID: String, _ appID: String) {
|
||
let order = dragOrderSnapshot ?? filtered
|
||
dragOrderSnapshot = nil
|
||
guard isLayoutEditable else { return }
|
||
layoutStore.captureVisibleOrder(rootApps(of: order))
|
||
layoutStore.addToFolder(folderID, app: appID)
|
||
relocateSelection(to: folderID)
|
||
}
|
||
|
||
private func moveAppToFront(_ app: LaunchpadAppItem) {
|
||
guard isLayoutEditable, let first = filtered.first, first.layoutID != app.id else { return }
|
||
layoutStore.captureVisibleOrder(rootApps(of: filtered))
|
||
layoutStore.move(id: app.id, before: first.layoutID)
|
||
relocateSelection(to: app.id)
|
||
}
|
||
|
||
private func moveAppToEnd(_ app: LaunchpadAppItem) {
|
||
guard isLayoutEditable, let last = filtered.last, last.layoutID != app.id else { return }
|
||
layoutStore.captureVisibleOrder(rootApps(of: filtered))
|
||
layoutStore.move(id: app.id, after: last.layoutID)
|
||
relocateSelection(to: app.id)
|
||
}
|
||
|
||
/// After a reorder, keep the selection on the moved item by layoutID (not position) and
|
||
/// bring its page on screen (design §5.5: selection is identity-, not index-, anchored).
|
||
private func relocateSelection(to id: String) {
|
||
guard let index = filtered.firstIndex(where: { $0.layoutID == id }) else { return }
|
||
selectedIndex = index
|
||
currentPage = perPage > 0 ? index / perPage : 0
|
||
}
|
||
|
||
/// Reorders only apply in the layout state; search is a read-only flat projection.
|
||
private var isLayoutEditable: Bool {
|
||
searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||
}
|
||
|
||
/// Name a freshly-stacked folder gets (persisted, so resolved in the user's language at creation).
|
||
private var folderDefaultName: String {
|
||
localization.string("folder.defaultName", defaultValue: "未命名")
|
||
}
|
||
|
||
private func pageIndicator(current: Int) -> some View {
|
||
HStack(spacing: 9) {
|
||
ForEach(0..<displayPageCount, id: \.self) { page in
|
||
Circle()
|
||
.fill(Color.primary.opacity(page == current ? 0.55 : 0.18))
|
||
.frame(width: 7, height: 7)
|
||
.onTapGesture {
|
||
guard dragCoordinator.carrySession == nil else { return } // §9.4 input freeze
|
||
goToPage(page)
|
||
}
|
||
// Mirror the cell fix: `.onTapGesture` is mouse-only, so VoiceOver /
|
||
// AX press needs an explicit action to actually change page. Carry the
|
||
// same input-freeze guard as the tap path (§9.4) so an AX-triggered flip
|
||
// can't move the grid out from under an in-flight carry.
|
||
.accessibilityAction {
|
||
guard dragCoordinator.carrySession == nil else { return }
|
||
goToPage(page)
|
||
}
|
||
.accessibilityLabel(
|
||
page >= pageCount
|
||
? localization.string("grid.page.virtualLabel", defaultValue: "新页面")
|
||
: localization.format("grid.page.accessibilityLabel", defaultValue: "第 %d 页", page + 1)
|
||
)
|
||
.accessibilityAddTraits(.isButton)
|
||
}
|
||
}
|
||
.padding(.top, 2)
|
||
}
|
||
|
||
// MARK: - Layout + navigation
|
||
|
||
private func updateLayout(size: CGSize) {
|
||
// Pure capacity math lives in LaunchpadLayoutMath.pageGrid (shared with the
|
||
// settings preview, design §1.2). Fixed columns that can't fit the width (e.g.
|
||
// 12 × 96pt icons) are silently clamped to what fits (ruling A4, P2 default).
|
||
let grid = LaunchpadLayoutMath.pageGrid(
|
||
viewport: size,
|
||
metrics: metrics,
|
||
fixedColumns: columns == LaunchpadPreferences.autoColumns ? nil : columns
|
||
)
|
||
columnCount = grid.columns
|
||
rowCount = grid.rows
|
||
// Layout (and thus perPage) changed: keep the selection valid and re-derive the
|
||
// visible page *from it*, so the source-of-truth selection is always on screen —
|
||
// not stranded on a now-wrong page (Codex P1). Mid-carry the pull-back is exempt:
|
||
// the carry may be hovering a page (incl. the virtual tail) with no selection on it,
|
||
// and selection follows the carried item by id at commit instead (design §8).
|
||
selectedIndex = min(max(selectedIndex, 0), max(0, filtered.count - 1))
|
||
guard dragCoordinator.carrySession == nil else { return }
|
||
currentPage = filtered.isEmpty ? 0 : selectedIndex / perPage
|
||
}
|
||
|
||
/// Book-style paged navigation: arrows move within the current page; pressing past an
|
||
/// edge turns the page keeping the cross-axis position (→ at the rightmost column jumps
|
||
/// to the next page's same row, etc.).
|
||
private func handleMove(_ direction: LaunchpadSearchField.MoveDirection) {
|
||
guard openFolder == nil, !filtered.isEmpty else { return } // folder open → grid nav inert
|
||
guard dragCoordinator.carrySession == nil else { return } // §9.4: keyboard nav frozen mid-carry
|
||
let cols = max(1, columnCount)
|
||
let rows = max(1, rowCount)
|
||
let per = perPage
|
||
let page = selectedIndex / per
|
||
let local = selectedIndex % per
|
||
let col = local % cols
|
||
let row = local / cols
|
||
let lastPage = max(0, (filtered.count - 1) / per)
|
||
|
||
var target = selectedIndex
|
||
switch direction {
|
||
case .right:
|
||
if col < cols - 1, selectedIndex + 1 < filtered.count { target = selectedIndex + 1 }
|
||
else if page < lastPage { target = (page + 1) * per + row * cols } // next page, same row
|
||
case .left:
|
||
if col > 0 { target = selectedIndex - 1 }
|
||
else if page > 0 { target = (page - 1) * per + row * cols + (cols - 1) } // prev page, same row, last col
|
||
case .up:
|
||
if row > 0 { target = selectedIndex - cols }
|
||
else if page > 0 { target = (page - 1) * per + (rows - 1) * cols + col } // prev page, bottom row, same col
|
||
case .down:
|
||
if row < rows - 1, selectedIndex + cols < filtered.count { target = selectedIndex + cols }
|
||
else if page < lastPage { target = (page + 1) * per + col } // next page, top row, same col
|
||
}
|
||
|
||
selectedIndex = min(max(target, 0), filtered.count - 1)
|
||
withAnimation(pageSnap) { currentPage = selectedIndex / per } // bring the selection's page on screen
|
||
}
|
||
|
||
private func goToPage(_ page: Int) {
|
||
// A stale gesture/dot closure could fire after an async catalog update emptied the
|
||
// list; avoid `filtered.count - 1 == -1` (Codex P2).
|
||
guard !filtered.isEmpty else { selectedIndex = 0; currentPage = 0; return }
|
||
let target = min(max(page, 0), displayPageCount - 1) // == pageCount-1 outside a carry
|
||
withAnimation(pageSnap) { currentPage = target }
|
||
// Move selection onto the page so keyboard nav resumes from what's visible — except
|
||
// mid-carry: selection follows the carried item by id at commit instead (design §8).
|
||
guard dragCoordinator.carrySession == nil else { return }
|
||
selectedIndex = min(target * perPage, filtered.count - 1)
|
||
}
|
||
|
||
private var pageSnap: Animation {
|
||
.spring(response: LaunchpadPageAnimation.snapResponse,
|
||
dampingFraction: LaunchpadPageAnimation.snapDamping)
|
||
}
|
||
|
||
/// Follow-cursor paging: while dragging empty space the page tracks the cursor; on release
|
||
/// it snaps to the adjacent page past a threshold, otherwise springs back.
|
||
private func handlePageDrag(_ translationX: CGFloat, width: CGFloat, ended: Bool) {
|
||
guard dragCoordinator.carrySession == nil else { return } // §9.4: edge dwell is the only mid-carry flip channel
|
||
let pageW = max(1, width) // real page width from the AppKit grid (no @State race)
|
||
let last = max(0, pageCount - 1)
|
||
let cur = min(currentPage, last)
|
||
var t = translationX
|
||
// Rubber-band so you can't pull past the first / last page.
|
||
if (cur == 0 && t > 0) || (cur == last && t < 0) { t *= 0.35 }
|
||
t = min(max(t, -pageW), pageW)
|
||
pageDragTranslation = t // ended too: snap must read the RELEASE translation, not the previous frame's
|
||
guard ended else { return }
|
||
snapToNearestPage(width: pageW)
|
||
}
|
||
|
||
/// Two-finger trackpad paging. The raw delta arrives from whichever page container is under
|
||
/// the (stationary) cursor; accumulating it HERE (shared) — not per-container — is what fixes
|
||
/// the every-frame offset oscillation. The gesture ends when deltas stop for a beat (debounce),
|
||
/// since trackpad phase is unreliable on slow drags.
|
||
private func handlePageScroll(_ delta: CGFloat, _ width: CGFloat) {
|
||
guard dragCoordinator.carrySession == nil else { return } // §9.4 input freeze
|
||
pageScrollEndWork?.cancel()
|
||
let pageW = max(1, width)
|
||
let last = max(0, pageCount - 1)
|
||
let cur = min(currentPage, last)
|
||
var t = pageDragTranslation + delta
|
||
if cur == 0, t > 0 { t = min(t, pageW * 0.12) } // soft over-pull at the ends
|
||
else if cur == last, t < 0 { t = max(t, -pageW * 0.12) }
|
||
else { t = min(max(t, -pageW), pageW) }
|
||
pageDragTranslation = t
|
||
let work = DispatchWorkItem { snapToNearestPage(width: pageW) }
|
||
pageScrollEndWork = work
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: work)
|
||
}
|
||
|
||
/// Snap `pageDragTranslation` to the nearest page (advancing past a threshold), shared by the
|
||
/// empty-space drag and the two-finger scroll on release.
|
||
private func snapToNearestPage(width: CGFloat) {
|
||
// Double insurance with the carry-entry residue clearing: the deferred scroll work item
|
||
// must never snap mid-carry (§9.4; the entry hook already cancelled it and zeroed the
|
||
// translation — this guard alone would freeze a stale offset on screen, AC-6/BC-2).
|
||
guard dragCoordinator.carrySession == nil else { return }
|
||
let pageW = max(1, width)
|
||
let last = max(0, pageCount - 1)
|
||
let cur = min(currentPage, last)
|
||
let t = pageDragTranslation
|
||
let threshold = max(45, pageW * 0.18)
|
||
withAnimation(pageSnap) {
|
||
if t <= -threshold, cur < last { currentPage = cur + 1 }
|
||
else if t >= threshold, cur > 0 { currentPage = cur - 1 }
|
||
pageDragTranslation = 0
|
||
}
|
||
selectedIndex = min(currentPage * perPage, max(0, filtered.count - 1))
|
||
}
|
||
|
||
private func activateSelection() {
|
||
guard openFolder == nil, filtered.indices.contains(selectedIndex) else { return }
|
||
activateCell(filtered[selectedIndex])
|
||
}
|
||
|
||
// MARK: - Folder overlay
|
||
|
||
private func handleCancel() {
|
||
if openFolderID != nil { closeFolder() } else { onDismiss() }
|
||
}
|
||
|
||
private func closeFolder() {
|
||
guard let id = openFolderID else { return }
|
||
// Disarm the Esc middle rung immediately (not at the unmount 0.34s later): a second
|
||
// Esc during the zoom-out should already reach the close-overlay rung.
|
||
dragCoordinator.folderPanelDidChange(open: false)
|
||
// Closing with a rename in flight (scrim tap / typing-to-search) commits it first —
|
||
// the panel may stay mounted for the zoom-out, so dismantle alone is too late (§2.3).
|
||
renameController.endEditing(commit: true)
|
||
folderShown = false // zoom back out
|
||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.34) {
|
||
// Remove only if still closing THIS folder — a reopen (new id, or folderShown back true)
|
||
// must not be wiped by this stale clear.
|
||
if openFolderID == id, !folderShown { openFolderID = nil }
|
||
}
|
||
}
|
||
|
||
/// Smooth, lightly-settled spring on open (a gentle zoom, minimal bounce — the iOS feel),
|
||
/// a touch quicker on close.
|
||
private var folderOpenAnimation: Animation { .spring(response: 0.50, dampingFraction: 0.78) }
|
||
private var folderCloseAnimation: Animation { .spring(response: 0.32, dampingFraction: 0.92) }
|
||
|
||
/// Collapsed scale of the folder panel — small enough to read as "from the cell" (~a folder
|
||
/// thumbnail), not the historical 0.55 centre-zoom (animation design 2026-06-13).
|
||
private let collapsedScale: CGFloat = 0.18
|
||
|
||
/// Offset that anchors the collapsed (closed) folder panel onto its grid cell, so it grows
|
||
/// FROM there (animation design 2026-06-13). Pure SwiftUI viewport math via
|
||
/// `LaunchpadLayoutMath.folderCollapsedOffset` — no AppKit `convert`.
|
||
///
|
||
/// Fallbacks (hard requirement — never sling the panel off-screen or compute on a half-laid-out
|
||
/// frame): `.zero` (→ the historical centre zoom) whenever the open folder isn't found, isn't on
|
||
/// the current page, or the container hasn't been measured yet. `firstIndex` is `nil`-guarded
|
||
/// (the folder can dissolve within the close window) — same nil → hard-cut philosophy as the
|
||
/// carry settle target.
|
||
private var collapsedOffset: CGSize {
|
||
guard containerSize.width > 0, containerSize.height > 0,
|
||
let openID = openFolderID,
|
||
let index = filtered.firstIndex(where: { $0.layoutID == openID })
|
||
else { return .zero }
|
||
let per = perPage
|
||
let folderPage = index / per
|
||
// The folder must be on the page that's actually on screen — a context-menu rename or a
|
||
// post-create auto-open could target a folder on another page; fall back to centre then.
|
||
guard folderPage == currentPage else { return .zero }
|
||
let slot = index % per
|
||
// Grid-container width = the post-chrome viewport `pagedGrid` measures (ZStack minus the
|
||
// horizontal padding ring), the same width its slotRect uses, so the cell centre matches.
|
||
let containerWidth = containerSize.width - 2 * chrome.horizontalPadding
|
||
guard containerWidth > 0 else { return .zero }
|
||
let cellRect = LaunchpadLayoutMath.slotRect(
|
||
index: slot,
|
||
columns: max(1, columnCount),
|
||
containerWidth: containerWidth,
|
||
metrics: metrics
|
||
)
|
||
return LaunchpadLayoutMath.folderCollapsedOffset(
|
||
cellRect: cellRect,
|
||
chrome: chrome,
|
||
viewportSize: containerSize
|
||
)
|
||
}
|
||
|
||
/// The open folder resolved from `filtered`, or `nil` when none is open *or* the open one
|
||
/// no longer renders (emptied / dissolved underneath). Driving both the overlay and the
|
||
/// grid's `interactionEnabled` off this one value keeps them from disagreeing — a stale
|
||
/// `openFolderID` can never leave the grid frozen with no scrim to dismiss.
|
||
private var openFolder: (id: String, name: String, items: [LaunchpadAppItem])? {
|
||
openFolderID.flatMap(folderCell)
|
||
}
|
||
|
||
private func folderCell(_ id: String) -> (id: String, name: String, items: [LaunchpadAppItem])? {
|
||
for cell in filtered {
|
||
if case .folder(let fid, let name, let items) = cell, fid == id {
|
||
return (fid, name, items)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
/// Frosted-glass dim behind the folder panel: a real NSVisualEffectView blur of the grid
|
||
/// (SwiftUI `.blur` can't touch the embedded AppKit grid) + a slight darken, with a clear
|
||
/// tap layer on top so a tap anywhere closes the folder.
|
||
private var folderScrim: some View {
|
||
ZStack {
|
||
// G4: deliberately NOT linked to the background style in v1 — the folder scrim
|
||
// is a surface floating over content, semantically independent of the backdrop.
|
||
// Same materials the former private FrostedGlassScrim hardcoded.
|
||
LaunchpadGlassBackdrop(
|
||
material: isCompact ? .frosted : .launchpad,
|
||
blendingMode: .withinWindow
|
||
)
|
||
.ignoresSafeArea()
|
||
Rectangle()
|
||
.fill(.black.opacity(0.18))
|
||
.ignoresSafeArea()
|
||
Rectangle()
|
||
.fill(.clear)
|
||
.contentShape(Rectangle())
|
||
.ignoresSafeArea()
|
||
.onTapGesture { closeFolder() }
|
||
}
|
||
}
|
||
|
||
private func folderPanel(_ folder: (id: String, name: String, items: [LaunchpadAppItem])) -> some View {
|
||
// Folder-local metrics: the session's icon size, but labels ALWAYS shown (ruling
|
||
// A1 — finding an app inside a folder depends on its name; only the root grid
|
||
// follows the hide-names switch). Re-derived through the single resolve entry
|
||
// point, so when labels are globally shown this IS the injected session metrics.
|
||
// The user's label-style selections (colour/weight/size) carry over from the
|
||
// session metrics so the folder grid labels + big title share one appearance
|
||
// (design 2026-06-13); these stay byte-compatible at the defaults.
|
||
var folderMetrics = LaunchpadGridMetrics.resolve(
|
||
LaunchpadAppearance(iconSide: metrics.iconSide, showsLabels: true))
|
||
folderMetrics.labelColor = metrics.labelColor
|
||
folderMetrics.labelFontSize = metrics.labelFontSize
|
||
folderMetrics.labelFontWeight = metrics.labelFontWeight
|
||
folderMetrics.folderTitleFontSize = metrics.folderTitleFontSize
|
||
folderMetrics.folderTitleWeight = metrics.folderTitleWeight
|
||
// The two-line strip follows the user's font size (only the larger tiers grow it past 32);
|
||
// cellHeight stays the labels-shown formula (iconSide + 60) just like the root grid — the
|
||
// fixed reserve already holds the band, so we don't widen the cell here.
|
||
folderMetrics.labelHeight = LaunchpadGridMetrics.labelHeight(
|
||
forFontSize: folderMetrics.labelFontSize, weight: folderMetrics.labelFontWeight)
|
||
let cols = min(max(folder.items.count, 1), 5) // Mac is wide; cap at 5 per row
|
||
let rows = max(1, (folder.items.count + cols - 1) / cols)
|
||
let gridW = CGFloat(cols) * folderMetrics.cellWidth + CGFloat(cols - 1) * folderMetrics.columnSpacing
|
||
let gridH = CGFloat(rows) * folderMetrics.cellHeight + CGFloat(max(0, rows - 1)) * folderMetrics.rowSpacing
|
||
// Cap the panel so a big folder can't outgrow the launcher (a compact panel is ~560-680pt
|
||
// tall; fullscreen leaves ~700+ after chrome). Past the cap the grid scrolls; the grid's
|
||
// scrollWheel bubbles up (folder grids don't page) so two-finger scrolling just works.
|
||
let maxVisibleRows = isCompact ? 3 : 4
|
||
let visibleRows = min(rows, maxVisibleRows)
|
||
let visibleH = CGFloat(visibleRows) * folderMetrics.cellHeight + CGFloat(max(0, visibleRows - 1)) * folderMetrics.rowSpacing
|
||
// Plate width cap, metrics-derived (design §1.1: 5 columns + the 30pt padding) —
|
||
// replaces the historical 760 literal, which a 96pt icon row would overflow.
|
||
// At the default 64pt this renders 672pt wide, a deliberate P2 appearance change
|
||
// (the plate now hugs its content). Derivation + pins live in LaunchpadLayoutMath.
|
||
let panelMaxWidth = LaunchpadLayoutMath.folderPanelMaxWidth(metrics: folderMetrics)
|
||
|
||
return VStack(spacing: 18) {
|
||
// Always-rendered inline-rename title: reads as a static title, edits on click
|
||
// (design §2.2 — no Text↔TextField identity swap, no extra hover affordance, R4).
|
||
LaunchpadFolderRenameField(
|
||
folderID: folder.id,
|
||
name: folder.name,
|
||
placeholder: localization.string("folder.rename.placeholder", defaultValue: "文件夹名称"),
|
||
titleColor: folderMetrics.labelColor.nsColor,
|
||
titleFont: .systemFont(ofSize: folderMetrics.folderTitleFontSize,
|
||
weight: folderMetrics.folderTitleWeight),
|
||
focusRequestID: pendingRenameFocusID,
|
||
controller: renameController,
|
||
editGate: { dragCoordinator.carrySession == nil }, // mid-carry: no rename entry
|
||
onCommit: { layoutStore.renameFolder(folder.id, name: $0, fallback: folderDefaultName) },
|
||
onFocusRequestHandled: { pendingRenameFocusID = nil }
|
||
)
|
||
.frame(width: min(gridW, 360), height: 26)
|
||
|
||
// ONE stable view identity for every folder size: an if/else here would swap SwiftUI
|
||
// branches when the row count crosses the cap — and a mid-carry eject SHRINKS the item
|
||
// count (the carried app is filtered out), so at the boundary the flip would dismantle
|
||
// the grid mid-drag and the unmount hook would instantly cancel the eject it serves.
|
||
// A ScrollView whose content fits is inert (scrolling disabled below the cap).
|
||
ScrollView(.vertical) {
|
||
folderGrid(folder, cols: cols, metrics: folderMetrics)
|
||
.frame(width: gridW, height: gridH)
|
||
}
|
||
.scrollDisabled(rows <= maxVisibleRows)
|
||
.frame(width: gridW, height: visibleH)
|
||
.launchpadHidingScrollEdgeEffect()
|
||
}
|
||
.padding(30)
|
||
// Clicking the panel's own blank space (padding / title gaps) commits an in-flight
|
||
// rename — a click on "nothing" never reaches the field's blur path by itself (§2.3).
|
||
// Cells and the field are AppKit views that hit-test first, so they're unaffected.
|
||
.contentShape(Rectangle())
|
||
.onTapGesture { renameController.endEditing(commit: true) }
|
||
// NOTE: this cap IS the rendered plate width, not a never-binding ceiling — a
|
||
// maxWidth-only frame fills its proposal up to the cap, and the material
|
||
// background below is applied to THIS frame. Both window modes propose more, so
|
||
// every folder plate renders exactly `panelMaxWidth` wide (derivation above).
|
||
.frame(maxWidth: panelMaxWidth)
|
||
// TODO(G5): on macOS 26+ adopt `.glassEffect(.regular, in: RoundedRectangle(cornerRadius: 28))`
|
||
// here (folder panel + compact floating panel only — the fullscreen backdrop stays an
|
||
// NSVisualEffectView per HIG). `#available` gated, separate commit, Tahoe screenshots.
|
||
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||
.overlay(
|
||
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||
.strokeBorder(.white.opacity(0.10))
|
||
)
|
||
.shadow(color: .black.opacity(0.4), radius: 36, y: 16)
|
||
.padding(40)
|
||
}
|
||
|
||
/// Same direct-tracking grid as the launcher itself → identical drag feel inside and
|
||
/// outside a folder. Merge is disabled (no nested folders); reorder maps to the folder's
|
||
/// child order; dragging a cell clearly OUT of the grid pulls the app out of the folder.
|
||
private func folderGrid(
|
||
_ folder: (id: String, name: String, items: [LaunchpadAppItem]),
|
||
cols: Int,
|
||
metrics: LaunchpadGridMetrics
|
||
) -> some View {
|
||
LaunchpadDragGrid(
|
||
items: folder.items.map { .app($0) },
|
||
columns: cols,
|
||
selectedID: nil,
|
||
isCompact: isCompact,
|
||
metrics: metrics,
|
||
localization: localization,
|
||
iconProvider: { catalog.icon(for: $0) },
|
||
onActivate: { cell in
|
||
if case .app(let appItem) = cell {
|
||
openFolderID = nil // clear up front, in case launch doesn't tear down
|
||
dragCoordinator.folderPanelDidChange(open: false)
|
||
onActivate(appItem)
|
||
}
|
||
},
|
||
onReveal: onReveal,
|
||
onCopyPath: copyPath,
|
||
onHide: hideApp,
|
||
onMoveToFront: { app in
|
||
guard let first = folder.items.first, first.id != app.id else { return }
|
||
layoutStore.moveChildWithinFolder(folder.id, child: app.id, before: first.id)
|
||
},
|
||
onMoveToEnd: { app in
|
||
guard let last = folder.items.last, last.id != app.id else { return }
|
||
layoutStore.moveChildWithinFolder(folder.id, child: app.id, after: last.id)
|
||
},
|
||
onSelect: { _ in },
|
||
onReorder: { id, target in
|
||
switch target {
|
||
case .before(let t): layoutStore.moveChildWithinFolder(folder.id, child: id, before: t)
|
||
case .after(let t): layoutStore.moveChildWithinFolder(folder.id, child: id, after: t)
|
||
}
|
||
},
|
||
onMakeFolder: { _, _ in },
|
||
onAddToFolder: { _, _ in },
|
||
// Freeze the visible ROOT order (+ editability) the moment an in-folder drag begins:
|
||
// if it escalates to an eject carry, the session adopts this snapshot so the commit's
|
||
// captureVisibleOrder resolves against what the user saw, not a list a mid-carry
|
||
// catalog reload may have changed (design §1.3 / constraint 15). A live rename commits
|
||
// FIRST — grid cells never take first responder, so blur alone can't end it (§2.7) —
|
||
// and its store write lands before the order freeze so the snapshot sees it.
|
||
onDragBegan: {
|
||
renameController.endEditing(commit: true)
|
||
dragCoordinator.freezeVisibleOrder(filtered, editable: isLayoutEditable)
|
||
},
|
||
onPageSwipe: { _ in },
|
||
onPageDrag: { _, _, _ in },
|
||
onPageScroll: { _, _ in },
|
||
onDismiss: {},
|
||
allowFolderCreation: false,
|
||
coordinator: dragCoordinator,
|
||
folderContextID: folder.id
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Measures the paging viewport (the visible page slot) in AppKit window space and pushes it to
|
||
/// the drag coordinator. It sits OUTSIDE the paging `.offset` — a render-only transform that never
|
||
/// enters the AppKit frame chain — so this view's own frame chain IS the viewport, with no per-page
|
||
/// correction and no y-flip guesswork (design §5.2; events pass through, hitTest nil).
|
||
private struct LaunchpadViewportRelay: NSViewRepresentable {
|
||
let coordinator: LaunchpadDragCoordinator
|
||
let pageCount: Int
|
||
let perPage: Int
|
||
|
||
final class RelayView: NSView {
|
||
weak var coordinator: LaunchpadDragCoordinator?
|
||
var pageCount = 1
|
||
var perPage = 1
|
||
|
||
override func hitTest(_ point: NSPoint) -> NSView? { nil }
|
||
|
||
override func layout() {
|
||
super.layout()
|
||
pushGeometry()
|
||
}
|
||
|
||
override func viewDidMoveToWindow() {
|
||
super.viewDidMoveToWindow()
|
||
pushGeometry()
|
||
}
|
||
|
||
func pushGeometry() {
|
||
guard window != nil, bounds.width > 0, bounds.height > 0 else { return }
|
||
let rect = convert(bounds, to: nil) // window space, y-up
|
||
coordinator?.syncGeometry(LaunchpadPageGeometry(
|
||
pageWidth: bounds.width,
|
||
gridHeight: bounds.height,
|
||
pageCount: pageCount,
|
||
perPage: perPage,
|
||
viewportMinX: rect.minX,
|
||
viewportTopY: rect.maxY)) // top edge in y-up space
|
||
}
|
||
}
|
||
|
||
func makeNSView(context _: Context) -> RelayView {
|
||
let view = RelayView()
|
||
view.coordinator = coordinator
|
||
return view
|
||
}
|
||
|
||
func updateNSView(_ view: RelayView, context _: Context) {
|
||
view.coordinator = coordinator
|
||
view.pageCount = pageCount
|
||
view.perPage = perPage
|
||
view.pushGeometry()
|
||
}
|
||
}
|
||
|
||
/// The full dependency set of `LaunchpadGridView.filtered`. Equatable so the cache
|
||
/// can compare it exactly — every input that changes the projected cells is here,
|
||
/// so a cache hit can never serve stale icons.
|
||
private struct FilteredInputs: Equatable {
|
||
var apps: [LaunchpadAppItem]
|
||
var layout: LaunchpadLayout?
|
||
var sessionHidden: Set<String>
|
||
var searchText: String
|
||
var folderEjectActive: Bool
|
||
var carriedAppID: String?
|
||
var carriedSourceFolderID: String?
|
||
}
|
||
|
||
/// Memo cache for the folder reconcile projection. During a page swipe the inputs are
|
||
/// unchanged (same COW buffers), so `resolve` returns the cached cells in O(1) without
|
||
/// rebuilding the projection each frame.
|
||
@MainActor
|
||
private final class FilteredCache {
|
||
private var inputs: FilteredInputs?
|
||
private var value: [LaunchpadDisplayCell] = []
|
||
|
||
func resolve(_ newInputs: FilteredInputs, compute: () -> [LaunchpadDisplayCell]) -> [LaunchpadDisplayCell] {
|
||
if let inputs, inputs == newInputs { return value }
|
||
let computed = compute()
|
||
inputs = newInputs
|
||
value = computed
|
||
return computed
|
||
}
|
||
}
|
||
|
||
private extension View {
|
||
/// Hides the macOS 26+ scroll edge effect — the faint line the system draws
|
||
/// at a scroll view's edge — which otherwise shows as a stray bar across the
|
||
/// top of an open folder's grid. No-op below macOS 26. The `#available`
|
||
/// branch is fixed per OS (not a runtime row-count flip), so it does not
|
||
/// violate the single-view-identity constraint the folder ScrollView needs.
|
||
@ViewBuilder
|
||
func launchpadHidingScrollEdgeEffect() -> some View {
|
||
if #available(macOS 26.0, *) {
|
||
scrollEdgeEffectHidden(true, for: .all)
|
||
} else {
|
||
self
|
||
}
|
||
}
|
||
}
|