169 lines
8.3 KiB
Swift
169 lines
8.3 KiB
Swift
import Foundation
|
||
|
||
/// Settings layout-preview geometry (design §4.1): a PURE derivation from the appearance
|
||
/// preferences to scaled preview rects — the view only draws, never computes.
|
||
///
|
||
/// Every capacity/geometry number flows through the SAME `LaunchpadLayoutMath` pipeline
|
||
/// the live overlay runs (`resolve → compactFrame → gridViewport → pageGrid → slot math`),
|
||
/// so WYSIWYG is a structural guarantee, not a convention. The model owns no persistence
|
||
/// and performs no IO: it projects the preference values it is handed and never reads the
|
||
/// app catalog (ruling P2 — the preview shows page CAPACITY, not the current census).
|
||
struct LaunchpadLayoutPreviewModel: Equatable {
|
||
struct Tile: Equatable {
|
||
var iconRect: CGRect
|
||
/// nil when app names are hidden (feature 7) — the view drops the label bar.
|
||
var labelRect: CGRect?
|
||
}
|
||
|
||
/// The scaled "screen" frame (origin .zero in the preview's coordinate space, top-left).
|
||
var screenSize: CGSize
|
||
/// The scaled launcher window. Fullscreen: == the whole screen frame. Compact: the
|
||
/// centred panel drawn as a SECOND layer over the screen frame (ruling P1 — two
|
||
/// layers, which also visualises the window-size slider for free).
|
||
var windowRect: CGRect
|
||
/// Scaled search-bar placeholder, positioned from `Chrome` — the same constants the
|
||
/// live view reads, so the preview cannot drift from the real chrome.
|
||
var searchBarRect: CGRect
|
||
/// One FULL page of placeholders, rows × columns, row-major.
|
||
var tiles: [Tile]
|
||
var columns: Int
|
||
var rows: Int
|
||
/// Scaled centre of the page-indicator dot row (inside `Chrome.pageIndicatorReserve`).
|
||
var pageDotsCenter: CGPoint
|
||
/// Preview points per screen point — uniform on both axes (aspect-fit).
|
||
var scale: CGFloat
|
||
|
||
/// The caption's "K per page" — and the A4 clamp's user-facing signal: when a fixed
|
||
/// column count cannot fit (e.g. 12 × 96pt icons), `pageGrid` clamps silently and
|
||
/// these numbers are how the user learns the effective layout.
|
||
var perPage: Int { columns * rows }
|
||
|
||
/// - Parameters:
|
||
/// - appearance: the normalized appearance snapshot (`LaunchpadPreferences.appearance`).
|
||
/// - fixedColumns: nil = auto columns; otherwise the user's fixed count (pre-clamp).
|
||
/// - compactScalePercent: the normalized window-size percentage; ignored in fullscreen.
|
||
/// - screenFrame: the representative screen's PHYSICAL frame size (`NSScreen.frame`)
|
||
/// — what the real fullscreen overlay covers, and the drawn screen canvas.
|
||
/// - visibleFrame: the screen's visible area (menu bar/Dock removed) expressed in
|
||
/// this model's TOP-LEFT space relative to `screenFrame` — what the real compact
|
||
/// panel centres on. The two sources mirror `LaunchpadOverlayController.targetFrame(on:)`
|
||
/// exactly; feeding one size to both modes made the fullscreen caption drop a
|
||
/// row/column vs the real launchpad on the very same screen.
|
||
/// - canvas: the available preview area; the screen frame aspect-fits into it.
|
||
static func make(
|
||
appearance: LaunchpadAppearance,
|
||
mode: LaunchpadPreferences.WindowMode,
|
||
fixedColumns: Int?,
|
||
compactScalePercent: Int,
|
||
screenFrame: CGSize,
|
||
visibleFrame: CGRect,
|
||
canvas: CGSize
|
||
) -> LaunchpadLayoutPreviewModel {
|
||
// Degenerate inputs (a zero-size GeometryReader pass, a missing screen) fall back
|
||
// to safe positives so nothing below divides by zero or returns NaN rects.
|
||
let screen = CGSize(width: max(screenFrame.width, 320), height: max(screenFrame.height, 200))
|
||
let screenRect = CGRect(origin: .zero, size: screen)
|
||
// The visible area must be a positive rect inside the (possibly clamped) screen
|
||
// frame; anything else — a zero rect from a headless host, a stale value after
|
||
// the frame fallback kicked in — degrades to the whole frame.
|
||
let visible = visibleFrame.width > 0 && visibleFrame.height > 0
|
||
&& screenRect.contains(visibleFrame) ? visibleFrame : screenRect
|
||
let scale = max(0.001, min(canvas.width / screen.width, canvas.height / screen.height))
|
||
|
||
// The production pipeline, verbatim (design §4.1):
|
||
// resolve → (compactFrame) → gridViewport → pageGrid.
|
||
let metrics = LaunchpadGridMetrics.resolve(appearance)
|
||
let window: CGRect
|
||
switch mode {
|
||
case .fullscreen:
|
||
// Frame parity with `targetFrame(on:)`: the real fullscreen overlay covers
|
||
// `screen.frame` (menu bar and Dock included), NOT the visible frame.
|
||
window = screenRect
|
||
case .compact:
|
||
// Frame parity again: the real compact panel centres `compactFrame` on
|
||
// `screen.visibleFrame`. `visible` is already in this top-left space, and
|
||
// centring survives the coordinate flip (centres map to centres), so the
|
||
// returned rect reads directly here.
|
||
window = LaunchpadLayoutMath.compactFrame(
|
||
visible: visible,
|
||
scalePercent: compactScalePercent,
|
||
metrics: metrics,
|
||
legacyCap: false
|
||
)
|
||
}
|
||
let chrome = LaunchpadLayoutMath.Chrome.standard(isCompact: mode == .compact)
|
||
let viewport = LaunchpadLayoutMath.gridViewport(mode: mode, windowSize: window.size)
|
||
let grid = LaunchpadLayoutMath.pageGrid(
|
||
viewport: viewport,
|
||
metrics: metrics,
|
||
fixedColumns: fixedColumns
|
||
)
|
||
|
||
// Grid viewport origin inside the window — mirrors `LaunchpadGridView.body`'s
|
||
// VStack: top padding, then the search bar, then the stack spacing.
|
||
let viewportOrigin = CGPoint(
|
||
x: window.minX + chrome.horizontalPadding,
|
||
y: window.minY + chrome.topPadding + chrome.searchBarHeight + chrome.stackSpacing
|
||
)
|
||
// Tile geometry comes from the SAME functions the live grid lays out with —
|
||
// `LaunchpadLayoutMath.slotRect` (incl. the floored centring inset) and the
|
||
// cell-local `iconFrameInCell` / `labelFrameInCell`. Shared source, not
|
||
// mirrored formulas: a cell-layout change moves both surfaces together.
|
||
var tiles: [Tile] = []
|
||
tiles.reserveCapacity(grid.columns * grid.rows)
|
||
for index in 0..<(grid.columns * grid.rows) {
|
||
let slot = LaunchpadLayoutMath.slotRect(
|
||
index: index,
|
||
columns: grid.columns,
|
||
containerWidth: viewport.width,
|
||
metrics: metrics
|
||
).offsetBy(dx: viewportOrigin.x, dy: viewportOrigin.y)
|
||
let icon = metrics.iconFrameInCell.offsetBy(dx: slot.minX, dy: slot.minY)
|
||
let label: CGRect? = metrics.showsLabels
|
||
? metrics.labelFrameInCell.offsetBy(dx: slot.minX, dy: slot.minY)
|
||
: nil
|
||
tiles.append(Tile(
|
||
iconRect: icon.scaled(by: scale),
|
||
labelRect: label.map { $0.scaled(by: scale) }
|
||
))
|
||
}
|
||
|
||
let searchBar = CGRect(
|
||
x: window.midX - chrome.searchBarWidth / 2,
|
||
y: window.minY + chrome.topPadding,
|
||
width: chrome.searchBarWidth,
|
||
height: chrome.searchBarHeight
|
||
)
|
||
// The dot row sits at the bottom of the grid viewport, inside the 26pt reserve
|
||
// `pageGrid` subtracts — i.e. just above the window's bottom padding.
|
||
let pageDotsCenter = CGPoint(
|
||
x: window.midX,
|
||
y: window.maxY - chrome.bottomPadding
|
||
- LaunchpadLayoutMath.Chrome.pageIndicatorReserve / 2
|
||
)
|
||
|
||
return LaunchpadLayoutPreviewModel(
|
||
screenSize: CGSize(width: screen.width * scale, height: screen.height * scale),
|
||
windowRect: window.scaled(by: scale),
|
||
searchBarRect: searchBar.scaled(by: scale),
|
||
tiles: tiles,
|
||
columns: grid.columns,
|
||
rows: grid.rows,
|
||
pageDotsCenter: pageDotsCenter.scaled(by: scale),
|
||
scale: scale
|
||
)
|
||
}
|
||
}
|
||
|
||
private extension CGRect {
|
||
func scaled(by factor: CGFloat) -> CGRect {
|
||
CGRect(x: minX * factor, y: minY * factor, width: width * factor, height: height * factor)
|
||
}
|
||
}
|
||
|
||
private extension CGPoint {
|
||
func scaled(by factor: CGFloat) -> CGPoint {
|
||
CGPoint(x: x * factor, y: y * factor)
|
||
}
|
||
}
|