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

1551 lines
87 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import AppKit
import MacToolsPluginKit
import SwiftUI
/// Cell geometry, single-sourced (design §1.2). The default initialiser stays
/// byte-compatible with the historical hardcoded layout; appearance preferences go
/// through `LaunchpadGridMetrics.resolve(_:)` (LaunchpadLayoutMath.swift), the ONLY
/// resolution entry point.
struct LaunchpadGridMetrics: Equatable {
var cellWidth: CGFloat = 116
var cellHeight: CGFloat = 124
var iconSide: CGFloat = 64 // smaller, airier icons (closer to iOS) inside the same pitch
var columnSpacing: CGFloat = 8
var rowSpacing: CGFloat = 16
// Derived appearance fields (design §1.2), reverse-engineered from the previous
// in-cell hardcodes (icon at y=8, label 8 below the icon, 32pt tall, labels shown)
// so the defaults keep `LaunchpadGridMetrics()` byte-compatible.
var showsLabels: Bool = true
var iconTopInset: CGFloat = 8
var labelGap: CGFloat = 8
var labelHeight: CGFloat = 32
// Label-style fields (design 2026-06-13): the "finished" values the cell injects.
// The COLOR is stored as the preset enum, never as an `NSColor` — that keeps
// `Equatable` clean (NSColor's `==` is unreliable across colour spaces) and the cell
// resolves `.nsColor` at apply time. Weight is `NSFont.Weight`, a RawRepresentable
// CGFloat, so it is Equatable-safe to store directly. Defaults reproduce the historical
// implicit rendering — `.automatic` (→ `.labelColor`), 12pt, regular weight — so an
// untouched `LaunchpadGridMetrics()` stays byte-compatible.
var labelColor: LaunchpadLabelColor = .automatic
var labelFontSize: CGFloat = 12
var labelFontWeight: NSFont.Weight = .regular
// Open-folder big-title style, derived in `resolve(_:)` so `LaunchpadFolderRenameField`
// no longer hardcodes the font. Defaults match the historical title: `.title2` point
// size, semibold weight.
var folderTitleFontSize: CGFloat = LaunchpadGridMetrics.defaultFolderTitleFontSize
var folderTitleWeight: NSFont.Weight = .semibold
/// The historical folder-title point size (`Text(...).font(.title2.weight(.semibold))`),
/// surfaced as a constant so both the default initialiser and `resolve(_:)` reference one
/// source. `.title2` resolves to 17pt on macOS today; reading it keeps parity if Apple
/// ever retunes the text style.
static let defaultFolderTitleFontSize: CGFloat = NSFont.preferredFont(forTextStyle: .title2).pointSize
}
/// Where a dragged app should land, expressed as a *relative* position (never an absolute
/// index) so hidden/uninstalled drift can't pollute the persisted order (design §5.1).
enum LaunchpadDropTarget: Equatable {
case before(String)
case after(String)
/// True when dropping `dragged` at this target leaves the visible `order` unchanged —
/// dropping on itself, or onto the side it already neighbours. Lets the caller skip a
/// reorder that would otherwise flip alphabetical → custom mode for no visible gain.
func isNoOp(dragged: String, in order: [String]) -> Bool {
guard let from = order.firstIndex(of: dragged) else { return true }
switch self {
case .before(let id):
if dragged == id { return true }
guard let to = order.firstIndex(of: id) else { return false }
return from + 1 == to
case .after(let id):
if dragged == id { return true }
guard let to = order.firstIndex(of: id) else { return false }
return from == to + 1
}
}
}
/// Outcome of releasing an externally-carried (folder-ejected) app over the root grid. Mirrors
/// `commitDrop`'s three branches, but as DATA the coordinator/view maps to store ops — the carried
/// app has no cell in this grid, so the container can't fire onReorder/onMakeFolder itself.
enum LaunchpadExternalDropResult: Equatable {
case makeFolder(targetAppID: String)
case addToFolder(folderID: String)
case reorder(LaunchpadDropTarget?)
}
/// One page of the launcher grid, rendered as a pure-AppKit subtree so per-item drag is tracked
/// directly via mouse events (the cell follows the cursor 1:1, like iOS — not NSDraggingSession)
/// and click/right-click are arbitrated by AppKit instead of fighting SwiftUI gestures.
/// Paging/selection/search stay in SwiftUI; this view only renders one page's items.
struct LaunchpadDragGrid: NSViewRepresentable {
var items: [LaunchpadDisplayCell]
var columns: Int
/// Rows per page (page capacity = rows × columns), pushed by the SwiftUI pager so make-way
/// overflow on a FULL page can fly out of the right edge instead of into a phantom row the
/// container's unbounded row-major math would otherwise invent. 0 = uncapped (folder grids,
/// which legitimately grow downward and scroll).
var rows: Int = 0
var selectedID: String? // the selected cell's layoutID
var isCompact: Bool
var interactionEnabled: Bool = true // false while a folder overlay is up
var metrics = LaunchpadGridMetrics()
var localization = PluginLocalization(bundle: .main) // context-menu titles
var iconProvider: (LaunchpadAppItem) -> NSImage
var onActivate: (LaunchpadDisplayCell) -> Void // app → launch, folder → open
var onReveal: (LaunchpadAppItem) -> Void
var onCopyPath: (LaunchpadAppItem) -> Void
var onHide: (LaunchpadAppItem) -> Void
var onMoveToFront: (LaunchpadAppItem) -> Void
var onMoveToEnd: (LaunchpadAppItem) -> Void
var onSelect: (String) -> Void // layoutID
var onReorder: (String, LaunchpadDropTarget) -> Void
var onMakeFolder: (String, String) -> Void // (targetAppID, draggedAppID) → new folder
var onAddToFolder: (String, String) -> Void // (folderID, draggedAppID)
var onRenameFolder: (String) -> Void = { _ in } // folderID — context-menu rename (19/P0a)
var onDissolveFolder: (String) -> Void = { _ in } // folderID — context-menu dissolve without confirmation (R2)
var onDragBegan: () -> Void
var onPageSwipe: (Int) -> Void
var onPageDrag: (CGFloat, CGFloat, Bool) -> Void // translationX, pageWidth, ended (empty-space mouse drag)
var onPageScroll: (CGFloat, CGFloat) -> Void // trackpad two-finger: raw deltaX, pageWidth — accumulated SHARED in SwiftUI (per-page containers must NOT each accumulate, or the offset oscillates)
var onDismiss: () -> Void
var allowFolderCreation: Bool = true // false inside an open folder (no nested folders → never arm a merge)
/// False while searching: the search list is a read-only flat projection, so a reorder drag
/// would show make-way/merge cues whose drop is then discarded — don't start one, and drop
/// the move-to-front and move-to-end menu items (their handlers no-op outside the layout state).
var allowsCustomOrderActions: Bool = true
var coordinator: LaunchpadDragCoordinator? = nil // shared across root pages + the open folder; owns the finger-bound folder-exit handoff
var folderContextID: String? = nil // non-nil only on the open folder's grid → its id, so an ejected app knows its source folder
var pageIndex: Int? = nil // root page number; nil on the folder grid (folder grids never register as page containers)
func makeNSView(context _: Context) -> LaunchpadGridContainerView {
let view = LaunchpadGridContainerView()
view.apply(grid: self)
return view
}
func updateNSView(_ view: LaunchpadGridContainerView, context _: Context) {
view.apply(grid: self)
}
}
// MARK: - Container
final class LaunchpadGridContainerView: NSView {
private var grid: LaunchpadDragGrid?
private var cells: [LaunchpadGridCellView] = []
private var columns = 7
/// Rows per page; 0 = uncapped (folder grids / legacy fixtures keep unbounded row-major math).
private var rows = 0
private var metrics = LaunchpadGridMetrics()
/// Slots this page can show. Beyond it a make-way gap has pushed a cell "to the next page" —
/// its frame goes off the right edge (the viewport clip swallows it) instead of a phantom row.
private var pageCapacity: Int { rows > 0 ? rows * columns : Int.max }
/// While a drag session is live, defer rebuilding the cell views (so the dragged view
/// isn't destroyed mid-flight) and remember the latest model to apply on drag end —
/// the `canSetItemViews` discipline from `MenuBarHiddenLayoutStripView`.
private var isDragging = false
private var pendingGrid: LaunchpadDragGrid?
/// Live visual order while a reorder drag hovers — cells animate aside so a gap opens at
/// the slot under the cursor (Apple-style live reorder). `nil` when not dragging.
private var dragOrder: [LaunchpadGridCellView]?
private weak var draggedCell: LaunchpadGridCellView?
/// Drag-to-stack (iOS folders): when the cursor moves into a cell's CENTRE zone the cell is
/// armed as a stack target (create / join a folder on drop); the cell's outer EDGE zone stays
/// reorder. This is driven purely by the movement `draggingUpdated` callbacks — never by a
/// timer or AppKit's periodic updates, which a live drag session starves. Arming on entry
/// means it survives the cursor then holding perfectly still (nothing disarms it), and the
/// drop commits it. The armed target is read by the cell's `draw` to paint the merge cue.
private(set) weak var stackTargetCell: LaunchpadGridCellView?
/// External drag (an item carried OUTSIDE any container — ejected from a folder, or lifted
/// from a root page in the carry model). For a folder eject the carried app is NOT among
/// `cells`; for a root lift it IS, but parked off-screen as `anchorCell` and excluded from
/// every layout/classification surface. Make-way opens a GAP by index (ACTIVE indexing — the
/// anchor doesn't count) and merge arms a real cell target. Readable for the handoff tests
/// (design §10-⑥): exactly one container is active at a time.
private(set) var externalDragActive = false
private(set) var externalGapIndex: Int? // ACTIVE slot the carried item would occupy (make-way gap)
private var externalDragAppID: String?
/// False when the carried item is a folder — folders never merge (no nesting, design §1.5).
private var externalAllowsMerge = true
/// Root-page carry anchor (design §2): the lifted cell STAYS in `cells` — it must keep
/// receiving the gesture's mouseDragged/mouseUp; `isHidden` or removeFromSuperview loses
/// them permanently (D4 spike) — but is parked far off-screen by FRAME and excluded from
/// every layout and classification path via `activeCells`. It never occupies `draggedCell`,
/// so `beginExternalDrag`'s mutual-exclusion guard keeps its meaning untouched (§0 ruling 2).
private(set) weak var anchorCell: LaunchpadGridCellView?
/// Where the anchor parks: far outside any plausible window. NOT hidden, NOT alpha 0 —
/// events must keep flowing and the view is not layer-backed.
static let carryParkOrigin = NSPoint(x: -100_000, y: -100_000)
/// The cells every layout/gap surface actually sees — the parked anchor is "not here".
var activeCells: [LaunchpadGridCellView] { cells.filter { $0 !== anchorCell } }
/// The gap (ACTIVE indexing) that reproduces the committed board with the anchor's own slot
/// left empty — the seed layout at lift, and the settle target while a merge is armed.
private var anchorSeedGap: Int? {
guard let anchorCell else { return nil }
return cells.firstIndex(where: { $0 === anchorCell })
}
/// Third leg of the layout-skip predicate (design §2.2/§7.3, AC-3): while the floating icon
/// is flying to its slot, the landed cell — freshly rebuilt by the post-commit apply, or the
/// still-parked source anchor — must never be written back to a slot by ANY layout pass.
/// Only the coordinator's reveal (clearing `settlingItemID`) un-parks it.
private func isParkedForSettle(_ cell: LaunchpadGridCellView) -> Bool {
guard let settling = grid?.coordinator?.settlingItemID else { return false }
return cell.layoutID == settling
}
// Empty-space drag (follow-cursor paging) + click (dismiss) tracking, plus scroll paging.
private var gapDownPoint: NSPoint?
private var gapMoved = false
private var pageDragActive = false
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
// NOT layer-backed: SwiftUI's `.offset` moving a layer-backed AppKit subtree during
// follow-cursor paging leaves CA presentation-layer ghost trails. The reorder shuffle
// still animates via the NSView animator proxy (no layer needed).
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var isFlipped: Bool { true } // top-left origin → natural grid math
var callbacks: LaunchpadDragGrid? { grid }
/// The laid-out cell views, in committed order. Exposed for drag-to-stack unit tests.
var cellViews: [LaunchpadGridCellView] { cells }
/// True while a local drag is live OR this container is the source of a root carry (the
/// parked anchor) — cells read this to suppress hover magnification, and the right-click
/// guard reads it so a menu's tracking loop can't swallow the drag's mouseUp.
var hasActiveDrag: Bool { draggedCell != nil || anchorCell != nil }
/// While a folder overlay is up, the grid is inert: returning `nil` lets the click fall
/// through to the SwiftUI scrim (which closes the folder) instead of being grabbed by a
/// cell or the empty-space pager underneath.
override func hitTest(_ point: NSPoint) -> NSView? {
guard grid?.interactionEnabled != false else { return nil }
return super.hitTest(point)
}
func apply(grid: LaunchpadDragGrid) {
// Registration must stay ABOVE the isDragging guard and read only the incoming `grid`
// (self.grid stays stale during a deferred apply): during a cross-page carry the source
// container has isDragging == true, and flipping away then back must still re-register it
// or the handoff dies on the most common path (design §3.1). Pure dictionary write — it
// never touches cells, so running it before the guard is safe.
if let page = grid.pageIndex { grid.coordinator?.registerPageContainer(self, page: page) }
guard !isDragging else { pendingGrid = grid; return }
// Compare the full display cells (Equatable) so a folder rename / contents change still
// rebuilds, whilst an offset/selection-only change during paging takes the fast path.
let sameItems = cells.map(\.cell) == grid.items
let sameColumns = columns == max(1, grid.columns)
let sameRows = rows == max(0, grid.rows)
// Metrics change with unchanged items must NOT take the fast path (design §1.3):
// the cells would keep their old icon/label frames and never re-layout.
let sameMetrics = metrics == grid.metrics
self.grid = grid
self.columns = max(1, grid.columns)
self.rows = max(0, grid.rows)
self.metrics = grid.metrics
if sameItems && sameMetrics {
for cell in cells { cell.isSelected = (cell.layoutID == grid.selectedID) }
if !sameColumns || !sameRows { needsLayout = true }
} else {
rebuildCells(items: grid.items, selectedID: grid.selectedID)
needsLayout = true
}
// Park the settling cell (design §7.3-2): the post-commit rebuild creates (or reuses) the
// landed cell, but it must stay OFF-SCREEN until the floating icon finishes its flight —
// surfacing it early would double the icon (grid cell + flying window). The layout-skip
// predicate keeps it parked through every pass; the coordinator's reveal un-parks it.
if let settling = grid.coordinator?.settlingItemID,
let parked = cells.first(where: { $0.layoutID == settling }) {
parked.setFrameOrigin(Self.carryParkOrigin)
}
// A container engaged while empty (late mount during a carry) gets the cursor replayed
// now that cells exist — see LaunchpadDragCoordinator.containerDidApplyCells.
if externalDragActive { grid.coordinator?.containerDidApplyCells(self) }
}
private func icons(for cell: LaunchpadDisplayCell) -> [NSImage] {
guard let provider = grid?.iconProvider else { return [] }
switch cell {
case .app(let item): return [provider(item)]
case .folder(_, _, let items): return items.prefix(9).map(provider)
}
}
private func rebuildCells(items: [LaunchpadDisplayCell], selectedID: String?) {
var reused: [LaunchpadGridCellView] = []
for item in items {
let cell = cells.first(where: { $0.layoutID == item.layoutID })
?? LaunchpadGridCellView(cell: item, icons: icons(for: item), metrics: metrics)
cell.container = self
cell.update(cell: item, icons: icons(for: item), metrics: metrics)
cell.isSelected = (item.layoutID == selectedID)
if cell.superview !== self { addSubview(cell) }
reused.append(cell)
}
for stale in cells where !reused.contains(where: { $0 === stale }) {
stale.removeFromSuperview()
}
cells = reused
}
override func layout() {
super.layout()
// While an item is being carried over this grid, preserve the make-way GAP —
// a plain re-layout (e.g. triggered by the folder-close animation's re-render) would snap the
// cells back to their committed slots and the make-way would "spring back".
if externalDragActive {
layoutCellsWithGap(animated: false)
} else if anchorCell != nil {
// Root carry handed off to another page: lay out COMPACT over activeCells so a stray
// layout pass can never punch the anchor's hole back into the grid (design §2.2/AC-2).
layoutCells(order: activeCells, animated: false)
} else {
layoutCells(order: dragOrder ?? cells, animated: false)
}
}
/// The frame of grid slot `index` (row-major), in container coordinates. The math
/// lives in `LaunchpadLayoutMath.slotRect` — shared with the settings layout
/// preview so the two can only move together (anti-drift, same as `Chrome`).
private func slotRect(_ index: Int) -> CGRect {
LaunchpadLayoutMath.slotRect(
index: index, columns: columns, containerWidth: bounds.width, metrics: metrics
)
}
/// Position cells by their index in `order` — the live `dragOrder` while reordering (so
/// the gap opens at the hover slot), otherwise the committed `cells`.
private func layoutCells(order: [LaunchpadGridCellView], animated: Bool, settle: Bool = false) {
guard !order.isEmpty else { return }
if animated {
NSAnimationContext.runAnimationGroup { ctx in
// Make-way (cells flowing aside as you drag): a smooth, slightly slower ease so it
// reads as elegant, not a snap. Settle (the drop landing): a touch longer with a
// gentle overshoot for a satisfying, iOS-like land.
ctx.duration = settle ? 0.30 : 0.20
ctx.timingFunction = settle
? CAMediaTimingFunction(controlPoints: 0.34, 1.18, 0.5, 1)
: CAMediaTimingFunction(name: .easeInEaseOut)
// Skip the lifted source cell — it follows the cursor (setFrameOrigin), not the
// grid — the parked carry anchor, and the settling (still-flying) cell: none may
// be written back to a slot until their reveal (design §2.2: the exclusion surface).
for (index, cell) in order.enumerated()
where cell !== draggedCell && cell !== anchorCell && !isParkedForSettle(cell) {
cell.animator().frame = slotRect(index)
}
}
} else {
// MUST also skip the dragged cell here — `layout()` uses this path during a drag, and
// writing the dragged cell back to its slot fights `updateDirectDrag`'s 1:1 follow
// (that tug-of-war was a source of the every-frame reorder flicker). Same for the
// parked anchor and the settling cell: only the explicit reveal un-parks them.
for (index, cell) in order.enumerated()
where cell !== draggedCell && cell !== anchorCell && !isParkedForSettle(cell) {
cell.frame = slotRect(index)
}
}
}
// MARK: Cell callbacks
func activate(_ cell: LaunchpadGridCellView) { grid?.onActivate(cell.cell) }
func select(_ cell: LaunchpadGridCellView) { grid?.onSelect(cell.layoutID) }
/// Context menu: apps get the launch/reveal/order/hide set; folders get open / rename /
/// dissolve (design §2.5, R2 — dissolve has no confirmation: the apps just return to the
/// grid, nothing is lost). Folder cells never exist while searching (folders dissolve into
/// flat results) and mid-drag suppression lives at the cell's `rightMouseDown`.
func contextMenu(for cell: LaunchpadGridCellView) -> NSMenu? {
guard let grid else { return nil }
switch cell.cell {
case .app(let app): return appContextMenu(app, grid: grid)
case .folder: return folderContextMenu(cell.cell, grid: grid)
}
}
private func appContextMenu(_ app: LaunchpadAppItem, grid: LaunchpadDragGrid) -> NSMenu {
let loc = grid.localization
let menu = NSMenu()
func add(_ title: String, _ action: Selector) {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.representedObject = app
menu.addItem(item)
}
add(loc.string("grid.menu.open", defaultValue: "打开"), #selector(menuOpen(_:)))
menu.addItem(.separator())
add(loc.string("grid.menu.revealInFinder", defaultValue: "在 Finder 中显示"), #selector(menuReveal(_:)))
add(loc.string("grid.menu.copyPath", defaultValue: "拷贝路径"), #selector(menuCopy(_:)))
menu.addItem(.separator())
if grid.allowsCustomOrderActions {
add(loc.string("grid.menu.moveToFront", defaultValue: "移到最前"), #selector(menuMoveFront(_:)))
add(loc.string("grid.menu.moveToEnd", defaultValue: "移到最后"), #selector(menuMoveEnd(_:)))
menu.addItem(.separator())
}
add(loc.string("grid.menu.hide", defaultValue: "隐藏"), #selector(menuHide(_:)))
return menu
}
private func folderContextMenu(_ cell: LaunchpadDisplayCell, grid: LaunchpadDragGrid) -> NSMenu {
let loc = grid.localization
let menu = NSMenu()
func add(_ title: String, _ action: Selector) {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.target = self
item.representedObject = cell // carries the folder's display cell (id + name)
menu.addItem(item)
}
add(loc.string("grid.menu.open", defaultValue: "打开"), #selector(menuOpenFolder(_:)))
menu.addItem(.separator())
add(loc.string("grid.menu.renameFolder", defaultValue: "重命名"), #selector(menuRenameFolder(_:)))
add(loc.string("grid.menu.dissolveFolder", defaultValue: "解散文件夹"), #selector(menuDissolveFolder(_:)))
return menu
}
private func menuApp(_ sender: NSMenuItem) -> LaunchpadAppItem? { sender.representedObject as? LaunchpadAppItem }
private func menuFolderID(_ sender: NSMenuItem) -> String? {
guard let cell = sender.representedObject as? LaunchpadDisplayCell,
case .folder(let id, _, _) = cell else { return nil }
return id
}
@objc private func menuOpen(_ sender: NSMenuItem) { menuApp(sender).map { grid?.onActivate(.app($0)) } }
@objc private func menuReveal(_ sender: NSMenuItem) { menuApp(sender).map { grid?.onReveal($0) } }
@objc private func menuCopy(_ sender: NSMenuItem) { menuApp(sender).map { grid?.onCopyPath($0) } }
@objc private func menuMoveFront(_ sender: NSMenuItem) { menuApp(sender).map { grid?.onMoveToFront($0) } }
@objc private func menuMoveEnd(_ sender: NSMenuItem) { menuApp(sender).map { grid?.onMoveToEnd($0) } }
@objc private func menuHide(_ sender: NSMenuItem) { menuApp(sender).map { grid?.onHide($0) } }
@objc private func menuOpenFolder(_ sender: NSMenuItem) {
guard let cell = sender.representedObject as? LaunchpadDisplayCell else { return }
grid?.onActivate(cell)
}
@objc private func menuRenameFolder(_ sender: NSMenuItem) { menuFolderID(sender).map { grid?.onRenameFolder($0) } }
@objc private func menuDissolveFolder(_ sender: NSMenuItem) { menuFolderID(sender).map { grid?.onDissolveFolder($0) } }
// MARK: Direct drag (mouse-tracked, like iOS — NOT NSDraggingSession)
//
// The system drag (NSDraggingSession) is the laggy "long-press / .onDrag" model: a separate
// system-managed drag image, input latency, and a modal drag loop that starves timers. iOS
// SpringBoard and the smooth SwiftUI reorder libraries instead track the pointer DIRECTLY and
// move the real view 1:1. So the cell forwards its own mouse-drag here: the lifted cell follows
// the cursor, the others spring aside, and a normal-run-loop dwell can drive pause-to-stack.
/// Cursor offset within the dragged cell, so it follows the pointer keeping the grab point.
private var dragGrabOffset = NSPoint.zero
/// Last drag point in local coords — used on drop to detect an "escape" (dragged clearly out of
/// the grid bounds → leave the folder).
private var lastDragPoint: NSPoint?
/// Last drag point in WINDOW coords — the coordinate space that survives the folder closing, so
/// the eject commit can resolve the root drop slot.
private var lastWindowDragPoint: NSPoint?
func beginDirectDrag(_ cell: LaunchpadGridCellView, atWindowPoint windowPoint: NSPoint) {
// New-gesture boundary: this entry fires exactly once per mouse gesture (the cell's
// didDrag threshold latch), so the coordinator's cancelled-gesture latch is dropped
// HERE, before any carry gate reads it. The latch's only other reset lives inside
// onDragBegan → freezeVisibleOrder, which the root-lift gate runs BEFORE — without
// this boundary one mid-carry cancel would wedge every later root drag (gate refuses
// → reset unreachable → repeat forever).
grid?.coordinator?.gestureBegan()
// Root-page lift = a CARRY (design §1.5/§2.1): the same phantom model the folder eject
// uses, so cross-page travel / edge flips / the virtual tail page all apply. Folder grids
// keep the local state machine (in-folder reorder + the eject escalation); a grid with no
// coordinator keeps the legacy local path — pinned by the existing test fleet.
if grid?.folderContextID == nil, let coordinator = grid?.coordinator {
beginCarryLift(cell, atWindowPoint: windowPoint, coordinator: coordinator)
return
}
isDragging = true
draggedCell = cell
dragOrder = cells
lastDragPoint = nil
lastWindowDragPoint = nil
disarmStackTarget()
let p = convert(windowPoint, from: nil)
dragGrabOffset = NSPoint(x: p.x - cell.frame.minX, y: p.y - cell.frame.minY)
cell.isLifted = true // picked-up look (scales the icon up)
addSubview(cell, positioned: .above, relativeTo: nil) // draw on top of its neighbours
grid?.onDragBegan() // freeze the visible order (§5.3)
}
/// Lift a root-page cell straight into a carry session (design §2.1, numbered order): the
/// floating window IS the lifted visual, the cell stays in `cells` as an off-screen event
/// anchor, and the visible page makes way through the same external-drag classification an
/// ejected app uses.
private func beginCarryLift(_ cell: LaunchpadGridCellView, atWindowPoint windowPoint: NSPoint,
coordinator: LaunchpadDragCoordinator) {
// 1. Gate FIRST (BR-4): a refused lift leaves the container with zero state changes —
// the cell's didDrag has already consumed the gesture.
guard coordinator.canBeginCarry,
let anchorIndex = cells.firstIndex(where: { $0 === cell }) else { return }
// 2. Container state. The anchor never occupies draggedCell/dragOrder — a dragOrder
// residue would make layout()'s fallback keep a hole after handoff (AC-2); the
// beginExternalDrag mutual-exclusion guard stays untouched (§0 ruling 2).
isDragging = true
anchorCell = cell
lastDragPoint = nil
lastWindowDragPoint = windowPoint
disarmStackTarget()
// 3. Freeze the visible order: the host view stages the snapshot + editability into the
// coordinator and clears any paging residue in the same closure (§2.1-3).
grid?.onDragBegan()
// 4. Floating visual keeps the GRAB POINT: the floating icon centre keeps the offset the
// cell's icon centre had from the cursor, so the lift doesn't visually jump (§2.1-4).
// Container is flipped (y down); screen space is y-up → negate the y delta.
// The cursor's page-local point comes from the PUSHED geometry, never the frame
// chain: `convert` is blind to the SwiftUI paging .offset, so on page > 0 it would
// skew the grab offset by page×pageWidth and the floating icon would ride that far
// from the cursor for the whole carry (design §5). Cold start (no geometry pushed
// yet) is page 0 by construction, where the frame chain IS correct; windowless
// harnesses treat the window point as already container-local.
let p = coordinator.pageLocalPoint(fromWindow: windowPoint)
?? (window != nil ? convert(windowPoint, from: nil) : windowPoint)
let iconCentre = NSPoint(x: cell.frame.minX + metrics.cellWidth / 2,
y: cell.frame.minY + metrics.iconTopInset + metrics.iconSide / 2)
let grabOffset = NSPoint(x: p.x - iconCentre.x, y: iconCentre.y - p.y)
let isApp: Bool = { if case .app = cell.cell { return true }; return false }()
let screen = window?.convertPoint(toScreen: windowPoint) ?? .zero
// 5. Open the session: presents the floating icon and engages THIS page with
// beginExternalDrag (the anchor is already excluded from every active surface).
guard coordinator.beginCarry(itemID: cell.layoutID, origin: .rootPage, isApp: isApp,
icon: cell.carryVisual(), iconSide: metrics.iconSide,
atScreenPoint: screen, aboveLevel: window?.level ?? .popUpMenu,
grabOffset: grabOffset, sourceContainer: self) else {
anchorCell = nil // defensive rollback; the gate above makes this unreachable
isDragging = false
return
}
// 6. Seed the gap at the anchor's own slot: identity layout over activeCells — the board
// doesn't move, the original slot IS the make-way gap (iOS pick-up look, §2.1-5).
seedExternalGap(at: anchorIndex)
// 7. Park the anchor off-screen. NEVER isHidden (mouseDragged/mouseUp would be lost
// permanently — D4 spike) and never alpha 0 (non-layer-backed). isLifted stays false:
// the floating window carries the enlarged visual.
cell.setFrameOrigin(Self.carryParkOrigin)
}
/// Seed the make-way gap (root lift: the anchor's own committed slot). With the anchor
/// excluded from `activeCells` this is the IDENTITY layout — neighbours don't move.
func seedExternalGap(at index: Int) {
guard externalDragActive else { return }
externalGapIndex = index
layoutCellsWithGap(animated: false)
}
/// Commit-path anchor recovery (design §1.4-7/§7.3): clear the carry state, APPLY any
/// deferred model (not drop: the source page must not keep a stale model), and let the next
/// layout pass place the cell back into a real slot. On the hard-cut branch this runs in the
/// mouseUp stack; on a flight settle it runs at the REVEAL — the deferred apply then already
/// holds the post-commit model, so the source page reflows exactly once, never through the
/// stale pre-commit snapshot mid-flight. While `settlingItemID` is set the cell stays parked
/// regardless (the layout predicate), so calling this never surfaces it early.
func endCarryAnchor() {
guard anchorCell != nil else { return }
anchorCell = nil
isDragging = false
lastDragPoint = nil
lastWindowDragPoint = nil
if let pending = pendingGrid {
pendingGrid = nil
apply(grid: pending)
}
needsLayout = true
}
/// Cancel-path anchor recovery (design §2.3): same mechanics — with the anchor back in the
/// layout surfaces, the next layout pass writes it straight back to its real slot. Instant
/// restore, fail-safe.
func cancelCarryAnchor() {
endCarryAnchor()
}
func updateDirectDrag(atWindowPoint windowPoint: NSPoint) {
// Carry forwarding sits ABOVE the draggedCell guard (design §1.5/BR-7): a root carry has
// no draggedCell, so the legacy position (inside the guard) would swallow every move.
// Covers BOTH origins — the floating window is the icon now; classification is driven by
// the coordinator against the CURRENT page's container, not necessarily this one.
if let coord = grid?.coordinator, coord.carryActive {
lastWindowDragPoint = windowPoint
let screen = window?.convertPoint(toScreen: windowPoint) ?? .zero
coord.carryMoved(atScreenPoint: screen, atWindowPoint: windowPoint)
return
}
guard isDragging, let cell = draggedCell else { return }
lastWindowDragPoint = windowPoint
let p = convert(windowPoint, from: nil)
lastDragPoint = p
var origin = NSPoint(x: p.x - dragGrabOffset.x, y: p.y - dragGrabOffset.y)
// Main grid: keep the lifted icon within the page so it can't be dragged off-screen / under
// the launcher edge (iOS keeps the dragged icon on-screen). The OPEN FOLDER grid is exempt —
// its cell must be free to leave the grid to trigger the eject.
if grid?.folderContextID == nil {
origin.x = min(max(origin.x, 0), max(0, bounds.width - cell.frame.width))
origin.y = min(max(origin.y, 0), max(0, bounds.height - cell.frame.height))
}
cell.setFrameOrigin(origin) // 1:1 follow (clamped on the main grid)
updateDrag(at: p) // reflow the others / arm a merge target
// Inside a folder: the moment the cell clearly leaves, begin the finger-bound eject — the
// folder zooms closed and a floating icon takes over (see LaunchpadDragCoordinator).
if let folderID = grid?.folderContextID, let coord = grid?.coordinator, draggedClearlyOutsideFolder(cell),
let screen = window?.convertPoint(toScreen: windowPoint) {
LaunchpadDragCoordinator.carryTrace("ARM eject app=\(cell.layoutID) window=\(windowPoint)")
coord.beginEject(appID: cell.layoutID, sourceFolderID: folderID, icon: cell.primaryIcon,
iconSide: metrics.iconSide, atScreenPoint: screen, aboveLevel: window?.level ?? .popUpMenu)
}
}
/// True when the DRAGGED CELL has clearly left the folder's cell cluster. Decided entirely in the
/// container's OWN coordinate space — the cell's laid-out frame vs the visible grid rect — never
/// via a window→view conversion. `convert(_:from:)` is unreliable through the folder overlay's
/// SwiftUI `scaleEffect`/centering, which made small in-folder reorders falsely eject; the cell
/// frame and the visible rect share one coordinate system, so this matches what the user sees.
///
/// The boundary is the grid's VISIBLE rect, with no outward growth. The folder grid lives inside
/// a clip view, so the instant the cell's centre leaves the visible rect the cell is clipped
/// invisible. Ejecting exactly here raises the floating icon while the cell is still mostly
/// visible — closing the gap that previously let the cell vanish into the clipped region 60-170pt
/// before the eject (and the float) caught up. A reorder keeps the cell centre inside the visible
/// rect, so it never trips this.
private func draggedClearlyOutsideFolder(_ cell: LaunchpadGridCellView) -> Bool {
let centre = NSPoint(x: cell.frame.midX, y: cell.frame.midY)
// Primary boundary: the clip view's visible rect (the region the user can actually see).
if let scrollView = enclosingScrollView {
return !scrollView.documentVisibleRect.contains(centre)
}
// No clip view (not expected for a real folder grid — only windowless tests and any
// detached fallback). Without a visible rect we can't tell "clipped" from "still shown",
// so keep the previous generous slot-union zone: an in-grid reorder near the edge must
// not falsely eject.
let union = (0..<cells.count).map(slotRect).reduce(CGRect.null) { $0.union($1) }
let base = union.isNull ? bounds : union
let region = CGRect(x: base.minX - 60, y: base.minY - 110,
width: base.width + 120, height: base.height + 170)
return !region.contains(centre)
}
/// Tear down the local drag state after an in-folder app is ejected to the root — no in-folder
/// reorder is committed (the app left the folder).
private func teardownDragState(_ cell: LaunchpadGridCellView) {
disarmStackTarget()
draggedCell = nil
dragOrder = nil
pendingGrid = nil // an eject closes the folder; drop any deferred apply so the next drag starts clean
isDragging = false
cell.isLifted = false
lastDragPoint = nil
lastWindowDragPoint = nil
}
/// The grid is being unmounted — the folder closed under the drag (Esc / typing flattened the
/// layout to search) or the whole overlay is being torn down. A live drag can never finish
/// here (its mouseUp is lost with the view), so abort it: tear down the coordinator's floating
/// eject window and settle any external make-way gap. Without this the floating icon window
/// outlives the launcher and the next session's drags are wedged.
override func viewWillMove(toWindow newWindow: NSWindow?) {
super.viewWillMove(toWindow: newWindow)
guard newWindow == nil else { return }
if anchorCell != nil {
// Root-carry source unmounting (filtered shrink / overlay teardown): the gesture's
// mouseUp is lost with the view, so cancel is the only safe option (§9.1 row 3).
// cancelCarry reaches back into cancelCarryAnchor via session.sourceContainer; the
// explicit call below is the belt-and-braces for a session already gone. Runs BEFORE
// the unregistration: the anchor restore can apply a deferred grid, which re-registers.
grid?.coordinator?.cancelCarry(.anchorUnmounted)
cancelCarryAnchor()
}
grid?.coordinator?.unregisterPageContainer(self)
if let cell = draggedCell {
if let coord = grid?.coordinator, grid?.folderContextID != nil, coord.ejectActive {
coord.cancelEject()
}
teardownDragState(cell)
}
if externalDragActive { endExternalDrag() }
}
func endDirectDrag() { endDirectDrag(atWindowPoint: lastWindowDragPoint ?? .zero) }
func endDirectDrag(atWindowPoint windowPoint: NSPoint) {
// Release routing sits ABOVE the draggedCell guard (design §9.1 row 1 / BR-7): a root
// carry has no draggedCell — at the legacy position the mouseUp would be swallowed and
// the session wedged. The coordinator lands the DATA synchronously here (§1.4) and
// reveals the source anchor itself (session.sourceContainer.endCarryAnchor).
if let coord = grid?.coordinator, coord.carryActive {
lastWindowDragPoint = windowPoint
let revealBefore = coord.folderRevealToken
coord.carryReleased(atWindowPoint: windowPoint)
if let cell = draggedCell { teardownDragState(cell) } // folder origin's local drag state
// A folder commit auto-opens the new folder for inline rename, and the hard-cut reveal
// now fires synchronously in this same mouseUp stack. Yanking first responder back to
// the search field here would race — and usually beat — that rename focus, dropping the
// typed name. Refocus search only when this release did NOT trigger a folder auto-open.
if coord.folderRevealToken == revealBefore {
refocusSearchField()
}
return
}
guard let cell = draggedCell else { return }
lastWindowDragPoint = windowPoint
// Finger-bound folder exit: if the eject is in flight (folder already closing) OR the cell is
// released clearly outside, drop the app at the cursor's ROOT slot (not the tail). Releasing
// INSIDE falls through to a normal in-folder reorder (the revert).
if let coordinator = grid?.coordinator, let folderID = grid?.folderContextID,
coordinator.ejectActive || draggedClearlyOutsideFolder(cell) {
LaunchpadDragCoordinator.carryTrace("endDirectDrag COMMIT branch ejectActive=\(coordinator.ejectActive) clearlyOutside=\(draggedClearlyOutsideFolder(cell)) p=\(windowPoint)")
coordinator.commitOut(folderID: folderID, appID: cell.layoutID, atWindowPoint: windowPoint)
teardownDragState(cell)
refocusSearchField()
return
}
let settleOrder = dragOrder ?? cells
let wasMerge = stackTargetCell != nil
commitDrop(dragged: cell) // reorder or make/join folder (disarms)
draggedCell = nil
dragOrder = nil
isDragging = false
cell.isLifted = false
lastDragPoint = nil
if let pending = pendingGrid {
pendingGrid = nil
apply(grid: pending) // committed order already includes the move
layoutCells(order: cells, animated: true, settle: true)
} else {
// Reorder: adopt the reflowed order as the committed `cells` NOW, so the async SwiftUI
// apply finds `sameItems` and is a no-op — no instant snap/flicker after the glide.
// (Merge changes the visible set, so let its apply rebuild into the new folder cell.)
if !wasMerge { cells = settleOrder }
layoutCells(order: settleOrder, animated: true, settle: true) // elegant glide to the drop slot
}
refocusSearchField()
}
/// Return first-responder focus to the search field after a grid interaction, so typing
/// and arrow-key navigation keep working (the field is the keyboard handler).
func refocusSearchField() {
guard let window, let field = Self.searchField(in: window.contentView) else { return }
window.makeFirstResponder(field)
}
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 field = searchField(in: subview) { return field }
}
return nil
}
/// iOS folders, movement-driven + STABLE grid: reorder opens a gap only at the seam BETWEEN
/// two icons; moving onto an icon's CENTRE keeps the grid exactly where it is and just
/// highlights that icon as a merge target (release = create / join a folder). Arming never
/// touches the layout, so the target stays put under the cursor.
///
/// Classify the cursor against the cell currently under it: inside that cell's central icon
/// rect → merge intent (arm it, leave the grid untouched); otherwise → reorder (slide the
/// dragged gap to that cell's near side — one local shift, never a full-grid reset). Exposed
/// so unit tests can drive it directly instead of a real `NSDraggingInfo`.
func updateDrag(at point: NSPoint) {
guard let dragged = draggedCell else { return }
// Anti-jitter #1: keep an armed merge target while the cursor stays over its cell (+ a
// little), so hovering near the merge-zone edge can't toggle merge↔reorder every frame.
if let armed = stackTargetCell, armed.frame.insetBy(dx: -6, dy: -6).contains(point) { return }
let slot = slotIndex(at: point, count: cells.count)
guard cells.indices.contains(slot) else { return }
// MERGE — classify against the COMMITTED layout (`cells`), NOT the reflowed `dragOrder`.
// While reordering, the dragged's own gap can occupy this slot in `dragOrder`, so detecting
// there makes the target read as "self" and silently kills merge (the regression). The
// committed cell at this geometric slot is the real icon under the cursor. On arm, settle the
// grid back to committed so that target sits exactly under the cursor and gets the highlight.
let target = cells[slot]
if grid?.allowFolderCreation != false, target !== dragged, dragged.cell.folderID == nil, mergeRect(forSlot: slot).contains(point) {
if stackTargetCell !== target {
setStackTarget(target)
if dragOrder != cells {
dragOrder = cells
layoutCells(order: cells, animated: true)
}
}
return
}
clearStackTarget()
// REORDER — slide the dragged to the hovered cell's NEAR side (before / after). Inserting
// *beside* the hovered cell (rather than absorbing its slot) keeps that cell where it is, so
// the next frame can still detect a merge on it. The central merge rect is the dead-band
// between "before" and "after", so a cursor sitting on the seam can't flap the order.
guard var order = dragOrder else { return }
let hovered = order[slot]
if hovered === dragged { return }
let m = mergeRect(forSlot: slot)
let before: Bool
if point.x < m.minX { before = true }
else if point.x > m.maxX { before = false }
else { return } // central band → reserved for merge
guard let cur = order.firstIndex(of: dragged) else { return }
order.remove(at: cur)
guard let hIdx = order.firstIndex(of: hovered) else { return }
order.insert(dragged, at: before ? hIdx : hIdx + 1)
if order != dragOrder {
dragOrder = order
// Animate the OTHER cells flowing aside (elegant make-way). This is safe — it fires only
// on an order CHANGE (not every frame) and animates non-dragged cells to FIXED slots; the
// earlier flicker was the *dragged* cell being re-targeted to a moving point each frame,
// which `layoutCells` excludes (`where cell !== draggedCell`).
layoutCells(order: order, animated: true)
}
}
/// The central icon rect of a slot — the merge "hot zone" (icon-centred ~52×52); the seams
/// to either side stay reorder.
private func mergeRect(forSlot slot: Int) -> CGRect {
let s = slotRect(slot)
// Scales with the icon (design §1.1): 8 at the long-standing 64pt (regression
// unchanged), floored at 6 so a 48pt icon keeps a usable 36×36 hot zone.
let inset: CGFloat = max(6, metrics.iconSide * 0.125)
return iconRect(in: s).insetBy(dx: inset, dy: inset)
}
private func setStackTarget(_ cell: LaunchpadGridCellView) {
guard stackTargetCell !== cell else { return }
stackTargetCell?.isMergeTarget = false
stackTargetCell = cell
cell.isMergeTarget = true
}
private func disarmStackTarget() { clearStackTarget() }
private func clearStackTarget() {
guard let target = stackTargetCell else { return }
stackTargetCell = nil
target.isMergeTarget = false
}
/// Resolve a drop: a dwelled-over target makes (app→app) or joins (app→folder) a folder;
/// otherwise reorder against the live order. Called by `endDirectDrag`; also unit-tested
/// directly (no real drag session needed).
@discardableResult
func commitDrop(dragged: LaunchpadGridCellView) -> Bool {
let armed = stackTargetCell
defer { disarmStackTarget() }
if let target = armed, target !== dragged, dragged.cell.folderID == nil {
if target.cell.folderID != nil {
grid?.onAddToFolder(target.layoutID, dragged.layoutID)
} else {
grid?.onMakeFolder(target.layoutID, dragged.layoutID)
}
return true
}
// Otherwise reorder: the dragged app lands at `index` → after the cell before it, or
// before the next cell when dropped at the very front.
guard let order = dragOrder, let index = order.firstIndex(of: dragged) else { return false }
let target: LaunchpadDropTarget
if index > 0 { target = .after(order[index - 1].layoutID) }
else if order.count > 1 { target = .before(order[index + 1].layoutID) }
else { return false }
grid?.onReorder(dragged.layoutID, target)
return true
}
/// Resolve a root drop target from a CONTAINER-LOCAL point — the coordinator maps the window
/// point through the pushed page geometry (`LaunchpadCarrySpace`), never through `convert`,
/// which is blind to the SwiftUI paging offset. The carried item lands at the slot under the
/// cursor (not the tail); the cursor's side of the slot midline picks before/after. An
/// ejected app is not among `cells` (it's still in the folder); a root-carried one IS — its
/// parked anchor resolves to a committed neighbour below, never to itself.
func rootDropTarget(atContainerPoint p: NSPoint) -> LaunchpadDropTarget? {
guard !cells.isEmpty else { return nil }
let slot = slotIndex(at: p, count: cells.count)
guard cells.indices.contains(slot) else { return nil }
let target = cells[slot]
if target === anchorCell {
// The carried item's own slot: resolve relative to a committed NEIGHBOUR so the
// commit's isNoOp guard reads it as "back where it started" — never as the anchor
// itself, and never as a global-tail fallback.
if let next = cells[(slot + 1)...].first(where: { $0 !== anchorCell }) {
return .before(next.layoutID)
}
if let previous = cells[..<slot].last(where: { $0 !== anchorCell }) {
return .after(previous.layoutID)
}
return nil
}
return p.x < slotRect(slot).midX ? .before(target.layoutID) : .after(target.layoutID)
}
/// Legacy window-point entrance (correct on page 0 only — kept for the cold-start fallback
/// before the first geometry push; retire once §11 step 9 proves it unreachable).
func rootDropTarget(atWindowPoint windowPoint: NSPoint) -> LaunchpadDropTarget? {
rootDropTarget(atContainerPoint: window != nil ? convert(windowPoint, from: nil) : windowPoint)
}
// MARK: External drag (an app carried over this root grid after being ejected from a folder)
/// Begin carrying an external item over this grid. Mutually exclusive with a real in-grid
/// drag (the root-carry anchor never occupies `draggedCell`, so the SOURCE container itself
/// passes this guard and can host the seed gap — design §0 ruling 2).
func beginExternalDrag(appID: String, allowsMerge: Bool = true) {
guard draggedCell == nil, !externalDragActive else { return }
externalDragActive = true
externalDragAppID = appID
externalAllowsMerge = allowsMerge
externalGapIndex = nil
clearStackTarget()
}
/// Run the SAME make-way + merge classification an in-grid reorder uses (`updateDrag`), but for a
/// phantom carried item: merge arms a real `cells` target (app → makeFolder, folder → addToFolder);
/// otherwise a make-way GAP opens on the hovered cell's near side. Reuses mergeRect/slotIndex and
/// the same central dead-band + sticky hysteresis so it can't flicker.
/// The point is CONTAINER-LOCAL, mapped by the coordinator through the pushed page geometry —
/// `convert` from window space is blind to the SwiftUI paging offset (page>0 misread).
func updateExternalDrag(atContainerPoint point: NSPoint) {
guard externalDragActive, draggedCell == nil, !cells.isEmpty, !activeCells.isEmpty else { return }
if let armed = stackTargetCell, armed.frame.insetBy(dx: -6, dy: -6).contains(point) { return }
// Classify against the COMMITTED slot owners — `cells` INCLUDING the anchor — so the
// seeded identity display and the classification agree (mirrors the local drag, which
// classifies against committed `cells` too). The GAP, however, lives in ACTIVE indexing
// (the anchor is never laid out), so targets are mapped through `activeCells` below.
let slot = slotIndex(at: point, count: cells.count)
guard cells.indices.contains(slot) else { return }
let target = cells[slot]
if target === anchorCell {
// The carried item's own committed slot: hovering yourself never arms or reflows —
// it re-opens the original gap, so "jiggle in place and release" is a no-op return
// and "drag back home" restores the board (design AR-1; local drag's `self` check).
clearStackTarget()
if externalGapIndex != anchorSeedGap {
externalGapIndex = anchorSeedGap
layoutCellsWithGap(animated: true)
}
return
}
if externalAllowsMerge, mergeRect(forSlot: slot).contains(point) {
if stackTargetCell !== target {
setStackTarget(target)
// Settle to the COMMITTED board so the armed target sits exactly under the
// cursor. With a parked anchor the committed board IS the seed-gap layout —
// nil (fully compacted) only when no anchor is present (the eject case).
let settleGap = anchorSeedGap
if externalGapIndex != settleGap {
externalGapIndex = settleGap
layoutCellsWithGap(animated: true)
}
}
return
}
clearStackTarget()
let m = mergeRect(forSlot: slot)
guard let targetActive = activeCells.firstIndex(where: { $0 === target }) else { return }
let gap: Int
if point.x < m.minX { gap = targetActive }
else if point.x > m.maxX { gap = targetActive + 1 }
else { return } // central band → reserved for merge
if gap != externalGapIndex {
externalGapIndex = gap
layoutCellsWithGap(animated: true)
}
}
/// Legacy window-point entrance (correct on page 0 only — cold-start fallback before the first
/// geometry push; windowless unit tests treat the point as already container-local).
func updateExternalDrag(atWindowPoint windowPoint: NSPoint) {
updateExternalDrag(atContainerPoint: window != nil ? convert(windowPoint, from: nil) : windowPoint)
}
/// Make-way for the carried item: lay ACTIVE cell `i` at `slotRect(i)`, shifted one slot
/// forward once past `externalGapIndex`, leaving an empty slot under the cursor. (When the
/// gap is nil this is the identity layout — i.e. the settle.) Iterates `activeCells`: the
/// parked anchor must never be written back to a slot (design §2.2), and `draggedCell` keeps
/// the shared skip discipline even though it can't coexist with an external drag.
private func layoutCellsWithGap(animated: Bool) {
func slot(for index: Int) -> Int {
guard let gap = externalGapIndex, index >= gap else { return index }
return index + 1
}
// A gap on a FULL page pushes the last cell past the page capacity. The container's
// row-major math knows no page boundary, so an unguarded slotRect would invent a
// phantom row BELOW the grid (visible — the strip only clips horizontally at the
// viewport). Send the overflow cell out of the RIGHT edge instead: the clip swallows
// it, the motion reads as "cascades to the next page", and the post-commit reconcile
// slice puts it on the real next page (or back) while it is already off-screen.
func frame(forSlot slot: Int) -> CGRect {
guard slot >= pageCapacity else { return slotRect(slot) }
let lastRow = slotRect(pageCapacity - 1)
return CGRect(x: bounds.width + metrics.cellWidth, y: lastRow.minY,
width: metrics.cellWidth, height: metrics.cellHeight)
}
let order = activeCells
if animated {
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.20
ctx.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
for (i, cell) in order.enumerated()
where cell !== draggedCell && !isParkedForSettle(cell) {
cell.animator().frame = frame(forSlot: slot(for: i))
}
}
} else {
for (i, cell) in order.enumerated()
where cell !== draggedCell && !isParkedForSettle(cell) {
cell.frame = frame(forSlot: slot(for: i))
}
}
}
/// Resolve the current external-drag state into a drop outcome WITHOUT tearing anything down
/// (pure peek, design §7.1) — the coordinator commits the data first and ends the drag after.
func resolveExternalDrop() -> LaunchpadExternalDropResult {
if let target = stackTargetCell {
if let fid = target.cell.folderID { return .addToFolder(folderID: fid) }
return .makeFolder(targetAppID: target.layoutID)
}
// Gap → id mapping over activeCells: the parked anchor can never become a .before/.after
// target, so a self-referential drop is structurally impossible (design §2.2; the seed
// gap resolves to a frozen-order neighbour, which the commit's isNoOp guard absorbs).
let active = activeCells
guard let gap = externalGapIndex, !active.isEmpty else { return .reorder(nil) }
if gap <= 0 { return .reorder(.before(active[0].layoutID)) }
if gap >= active.count { return .reorder(.after(active[active.count - 1].layoutID)) }
return .reorder(.after(active[gap - 1].layoutID))
}
/// Legacy resolve-and-teardown entrance (windowless test fixtures).
func commitExternalDrag() -> LaunchpadExternalDropResult {
defer { endExternalDrag() }
return resolveExternalDrop()
}
/// Where the floating icon's flight should land, in CONTAINER coordinates (pure peek — design
/// §7.1). An armed merge target answers its icon rect (the iOS absorb feel); an open make-way
/// gap answers the empty slot's icon rect (the gap layout leaves slot `externalGapIndex`
/// itself empty). nil when classification never settled — AND, deliberately, for an
/// engaged-but-EMPTY page: the only empty engaged container in production is the VIRTUAL tail
/// page (no sparse pages exist), which collapses the moment the release flips
/// `carryActive = false` — the commitToken handler then snap-animates `currentPage` back to
/// the last real page (§6.2) WHILE a 0.25s flight would still be airborne. A slot-0 flight
/// would chase a viewport that is sliding away, and the landed cell reveals at the LAST REAL
/// page's tail anyway (a root reorder never grows pageCount; a folder eject grows it only
/// when the last real page was exactly full — undecidable here without re-deriving the store
/// mutation). So every empty-page release answers nil and the coordinator degrades it to the
/// hard-cut dismiss instead of an aimless flight.
func settleTargetLocalRect() -> CGRect? {
guard externalDragActive else { return nil }
if let target = stackTargetCell { return iconRect(in: target.frame) }
if let gap = externalGapIndex {
// A gap at/after the page capacity (full page, last cell's right seam) has no
// visible slot on THIS page — slotRect would aim the flight at the phantom row.
// Degrade to the hard-cut dismiss, same as the empty-virtual-page nil above.
guard gap < pageCapacity else { return nil }
return iconRect(in: slotRect(gap))
}
return nil
}
/// The icon area inside a slot/cell frame — the cell's own `iconFrame`, offset into
/// container coordinates (shared source: `LaunchpadGridMetrics.iconFrameInCell`).
private func iconRect(in slot: CGRect) -> CGRect {
metrics.iconFrameInCell.offsetBy(dx: slot.minX, dy: slot.minY)
}
/// Commit-path external-drag teardown while a settle flight is airborne (design §1.4-3/§7.3-1,
/// BR-5): clear the flags but do NOT re-layout — the make-way frames freeze in place. The
/// post-commit apply lays out the new committed model, which is slot-for-slot identical to
/// the frozen gap layout, so the handover happens with zero motion. (`endExternalDrag` stays
/// the cancel/handoff collapse, which DOES animate the gap closed.)
func freezeExternalDrag() {
externalDragActive = false
externalDragAppID = nil
externalAllowsMerge = true
externalGapIndex = nil
clearStackTarget()
}
/// Settle-flight reveal (design §7.3-3): the coordinator has already cleared
/// `settlingItemID`, so the layout predicate no longer excludes the landed cell — one plain
/// pass writes it straight into its slot, exactly under the just-landed floating icon.
/// Idempotent and cheap; broadcast to every registered page (the landed cell lives on exactly
/// one; a merge-into-folder leaves no cell anywhere, and the pass is then a no-op).
func revealSettledCell() {
needsLayout = true
layoutSubtreeIfNeeded()
}
/// Clear external-drag state and settle the make-way gap closed. On a root-carry SOURCE page
/// this compacts over activeCells — the carried item "isn't on this page" while it travels;
/// the parked anchor itself is untouched (only endCarryAnchor/cancelCarryAnchor reveal it).
func endExternalDrag() {
externalDragActive = false
externalDragAppID = nil
externalAllowsMerge = true
externalGapIndex = nil
clearStackTarget()
layoutCellsWithGap(animated: true)
}
/// The page-local x spans of the outermost grid COLUMNS (pure peek): the edge turner
/// exempts them so hovering an edge column reads as drop aiming, never as a flip request.
/// Geometry only — gap state is deliberately ignored (slotIndex clamps every x to the last
/// column, so "a gap is open" holds even with the cursor in the bare margin, and keying the
/// exemption on it would kill right-edge flipping outright). nil while the page has no
/// active cells (virtual tail page): an empty page has no drop slot to aim at — fail open.
func outerColumnXSpans() -> (left: ClosedRange<CGFloat>, right: ClosedRange<CGFloat>)? {
guard !activeCells.isEmpty else { return nil }
let first = slotRect(0)
let last = slotRect(columns - 1)
return (first.minX...first.maxX, last.minX...last.maxX)
}
/// Grid slot (row-major index) under a point, clamped to the current item range.
private func slotIndex(at point: NSPoint, count: Int) -> Int {
let gridWidth = CGFloat(columns) * metrics.cellWidth + CGFloat(max(0, columns - 1)) * metrics.columnSpacing
let leftInset = max(0, (bounds.width - gridWidth) / 2)
let col = min(max(Int((point.x - leftInset) / (metrics.cellWidth + metrics.columnSpacing)), 0), columns - 1)
let maxRow = max(0, (count - 1) / columns)
let row = min(max(Int(point.y / (metrics.cellHeight + metrics.rowSpacing)), 0), maxRow)
return min(max(row * columns + col, 0), max(0, count - 1))
}
// MARK: Scroll paging + gap click-to-dismiss
/// Two-finger horizontal swipe pages the grid — the native Launchpad gesture. Paging is
/// a scroll gesture (no mouse button), kept entirely separate from per-item click-drag,
/// so swiping to page can never grab an app. Cells don't override `scrollWheel`, so a
/// swipe over an icon bubbles up to here.
override func scrollWheel(with event: NSEvent) {
// The open-folder grid doesn't page — let the event bubble to the panel's enclosing
// ScrollView so a folder taller than the visible cap can be two-finger scrolled.
if grid?.folderContextID != nil { super.scrollWheel(with: event); return }
// Scroll events aren't anchored to the mouse-down view, so they still arrive mid-carry —
// edge dwell is the only flip channel while one is live (§9.4). Gate on the SESSION, not
// `carryActive`: the settle flight (mouse already up) must also freeze paging, or the
// floating icon lands on a page that just slid away (§8/BR-3b).
if grid?.coordinator?.carrySession != nil { return }
if event.hasPreciseScrollingDeltas {
// Trackpad two-finger swipe: follow-the-finger paging — the SAME live-track + snap
// as the empty-space mouse drag, so the two gestures feel identical.
guard event.momentumPhase == [] else { return } // ignore the inertial tail
// Forward the RAW delta only — accumulation + end-debounce live in SwiftUI so they're
// SHARED across all per-page containers. (Each page is a separate container; if each
// kept its own running total, the sliding grid would feed two different totals into the
// shared offset on alternating frames → the every-frame flicker.)
grid?.onPageScroll(event.scrollingDeltaX, bounds.width)
} else {
// Mouse wheel (discrete notches): one notch → one page, on whichever axis moved.
let delta = abs(event.scrollingDeltaX) >= abs(event.scrollingDeltaY)
? event.scrollingDeltaX : event.scrollingDeltaY
guard delta != 0 else { return }
grid?.onPageSwipe(delta < 0 ? 1 : -1)
}
}
override func mouseDown(with event: NSEvent) {
gapDownPoint = event.locationInWindow // window coords — stable while the page offsets
gapMoved = false
pageDragActive = false
}
/// A horizontal drag that starts on *empty space* (margins / gaps / below the grid —
/// cells handle their own drags) pages the grid, follow-the-cursor: the page tracks the
/// drag live and snaps once past a threshold on release. Can't grab an app because the
/// press never landed on a cell.
///
/// The delta is measured in WINDOW coordinates: the container itself slides via the SwiftUI
/// page offset, so converting the point into its (moving) coordinate space would feed the
/// offset back into the delta — an oscillation that shows up as ghosting. Window space is
/// stable, so the drag tracks the real cursor displacement.
override func mouseDragged(with event: NSEvent) {
guard let start = gapDownPoint else { return }
let dx = event.locationInWindow.x - start.x
let dy = event.locationInWindow.y - start.y
if hypot(dx, dy) > 6 { gapMoved = true }
if !pageDragActive {
guard abs(dx) > 8, abs(dx) > abs(dy) else { return } // commit to a horizontal page drag
pageDragActive = true
}
grid?.onPageDrag(dx, bounds.width, false) // live: page follows the cursor
}
override func mouseUp(with event: NSEvent) {
let start = gapDownPoint
gapDownPoint = nil
if pageDragActive {
let dx = event.locationInWindow.x - (start?.x ?? 0)
grid?.onPageDrag(dx, bounds.width, true) // release: snap or spring back
pageDragActive = false
} else if !gapMoved, grid?.isCompact == false {
// A click (no drag) on empty space dismisses in fullscreen.
grid?.onDismiss()
}
}
}
// MARK: - Cell
final class LaunchpadGridCellView: NSView {
private(set) var cell: LaunchpadDisplayCell
/// The store-facing id (app path / folder uuid) for drag payload + reorder.
var layoutID: String { cell.layoutID }
/// The app icon, for the window-level floating view when this cell is ejected from a folder.
var primaryIcon: NSImage? { imageView.image }
/// The rendered label font / colour — surfaced so tests can verify that `init`/`update`
/// actually apply the resolved label style (design 2026-06-13). Read-only; the cell owns
/// the field.
var labelFontForTesting: NSFont? { label.font }
var labelColorForTesting: NSColor? { label.textColor }
/// The floating-window visual for a carried cell (design §2.1-4): an app provides its icon
/// image; a folder plate is DRAWN (no image view), so it must be snapshot via cacheDisplay.
func carryVisual() -> NSImage? {
if !imageView.isHidden { return imageView.image }
let rect = iconFrame
guard rect.width > 0, rect.height > 0,
let rep = bitmapImageRepForCachingDisplay(in: rect) else { return nil }
cacheDisplay(in: rect, to: rep)
let image = NSImage(size: rect.size)
image.addRepresentation(rep)
return image
}
private let imageView = NSImageView() // single icon — apps only (hidden for folders)
private let label = NSTextField(labelWithString: "")
private var folderIcons: [NSImage] = [] // up to 9 child icons (3×3 preview), drawn for a folder cell
private var metrics: LaunchpadGridMetrics
weak var container: LaunchpadGridContainerView?
var isSelected = false { didSet { if isSelected != oldValue { needsDisplay = true } } }
/// While being directly dragged, the cell follows the cursor and its icon scales up (the
/// iOS pick-up). It stays VISIBLE (unlike the old system-drag, which hid the source).
var isLifted = false { didSet { if isLifted != oldValue { syncIconScale() } } }
/// Armed as a folder merge target during a drag: the icon blooms slightly larger and a soft
/// accent well is drawn behind it (iOS pre-merge feel).
var isMergeTarget = false { didSet { if isMergeTarget != oldValue { syncIconScale(); needsDisplay = true } } }
/// Cursor is over this cell → magnify the icon (macOS Dock-style). Suppressed while ANY cell is
/// being dragged so passing the lifted icon over others doesn't make them twitch.
var isHovered = false { didSet { if isHovered != oldValue { syncIconScale() } } }
private var hoverTracking: NSTrackingArea?
/// The icon scales up ~1.1× while HOVERED (macOS Dock-style magnification), being dragged, or
/// armed as a merge target — ANIMATED over a short ease so it grows smoothly. Folders scale the
/// same way (the whole plate enlarges) so a folder and an app feel identical to point at.
private func syncIconScale() {
let big = isHovered || isLifted || isMergeTarget
let target = big ? mergeIconFrame : iconFrame
if imageView.isHidden { needsDisplay = true } // folder: the plate is drawn, redraw at the new size
guard imageView.frame != target else { return }
NSAnimationContext.runAnimationGroup { ctx in
ctx.duration = 0.24
ctx.timingFunction = CAMediaTimingFunction(controlPoints: 0.32, 1.12, 0.42, 1) // soft, slightly springy
imageView.animator().frame = target
}
}
private var mouseDownPoint: NSPoint?
private var didDrag = false
private let dragThreshold: CGFloat = 6
/// The icon area both the app image view and the folder thumbnail occupy. Sourced
/// from `LaunchpadGridMetrics.iconFrameInCell` — shared with the settings layout
/// preview (anti-drift: one formula, two consumers).
private var iconFrame: CGRect { metrics.iconFrameInCell }
/// The label strip below the icon. With labels hidden (`showsLabels == false`) the
/// height collapses to 0 and the field is hidden — accessibility label and toolTip
/// still carry the name (design §1.2). Shared source: `labelFrameInCell`.
private var labelFrame: CGRect { metrics.labelFrameInCell }
/// The icon area enlarged ~1.1× while armed as a merge target.
private var mergeIconFrame: CGRect {
iconFrame.insetBy(dx: -metrics.iconSide * 0.05, dy: -metrics.iconSide * 0.05)
}
init(cell: LaunchpadDisplayCell, icons: [NSImage], metrics: LaunchpadGridMetrics) {
self.cell = cell
self.metrics = metrics
super.init(frame: CGRect(x: 0, y: 0, width: metrics.cellWidth, height: metrics.cellHeight))
imageView.imageScaling = .scaleProportionallyUpOrDown
imageView.frame = iconFrame
addSubview(imageView)
label.frame = labelFrame
label.isHidden = !metrics.showsLabels
// Label-style injection (design 2026-06-13): size/weight from the chosen tier, color
// from the preset. Defaults (`.medium` 12pt / regular / `.automatic` → `.labelColor`)
// reproduce the historical rendering. `update` MUST re-apply both, or the same view
// reused with new metrics keeps the old font/colour.
label.font = .systemFont(ofSize: metrics.labelFontSize, weight: metrics.labelFontWeight)
label.textColor = metrics.labelColor.nsColor
label.alignment = .center
label.maximumNumberOfLines = 2
label.lineBreakMode = .byTruncatingTail
label.cell?.wraps = true
label.cell?.truncatesLastVisibleLine = true
addSubview(label)
setAccessibilityElement(true)
setAccessibilityRole(.button)
configure(cell: cell, icons: icons)
}
@available(*, unavailable)
required init?(coder: NSCoder) { fatalError() }
override var isFlipped: Bool { true }
func update(cell: LaunchpadDisplayCell, icons: [NSImage], metrics: LaunchpadGridMetrics) {
self.metrics = metrics
imageView.frame = iconFrame
label.frame = labelFrame
label.isHidden = !metrics.showsLabels
// Symmetric with `init`: re-apply the label style so reusing this view with new
// metrics (e.g. the user changes the size/weight/colour preset) actually re-renders.
// Omitting this would silently keep the previous appearance for already-mounted cells.
label.font = .systemFont(ofSize: metrics.labelFontSize, weight: metrics.labelFontWeight)
label.textColor = metrics.labelColor.nsColor
configure(cell: cell, icons: icons)
}
/// Bind the view to a display cell: an app shows its single icon, a folder hides the image
/// view and draws a 2×2 thumbnail of its first four children.
private func configure(cell: LaunchpadDisplayCell, icons: [NSImage]) {
self.cell = cell
switch cell {
case .app(let item):
folderIcons = []
imageView.isHidden = false
imageView.image = icons.first
if label.stringValue != item.name { label.stringValue = item.name }
toolTip = item.name
case .folder(_, let name, _):
imageView.isHidden = true
folderIcons = Array(icons.prefix(9))
if label.stringValue != name { label.stringValue = name }
toolTip = name
}
setAccessibilityLabel(label.stringValue)
needsDisplay = true
}
override func draw(_ dirtyRect: NSRect) {
super.draw(dirtyRect)
let isFolder: Bool = { if case .folder = cell { return true } else { return false } }()
// App selection: a tight grey rounded square hugging the ICON (not the whole cell). Less
// vertical inset than horizontal so the top stays inside the cell (the icon sits at y=8).
if isSelected, !isFolder {
let sel = iconFrame.insetBy(dx: -8, dy: -6)
let path = NSBezierPath(roundedRect: sel, xRadius: sel.width * 0.24, yRadius: sel.width * 0.24)
NSColor.labelColor.withAlphaComponent(0.10).setFill()
path.fill()
}
if isMergeTarget { drawStackTargetCue() } // behind the icon / thumbnail
if isFolder { drawFolderThumbnail() }
// Folder selection is drawn INTO the plate (drawFolderThumbnail): both outer treatments
// tried here before — a grey fill behind the frosted plate (ugly halo) and an accent
// ring around it (double frame over the plate's own background) — read as two stacked
// shapes. Deepening the plate itself keeps one shape and matches the app wash language.
}
/// iOS-style folder tile: a frosted rounded "squircle" plate carrying a 3×3 preview of the
/// first nine child icons. Drawn (not subviews) so the cell stays non-layer-backed — see the
/// container's `init` note on why layer-backing here would ghost during follow-cursor paging.
/// The folder plate, inset to match an app icon's VISIBLE footprint (app icons carry ~10%
/// transparent padding inside their frame, so an un-inset plate looks bigger than the apps).
private var folderPlateRect: CGRect { iconFrame.insetBy(dx: 6, dy: 6) }
private func drawFolderThumbnail() {
// Hover / lift / merge enlarges the whole plate ~1.1× — same magnification an app icon gets.
let big = isHovered || isLifted || isMergeTarget
let plate = big
? folderPlateRect.insetBy(dx: -folderPlateRect.width * 0.05, dy: -folderPlateRect.height * 0.05)
: folderPlateRect
let radius = plate.width * 0.29 // iOS squircle proportion
let platePath = NSBezierPath(roundedRect: plate, xRadius: radius, yRadius: radius)
// Frosted plate: a translucent fill that reads in both light and dark, plus a thin top
// highlight and bottom shade for a subtle "raised glass" depth.
NSColor.white.withAlphaComponent(0.10).setFill()
platePath.fill()
NSColor.labelColor.withAlphaComponent(0.08).setFill()
platePath.fill()
do {
// Clip the highlight/shade to the plate so their straight full-width ends don't
// bleed PAST the (large, 0.29×) rounded corners onto the cell background — at
// y = maxY-2.5 the corner has already curved in ~8pt, so an unclipped `sh` drew a
// faint horizontal "bar" sticking out below the plate's rounded bottom (apps have no
// plate, hence no bar). Clipping makes both depth cues follow the squircle contour.
NSGraphicsContext.saveGraphicsState()
platePath.addClip()
let hl = NSRect(x: plate.minX + 1, y: plate.minY + 1, width: plate.width - 2, height: 1.5)
NSColor.white.withAlphaComponent(0.18).setFill()
NSBezierPath(roundedRect: hl, xRadius: 1, yRadius: 1).fill()
let sh = NSRect(x: plate.minX + 1, y: plate.maxY - 2.5, width: plate.width - 2, height: 1.5)
NSColor.black.withAlphaComponent(0.07).setFill()
NSBezierPath(roundedRect: sh, xRadius: 1, yRadius: 1).fill()
NSGraphicsContext.restoreGraphicsState()
}
// Selected folder: deepen the plate itself along the SAME squircle path — the iOS folder
// press look, and the same labelColor-wash language the app selection uses (the app's
// wash hugs its icon because an app has no plate; the folder's goes into the plate).
// No ring, no second shape — and the carried-folder snapshot (cacheDisplay) now picks up
// a slightly deeper plate instead of the old accent ring baked into the floating icon.
if isSelected {
NSColor.labelColor.withAlphaComponent(0.12).setFill()
platePath.fill()
}
// Mini-icon grid, CENTRED and filled top-left first. 2×2 for ≤4 apps (bigger icons that
// fill the plate, like iOS) and 3×3 for 59, so a small folder doesn't look sparse/empty.
let count = folderIcons.count
let cols = count <= 4 ? 2 : 3
let inset: CGFloat = plate.width * (cols == 2 ? 0.17 : 0.12)
let gap: CGFloat = plate.width * (cols == 2 ? 0.11 : 0.08)
let mini = ((plate.width - inset * 2 - gap * CGFloat(cols - 1)) / CGFloat(cols)).rounded(.down)
let cornerR = mini * 0.24
// Centre the actually-used rows/cols within the plate so 14 icons sit in the middle.
let usedRows = Int((Double(min(count, cols * cols)) / Double(cols)).rounded(.up))
let gridW = CGFloat(cols) * mini + CGFloat(cols - 1) * gap
let gridH = CGFloat(usedRows) * mini + CGFloat(max(0, usedRows - 1)) * gap
let originX = plate.minX + (plate.width - gridW) / 2
let originY = plate.minY + (plate.height - gridH) / 2
for (index, icon) in folderIcons.prefix(cols * cols).enumerated() {
let col = index % cols, row = index / cols
let rect = NSRect(
x: originX + CGFloat(col) * (mini + gap),
y: originY + CGFloat(row) * (mini + gap),
width: mini, height: mini
)
NSGraphicsContext.saveGraphicsState()
NSBezierPath(roundedRect: rect, xRadius: cornerR, yRadius: cornerR).addClip()
icon.draw(in: rect, from: .zero, operation: .sourceOver, fraction: 1)
NSGraphicsContext.restoreGraphicsState()
}
}
/// "Drop here to make / join a folder" cue on the armed merge target — a soft accent "well"
/// (bloom) behind the enlarged icon, like iOS's pre-merge highlight. Light fill, no hard
/// stroke, sized larger than the icon so it reads around a folder thumbnail too.
private func drawStackTargetCue() {
// Bloom around the (enlarged) icon, but keep the TOP inside the cell — `mergeIconFrame` sits
// at y≈4.8, so a -6 vertical inset would push the cue above y=0 and clip on the first row.
let zone = iconFrame.insetBy(dx: -6, dy: -4)
let radius = zone.width * 0.30
let path = NSBezierPath(roundedRect: zone, xRadius: radius, yRadius: radius)
NSColor.controlAccentColor.withAlphaComponent(0.22).setFill()
path.fill()
path.lineWidth = 1
NSColor.controlAccentColor.withAlphaComponent(0.4).setStroke()
path.stroke()
}
// MARK: Hover magnification (macOS Dock-style)
override func updateTrackingAreas() {
super.updateTrackingAreas()
if let hoverTracking { removeTrackingArea(hoverTracking) }
let ta = NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeInActiveApp], owner: self)
addTrackingArea(ta)
hoverTracking = ta
}
override func mouseEntered(with event: NSEvent) {
guard container?.hasActiveDrag != true else { return } // don't magnify while dragging
isHovered = true
}
override func mouseExited(with event: NSEvent) { isHovered = false }
// MARK: Click vs direct drag (mouse-tracked — the cell receives all drag events itself)
override func mouseDown(with event: NSEvent) {
mouseDownPoint = convert(event.locationInWindow, from: nil)
didDrag = false
container?.select(self)
}
override func mouseDragged(with event: NSEvent) {
guard let start = mouseDownPoint else { return }
if !didDrag {
let point = convert(event.locationInWindow, from: nil)
guard hypot(point.x - start.x, point.y - start.y) > dragThreshold else { return }
didDrag = true
// Searching: read-only projection — never lift, so no make-way/merge cues whose drop
// would be silently discarded. `didDrag` is STILL set so the gesture is consumed:
// mouseUp must treat a long drag as a cancelled drag, not as a click that launches.
guard container?.callbacks?.allowsCustomOrderActions != false else { return }
container?.beginDirectDrag(self, atWindowPoint: event.locationInWindow)
}
container?.updateDirectDrag(atWindowPoint: event.locationInWindow) // 1:1 follow (no-op when nothing lifted)
}
override func mouseUp(with event: NSEvent) {
defer { mouseDownPoint = nil }
if didDrag {
container?.endDirectDrag(atWindowPoint: event.locationInWindow)
} else {
container?.activate(self)
}
}
override func rightMouseDown(with event: NSEvent) {
// Mid-drag right-click would open a menu whose tracking loop swallows the drag's mouseUp,
// wedging the lifted cell and the deferred grid apply. Ignore it; finish the drag first.
// The coordinator-level gate also covers a cross-page carry hovering a NON-source page,
// where this container's own hasActiveDrag is false (design §9.1 row 10).
guard container?.hasActiveDrag != true,
container?.callbacks?.coordinator?.carryActive != true else { return }
container?.select(self) // highlight which app the menu targets (user feedback)
guard let menu = container?.contextMenu(for: self) else { return }
NSMenu.popUpContextMenu(menu, with: event, for: self)
container?.refocusSearchField() // keep arrow-key nav working after the menu closes
}
/// Claim mouse events tightly: the icon itself (+2px) and a label strip the width of the
/// icon — NOT the whole cell. The surrounding padding and the dead band below the label fall
/// through to the container so a drag/click there pages instead of grabbing the app. Keeps
/// the per-app hit footprint close to the icon, like iOS (user: the range felt too large).
override func hitTest(_ point: NSPoint) -> NSView? {
let local = convert(point, from: superview)
guard bounds.contains(local) else { return nil }
let icon = iconFrame.insetBy(dx: -2, dy: -2)
// Hidden labels (design §1.2): the label strip is skipped outright — only the
// icon (±2pt) claims events, everything else falls through to the pager.
guard metrics.showsLabels else { return icon.contains(local) ? self : nil }
let labelHit = NSRect(x: icon.minX, y: label.frame.minY, width: icon.width, height: label.frame.height)
return (icon.contains(local) || labelHit.contains(local)) ? self : nil
}
override func accessibilityPerformPress() -> Bool {
container?.activate(self)
return true
}
}