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

195 lines
9.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 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)
}
}