Files
ggbond268--mactools/Plugins/Launchpad/Sources/LaunchpadLayoutPreviewModel.swift
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

169 lines
8.3 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 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)
}
}