import AppKit import Localization import Metrics import SwiftUI /// 主窗口:侧边栏、标题栏与页面在同一张底色上,不分栏着色,也不画分隔线。 /// 左侧切换页面,右侧是仪表盘、各指标详情与工具页;窗口宽高都可调整 public struct MainWindowView: View { @Environment(AppModel.self) private var model @Environment(\.isSnapshot) private var isSnapshot public init() {} public var body: some View { HStack(alignment: .top, spacing: 0) { MainSidebar() .frame(width: DS.Size.sidebarWidth) .frame(maxHeight: .infinity) .background(alignment: .top) { WindowDragArea().frame(height: DS.Size.windowHeader) } 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) } .frame(width: isSnapshot ? DS.Size.sidebarWidth + DS.Size.panelWidth : nil) .fixedSize(horizontal: false, vertical: isSnapshot) .background(DS.Palette.background) // 内容延伸到透明标题栏下方,由顶栏高度留出红绿灯按钮的位置 .ignoresSafeArea() // 文案在各视图计算时翻译好,切换语言后整棵视图重建 .id(model.settings.language) } } /// 指标详情页:复用菜单栏弹窗的内容,显示全部区块 private struct DetailPage: View { @ViewBuilder var content: Content var body: some View { PageScroll { content } .environment(\.isDetailPage, true) } } private struct MainSidebar: View { @Environment(AppModel.self) private var model @Environment(\.isSnapshot) private var isSnapshot var body: some View { VStack(alignment: .leading, spacing: DS.Space.s3) { // 三组导航加起来较高,窗口矮时侧边栏自己滚动,底部按钮始终可见 if isSnapshot { navigation } else { ScrollView { navigation.overlayScrollers() } .scrollBounceBehavior(.basedOnSize) } HStack(spacing: DS.Space.s1) { ThemeToggle() Spacer(minLength: 0) IconButton(systemName: "power", help: tr("退出 OpenStats")) { model.quit() } } .padding(.leading, DS.Space.s2) } .padding(.leading, DS.Space.s1) .padding(.trailing, DS.Space.s3) // 顶部留出窗口红绿灯按钮的位置 .padding(.top, DS.Size.windowHeader + DS.Space.s2) .padding(.bottom, DS.Space.s3) } private var navigation: some View { VStack(alignment: .leading, spacing: DS.Space.s1) { group(tr("监控"), PanelTab.monitors) group(tr("工具"), PanelTab.tools) group(tr("设置"), PanelTab.settings) } .sidebarGlider() // 光条的发光向左溢出几个点,留出空间避免被滚动区域裁掉 .padding(.leading, DS.Space.s2) .frame(maxWidth: .infinity, alignment: .leading) } private func badge(for tab: PanelTab) -> Tone? { switch tab { case .settingsAbout: model.updates.release != nil ? .primary : nil case .settingsHelper: model.helper.isReady && model.helper.isOutdated ? .warning : nil case .settingsAccount: if model.sync.conflict != nil { .primary } else if case .failed = model.sync.phase { .warning } else { nil } default: nil } } @ViewBuilder private func group(_ title: String, _ tabs: [PanelTab]) -> some View { Text(title) .dsFont(.xs, weight: .medium) .foregroundStyle(DS.Palette.textTertiary) .padding(.horizontal, DS.Space.s2) .padding(.top, DS.Space.s2) ForEach(tabs) { tab in SidebarButton(title: tab.title, symbol: tab.symbol, isSelected: model.settings.panelTab == tab, badge: badge(for: tab)) { model.settings.panelTab = tab } } } } private struct PageHeader: View { @Environment(AppModel.self) private var model var body: some View { let settings = model.settings let tab = settings.panelTab HStack(spacing: DS.Space.s3) { // 拖动窗口的区域只覆盖标题和空白,按钮与开关不在拖动层上 HStack(spacing: 0) { Text(tab.headerTitle) .dsFont(.base, weight: .semibold) .foregroundStyle(DS.Palette.textPrimary) Spacer(minLength: DS.Space.s3) } .frame(maxHeight: .infinity) .background(WindowDragArea()) if model.keepAwake.isActive { Chip(text: tr("防休眠已开启"), icon: "cup.and.saucer.fill", tone: .primary) } if tab == .memory { PurgeMemoryButton() } // 每个监控页都能在这里开关自己的菜单栏项目;温度与风扇页有两项,各带一个小标签 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) } DSToggle(isOn: Binding(get: { settings.isEnabled(item) }, set: { settings.setEnabled(item, $0) }), label: tr("在菜单栏显示\(item.title)")) .help(tr("开启后图标出现在菜单栏;按住 ⌘ 键拖动图标可以调整位置")) } } } .padding(.horizontal, DS.Space.s3 + DS.Space.s1) .frame(height: DS.Size.windowHeader) } } /// 在浅色与深色之间切换;当前跟随系统时,切到与系统相反的那一种 private struct ThemeToggle: View { @Environment(AppModel.self) private var model @Environment(\.colorScheme) private var colorScheme var body: some View { let isDark = colorScheme == .dark IconButton(systemName: isDark ? "sun.max" : "moon", help: isDark ? tr("切换到浅色") : tr("切换到深色")) { model.settings.appearance = isDark ? .light : .dark } } } struct AppGlyph: View { var size: CGFloat = DS.Size.controlHeight var body: some View { Image(nsImage: NSApp.applicationIconImage) .resizable() .interpolation(.high) .frame(width: size, height: size) .accessibilityHidden(true) } }