Files

206 lines
8.4 KiB
Swift

import Cleaner
import Localization
import Metrics
import SwiftUI
// MARK: - 液态玻璃
/// macOS 26 起,侧边栏、顶栏控件、按钮与切换控件用系统的液态玻璃;更早的系统退回原来的实色样式。
/// 玻璃只给浮在内容之上的导航与控件用,卡片、设置分组这类内容本身保持实色(与系统设置一致)。
extension EnvironmentValues {
/// 已经在一块玻璃里面(比如侧边栏面板):里面的控件不再叠一层玻璃,改用普通的悬停底色
@Entry var isInsideGlass = false
}
extension DS {
enum Glass {
/// 当前系统是否支持液态玻璃
static var isAvailable: Bool {
if #available(macOS 26.0, *) { true } else { false }
}
}
}
extension View {
/// 给控件加一层液态玻璃。`tint` 为品牌色时是蓝色玻璃(主按钮、选中项);
/// `interactive` 为真时按下会有玻璃的形变反馈。不支持玻璃的系统上、以及离屏截图时(拍不出玻璃)画 `fallback` 实色
func dsGlass<S: InsettableShape>(in shape: S, tint: Color? = nil, interactive: Bool = false,
fallback: Color = DS.Palette.elevated) -> some View {
modifier(GlassSurface(shape: shape, tint: tint, interactive: interactive, fallback: fallback))
}
}
private struct GlassSurface<S: InsettableShape>: ViewModifier {
let shape: S
let tint: Color?
let interactive: Bool
let fallback: Color
@Environment(\.isSnapshot) private var isSnapshot
func body(content: Content) -> some View {
if #available(macOS 26.0, *), !isSnapshot {
content.glassEffect(DS.Glass.style(tint: tint, interactive: interactive), in: shape)
} else {
content.background(tint ?? fallback, in: shape)
}
}
}
@available(macOS 26.0, *)
extension DS.Glass {
static func style(tint: Color?, interactive: Bool) -> SwiftUI.Glass {
var glass = SwiftUI.Glass.regular
if let tint { glass = glass.tint(tint) }
if interactive { glass = glass.interactive() }
return glass
}
}
// MARK: - 全局加载
enum CleanerHUDStage {
case cleaning, stopping, verifying, skippingVerification
var message: String {
switch self {
case .cleaning: tr("正在清理…")
case .stopping: tr("正在停止清理…")
case .verifying: tr("正在核对剩余项目…")
case .skippingVerification: tr("正在跳过核对…")
}
}
var isVerifying: Bool { self == .verifying || self == .skippingVerification }
var isStopping: Bool { self == .stopping || self == .skippingVerification }
}
/// 需要等待、期间不宜继续操作的任务(清理、卸载、导出诊断、修改 DNS):
/// 整个窗口压暗,中间浮一块玻璃加载框,写明正在做什么;任务结束自动消失
struct LoadingHUD: View {
let message: String
let progress: CleanProgress?
let cleanerStage: CleanerHUDStage?
let onCancel: (() -> Void)?
var body: some View {
ZStack {
// 压暗并拦住背景点击;可取消任务在加载框内提供操作。
Rectangle()
.fill(DS.Palette.scrim)
.contentShape(Rectangle())
.onTapGesture {}
VStack(alignment: .leading, spacing: DS.Space.s3) {
HStack(spacing: DS.Space.s3) {
ProgressView().controlSize(.regular)
Text(verbatim: message)
.dsFont(.base, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
}
if let cleanerStage {
cleanerDetails(cleanerStage)
HStack {
Spacer(minLength: 0)
Button(cleanerStage.isVerifying ? tr("跳过核对") : tr("停止清理")) { onCancel?() }
.buttonStyle(DSButtonStyle(kind: .secondary))
.disabled(cleanerStage.isStopping || onCancel == nil)
}
} else if let onCancel {
HStack {
Spacer(minLength: 0)
Button(tr("取消"), action: onCancel)
.buttonStyle(DSButtonStyle(kind: .secondary))
}
}
}
.padding(.horizontal, DS.Space.s8)
.padding(.vertical, DS.Space.s6)
.frame(minWidth: DS.Size.sidebarWidth + DS.Space.s16, maxWidth: 420)
.modifier(HUDBackground())
.accessibilityElement(children: cleanerStage == nil && onCancel == nil ? .combine : .contain)
.accessibilityAddTraits(.updatesFrequently)
}
.transition(.opacity)
}
private func cleanerDetails(_ stage: CleanerHUDStage) -> some View {
VStack(alignment: .leading, spacing: DS.Space.s2) {
if stage.isVerifying {
Text(tr("清理已结束,正在更新可清理空间"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
} else if let progress {
HStack(spacing: DS.Space.s2) {
Text(verbatim: progress.currentRule)
.dsFont(.sm, weight: .medium)
.foregroundStyle(DS.Palette.textPrimary)
.lineLimit(2)
Spacer(minLength: 0)
if progress.ruleCount > 1 {
Text(tr("第 \(progress.ruleNumber)/\(progress.ruleCount) 项"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
.monospacedDigit()
}
}
if progress.toolScannedBytes == nil, progress.currentRuleTotal > 1 {
ProgressView(value: Double(progress.currentRuleCompleted),
total: Double(progress.currentRuleTotal))
.accessibilityLabel(progress.currentRule)
.accessibilityValue(tr("已处理 \(progress.currentRuleCompleted)/\(progress.currentRuleTotal) 项"))
}
HStack(alignment: .firstTextBaseline, spacing: DS.Space.s2) {
Text(verbatim: detail(for: progress))
.lineLimit(2)
Spacer(minLength: 0)
Text(tr("已运行"))
Text(progress.currentRuleStartedAt, style: .timer)
.monospacedDigit()
}
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
} else {
Text(tr("正在准备清理…"))
.dsFont(.xs)
.foregroundStyle(DS.Palette.textSecondary)
}
}
.frame(maxWidth: .infinity, minHeight: DS.Space.s16, alignment: .topLeading)
}
private func detail(for progress: CleanProgress) -> String {
if let bytes = progress.toolScannedBytes {
return tr("扫描大小约 \(Format.bytes(bytes, base: .decimal))")
}
if progress.currentRuleTotal > 1 {
return tr("已处理 \(progress.currentRuleCompleted)/\(progress.currentRuleTotal) 项")
}
return tr("正在处理当前项目")
}
}
private struct HUDBackground: ViewModifier {
func body(content: Content) -> some View {
if #available(macOS 26.0, *) {
content.glassEffect(.regular, in: RoundedRectangle(cornerRadius: DS.Radius.xl, style: .continuous))
} else {
content
.background(DS.Palette.elevated, in: RoundedRectangle(cornerRadius: DS.Radius.xl, style: .continuous))
.dsShadow(DS.Shadow.level2)
}
}
}
extension View {
/// 有文字时在上方盖一层全局加载框
func loadingHUD(_ message: String?, progress: CleanProgress? = nil, cleanerStage: CleanerHUDStage? = nil,
onCancel: (() -> Void)? = nil) -> some View {
overlay {
if let message { LoadingHUD(message: message, progress: progress, cleanerStage: cleanerStage, onCancel: onCancel) }
}
.animation(DS.Motion.quick, value: message)
}
}