主窗口改用液态玻璃(macOS 26),分段切换改为苹果标准样式,新增全局加载框
- 新增 Design/LiquidGlass.swift:dsGlass 修饰器(macOS 26 用 glassEffect,更早系统与离屏截图退回实色)、isInsideGlass 环境值避免玻璃叠玻璃、LoadingHUD 全局加载框 - 侧边栏是浮在窗口里的玻璃面板,选中项为滑动的淡蓝圆角底;顶栏“在菜单栏显示”一组收进玻璃胶囊 - DSButtonStyle:主按钮蓝色玻璃胶囊、次要按钮透明玻璃;IconButton / MiniIconButton 为圆形玻璃;ChipButton 为玻璃胶囊;DSToggle 在 macOS 26 用系统开关 - SegmentedControl 统一为苹果标准样式:灰色圆角底槽、等宽分段、选中段实心品牌蓝白字并滑动;PillSwitch 同样灰底 - 清理、卸载、导出诊断、修改 DNS 进行中显示全局加载框 - 没有用 safeAreaBar 让页面从顶栏下滚过:macOS 上交界处会画一条分隔线,柔和或隐藏滚动边缘都去不掉
This commit is contained in:
1 parent
f3c0f860cb
commit
d68fbce42a
10 files changed
+421
-160
No files matched your search
@@ -680,6 +680,7 @@ npm 缓存 npm Cache
|
||||
正在同步… Syncing…
|
||||
正在安装,完成后 OpenStats 会自动重启。 Installing. OpenStats will relaunch when it's done.
|
||||
正在导出… Exporting…
|
||||
正在导出诊断信息… Exporting diagnostics…
|
||||
正在扫描应用… Scanning apps…
|
||||
正在收集走势… Collecting trend…
|
||||
正在本机生成… Generating on this Mac…
|
||||
|
||||
@@ -331,8 +331,31 @@ private struct DSButtonBody: View {
|
||||
let kind: DSButtonStyle.Kind
|
||||
@State private var hovering = false
|
||||
@Environment(\.isEnabled) private var isEnabled
|
||||
@Environment(\.isInsideGlass) private var isInsideGlass
|
||||
|
||||
var body: some View {
|
||||
if DS.Glass.isAvailable {
|
||||
glassBody
|
||||
} else {
|
||||
solidBody
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS 26:胶囊形玻璃按钮。主按钮是蓝色玻璃,次要按钮是透明玻璃,文字按钮不加玻璃
|
||||
private var glassBody: some View {
|
||||
configuration.label
|
||||
.dsFont(.sm, weight: .medium)
|
||||
.foregroundStyle(foreground)
|
||||
.padding(.horizontal, DS.Space.s4)
|
||||
.frame(height: DS.Size.controlHeight)
|
||||
.contentShape(Capsule())
|
||||
.modifier(GlassButtonSurface(kind: kind, active: active, isInsideGlass: isInsideGlass))
|
||||
.opacity(isEnabled ? 1 : 0.5)
|
||||
.onHover { hovering = $0 }
|
||||
.animation(DS.Motion.quick, value: hovering)
|
||||
}
|
||||
|
||||
private var solidBody: some View {
|
||||
configuration.label
|
||||
.dsFont(.sm, weight: .medium)
|
||||
.foregroundStyle(foreground)
|
||||
@@ -374,15 +397,18 @@ struct IconButton: View {
|
||||
let help: String
|
||||
let action: () -> Void
|
||||
@State private var hovering = false
|
||||
@Environment(\.isInsideGlass) private var isInsideGlass
|
||||
|
||||
var body: some View {
|
||||
// macOS 26 上是圆形玻璃按钮(与系统设置的前进后退一致);已经在玻璃面板里时只有悬停底色
|
||||
let glass = DS.Glass.isAvailable && !isInsideGlass
|
||||
Button(action: action) {
|
||||
Image(systemName: systemName)
|
||||
.font(.system(size: DS.TextSize.sm.rawValue, weight: .medium))
|
||||
.foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textSecondary)
|
||||
.foregroundStyle(hovering ? DS.Palette.primary : glass ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
||||
.frame(width: DS.Size.controlHeight, height: DS.Size.controlHeight)
|
||||
.background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||||
.contentShape(Rectangle())
|
||||
.modifier(IconButtonSurface(hovering: hovering, glass: glass))
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering = $0 }
|
||||
@@ -391,6 +417,39 @@ struct IconButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 玻璃按钮的底:主按钮蓝色玻璃,次要按钮透明玻璃;文字按钮、或已在玻璃面板里的次要按钮只有悬停底色
|
||||
private struct GlassButtonSurface: ViewModifier {
|
||||
let kind: DSButtonStyle.Kind
|
||||
let active: Bool
|
||||
let isInsideGlass: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
switch kind {
|
||||
case .primary:
|
||||
content.dsGlass(in: Capsule(), tint: DS.Palette.primary, interactive: true)
|
||||
case .secondary where !isInsideGlass:
|
||||
content.dsGlass(in: Capsule(), interactive: true)
|
||||
default:
|
||||
content.background(active ? DS.Palette.surfaceHover : .clear, in: Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct IconButtonSurface: ViewModifier {
|
||||
let hovering: Bool
|
||||
let glass: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if glass {
|
||||
content.dsGlass(in: Circle(), interactive: true)
|
||||
} else if DS.Glass.isAvailable {
|
||||
content.background(hovering ? DS.Palette.surfaceHover : .clear, in: Circle())
|
||||
} else {
|
||||
content.background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 侧边栏
|
||||
|
||||
/// 侧边栏的一项。选中态不填色:由容器上的 `sidebarGlider()` 画左侧滑动的蓝色光条与淡淡的高亮
|
||||
@@ -442,15 +501,32 @@ struct SidebarRowsKey: PreferenceKey {
|
||||
}
|
||||
|
||||
extension View {
|
||||
/// 放在包含 SidebarButton 的容器上:左侧一条两端渐隐的细线,选中项位置有一段发光的蓝色光条,
|
||||
/// 切换时带回弹地滑过去,光条右侧拖出一片渐淡的蓝色高亮
|
||||
/// 放在包含 SidebarButton 的容器上,画出选中态。
|
||||
/// macOS 26:选中项垫一块淡蓝色圆角底,切换时滑过去(侧边栏本身是玻璃面板,里面不再叠玻璃);
|
||||
/// 更早的系统:左侧一条两端渐隐的细线,选中项位置有一段发光的蓝色光条,带回弹地滑过去
|
||||
@ViewBuilder
|
||||
func sidebarGlider() -> some View {
|
||||
overlayPreferenceValue(SidebarRowsKey.self) { rows in
|
||||
GeometryReader { proxy in
|
||||
SidebarGlider(frames: rows.map { proxy[$0.bounds] },
|
||||
selected: rows.first(where: \.isSelected).map { proxy[$0.bounds] })
|
||||
if DS.Glass.isAvailable {
|
||||
backgroundPreferenceValue(SidebarRowsKey.self) { rows in
|
||||
GeometryReader { proxy in
|
||||
if let selected = rows.first(where: \.isSelected).map({ proxy[$0.bounds] }) {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md, style: .continuous)
|
||||
.fill(DS.Palette.sidebarSelected)
|
||||
.frame(width: selected.width, height: selected.height)
|
||||
.offset(x: selected.minX, y: selected.minY)
|
||||
.animation(.spring(response: 0.35, dampingFraction: 0.8), value: selected)
|
||||
}
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
} else {
|
||||
overlayPreferenceValue(SidebarRowsKey.self) { rows in
|
||||
GeometryReader { proxy in
|
||||
SidebarGlider(frames: rows.map { proxy[$0.bounds] },
|
||||
selected: rows.first(where: \.isSelected).map { proxy[$0.bounds] })
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
.allowsHitTesting(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -497,9 +573,14 @@ private struct SidebarGlider: View {
|
||||
|
||||
// MARK: - 分段控件
|
||||
|
||||
/// 分段切换(苹果标准样式,与系统设置一致):一条灰色圆角底槽,各段等宽铺满,
|
||||
/// 选中的一段是实心品牌蓝、白字,切换时蓝块滑过去
|
||||
struct SegmentedControl<Value: Hashable>: View {
|
||||
@Binding var selection: Value
|
||||
let options: [(value: Value, title: String)]
|
||||
@Namespace private var namespace
|
||||
|
||||
private static var radius: CGFloat { DS.Radius.md - DS.Space.s1 / 2 }
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 0) {
|
||||
@@ -509,16 +590,17 @@ struct SegmentedControl<Value: Hashable>: View {
|
||||
selection = option.value
|
||||
} label: {
|
||||
Text(option.title)
|
||||
.dsFont(.sm, weight: selected ? .semibold : .regular)
|
||||
.foregroundStyle(selected ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
||||
.dsFont(.sm)
|
||||
.foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.padding(.horizontal, DS.Space.s2)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: DS.Size.segmentHeight)
|
||||
.frame(height: DS.Size.segmentHeight + DS.Space.s1)
|
||||
.background {
|
||||
if selected {
|
||||
RoundedRectangle(cornerRadius: DS.Radius.md - DS.Space.s1)
|
||||
.fill(DS.Palette.elevated)
|
||||
.dsShadow(DS.Shadow.level1)
|
||||
RoundedRectangle(cornerRadius: Self.radius, style: .continuous)
|
||||
.fill(DS.Palette.primary)
|
||||
.matchedGeometryEffect(id: "selection", in: namespace)
|
||||
}
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
@@ -527,9 +609,8 @@ struct SegmentedControl<Value: Hashable>: View {
|
||||
.accessibilityAddTraits(selected ? .isSelected : [])
|
||||
}
|
||||
}
|
||||
.padding(DS.Space.s1)
|
||||
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: DS.Radius.md))
|
||||
.animation(DS.Motion.quick, value: selection)
|
||||
.background(DS.Palette.track, in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous))
|
||||
.animation(.spring(response: 0.3, dampingFraction: 0.85), value: selection)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -538,8 +619,22 @@ struct SegmentedControl<Value: Hashable>: View {
|
||||
struct DSToggle: View {
|
||||
@Binding var isOn: Bool
|
||||
var label: String = ""
|
||||
@Environment(\.isSnapshot) private var isSnapshot
|
||||
|
||||
var body: some View {
|
||||
// 离屏截图时窗口不在前台,系统开关会画成灰色,截图里用自绘的蓝色开关
|
||||
if DS.Glass.isAvailable, !isSnapshot {
|
||||
// macOS 26 的系统开关自带液态玻璃(拖动时滑块变成玻璃),颜色沿用品牌蓝
|
||||
Toggle(isOn: $isOn) { Text(label) }
|
||||
.labelsHidden()
|
||||
.toggleStyle(.switch)
|
||||
.tint(DS.Palette.primary)
|
||||
} else {
|
||||
solidBody
|
||||
}
|
||||
}
|
||||
|
||||
private var solidBody: some View {
|
||||
Button {
|
||||
isOn.toggle()
|
||||
} label: {
|
||||
@@ -785,6 +880,27 @@ struct ChipButton: View {
|
||||
@State private var hovering = false
|
||||
|
||||
var body: some View {
|
||||
if DS.Glass.isAvailable {
|
||||
// macOS 26:每个选项是一颗玻璃胶囊,选中的是蓝色玻璃
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.dsFont(.xs, weight: isSelected ? .semibold : .medium)
|
||||
.foregroundStyle(isSelected ? DS.Palette.onPrimary : DS.Palette.textPrimary)
|
||||
.lineLimit(1)
|
||||
.frame(maxWidth: .infinity)
|
||||
.frame(height: DS.Size.segmentHeight)
|
||||
.contentShape(Capsule())
|
||||
.dsGlass(in: Capsule(), tint: isSelected ? DS.Palette.primary : nil, interactive: true)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.animation(DS.Motion.quick, value: isSelected)
|
||||
.accessibilityAddTraits(isSelected ? .isSelected : [])
|
||||
} else {
|
||||
solidBody
|
||||
}
|
||||
}
|
||||
|
||||
private var solidBody: some View {
|
||||
Button(action: action) {
|
||||
Text(title)
|
||||
.dsFont(.xs, weight: isSelected ? .semibold : .medium)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import Localization
|
||||
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: - 全局加载
|
||||
|
||||
/// 需要等待、期间不宜继续操作的任务(清理、卸载、导出诊断、修改 DNS):
|
||||
/// 整个窗口压暗,中间浮一块玻璃加载框,写明正在做什么;任务结束自动消失
|
||||
struct LoadingHUD: View {
|
||||
let message: String
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
// 压暗并拦住点击,避免任务进行中又触发别的操作
|
||||
Rectangle()
|
||||
.fill(DS.Palette.scrim)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {}
|
||||
|
||||
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)
|
||||
}
|
||||
.padding(.horizontal, DS.Space.s8)
|
||||
.padding(.vertical, DS.Space.s6)
|
||||
.frame(minWidth: DS.Size.sidebarWidth + DS.Space.s16)
|
||||
.modifier(HUDBackground())
|
||||
.accessibilityElement(children: .combine)
|
||||
.accessibilityAddTraits(.updatesFrequently)
|
||||
}
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
|
||||
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?) -> some View {
|
||||
overlay {
|
||||
if let message { LoadingHUD(message: message) }
|
||||
}
|
||||
.animation(DS.Motion.quick, value: message)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,10 @@ extension DS {
|
||||
static let neutral500 = Color.dynamic(light: 0x6B7280, dark: 0x4B5563)
|
||||
/// 浅一档的品牌蓝,用来区分同一图表里的第二类数据(如性能核)
|
||||
static let primarySoft = Color.dynamic(light: 0x93C5FD, dark: 0x60A5FA)
|
||||
/// 侧边栏选中项的淡蓝底
|
||||
static let sidebarSelected = Color.dynamic(light: 0x2563EB, lightAlpha: 0.12, dark: 0x3B82F6, darkAlpha: 0.22)
|
||||
/// 全局加载时压暗整个窗口
|
||||
static let scrim = Color.dynamic(light: 0x000000, lightAlpha: 0.12, dark: 0x000000, darkAlpha: 0.40)
|
||||
|
||||
/// 核心类型配色:最高性能档为品牌蓝,其次浅蓝,能效核灰色
|
||||
static func cluster(_ id: Int) -> Color {
|
||||
@@ -184,6 +188,7 @@ extension DS {
|
||||
}
|
||||
|
||||
static let level1 = Level(color: .black.opacity(0.08), radius: 3, y: 1)
|
||||
static let level2 = Level(color: .black.opacity(0.10), radius: 12, y: 4)
|
||||
}
|
||||
|
||||
enum Motion {
|
||||
|
||||
@@ -3,8 +3,9 @@ import Localization
|
||||
import Metrics
|
||||
import SwiftUI
|
||||
|
||||
/// 主窗口:侧边栏、标题栏与页面在同一张底色上,不分栏着色,也不画分隔线。
|
||||
/// 左侧切换页面,右侧是仪表盘、各指标详情与工具页;窗口宽高都可调整
|
||||
/// 主窗口。左侧切换页面,右侧是仪表盘、各指标详情与工具页;窗口宽高都可调整。
|
||||
/// macOS 26 起侧边栏是一块浮在窗口里的液态玻璃面板,顶栏控件是玻璃胶囊;
|
||||
/// 更早的系统上侧边栏、标题栏与页面在同一张底色上,不分栏着色,也不画分隔线
|
||||
public struct MainWindowView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
@Environment(\.isSnapshot) private var isSnapshot
|
||||
@@ -19,55 +20,93 @@ public struct MainWindowView: View {
|
||||
.background(alignment: .top) {
|
||||
WindowDragArea().frame(height: DS.Size.windowHeader)
|
||||
}
|
||||
.modifier(SidebarPanel())
|
||||
|
||||
VStack(spacing: 0) {
|
||||
// 页面的滚动视图会自动向上延伸到标题栏下面,与顶栏重叠;顶栏必须在它之上,否则开关收不到点击
|
||||
PageHeader()
|
||||
.zIndex(1)
|
||||
Group {
|
||||
switch model.settings.panelTab {
|
||||
case .overview: OverviewPage()
|
||||
case .system: SystemInfoPage()
|
||||
case .history: HistoryPage()
|
||||
case .cpu: DetailPage { CPUPopover() }
|
||||
case .gpu: DetailPage { GPUPopover() }
|
||||
case .memory: DetailPage { MemoryPopover() }
|
||||
case .disk: DiskPage()
|
||||
case .network:
|
||||
DetailPage {
|
||||
NetworkPopover()
|
||||
NetworkSettings()
|
||||
}
|
||||
case .thermal: ThermalPage()
|
||||
case .battery: DetailPage { BatteryPopover() }
|
||||
case .processes: ProcessesPage()
|
||||
case .keepAwake: KeepAwakePage()
|
||||
case .cleaner: CleanerPage()
|
||||
case .uninstaller: UninstallerPage()
|
||||
case .startupItems: StartupItemsPage()
|
||||
case .settingsGeneral: SettingsTabPage { GeneralSettings() }
|
||||
case .settingsMenuBar: SettingsTabPage { MenuBarSettings() }
|
||||
case .settingsNotifications: SettingsTabPage { NotificationSettings() }
|
||||
case .settingsAccount: SettingsTabPage { AccountSettings() }
|
||||
case .settingsHelper: SettingsTabPage { HelperSettings() }
|
||||
case .settingsAbout: SettingsTabPage { AboutSettings() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
// 截图时页面取自身高度:侧边栏更高时,多出的高度不能分给页面里可伸展的卡片
|
||||
.fixedSize(horizontal: false, vertical: isSnapshot)
|
||||
}
|
||||
.frame(minWidth: DS.Size.panelWidth, maxWidth: .infinity)
|
||||
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
content
|
||||
.frame(minWidth: DS.Size.panelWidth, maxWidth: .infinity)
|
||||
.frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
}
|
||||
.frame(width: isSnapshot ? DS.Size.sidebarWidth + DS.Size.panelWidth : nil)
|
||||
.fixedSize(horizontal: false, vertical: isSnapshot)
|
||||
.background(DS.Palette.background)
|
||||
// 需要等待的任务进行中,整个窗口压暗并显示加载框
|
||||
.loadingHUD(isSnapshot ? nil : busyMessage)
|
||||
// 内容延伸到透明标题栏下方,由顶栏高度留出红绿灯按钮的位置
|
||||
.ignoresSafeArea()
|
||||
// 文案在各视图计算时翻译好,切换语言后整棵视图重建
|
||||
.id(model.settings.language)
|
||||
}
|
||||
|
||||
/// 顶栏在上、页面在下。没有用 macOS 26 的 safeAreaBar 让页面从顶栏下面滚过:
|
||||
/// 它在 macOS 上会在交界处画一条分隔线,滚动边缘样式设成柔和或隐藏都去不掉
|
||||
private var content: some View {
|
||||
VStack(spacing: 0) {
|
||||
// 页面的滚动视图会自动向上延伸到标题栏下面,与顶栏重叠;顶栏必须在它之上,否则开关收不到点击
|
||||
PageHeader()
|
||||
.zIndex(1)
|
||||
page
|
||||
}
|
||||
}
|
||||
|
||||
/// 需要等待、期间不宜继续操作的任务
|
||||
private var busyMessage: String? {
|
||||
if model.cleaner.phase == .cleaning { return tr("正在清理…") }
|
||||
if model.uninstaller.isRemoving { return tr("正在移除…") }
|
||||
if model.diagnostics.phase == .collecting { return tr("正在导出诊断信息…") }
|
||||
if model.maintenance.isApplyingDNS { return tr("正在修改 DNS…") }
|
||||
return nil
|
||||
}
|
||||
|
||||
private var page: some View {
|
||||
Group {
|
||||
switch model.settings.panelTab {
|
||||
case .overview: OverviewPage()
|
||||
case .system: SystemInfoPage()
|
||||
case .history: HistoryPage()
|
||||
case .cpu: DetailPage { CPUPopover() }
|
||||
case .gpu: DetailPage { GPUPopover() }
|
||||
case .memory: DetailPage { MemoryPopover() }
|
||||
case .disk: DiskPage()
|
||||
case .network:
|
||||
DetailPage {
|
||||
NetworkPopover()
|
||||
NetworkSettings()
|
||||
}
|
||||
case .thermal: ThermalPage()
|
||||
case .battery: DetailPage { BatteryPopover() }
|
||||
case .processes: ProcessesPage()
|
||||
case .keepAwake: KeepAwakePage()
|
||||
case .cleaner: CleanerPage()
|
||||
case .uninstaller: UninstallerPage()
|
||||
case .startupItems: StartupItemsPage()
|
||||
case .settingsGeneral: SettingsTabPage { GeneralSettings() }
|
||||
case .settingsMenuBar: SettingsTabPage { MenuBarSettings() }
|
||||
case .settingsNotifications: SettingsTabPage { NotificationSettings() }
|
||||
case .settingsAccount: SettingsTabPage { AccountSettings() }
|
||||
case .settingsHelper: SettingsTabPage { HelperSettings() }
|
||||
case .settingsAbout: SettingsTabPage { AboutSettings() }
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top)
|
||||
// 截图时页面取自身高度:侧边栏更高时,多出的高度不能分给页面里可伸展的卡片
|
||||
.fixedSize(horizontal: false, vertical: isSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS 26:侧边栏浮在窗口里,四周留一圈边距,整块是液态玻璃,红绿灯按钮落在面板里
|
||||
private struct SidebarPanel: ViewModifier {
|
||||
@Environment(\.isSnapshot) private var isSnapshot
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if DS.Glass.isAvailable, !isSnapshot {
|
||||
content
|
||||
.environment(\.isInsideGlass, true)
|
||||
.dsGlass(in: RoundedRectangle(cornerRadius: DS.Radius.xl, style: .continuous))
|
||||
.padding(DS.Space.s2)
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 指标详情页:复用菜单栏弹窗的内容,显示全部区块
|
||||
@@ -104,11 +143,13 @@ private struct MainSidebar: View {
|
||||
}
|
||||
.padding(.leading, DS.Space.s1)
|
||||
.padding(.trailing, DS.Space.s3)
|
||||
// 顶部留出窗口红绿灯按钮的位置
|
||||
.padding(.top, DS.Size.windowHeader + DS.Space.s2)
|
||||
// 顶部留出窗口红绿灯按钮的位置;玻璃面板本身离窗口顶边已有一圈边距
|
||||
.padding(.top, DS.Size.windowHeader + (usesGlassPanel ? 0 : DS.Space.s2))
|
||||
.padding(.bottom, DS.Space.s3)
|
||||
}
|
||||
|
||||
private var usesGlassPanel: Bool { DS.Glass.isAvailable && !isSnapshot }
|
||||
|
||||
private var navigation: some View {
|
||||
VStack(alignment: .leading, spacing: DS.Space.s1) {
|
||||
group(tr("监控"), PanelTab.monitors)
|
||||
@@ -171,15 +212,19 @@ private struct PageHeader: View {
|
||||
// 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签
|
||||
let items = tab.menuBarItems
|
||||
if !items.isEmpty {
|
||||
Text(tr("在菜单栏显示")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
ForEach(items) { item in
|
||||
if items.count > 1 {
|
||||
Text(item.popoverTitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
// macOS 26 上这一组是一颗玻璃胶囊,与访达工具栏的分组一致
|
||||
HStack(spacing: DS.Space.s3) {
|
||||
Text(tr("在菜单栏显示")).dsFont(.xs).foregroundStyle(DS.Palette.textSecondary)
|
||||
ForEach(items) { item in
|
||||
if items.count > 1 {
|
||||
Text(item.popoverTitle).dsFont(.xs).foregroundStyle(DS.Palette.textTertiary)
|
||||
}
|
||||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }),
|
||||
label: tr("在菜单栏显示\(item.title)"))
|
||||
.help(tr("开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置"))
|
||||
}
|
||||
DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }),
|
||||
label: tr("在菜单栏显示\(item.title)"))
|
||||
.help(tr("开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置"))
|
||||
}
|
||||
.modifier(HeaderControlGroup())
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, DS.Space.s3 + DS.Space.s1)
|
||||
@@ -187,6 +232,21 @@ private struct PageHeader: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// 顶栏右侧的一组控件:macOS 26 上收进一颗玻璃胶囊,更早的系统直接排开
|
||||
private struct HeaderControlGroup: ViewModifier {
|
||||
func body(content: Content) -> some View {
|
||||
if DS.Glass.isAvailable {
|
||||
content
|
||||
.padding(.leading, DS.Space.s4)
|
||||
.padding(.trailing, DS.Space.s2)
|
||||
.frame(height: DS.Size.controlHeight)
|
||||
.dsGlass(in: Capsule())
|
||||
} else {
|
||||
content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 在浅色与深色之间切换;当前跟随系统时,切到与系统相反的那一种
|
||||
private struct ThemeToggle: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
@@ -187,8 +187,8 @@ struct MiniIconButton: View {
|
||||
.font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold))
|
||||
.foregroundStyle(hovering ? DS.Palette.textPrimary : DS.Palette.textSecondary)
|
||||
.frame(width: DS.Size.segmentHeight, height: DS.Size.segmentHeight)
|
||||
.background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||||
.contentShape(Rectangle())
|
||||
.modifier(MiniIconSurface(hovering: hovering))
|
||||
.contentShape(Circle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.onHover { hovering = $0 }
|
||||
@@ -197,6 +197,19 @@ struct MiniIconButton: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// macOS 26 上是一颗小玻璃圆钮,更早的系统只有悬停底色
|
||||
private struct MiniIconSurface: ViewModifier {
|
||||
let hovering: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
if DS.Glass.isAvailable {
|
||||
content.dsGlass(in: Circle(), interactive: true)
|
||||
} else {
|
||||
content.background(hovering ? DS.Palette.surfaceHover : .clear, in: RoundedRectangle(cornerRadius: DS.Radius.sm))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 区块标题栏里的刷新按钮:查询进行中换成系统的转圈,查完自动变回图标
|
||||
struct RefreshButton: View {
|
||||
var loading: Bool
|
||||
|
||||
Reference in new issue
Block a user