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

105 lines
4.5 KiB
Swift

import Foundation
import MacToolsPluginKit
/// Pure model for the launcher's glass background (design 2026-06-11 §5). Deliberately
/// AppKit-free — the `NSVisualEffectView.Material` mapping lives in the view layer
/// (`LaunchpadGlassBackdrop.swift`) so this file stays a unit-testable value layer.
///
/// Presets resolve to a `LaunchpadBackgroundRecipe` via `recipe(customMaterial:customDimPercent:)`;
/// only the `.custom` style reads the two custom knobs (G3: 4-material whitelist, no separate
/// dark toggle — the deep preset owns forced-dark).
enum LaunchpadBackgroundStyle: String, CaseIterable, Identifiable {
case clear
case standard
case deep
case custom
var id: String { rawValue }
func label(localization: PluginLocalization) -> String {
switch self {
case .clear:
localization.string("backgroundStyle.clear", defaultValue: "清透")
case .standard:
localization.string("backgroundStyle.standard", defaultValue: "标准")
case .deep:
localization.string("backgroundStyle.deep", defaultValue: "深邃")
case .custom:
localization.string("backgroundStyle.custom", defaultValue: "自定义")
}
}
/// Preset → rendering recipe. Pure; safe to call from any context.
///
/// G1 regression anchor: `.standard` (the default) resolves to `.legacyUltraThin`, the
/// byte-identical pre-existing SwiftUI `.ultraThinMaterial` path — an untouched preference
/// renders exactly like previous builds, no on-device calibration required. The design's
/// `.fullScreenUI + 0.12` approximation remains reachable through the custom style.
func recipe(
customMaterial: LaunchpadGlassMaterial,
customDimPercent: Int
) -> LaunchpadBackgroundRecipe {
switch self {
case .clear:
.glass(material: .launchpad, dimOpacity: 0, forcesDarkAppearance: false)
case .standard:
.legacyUltraThin
case .deep:
// G2: forced dark applies to the backdrop ONLY, never the hosting view.
.glass(material: .hud, dimOpacity: 0.28, forcesDarkAppearance: true)
case .custom:
.glass(
material: customMaterial,
dimOpacity: Double(LaunchpadBackgroundDim.normalized(customDimPercent)) / 100,
forcesDarkAppearance: false
)
}
}
}
/// Material whitelist for the custom style (G3). The excluded materials (`.sheet`, `.menu`,
/// `.windowBackground`, …) read as near-opaque chrome, not launcher glass.
enum LaunchpadGlassMaterial: String, CaseIterable, Identifiable {
case launchpad // → .fullScreenUI: what system full-screen UI (Mission Control) uses
case frosted // → .popover
case hud // → .hudWindow
case subtle // → .underWindowBackground
var id: String { rawValue }
func label(localization: PluginLocalization) -> String {
switch self {
case .launchpad:
localization.string("backgroundMaterial.launchpad", defaultValue: "启动台")
case .frosted:
localization.string("backgroundMaterial.frosted", defaultValue: "磨砂")
case .hud:
localization.string("backgroundMaterial.hud", defaultValue: "深色面板")
case .subtle:
localization.string("backgroundMaterial.subtle", defaultValue: "柔和")
}
}
}
/// The full rendering description the overlay snapshots at `open()` and the settings
/// preview card mirrors. Two hard AppKit constraints shape it (design §5.1): an effect
/// view's `alphaValue` must stay 1.0 (partial transparency renders undefined), so the
/// user-facing "transparency" is material choice + this separate dim-layer opacity.
enum LaunchpadBackgroundRecipe: Equatable {
/// The pre-change rendering: one SwiftUI `.ultraThinMaterial` fill, no dim layer.
/// Kept as its own case so the default style is pixel-identical by construction (G1).
case legacyUltraThin
/// Parameterised `NSVisualEffectView` + black dim layer (`dimOpacity` 0...0.6).
case glass(material: LaunchpadGlassMaterial, dimOpacity: Double, forcesDarkAppearance: Bool)
}
/// Dim-percent domain shared by the preference clamp and the recipe resolution.
enum LaunchpadBackgroundDim {
static let percentRange: ClosedRange<Int> = 0...60
static let defaultPercent = 12
static func normalized(_ value: Int) -> Int {
min(max(value, percentRange.lowerBound), percentRange.upperBound)
}
}