From b5c1a330c774185fd09f9e9adc0ba3d7531b8c88 Mon Sep 17 00:00:00 2001 From: gentpan Date: Fri, 18 Sep 2026 13:59:20 +0500 Subject: [PATCH] =?UTF-8?q?=E8=8F=9C=E5=8D=95=E6=A0=8F=E5=90=88=E5=B9=B6?= =?UTF-8?q?=E6=97=B6=E7=82=B9=E5=87=BB=E5=BC=B9=E5=87=BA=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E6=80=BB=E8=A7=88=EF=BC=8C=E5=8F=AF=E5=88=87=E5=88=B0=E5=90=84?= =?UTF-8?q?=E9=A1=B9=E8=AF=A6=E6=83=85=EF=BC=88#2=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 合并模式点击图标不再直接打开主窗口,改为弹出面板:顶部标签在总览与菜单栏开启的各项之间切换 - 总览每项一行:数值、状态、走势,偏高时变色;点一行进入该项完整详情,与每项独立时的弹窗共用 PopoverDetail - 切到某一项时 openPopover 跟着切换,采样范围与单项弹窗一致;面板打开期间每秒刷新 - 合并面板最小高度减半,项目少时不留空白;设置里的弹窗区块选项在合并模式下也可用 - 电池状态文案合成 BatteryStatus.stateText;--show-panel overview 用于调试 --- ARCHITECTURE.md | 5 +- CHANGELOG.md | 8 + .../Sources/Localization/Translations.swift | 8 +- .../OpenStatsUI/AppKit/AppController.swift | 12 +- .../AppKit/MenuBarController.swift | 55 ++- .../OpenStatsUI/AppKit/StatusPanel.swift | 14 +- .../OpenStatsUI/Panel/OverviewPage.swift | 10 +- .../OpenStatsUI/Popover/BatteryPopover.swift | 11 +- .../OpenStatsUI/Popover/CombinedPopover.swift | 322 ++++++++++++++++++ .../OpenStatsUI/Popover/PopoverRootView.swift | 52 ++- .../OpenStatsUI/Settings/SettingsPages.swift | 6 +- .../Snapshot/SnapshotRenderer.swift | 4 + .../Sources/OpenStatsUI/State/AppModel.swift | 20 +- .../OpenStatsUI/State/AppSettings.swift | 2 +- README.md | 20 +- README.zh-CN.md | 20 +- project.yml | 2 +- 17 files changed, 500 insertions(+), 71 deletions(-) create mode 100644 Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 74704c1..fa165eb 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -135,7 +135,10 @@ pieces of read-mostly logic in `Cleaner/DiskTools.swift`, each testable without `MenuBarController` owns the status items. In the *separate* layout every enabled metric gets its own `NSStatusItem` (created in reverse so they read left to right) and opens a 320 pt -popover for that metric; in the *combined* layout a single item opens the main window. +popover for that metric; in the *combined* layout a single item opens `CombinedPopoverView`: a status +overview with one row per enabled metric, plus tabs that switch to each metric's full popover content +(`PopoverDetail`, shared with the separate layout). While a detail tab is showing, `AppModel.openPopover` +is set to that metric so sampling matches the standalone popover. Popovers are borderless, non-activating `NSPanel`s. The SwiftUI tree is created on open and destroyed on close, so a hidden popover costs nothing. Height comes from measuring a flat, diff --git a/CHANGELOG.md b/CHANGELOG.md index 6def58b..1be9bb0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,14 @@ 记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。 官网、构建与发布脚本等不影响应用本身的改动不在此记录。 +## 未发布 + +### 2026-09-18 + +#### 新增 + +- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。 + ## 0.6.0 · 2026-09-18 ### 2026-09-18 diff --git a/Packages/OpenStatsKit/Sources/Localization/Translations.swift b/Packages/OpenStatsKit/Sources/Localization/Translations.swift index db1efb6..70eb343 100644 --- a/Packages/OpenStatsKit/Sources/Localization/Translations.swift +++ b/Packages/OpenStatsKit/Sources/Localization/Translations.swift @@ -566,12 +566,13 @@ npm 缓存 npm Cache 快照名称格式不认识,没有删除 Unrecognized snapshot names; nothing was deleted 性能核 Performance 总占用 Total +总览 Overview 恢复系统睡眠失败:{} Failed to restore system sleep: {} 我的 Mine 截图已输出到 {} Snapshots saved to {} 所属应用:{}({}) App: {} ({}) 所有内容先移到废纸篓。 Everything goes to the Trash first. -所有指标合成一个图标,点击打开主窗口 All metrics in one icon; click to open the main window +所有指标合成一个图标,点击弹出状态总览,可切到各项详情 All metrics in one icon; click for a status overview, with tabs for each metric's details 所有检测目标都连不上。请检查网络连接,或者代理软件是否正常运行。 None of the test targets could be reached. Check your network connection and whether your proxy app is running properly. 所有用户 All Users 所有用户与系统级的启动项需要管理员权限,请在系统设置的登录项中管理 All-users and system items need admin rights; manage them in Login Items in System Settings @@ -719,6 +720,7 @@ npm 缓存 npm Cache 极高风险 Very high risk 构建 Build 查看 View +查看{}详情 Show {} details 查看全部进程 All Processes 查看包含的项目 Show items 查看完整更新日志 Full Changelog @@ -805,6 +807,7 @@ npm 缓存 npm Cache 没有已连接的蓝牙设备 No connected Bluetooth devices 没有检测到 VPN 或系统代理,所有流量都从本机网络直接出去。 No VPN or system proxy detected. All traffic leaves directly from your network. 没有检测到显示器 No displays detected +没有电池 No battery 没有移动任何文件{} Nothing was moved{} 没有超过 50 MB 的文件 No files over 50 MB 流量历史 Traffic History @@ -845,6 +848,7 @@ npm 缓存 npm Cache 物理地址 Hardware address 状态 Status 状态圆点 Status Dot +状态总览 Status Overview 状态正常 Healthy 状态良好 Healthy 现在 Now @@ -999,6 +1003,7 @@ npm 缓存 npm Cache 菜单栏图标 Menu Bar Icons 菜单栏布局 Menu Bar Layout 菜单栏里的图标可以调整顺序:按住 ⌘ 键拖动任意一个,松开后位置会一直保留。新开启的项目由系统安排位置,可能离其他图标较远,拖一下就能挪到一起。 You can reorder the icons in the menu bar: hold ⌘ and drag any of them, and the position sticks. macOS picks where a newly enabled item first appears, sometimes away from the others; just drag it over. +菜单栏里还没有开启任何项目。 No items are turned on in the menu bar yet. 菜单栏项目 Menu Bar Items 菜单栏风格 Menu bar style 蓝牙设备 Bluetooth Devices @@ -1092,6 +1097,7 @@ npm 缓存 npm Cache 退出应用… Quit App… 退出登录 Sign out 适中 Moderate +选择显示项目 Choose Items 通用 General 通电时间 Power-on hours 通电次数 Power cycles diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift index 24819a0..1e6c09e 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/AppController.swift @@ -79,14 +79,20 @@ public final class AppController: NSObject, NSApplicationDelegate { model.sync.presentationAnchor = { [weak self] in self?.mainWindow.nsWindow } model.sync.start() - // 开发调试:--show-panel [cpu|memory|network|gpu|disk|temperature|fan|battery] 启动后展开并固定弹窗;--show-window 打开主窗口; + // 开发调试:--show-panel [overview|cpu|memory|network|gpu|disk|temperature|fan|battery] 启动后展开并固定弹窗 + // (overview 是合并模式的状态总览;合并模式下给某一项则打开面板并切到这一项);--show-window 打开主窗口; // --show-egress 打开出口与分流窗口 let arguments = CommandLine.arguments if let index = arguments.firstIndex(of: "--show-panel") { - let item = arguments.dropFirst(index + 1).first.flatMap(MenuBarItem.init(rawValue:)) ?? .network + let argument = arguments.dropFirst(index + 1).first + let item = argument.flatMap(MenuBarItem.init(rawValue:)) ?? .network DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in self?.menuBar.pinsNextPopover = true - self?.menuBar.togglePopover(item) + if argument == "overview" { + self?.menuBar.toggleOverview() + } else { + self?.menuBar.togglePopover(item) + } } } if arguments.contains("--show-window") { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift index b508933..0564292 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/MenuBarController.swift @@ -2,12 +2,15 @@ import AppKit import Localization import SwiftUI -/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击打开主窗口 +/// 菜单栏图标:每项独立时一个指标一个图标,点击弹出该项详情;合并时只有一个图标,点击弹出状态总览, +/// 总览顶部的标签可以切到各项的详情 @MainActor final class MenuBarController: NSObject { private let model: AppModel private var items: [(key: MenuBarItem?, statusItem: NSStatusItem)] = [] private var panels: [MenuBarItem: StatusPanel] = [:] + /// 合并模式的面板:状态总览与各项详情 + private var combinedPanel: StatusPanel? private var layoutSignature: [MenuBarItem?] = [] /// 开发调试:启动后展开并固定某个弹窗 @@ -24,7 +27,7 @@ final class MenuBarController: NSObject { func update() { let settings = model.settings let enabled = settings.orderedMenuBarItems - // 没有任何项目时保留一个图标,确保仍能打开主窗口 + // 没有任何项目时保留一个图标,确保仍能打开面板与主窗口 let signature: [MenuBarItem?] = settings.menuBarLayout == .separate && !enabled.isEmpty ? enabled : [nil] if signature != layoutSignature { rebuild(signature) @@ -96,13 +99,18 @@ final class MenuBarController: NSObject { } else if let key = entry.key { togglePopover(key, button: sender) } else { - model.openMainWindow(nil) + toggleCombinedPanel(tab: nil, button: sender) } } func togglePopover(_ item: MenuBarItem, button: NSStatusBarButton? = nil) { - guard let button = button ?? items.first(where: { $0.key == item })?.statusItem.button, - let window = button.window else { return } + // 合并模式下没有这一项自己的图标:打开合并面板并切到这一项 + guard let button = button ?? items.first(where: { $0.key == item })?.statusItem.button else { + toggleCombinedPanel(tab: item, button: nil) + return + } + guard let window = button.window else { return } + combinedPanel?.dismiss() for (key, panel) in panels where key != item { panel.dismiss() } let frame = window.convertToScreen(button.convert(button.bounds, to: nil)) let panel = self.panel(for: item) @@ -113,12 +121,49 @@ final class MenuBarController: NSObject { } } + /// 合并模式的状态总览 + func toggleOverview() { + toggleCombinedPanel(tab: nil, button: nil) + } + + /// 合并模式:点图标打开时总是先看总览;从快捷入口打开某一项时直接切到那一项 + private func toggleCombinedPanel(tab: MenuBarItem?, button: NSStatusBarButton?) { + guard let button = button ?? items.first(where: { $0.key == nil })?.statusItem.button, + let window = button.window else { return } + for panel in panels.values { panel.dismiss() } + let panel = combinedPanel ?? makeCombinedPanel() + if !panel.isVisible { model.combinedPopoverTab = tab } + let frame = window.convertToScreen(button.convert(button.bounds, to: nil)) + panel.toggle(below: frame, on: window.screen) + if pinsNextPopover, panel.isVisible { + panel.isPinned = true + pinsNextPopover = false + } + } + + private func makeCombinedPanel() -> StatusPanel { + let model = self.model + let panel = StatusPanel(width: DS.Size.popoverWidth, minHeight: DS.Size.panelMinHeight / 2, + content: { CombinedPopoverView().environment(model) }, + measuring: { CombinedPopoverView().environment(model).environment(\.isSnapshot, true) }) + panel.onVisibilityChange = { [weak self] visible in + guard let self else { return } + self.model.isCombinedPopoverOpen = visible + if visible { self.model.helper.refreshStatus() } + self.items.first { $0.key == nil }?.statusItem.button?.highlight(visible) + } + combinedPanel = panel + return panel + } + func dismissPopovers() { for panel in panels.values { panel.dismiss() } + combinedPanel?.dismiss() } func refreshPopoverHeight() { for panel in panels.values { panel.refreshHeight() } + combinedPanel?.refreshHeight() } private func panel(for item: MenuBarItem) -> StatusPanel { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift index 7e1f068..ba93443 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/AppKit/StatusPanel.swift @@ -20,11 +20,15 @@ final class StatusPanel: NSPanel { /// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度 private let makeMeasuringContent: () -> NSView private let width: CGFloat + /// 内容再少也不低于这个高度;合并模式的状态总览项目少时很矮,用更小的下限 + private let minHeight: CGFloat - init(width: CGFloat, content: @escaping () -> Content, measuring: @escaping () -> Measuring) { + init(width: CGFloat, minHeight: CGFloat = DS.Size.panelMinHeight, + content: @escaping () -> Content, measuring: @escaping () -> Measuring) { self.width = width + self.minHeight = minHeight makeMeasuringContent = { NSHostingView(rootView: measuring()) } - super.init(contentRect: NSRect(x: 0, y: 0, width: width, height: DS.Size.panelMinHeight), + super.init(contentRect: NSRect(x: 0, y: 0, width: width, height: minHeight), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true) @@ -89,7 +93,7 @@ final class StatusPanel: NSPanel { /// 滚动内容比可视区域高出(或矮出)一截时调整窗口高度,顶边不动;屏幕放不下时停在最高处,由滚动条兜底 func adjustHeight(by overflow: CGFloat) { guard contentView != nil, abs(overflow) > 1 else { return } - let height = min(max(frame.height + overflow, DS.Size.panelMinHeight), availableHeight()) + let height = min(max(frame.height + overflow, minHeight), availableHeight()) guard abs(height - frame.height) > 1 else { return } setFrame(NSRect(x: frame.minX, y: frame.maxY - height, width: width, height: height), display: true) invalidateShadow() @@ -132,7 +136,7 @@ final class StatusPanel: NSPanel { let top = min(anchor.minY - DS.Size.panelGap, visible.maxY) let natural = makeMeasuringContent().fittingSize.height - let height = min(max(natural, DS.Size.panelMinHeight), availableHeight()) + let height = min(max(natural, minHeight), availableHeight()) var x = anchor.midX - width / 2 x = min(max(x, visible.minX + margin), visible.maxX - width - margin) @@ -143,7 +147,7 @@ final class StatusPanel: NSPanel { private func availableHeight() -> CGFloat { let visible = (anchorScreen ?? NSScreen.main)?.visibleFrame ?? .zero let top = min(anchor.minY - DS.Size.panelGap, visible.maxY) - return max(DS.Size.panelMinHeight, top - visible.minY - DS.Space.s2) + return max(minHeight, top - visible.minY - DS.Space.s2) } // MARK: 事件 diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift index 5aaf924..0b95a64 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Panel/OverviewPage.swift @@ -154,7 +154,7 @@ private struct HealthHeader: View { // MARK: - 指标卡片 -private func loadLevel(_ value: Double) -> String { +func loadLevel(_ value: Double) -> String { value < 0.3 ? tr("低负载") : value < 0.7 ? tr("中等负载") : tr("高负载") } @@ -368,7 +368,7 @@ private struct BatteryCard: View { .dsFont(.xl, weight: .semibold) .monospacedDigit() .foregroundStyle(DS.Palette.textPrimary) - Text(stateText(battery)) + Text(battery.stateText) .dsFont(.xs, weight: .medium) .foregroundStyle(DS.Palette.textSecondary) if let watts = battery.adapterWatts, battery.isPluggedIn { @@ -393,12 +393,6 @@ private struct BatteryCard: View { } } - private func stateText(_ battery: BatteryStatus) -> String { - if battery.isCharging { return tr("充电中") } - if battery.isPluggedIn { return battery.isFullyCharged ? tr("已充满") : tr("电源供电") } - return tr("电池供电") - } - private func detailText(_ battery: BatteryStatus) -> String { var parts: [String] = [] if let minutes = battery.minutesRemaining { diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift index d5d1049..93e5cae 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/BatteryPopover.swift @@ -59,7 +59,7 @@ private struct BatteryHero: View { } VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { HeroValue(value: Format.percent(battery.level)) - Text(Self.stateText(battery)) + Text(battery.stateText) .dsFont(.xs, weight: .medium) .foregroundStyle(DS.Palette.textSecondary) if let minutes = battery.minutesRemaining { @@ -93,10 +93,13 @@ private struct BatteryHero: View { } } } +} - static func stateText(_ battery: BatteryStatus) -> String { - if battery.isCharging { return tr("充电中") } - if battery.isPluggedIn { return battery.isFullyCharged ? tr("已充满") : tr("电源供电") } +extension BatteryStatus { + /// 充电中 / 已充满 / 电源供电 / 电池供电 + var stateText: String { + if isCharging { return tr("充电中") } + if isPluggedIn { return isFullyCharged ? tr("已充满") : tr("电源供电") } return tr("电池供电") } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift new file mode 100644 index 0000000..94404b8 --- /dev/null +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/CombinedPopover.swift @@ -0,0 +1,322 @@ +import Localization +import Metrics +import SMC +import SwiftUI + +/// 菜单栏合并为一个图标时点击弹出的面板:顶部一排标签切换“总览”与菜单栏里开启的各项。 +/// 总览每项一行(名称、状态、走势、主数值),点一行或点标签看该项的完整详情,与每项独立时的弹窗相同 +struct CombinedPopoverView: View { + @Environment(AppModel.self) private var model + + var body: some View { + let items = model.settings.orderedMenuBarItems + // 标签对应的项目在设置里被关掉后回到总览 + let tab = model.combinedPopoverTab.flatMap { items.contains($0) ? $0 : nil } + + PopoverFrame { + VStack(spacing: DS.Space.s2) { + if !items.isEmpty { + PopoverTabStrip(items: items, selection: tab) { model.combinedPopoverTab = $0 } + } + if let tab { + PopoverHeader(item: tab) + } else { + OverviewPopoverHeader() + } + } + } content: { + if let tab { + PopoverDetail(item: tab).id(tab) + } else { + OverviewPopover(items: items) { model.combinedPopoverTab = $0 } + } + } + } +} + +// MARK: - 标签 + +/// 顶部标签:总览 + 各项,苹果标准分段样式(灰色底槽、各段等宽、选中段品牌蓝),名称在悬停提示里 +private struct PopoverTabStrip: View { + let items: [MenuBarItem] + let selection: MenuBarItem? + let select: (MenuBarItem?) -> Void + @Namespace private var namespace + + private static var radius: CGFloat { DS.Radius.md - DS.Space.s1 / 2 } + + var body: some View { + HStack(spacing: 0) { + segment(nil, symbol: overviewSymbol, title: tr("总览")) + ForEach(items) { item in + segment(item, symbol: item.symbol, title: item.popoverTitle) + } + } + .background(DS.Palette.track, in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous)) + .animation(.spring(response: 0.3, dampingFraction: 0.85), value: selection) + } + + private func segment(_ item: MenuBarItem?, symbol: String, title: String) -> some View { + let selected = item == selection + return Button { select(item) } label: { + Image(systemName: symbol) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) + .foregroundStyle(selected ? DS.Palette.onPrimary : DS.Palette.textSecondary) + .frame(maxWidth: .infinity) + .frame(height: DS.Size.segmentHeight + DS.Space.s1) + .background { + if selected { + RoundedRectangle(cornerRadius: Self.radius, style: .continuous) + .fill(DS.Palette.primary) + .matchedGeometryEffect(id: "selection", in: namespace) + } + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .help(title) + .accessibilityLabel(title) + .accessibilityAddTraits(selected ? .isSelected : []) + } +} + +/// 总览的图标;主窗口“仪表盘”已经用了方格图标,这里换成仪表盘指针以示区别 +private let overviewSymbol = "gauge.with.dots.needle.50percent" + +private struct OverviewPopoverHeader: View { + @Environment(AppModel.self) private var model + + var body: some View { + HStack(spacing: DS.Space.s2) { + Image(systemName: overviewSymbol) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .semibold)) + .foregroundStyle(DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone) + Text(tr("状态总览")) + .dsFont(.base, weight: .semibold) + .foregroundStyle(DS.Palette.textPrimary) + Spacer(minLength: DS.Space.s2) + let page = PanelTab.overview + MiniIconButton(systemName: page.symbol, help: tr("在主窗口打开“\(page.title)”")) { model.openMainWindow(page) } + MiniIconButton(systemName: "gearshape", help: tr("设置")) { model.openSettings() } + } + } +} + +// MARK: - 总览 + +private struct OverviewPopover: View { + let items: [MenuBarItem] + let open: (MenuBarItem) -> Void + @Environment(AppModel.self) private var model + + var body: some View { + if items.isEmpty { + Card { + Text(tr("菜单栏里还没有开启任何项目。")) + .dsFont(.sm) + .foregroundStyle(DS.Palette.textSecondary) + Button(tr("选择显示项目")) { model.openMainWindow(.settingsMenuBar) } + .buttonStyle(DSButtonStyle(kind: .secondary)) + } + } else { + ForEach(items) { item in + OverviewRow(item: item) { open(item) } + } + } + } +} + +/// 总览里的一行:左边名称与一句状态,中间是走势,右边是主数值;点一下进入这一项的详情 +private struct OverviewRow: View { + let item: MenuBarItem + let open: () -> Void + @Environment(AppModel.self) private var model + @State private var hovering = false + + var body: some View { + let reading = OverviewReading(item: item, model: model) + + Card { + Button(action: open) { + HStack(spacing: DS.Space.s2) { + Image(systemName: item.symbol) + .font(.system(size: DS.TextSize.sm.rawValue, weight: .medium)) + .foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textSecondary) + .frame(width: DS.Size.iconStandalone) + VStack(alignment: .leading, spacing: DS.Space.s1 / 2) { + Text(item.popoverTitle) + .dsFont(.sm, weight: .semibold) + .foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textPrimary) + Text(verbatim: reading.detail) + .dsFont(.xs) + .foregroundStyle(DS.Palette.textSecondary) + .monospacedDigit() + } + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + + OverviewChart(chart: reading.chart) + .frame(width: DS.Space.s12, height: DS.Space.s6) + + HStack(alignment: .firstTextBaseline, spacing: DS.Space.s1 / 2) { + Text(verbatim: reading.value) + .dsFont(.base, weight: .semibold) + .foregroundStyle(reading.tone == .neutral ? DS.Palette.textPrimary : reading.tone.color) + if let unit = reading.unit { + Text(verbatim: unit) + .dsFont(.xs, weight: .medium) + .foregroundStyle(DS.Palette.textSecondary) + } + } + .monospacedDigit() + .lineLimit(1) + .frame(minWidth: DS.Space.s12, alignment: .trailing) + + Image(systemName: "chevron.right") + .font(.system(size: DS.TextSize.xs.rawValue, weight: .semibold)) + .foregroundStyle(hovering ? DS.Palette.primary : DS.Palette.textTertiary) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovering = $0 } + .help(tr("查看\(item.popoverTitle)详情")) + } + } +} + +private struct OverviewChart: View { + let chart: OverviewReading.Chart + + var body: some View { + switch chart { + case .bars(let values): + BarHistoryChart(values: values, capacity: 20, height: DS.Space.s6) + case .line(let values, let color): + LineHistoryChart(values: values, capacity: 30, color: color, height: DS.Space.s6) + case .rates(let upload, let download): + DualLineChart(upload: upload, download: download, capacity: 30, height: DS.Space.s6) + case .level(let fraction, let color): + ProgressTrack(fraction: fraction, color: color, height: DS.Space.s2) + .frame(maxHeight: .infinity) + case .none: + Color.clear + } + } +} + +/// 每一项在总览里显示的内容 +private struct OverviewReading { + enum Chart { + case bars([Double]) + case line([Double], Color) + case rates(upload: [Double], download: [Double]) + case level(Double, Color) + case none + } + + var value = "—" + var unit: String? + var detail = "" + var tone = Tone.neutral + var chart = Chart.none + + @MainActor + init(item: MenuBarItem, model: AppModel) { + let store = model.store + let fahrenheit = model.settings.useFahrenheit + let temperature = { (group: TemperatureGroup) in + store.sensors?.temperature(group).map { Format.temperature($0.maximum, fahrenheit: fahrenheit) } + } + + switch item { + case .cpu: + chart = .bars(store.cpuTotal.elements) + if let total = store.cpu?.total { + setPercent(total) + tone = Self.loadTone(total) + detail = [loadLevel(total), temperature(.cpu)].compactMap { $0 }.joined(separator: " · ") + } + case .memory: + chart = .line(store.memoryHistory.elements, DS.Palette.primary) + if let memory = store.memory { + setPercent(memory.usedFraction) + tone = memory.pressure == .critical ? .error : memory.pressure == .warning ? .warning : .neutral + // 压力正常时不占地方,偏高才写出来(数值同时变色) + let usage = "\(Format.bytes(memory.used)) / \(Format.bytes(memory.total))" + detail = memory.pressure == .normal ? usage : tr("压力 \(memory.pressure.title)") + " · " + usage + } + case .network: + chart = .rates(upload: store.uploadHistory.elements, download: store.downloadHistory.elements) + if let rate = store.network { + let total = Format.menuBarRate(rate.uploadBytesPerSecond + rate.downloadBytesPerSecond) + let parts = total.split(separator: " ", maxSplits: 1).map(String.init) + value = parts.first ?? total + unit = parts.count > 1 ? parts[1] : nil + detail = "↑ \(Format.menuBarRate(rate.uploadBytesPerSecond)) ↓ \(Format.menuBarRate(rate.downloadBytesPerSecond))" + } + case .gpu: + chart = .line(store.gpuHistory.elements, DS.Palette.secondary) + if let gpu = store.gpu { + setPercent(gpu.utilization) + tone = Self.loadTone(gpu.utilization) + detail = [loadLevel(gpu.utilization), temperature(.gpu)].compactMap { $0 }.joined(separator: " · ") + } + case .disk: + if let disk = store.disk { + setPercent(disk.usedFraction) + chart = .level(disk.usedFraction, disk.usedFraction > 0.9 ? DS.Palette.warning : DS.Palette.primary) + detail = tr("可用 \(Format.bytes(disk.available, base: .decimal))") + } + case .temperature: + if let cpu = store.sensors?.temperature(.cpu) { + value = Format.temperature(cpu.maximum, fahrenheit: fahrenheit) + tone = Tone.forTemperature(cpu.maximum) == .primary ? .neutral : Tone.forTemperature(cpu.maximum) + chart = .level(cpu.maximum / 100, Tone.forTemperature(cpu.maximum).color) + // 状态行写 CPU 以外温度最高的一组 + let next = (store.sensors?.temperatures ?? []) + .filter { $0.group != .cpu } + .max { $0.maximum < $1.maximum } + detail = next.map { "\($0.group.title) \(Format.temperature($0.maximum, fahrenheit: fahrenheit))" } + ?? tr("CPU 核心最高温度") + } + case .fan: + let fans = store.sensors?.fans ?? [] + if let fastest = store.fastestFan { + value = Int(fastest.current).formatted() + unit = "RPM" + let average = fans.map { $0.maximum > 0 ? $0.current / $0.maximum : 0 }.reduce(0, +) / Double(max(1, fans.count)) + chart = .level(average, DS.Palette.primary) + } + detail = fanStatusText(fans: fans, mode: model.fans.mode) + case .battery: + if let battery = store.battery { + setPercent(battery.level) + tone = battery.level <= 0.2 && !battery.isCharging ? .error : .neutral + chart = .level(battery.level, battery.level <= 0.2 ? DS.Palette.error : battery.isCharging ? DS.Palette.primary : DS.Palette.success) + // 充电中的时间是充满所需,其余是还能用多久,状态已经说明了是哪一种 + detail = [battery.stateText, battery.minutesRemaining.map { Format.duration(minutes: $0) }] + .compactMap { $0 }.joined(separator: " · ") + } else if let lowest = model.bluetooth.lowest { + // 没有电池的 Mac:与菜单栏一致,显示电量最低的蓝牙设备 + value = "\(lowest.percent)" + unit = "%" + tone = lowest.percent <= MenuBarReading.lowBluetoothPercent ? .error : .neutral + chart = .level(Double(lowest.percent) / 100, tone == .error ? DS.Palette.error : DS.Palette.success) + detail = lowest.label.isEmpty ? lowest.device.name : "\(lowest.device.name) · \(lowest.label)" + } else { + detail = tr("没有电池") + } + } + } + + private mutating func setPercent(_ fraction: Double) { + value = "\(Int((fraction * 100).rounded()))" + unit = "%" + } + + private static func loadTone(_ value: Double) -> Tone { + value >= 0.85 ? .error : value >= 0.6 ? .warning : .neutral + } +} diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift index aea470f..75aad24 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Popover/PopoverRootView.swift @@ -6,28 +6,31 @@ import SwiftUI /// 点击菜单栏单个指标弹出的窄详情。区块可在设置中逐个隐藏 struct PopoverRootView: View { let item: MenuBarItem - @Environment(AppModel.self) private var model + + var body: some View { + PopoverFrame { + PopoverHeader(item: item) + } content: { + PopoverDetail(item: item) + } + } +} + +/// 菜单栏弹窗的外框:顶部标题栏固定,下面的内容可滚动 +struct PopoverFrame: View { + @ViewBuilder var header: Header + @ViewBuilder var content: Content @Environment(\.isSnapshot) private var isSnapshot var body: some View { VStack(spacing: 0) { - PopoverHeader(item: item) + header .padding(.horizontal, DS.Space.s3) .padding(.vertical, DS.Space.s2) - PageScroll { - switch item { - case .cpu: CPUPopover() - case .memory: MemoryPopover() - case .network: NetworkPopover() - case .gpu: GPUPopover() - case .disk: DiskPopover() - case .temperature, .fan: ThermalPopover(item: item) - case .battery: BatteryPopover() - } - } - .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) - .environment(\.isPopover, true) + PageScroll { content } + .frame(maxWidth: .infinity, maxHeight: isSnapshot ? nil : .infinity, alignment: .top) + .environment(\.isPopover, true) } .frame(width: DS.Size.popoverWidth) .frame(maxHeight: isSnapshot ? nil : .infinity, alignment: .top) @@ -39,7 +42,24 @@ struct PopoverRootView: View { } } -private struct PopoverHeader: View { +/// 某一项的详情内容,每项独立时的弹窗与合并时的弹窗共用 +struct PopoverDetail: View { + let item: MenuBarItem + + var body: some View { + switch item { + case .cpu: CPUPopover() + case .memory: MemoryPopover() + case .network: NetworkPopover() + case .gpu: GPUPopover() + case .disk: DiskPopover() + case .temperature, .fan: ThermalPopover(item: item) + case .battery: BatteryPopover() + } + } +} + +struct PopoverHeader: View { let item: MenuBarItem @Environment(AppModel.self) private var model diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift index 74202b7..047880a 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Settings/SettingsPages.swift @@ -287,7 +287,7 @@ struct MenuBarSettings: View { SettingRow(title: tr("菜单栏图标"), subtitle: settings.menuBarLayout == .separate ? tr("每个指标一个图标,点击弹出该项详情") - : tr("所有指标合成一个图标,点击打开主窗口")) { + : tr("所有指标合成一个图标,点击弹出状态总览,可切到各项详情")) { SegmentedControl(selection: $settings.menuBarLayout, options: MenuBarLayout.allCases.map { ($0, $0.title) }) .frame(width: DS.Size.sidebarWidth + DS.Space.s6) @@ -329,9 +329,7 @@ struct MenuBarSettings: View { } if settings.isEnabled(item) { ItemStyleRow(item: item) - if settings.menuBarLayout == .separate { - PopoverSectionPicker(item: item) - } + PopoverSectionPicker(item: item) } } } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift index a919548..2c2281b 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/Snapshot/SnapshotRenderer.swift @@ -82,6 +82,10 @@ enum SnapshotRenderer { write(PopoverRootView(item: item), model: model, appearance: appearance, to: outputDirectory.appendingPathComponent("popover-\(item.rawValue)-\(suffix).png")) } + // 合并模式的面板:状态总览 + model.combinedPopoverTab = nil + write(CombinedPopoverView(), model: model, appearance: appearance, + to: outputDirectory.appendingPathComponent("popover-combined-\(suffix).png")) } let menuBar = MenuBarRenderer.image(for: model) diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift index efdd95f..b3a2837 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppModel.swift @@ -34,8 +34,24 @@ public final class AppModel { public var isMainWindowVisible = false /// 被其他应用占用、没能注册的快捷键 public var hotKeyConflicts: Set = [] - /// 当前打开的菜单栏详情弹窗 + /// 当前打开的菜单栏详情弹窗;合并模式的弹窗切到某一项时也是这一项 public var openPopover: MenuBarItem? + /// 合并模式下点击菜单栏图标弹出的面板正在显示。 + /// 面板切到某一项的详情时 openPopover 就是这一项,按那一项的弹窗加采数据;总览只用菜单栏本来就在采的指标 + public var isCombinedPopoverOpen = false { + didSet { + guard isCombinedPopoverOpen != oldValue else { return } + if isCombinedPopoverOpen { + openPopover = combinedPopoverTab + } else if openPopover == combinedPopoverTab { + openPopover = nil + } + } + } + /// 合并模式面板当前的标签:nil 是状态总览,否则是某一项的详情 + public var combinedPopoverTab: MenuBarItem? { + didSet { if isCombinedPopoverOpen { openPopover = combinedPopoverTab } } + } public private(set) var launchAtLoginEnabled = SMAppService.mainApp.status == .enabled public private(set) var launchAtLoginError: String? @@ -75,7 +91,7 @@ public final class AppModel { // 进程页要读全系统进程(启动 ps),每 2 秒刷新一次足够,也更省电 demand.interval = window && tab == .processes && popover == nil ? .seconds(2) - : window || popover != nil ? .seconds(1) : .seconds(settings.refreshSeconds) + : window || popover != nil || isCombinedPopoverOpen ? .seconds(1) : .seconds(settings.refreshSeconds) demand.memory = true demand.network = true let showing = { (page: PanelTab, item: MenuBarItem) in (window && tab == page) || popover == item } diff --git a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift index fb1d41e..cdbdd10 100644 --- a/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift +++ b/Packages/OpenStatsKit/Sources/OpenStatsUI/State/AppSettings.swift @@ -144,7 +144,7 @@ public enum PopoverSection: String, CaseIterable, Identifiable, Sendable { public enum MenuBarLayout: String, CaseIterable, Identifiable, Sendable { /// 每个指标一个图标,点击弹出该项详情 case separate - /// 所有指标合成一个图标,点击打开主窗口 + /// 所有指标合成一个图标,点击弹出状态总览,可切到各项详情 case combined public var id: String { rawValue } diff --git a/README.md b/README.md index b57ea36..8b8fafd 100644 --- a/README.md +++ b/README.md @@ -59,9 +59,18 @@ system language by default; switch it in Settings → General → Language. -Latest release **0.6.0** (2026-09-18) · [full changelog](CHANGELOG.md) (kept in Chinese) +Latest release **0.6.0** (2026-09-18) · **1** changes in development · [full changelog](CHANGELOG.md) (kept in Chinese)
+2026-09-18 · Unreleased · 1 added + +**Added** + +- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。 + +
+ +
2026-09-18 · 0.6.0 · 5 added · 1 style **Added** @@ -92,15 +101,6 @@ Latest release **0.6.0** (2026-09-18) · [full changelog](CHANGELOG.md) (kept in
-
-2026-09-17 · 0.5.0 · 1 changed - -**Changed** - -- 程序坞默认不再显示 OpenStats 图标:以前打开主窗口(比如从风扇弹窗进入温度与风扇页)时,图标会出现在程序坞与 ⌘Tab 里,关窗后才消失。现在和其他菜单栏工具一样始终只在菜单栏运行,主窗口照常打开并置于最前。想从程序坞或 ⌘Tab 切回主窗口的,可以在「设置 · 通用」打开“在程序坞显示图标”,开关立即生效。 - -
- ## Activity diff --git a/README.zh-CN.md b/README.zh-CN.md index b04c325..43e0db3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -55,9 +55,18 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。 -最新版本 **0.6.0**(2026-09-18) · [完整更新日志](CHANGELOG.md) +最新版本 **0.6.0**(2026-09-18) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
+2026-09-18 · 未发布 · 新增 1 + +**新增** + +- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。 + +
+ +
2026-09-18 · 0.6.0 · 新增 5 · 样式 1 **新增** @@ -88,15 +97,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
-
-2026-09-17 · 0.5.0 · 调整 1 - -**调整** - -- 程序坞默认不再显示 OpenStats 图标:以前打开主窗口(比如从风扇弹窗进入温度与风扇页)时,图标会出现在程序坞与 ⌘Tab 里,关窗后才消失。现在和其他菜单栏工具一样始终只在菜单栏运行,主窗口照常打开并置于最前。想从程序坞或 ⌘Tab 切回主窗口的,可以在「设置 · 通用」打开“在程序坞显示图标”,开关立即生效。 - -
- ## 活跃度 diff --git a/project.yml b/project.yml index 2eb1b5c..cfc5b69 100644 --- a/project.yml +++ b/project.yml @@ -13,7 +13,7 @@ settings: SWIFT_VERSION: "6.0" MARKETING_VERSION: "0.6.0" # 构建号四位数字,每次 make build 加一;营销版本只在发布时用 Scripts/version.sh patch / minor 修改 - CURRENT_PROJECT_VERSION: "0100" + CURRENT_PROJECT_VERSION: "0104" MACOSX_DEPLOYMENT_TARGET: "14.0" # 本地开发使用 ad-hoc 签名;发布时改为 Developer ID(见 docs/PLAN.md) CODE_SIGN_STYLE: Manual