195 lines
9.3 KiB
Swift
195 lines
9.3 KiB
Swift
import AppKit
|
||
import SwiftUI
|
||
import MacToolsPluginKit
|
||
|
||
/// Layout thumbnail preview (design §4.2): the first row of the settings appearance group. Pure SwiftUI
|
||
/// over `LaunchpadLayoutPreviewModel` — all geometry comes from the model (which runs the
|
||
/// SAME `LaunchpadLayoutMath` pipeline as the live overlay), this view only draws.
|
||
///
|
||
/// Layers (ruling P1 — two layers in compact): mock desktop gradient (the "screen") →
|
||
/// the launcher window filled with the user's actual glass recipe (ruling G6 — the P0b
|
||
/// preview formula absorbed as the canvas floor) → search-bar capsule, full-page tile
|
||
/// placeholders and page dots, drawn in one `Canvas` so even a hidden-names 48pt page on
|
||
/// a large screen (hundreds of tiles) repaints cheaply on every slider tick.
|
||
struct LaunchpadLayoutPreviewView: View {
|
||
@ObservedObject var preferences: LaunchpadPreferences
|
||
let localization: PluginLocalization
|
||
|
||
/// Fixed canvas height (≤ the design's 200pt cap); the screen aspect-fits inside,
|
||
/// so window resizes rescale proportionally without layout jumps.
|
||
private static let canvasHeight: CGFloat = 168
|
||
/// Design §4.1 fallback when no screen is attachable (headless test hosts).
|
||
private static let fallbackScreen = CGSize(width: 1512, height: 982)
|
||
|
||
/// Representative screen geometry: the main screen's CURRENT frames (multi-display
|
||
/// is not enumerated — same single-screen decision as the overlay; the caption is an
|
||
/// estimate for this screen). TWO frames, mirroring `targetFrame(on:)` exactly:
|
||
/// the physical `frame` (what the fullscreen overlay covers — menu bar and Dock
|
||
/// included) plus the `visibleFrame` (what the compact panel centres on), converted
|
||
/// from AppKit's bottom-left globals into top-left space relative to the frame.
|
||
private var screenGeometry: (frame: CGSize, visible: CGRect) {
|
||
guard let screen = NSScreen.main,
|
||
screen.frame.width > 0, screen.frame.height > 0 else {
|
||
return (Self.fallbackScreen, CGRect(origin: .zero, size: Self.fallbackScreen))
|
||
}
|
||
let frame = screen.frame
|
||
let visible = screen.visibleFrame
|
||
return (
|
||
frame.size,
|
||
CGRect(
|
||
x: visible.minX - frame.minX,
|
||
y: frame.maxY - visible.maxY, // bottom-left global → top-left local
|
||
width: visible.width,
|
||
height: visible.height
|
||
)
|
||
)
|
||
}
|
||
|
||
private func model(canvas: CGSize) -> LaunchpadLayoutPreviewModel {
|
||
let screen = screenGeometry
|
||
return LaunchpadLayoutPreviewModel.make(
|
||
appearance: preferences.appearance,
|
||
mode: preferences.windowMode,
|
||
fixedColumns: preferences.columns == LaunchpadPreferences.autoColumns
|
||
? nil : preferences.columns,
|
||
compactScalePercent: preferences.compactScalePercent,
|
||
screenFrame: screen.frame,
|
||
visibleFrame: screen.visible,
|
||
canvas: canvas
|
||
)
|
||
}
|
||
|
||
var body: some View {
|
||
// Capacity numbers depend only on the screen + preferences, never on the canvas
|
||
// size (scale shrinks rects, not counts) — so the caption can derive from a
|
||
// nominal-size model: same source, no GeometryReader value escape.
|
||
let caption = model(canvas: CGSize(width: 100, height: 100))
|
||
VStack(spacing: PluginSettingsTheme.Spacing.rowTitleDescription) {
|
||
GeometryReader { geo in
|
||
let model = model(canvas: CGSize(width: geo.size.width, height: geo.size.height))
|
||
canvas(model)
|
||
// Centre the aspect-fit screen frame in the available row width.
|
||
.frame(width: geo.size.width, height: geo.size.height)
|
||
}
|
||
.frame(height: Self.canvasHeight)
|
||
// Caption carries the A4-clamp signal: the localized "columns x rows, items per page"
|
||
// text is how a clamped fixed-column setting becomes visible (design §4.2).
|
||
Text(localization.format(
|
||
"settings.appearance.preview.caption",
|
||
defaultValue: "%1$d 列 × %2$d 行 · 每页 %3$d 个",
|
||
caption.columns, caption.rows, caption.perPage
|
||
))
|
||
.font(PluginSettingsTheme.Typography.monospacedValue)
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.accessibilityElement(children: .ignore)
|
||
.accessibilityLabel(localization.format(
|
||
"settings.appearance.preview.accessibility",
|
||
defaultValue: "排列预览:当前设置每页 %1$d 列 %2$d 行,共 %3$d 个",
|
||
caption.columns, caption.rows, caption.perPage
|
||
))
|
||
}
|
||
|
||
// MARK: - Canvas layers
|
||
|
||
private func canvas(_ model: LaunchpadLayoutPreviewModel) -> some View {
|
||
ZStack(alignment: .topLeading) {
|
||
desktopBackdrop(model)
|
||
// The launcher window, filled with the user's REAL glass recipe — in compact
|
||
// this is the second, centred layer; in fullscreen it covers the screen frame.
|
||
LaunchpadRecipeGlassFill(recipe: preferences.backgroundRecipe)
|
||
.clipShape(RoundedRectangle(
|
||
cornerRadius: windowCornerRadius(model),
|
||
style: .continuous
|
||
))
|
||
.frame(width: model.windowRect.width, height: model.windowRect.height)
|
||
.offset(x: model.windowRect.minX, y: model.windowRect.minY)
|
||
tileLayer(model)
|
||
}
|
||
.frame(width: model.screenSize.width, height: model.screenSize.height)
|
||
.clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous))
|
||
// Model is Equatable → geometry layers tween on every preference change. (The
|
||
// Canvas pass inside repaints instantly; only shape frames interpolate.)
|
||
.animation(.easeOut(duration: 0.15), value: model)
|
||
.frame(maxWidth: .infinity)
|
||
}
|
||
|
||
/// Mock desktop under the glass: shared gradient + two soft shapes placed
|
||
/// proportionally so the blur reads at any canvas size.
|
||
private func desktopBackdrop(_ model: LaunchpadLayoutPreviewModel) -> some View {
|
||
ZStack(alignment: .topLeading) {
|
||
LaunchpadDesktopMockGradient()
|
||
Circle()
|
||
.fill(.white.opacity(0.55))
|
||
.frame(width: model.screenSize.width * 0.18)
|
||
.offset(x: model.screenSize.width * 0.12,
|
||
y: model.screenSize.height * 0.16)
|
||
Circle()
|
||
.fill(Color(red: 0.18, green: 0.65, blue: 0.45).opacity(0.8))
|
||
.frame(width: model.screenSize.width * 0.13)
|
||
.offset(x: model.screenSize.width * 0.68,
|
||
y: model.screenSize.height * 0.58)
|
||
}
|
||
}
|
||
|
||
/// Compact rounds like the real panel (22pt scaled, floored so it stays visible at
|
||
/// preview scale); fullscreen has square window corners (the screen clip rounds).
|
||
private func windowCornerRadius(_ model: LaunchpadLayoutPreviewModel) -> CGFloat {
|
||
preferences.windowMode == .compact
|
||
? max(3, LaunchpadCompactPanelMetrics.cornerRadius * model.scale)
|
||
: 0
|
||
}
|
||
|
||
/// Search bar + one full page of tile placeholders + page dots, one Canvas pass.
|
||
/// Translucent shapes only — no icons, no catalog IO (ruling P2).
|
||
private func tileLayer(_ model: LaunchpadLayoutPreviewModel) -> some View {
|
||
Canvas { context, _ in
|
||
context.fill(
|
||
Path(roundedRect: model.searchBarRect,
|
||
cornerRadius: model.searchBarRect.height / 2,
|
||
style: .continuous),
|
||
with: .color(.white.opacity(0.22))
|
||
)
|
||
for tile in model.tiles {
|
||
// Same corner ratio as the live selection squircle (0.24 × width).
|
||
context.fill(
|
||
Path(roundedRect: tile.iconRect,
|
||
cornerRadius: tile.iconRect.width * 0.24,
|
||
style: .continuous),
|
||
with: .color(.white.opacity(0.16))
|
||
)
|
||
if let label = tile.labelRect {
|
||
// A thin centred bar standing in for the first text line.
|
||
let bar = CGRect(
|
||
x: label.midX - label.width * 0.3,
|
||
y: label.minY,
|
||
width: label.width * 0.6,
|
||
height: max(1.5, label.height * 0.28)
|
||
)
|
||
context.fill(
|
||
Path(roundedRect: bar, cornerRadius: bar.height / 2),
|
||
with: .color(.white.opacity(0.10))
|
||
)
|
||
}
|
||
}
|
||
// Three decorative page dots in the indicator reserve, first one "current".
|
||
let dotSide: CGFloat = 3.5
|
||
let dotGap: CGFloat = 4.5
|
||
for index in 0..<3 {
|
||
let rect = CGRect(
|
||
x: model.pageDotsCenter.x + CGFloat(index - 1) * (dotSide + dotGap)
|
||
- dotSide / 2,
|
||
y: model.pageDotsCenter.y - dotSide / 2,
|
||
width: dotSide,
|
||
height: dotSide
|
||
)
|
||
context.fill(
|
||
Path(ellipseIn: rect),
|
||
with: .color(.white.opacity(index == 0 ? 0.65 : 0.3))
|
||
)
|
||
}
|
||
}
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|