285 lines
14 KiB
Swift
285 lines
14 KiB
Swift
import AppKit
|
||
|
||
/// Appearance preference snapshot (design §1.2) — the single input to
|
||
/// `LaunchpadGridMetrics.resolve(_:)` and, later, the settings layout preview.
|
||
/// Features 7/8 (hide names / icon size) own the writes; everything else only consumes.
|
||
struct LaunchpadAppearance: Equatable {
|
||
/// Feature 8: 48...96pt, step 4. Default mirrors the long-standing 64pt icon.
|
||
var iconSide: CGFloat = 64
|
||
/// Feature 7: `!hidesAppNames`. Default = labels visible (today's behaviour).
|
||
var showsLabels: Bool = true
|
||
/// Label-style presets (design 2026-06-13). Defaults reproduce the historical implicit
|
||
/// rendering — `.automatic` (→ `.labelColor`), regular weight, the `.medium` size tier
|
||
/// (12pt at the default 64pt icon) — so an untouched appearance is byte-compatible.
|
||
var labelColor: LaunchpadLabelColor = .automatic
|
||
var labelWeight: LaunchpadLabelWeight = .regular
|
||
var labelSize: LaunchpadLabelSize = .medium
|
||
}
|
||
|
||
extension LaunchpadGridMetrics {
|
||
/// The ONLY resolution entry point from appearance preferences to grid metrics.
|
||
/// Regression anchor (design §1.2): `resolve(LaunchpadAppearance())` must equal
|
||
/// `LaunchpadGridMetrics()` field by field — the default appearance reproduces the
|
||
/// historical hardcoded layout byte for byte.
|
||
///
|
||
/// Derivation (@64pt check): labels shown → cellWidth = iconSide + 52 = 116,
|
||
/// cellHeight = 8 + iconSide + 8 + 32 + 12 = iconSide + 60 = 124. Labels hidden
|
||
/// (ruling A3, tightened) → cellWidth = iconSide + 28, cellHeight = 8 + iconSide
|
||
/// + 12 = iconSide + 20, labelHeight = 0. Fixed chrome rather than a scale factor:
|
||
/// at 48pt the label still fits two lines; at 96pt density approaches iOS.
|
||
/// Spacing stays fixed in v1 (ruling A7); the parameters reserve the slot.
|
||
static func resolve(
|
||
_ appearance: LaunchpadAppearance,
|
||
columnSpacing: CGFloat = 8,
|
||
rowSpacing: CGFloat = 16
|
||
) -> LaunchpadGridMetrics {
|
||
let iconSide = appearance.iconSide
|
||
let showsLabels = appearance.showsLabels
|
||
// Label-style derivation (design 2026-06-13). Font size scales with the icon per the
|
||
// chosen tier; the grid label height only grows for the larger tiers — at the default
|
||
// `.medium` size (12pt @64pt) it stays exactly 32, the byte-compat anchor.
|
||
let labelFontSize = appearance.labelSize.fontSize(iconSide: iconSide)
|
||
let labelFontWeight = appearance.labelWeight.nsFontWeight
|
||
let labelHeight: CGFloat = showsLabels
|
||
? Self.labelHeight(forFontSize: labelFontSize, weight: labelFontWeight)
|
||
: 0
|
||
// The open-folder big title never drops below the historical `.title2`/semibold style
|
||
// and is always ≥ the grid label, so a larger app-name size pushes the title up too.
|
||
let folderTitleFontSize = max(Self.defaultFolderTitleFontSize, labelFontSize)
|
||
let folderTitleWeight = appearance.labelWeight.emphasized
|
||
return LaunchpadGridMetrics(
|
||
cellWidth: iconSide + (showsLabels ? 52 : 28),
|
||
cellHeight: iconSide + (showsLabels ? 60 : 20),
|
||
iconSide: iconSide,
|
||
columnSpacing: columnSpacing,
|
||
rowSpacing: rowSpacing,
|
||
showsLabels: showsLabels,
|
||
iconTopInset: 8,
|
||
labelGap: 8,
|
||
labelHeight: labelHeight,
|
||
labelColor: appearance.labelColor,
|
||
labelFontSize: labelFontSize,
|
||
labelFontWeight: labelFontWeight,
|
||
folderTitleFontSize: folderTitleFontSize,
|
||
folderTitleWeight: folderTitleWeight
|
||
)
|
||
}
|
||
|
||
/// Two-line label strip height for a given font size/weight: `max(32, ceil(lineHeight × 2))`.
|
||
/// The 32 floor pins the historical height, so the default `.medium` size (12pt, line
|
||
/// height 15) and every tier up to 13pt stay at exactly 32 — only 15pt+ (the larger tiers
|
||
/// at big icons) grow the strip. Returns 32 verbatim at the historical font, so the
|
||
/// byte-compat anchor holds.
|
||
static func labelHeight(forFontSize size: CGFloat, weight: NSFont.Weight) -> CGFloat {
|
||
let font = NSFont.systemFont(ofSize: size, weight: weight)
|
||
let lineHeight = NSLayoutManager().defaultLineHeight(for: font)
|
||
return max(32, ceil(lineHeight * 2))
|
||
}
|
||
|
||
/// The icon area inside a cell, in CELL-LOCAL coordinates — the single source for
|
||
/// the live `LaunchpadGridCellView` AND the settings layout preview. Extracted for
|
||
/// the same anti-drift reason as `Chrome`: a mirrored copy of this formula in the
|
||
/// preview would drift silently the day the cell layout changes.
|
||
var iconFrameInCell: CGRect {
|
||
CGRect(
|
||
x: (cellWidth - iconSide) / 2,
|
||
y: iconTopInset,
|
||
width: iconSide,
|
||
height: iconSide
|
||
)
|
||
}
|
||
|
||
/// The label strip below the icon, cell-local (2pt side insets, `labelGap` under
|
||
/// the icon). With labels hidden the height collapses to 0 — the live cell hides
|
||
/// the field, the preview drops the bar.
|
||
var labelFrameInCell: CGRect {
|
||
CGRect(
|
||
x: 2,
|
||
y: iconTopInset + iconSide + labelGap,
|
||
width: cellWidth - 4,
|
||
height: labelHeight
|
||
)
|
||
}
|
||
}
|
||
|
||
/// Pure layout math shared by the real launcher views and (P3) the settings preview —
|
||
/// one source of truth, so WYSIWYG is a structural guarantee, not a convention
|
||
/// (design §1.2). No view state, no side effects: everything here is unit-testable.
|
||
enum LaunchpadLayoutMath {
|
||
/// Chrome constants pulled out of `LaunchpadGridView.body` / `updateLayout` so the
|
||
/// live view and the preview read the SAME numbers (design §1.1 — anti-drift).
|
||
struct Chrome: Equatable {
|
||
var searchBarWidth: CGFloat
|
||
var searchBarHeight: CGFloat
|
||
/// VStack spacing between the search bar and the paged grid.
|
||
var stackSpacing: CGFloat
|
||
var topPadding: CGFloat
|
||
var bottomPadding: CGFloat
|
||
var horizontalPadding: CGFloat
|
||
/// Height reserved below the grid for the page-indicator row (shared by
|
||
/// `pageGrid` and the preview's "subtract chrome" math).
|
||
static let pageIndicatorReserve: CGFloat = 26
|
||
|
||
static func standard(isCompact: Bool) -> Chrome {
|
||
Chrome(
|
||
searchBarWidth: 360,
|
||
searchBarHeight: 28,
|
||
stackSpacing: isCompact ? 14 : 20,
|
||
topPadding: isCompact ? 24 : 60,
|
||
bottomPadding: isCompact ? 20 : 32,
|
||
horizontalPadding: isCompact ? 24 : 48
|
||
)
|
||
}
|
||
}
|
||
|
||
/// The compact panel's frame on `visible` (the screen's `visibleFrame`).
|
||
///
|
||
/// Production (P2) runs the scaled branch: `scalePercent` of the visible width, a
|
||
/// 4×3-grid floor that rises with the icon size, a 95% ceiling — and NO 960×680
|
||
/// hard cap (design §3.3, ruling A5 — a deliberate behaviour change: any screen
|
||
/// whose visibleFrame exceeds ~1333×829pt gets a proportionally larger panel, and
|
||
/// that includes ALL modern built-in laptop displays at default scaled resolution,
|
||
/// not just external monitors — final-review scope correction; the window-size
|
||
/// slider is the dial). The `legacyCap: true` branch keeps the historical
|
||
/// `min(960, w × 0.72) × min(680, h × 0.82)` formula reproducible for the
|
||
/// equivalence tests.
|
||
static func compactFrame(
|
||
visible: NSRect,
|
||
scalePercent: Int = 72,
|
||
metrics: LaunchpadGridMetrics = LaunchpadGridMetrics(),
|
||
legacyCap: Bool = true
|
||
) -> NSRect {
|
||
let width: CGFloat
|
||
let height: CGFloat
|
||
if legacyCap {
|
||
width = min(960, visible.width * 0.72)
|
||
height = min(680, visible.height * 0.82)
|
||
} else {
|
||
let s = CGFloat(scalePercent) / 100
|
||
// Floor: a 4-column × 3-row grid plus chrome must always fit (§3.3), so a
|
||
// bigger icon raises the floor and the panel can never shrink below 4×3.
|
||
// The width floor back-adds a FOURTH columnSpacing on purpose: `pageGrid`'s
|
||
// byte-compat `columnsThatFit` divides by the full pitch (cellWidth +
|
||
// columnSpacing) without crediting the last column's missing trailing gap,
|
||
// so "4 cells + 3 gaps" floors at only 3 columns. The rows formula already
|
||
// gets this credit from its `+ rowSpacing` numerator, hence 2 rowSpacings
|
||
// suffice there. (48 = 2 × compact horizontalPadding; 112 = compact vertical
|
||
// chrome 86 + the 26pt page-indicator reserve.)
|
||
let minW = 4 * (metrics.cellWidth + metrics.columnSpacing) + 48
|
||
let minH = 3 * metrics.cellHeight + 2 * metrics.rowSpacing + 112
|
||
width = min(max(visible.width * s, minW), visible.width * 0.95)
|
||
height = min(max(visible.height * min(s + 0.10, 0.92), minH), visible.height * 0.95)
|
||
}
|
||
return NSRect(
|
||
x: visible.midX - width / 2,
|
||
y: visible.midY - height / 2,
|
||
width: width,
|
||
height: height
|
||
)
|
||
}
|
||
|
||
/// Window size → the paged grid's viewport (what `LaunchpadGridView`'s
|
||
/// GeometryReader measures): subtract the outer paddings, the search bar and the
|
||
/// VStack spacing. The page-indicator reserve deliberately stays inside `pageGrid`,
|
||
/// exactly like the live view (the GeometryReader wraps grid + dots together).
|
||
static func gridViewport(mode: LaunchpadPreferences.WindowMode, windowSize: CGSize) -> CGSize {
|
||
let chrome = Chrome.standard(isCompact: mode == .compact)
|
||
return CGSize(
|
||
width: max(0, windowSize.width - 2 * chrome.horizontalPadding),
|
||
height: max(0, windowSize.height - chrome.topPadding - chrome.bottomPadding
|
||
- chrome.searchBarHeight - chrome.stackSpacing)
|
||
)
|
||
}
|
||
|
||
/// Folder-panel plate width cap (design §1.1: 5 columns + the 30pt padding ring),
|
||
/// replacing the historical `maxWidth: 760` literal a 96pt icon row would overflow
|
||
/// (5 × 148 + 32 = 772 > 760). Pass the FOLDER metrics (labels always shown, ruling
|
||
/// A1). At the default 64pt this is 672 — a deliberate P2 appearance change from
|
||
/// 760 (the plate now hugs its content); at 96pt it is 832 (pinned in tests).
|
||
static func folderPanelMaxWidth(metrics: LaunchpadGridMetrics) -> CGFloat {
|
||
5 * metrics.cellWidth + 4 * metrics.columnSpacing + 60
|
||
}
|
||
|
||
/// Offset (relative to a panel CENTRED in the ZStack) that lands the panel's centre on the
|
||
/// opened folder's grid cell — the "from the cell" anchor for the iOS-style pop-out
|
||
/// (animation design 2026-06-13). Pure SwiftUI viewport coordinates, zero AppKit `convert`
|
||
/// (the y-flip / scale-transform sampling traps are avoided by never crossing the bridge).
|
||
///
|
||
/// `cellRect` is the slot frame from `slotRect` — top-left origin, y-down, exactly the SwiftUI
|
||
/// orientation. `Δ` shifts grid-container space into ZStack space: the grid begins below the
|
||
/// chrome (search bar + paddings + VStack spacing), so the cell centre in the viewport is
|
||
/// `Δ + cellRect.mid`. Subtracting the viewport centre gives the offset a centred panel needs.
|
||
/// Δ is derived from `chrome` — never hard-coded literals, since compact / fullscreen differ.
|
||
static func folderCollapsedOffset(
|
||
cellRect: CGRect,
|
||
chrome: Chrome,
|
||
viewportSize: CGSize
|
||
) -> CGSize {
|
||
let deltaX = chrome.horizontalPadding
|
||
let deltaY = chrome.topPadding + chrome.searchBarHeight + chrome.stackSpacing
|
||
let cellCenterX = deltaX + cellRect.midX
|
||
let cellCenterY = deltaY + cellRect.midY
|
||
return CGSize(
|
||
width: cellCenterX - viewportSize.width / 2,
|
||
height: cellCenterY - viewportSize.height / 2
|
||
)
|
||
}
|
||
|
||
/// The frame of grid slot `index` (row-major) inside a top-aligned container of
|
||
/// `containerWidth`: the grid block is horizontally centred with a FLOORED left
|
||
/// inset (whole-point cell origins, exactly like the live container). Shared by
|
||
/// `LaunchpadGridContainerView.slotRect` and the settings preview model — one
|
||
/// source, so the preview matches the live grid to the point.
|
||
static func slotRect(
|
||
index: Int,
|
||
columns: Int,
|
||
containerWidth: CGFloat,
|
||
metrics: LaunchpadGridMetrics
|
||
) -> CGRect {
|
||
let columns = max(1, columns)
|
||
let gridWidth = CGFloat(columns) * metrics.cellWidth
|
||
+ CGFloat(columns - 1) * metrics.columnSpacing
|
||
let leftInset = max(0, (containerWidth - gridWidth) / 2).rounded(.down)
|
||
let col = index % columns, row = index / columns
|
||
return CGRect(
|
||
x: leftInset + CGFloat(col) * (metrics.cellWidth + metrics.columnSpacing),
|
||
y: CGFloat(row) * (metrics.cellHeight + metrics.rowSpacing),
|
||
width: metrics.cellWidth,
|
||
height: metrics.cellHeight
|
||
)
|
||
}
|
||
|
||
/// Page capacity — `LaunchpadGridView.updateLayout(size:)` made pure, algorithm
|
||
/// byte-compatible. `fixedColumns == nil` fits columns to the width.
|
||
///
|
||
/// `clampsOverflowingFixedColumns` defaults to `true` since P2 (ruling A4): a fixed
|
||
/// column count that cannot fit the width — reachable now that icons scale to 96pt —
|
||
/// is silently clamped to what fits (the settings preview's "N × M" caption is the
|
||
/// user-facing signal). Pass `false` only to reproduce the pre-P2 overflow behaviour
|
||
/// (kept for the legacy-equivalence proof in tests).
|
||
static func pageGrid(
|
||
viewport: CGSize,
|
||
metrics: LaunchpadGridMetrics,
|
||
fixedColumns: Int?,
|
||
clampsOverflowingFixedColumns: Bool = true
|
||
) -> (columns: Int, rows: Int) {
|
||
func columnsThatFit(_ width: CGFloat) -> Int {
|
||
let usable = max(width, metrics.cellWidth)
|
||
return max(1, Int(usable / (metrics.cellWidth + metrics.columnSpacing)))
|
||
}
|
||
let columns: Int
|
||
if let fixed = fixedColumns {
|
||
let requested = max(1, fixed)
|
||
columns = clampsOverflowingFixedColumns
|
||
? min(requested, columnsThatFit(viewport.width))
|
||
: requested
|
||
} else {
|
||
columns = columnsThatFit(viewport.width)
|
||
}
|
||
// Reserve ~26pt for the page indicator row below the grid.
|
||
let usableHeight = max(metrics.cellHeight, viewport.height - Chrome.pageIndicatorReserve)
|
||
let rows = max(1, Int((usableHeight + metrics.rowSpacing) / (metrics.cellHeight + metrics.rowSpacing)))
|
||
return (columns, rows)
|
||
}
|
||
}
|