菜单栏合并时点击弹出状态总览,可切到各项详情(#2)

- 合并模式点击图标不再直接打开主窗口,改为弹出面板:顶部标签在总览与菜单栏开启的各项之间切换
- 总览每项一行:数值、状态、走势,偏高时变色;点一行进入该项完整详情,与每项独立时的弹窗共用 PopoverDetail
- 切到某一项时 openPopover 跟着切换,采样范围与单项弹窗一致;面板打开期间每秒刷新
- 合并面板最小高度减半,项目少时不留空白;设置里的弹窗区块选项在合并模式下也可用
- 电池状态文案合成 BatteryStatus.stateText;--show-panel overview 用于调试
This commit is contained in:
gentpan committed 2026-09-18 13:59:20 +05:00
1 parent 7095a9022f
commit b5c1a330c7
17 files changed
+500 -71

No files matched your search

+4 -1
View File
@@ -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,
+8
View File
@@ -3,6 +3,14 @@
记录 OpenStats 应用的功能新增、样式调整和问题修复,按版本与日期倒序排列。
官网、构建与发布脚本等不影响应用本身的改动不在此记录。
## 未发布
### 2026-09-18
#### 新增
- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。
## 0.6.0 · 2026-09-18
### 2026-09-18
@@ -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
@@ -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") {
@@ -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 {
@@ -20,11 +20,15 @@ final class StatusPanel: NSPanel {
/// 平铺布局(不含滚动容器)的视图,只用来测量内容的自然高度
private let makeMeasuringContent: () -> NSView
private let width: CGFloat
/// 内容再少也不低于这个高度;合并模式的状态总览项目少时很矮,用更小的下限
private let minHeight: CGFloat
init<Content: View, Measuring: View>(width: CGFloat, content: @escaping () -> Content, measuring: @escaping () -> Measuring) {
init<Content: View, Measuring: View>(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: 事件
@@ -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 {
@@ -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("电池供电")
}
}
@@ -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
}
}
@@ -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<Header: View, Content: View>: 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
@@ -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)
}
}
}
@@ -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)
@@ -34,8 +34,24 @@ public final class AppModel {
public var isMainWindowVisible = false
/// 被其他应用占用、没能注册的快捷键
public var hotKeyConflicts: Set<HotKeyAction> = []
/// 当前打开的菜单栏详情弹窗
/// 当前打开的菜单栏详情弹窗;合并模式的弹窗切到某一项时也是这一项
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 }
@@ -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 }
+10 -10
View File
@@ -59,9 +59,18 @@ system language by default; switch it in Settings → General → Language.
<!-- changelog:start -->
<!-- Generated from CHANGELOG.md by Scripts/sync_changelog.py. Do not edit by hand. -->
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)
<details open>
<summary><b>2026-09-18</b> · Unreleased · 1 added</summary>
**Added**
- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。
</details>
<details>
<summary><b>2026-09-18</b> · 0.6.0 · 5 added · 1 style</summary>
**Added**
@@ -92,15 +101,6 @@ Latest release **0.6.0** (2026-09-18) · [full changelog](CHANGELOG.md) (kept in
</details>
<details>
<summary><b>2026-09-17</b> · 0.5.0 · 1 changed</summary>
**Changed**
- 程序坞默认不再显示 OpenStats 图标:以前打开主窗口(比如从风扇弹窗进入温度与风扇页)时,图标会出现在程序坞与 ⌘Tab 里,关窗后才消失。现在和其他菜单栏工具一样始终只在菜单栏运行,主窗口照常打开并置于最前。想从程序坞或 ⌘Tab 切回主窗口的,可以在「设置 · 通用」打开“在程序坞显示图标”,开关立即生效。
</details>
<!-- changelog:end -->
## Activity
+10 -10
View File
@@ -55,9 +55,18 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
<!-- changelog:start -->
<!-- 由 Scripts/sync_changelog.py 从 CHANGELOG.md 生成,请勿手改。 -->
最新版本 **0.6.0**(2026-09-18) · [完整更新日志](CHANGELOG.md)
最新版本 **0.6.0**(2026-09-18) · 开发中 **1** 项改动尚未发布 · [完整更新日志](CHANGELOG.md)
<details open>
<summary><b>2026-09-18</b> · 未发布 · 新增 1</summary>
**新增**
- 菜单栏“合并为一个”时,点击图标弹出状态总览,不再直接打开主窗口:菜单栏里开启的每一项占一行,写着当前数值、一句状态(负载与温度、已用内存、上下行速度、剩余时间等)和最近的走势,数值偏高时变色;点一行或点顶部的标签,就能看到该项的完整详情,与“每项独立”时点单个图标看到的一样。顶部标签可在总览与各项之间来回切换,面板高度随内容变化;标题栏右侧可以打开主窗口的仪表盘或设置。「设置 · 菜单栏」里各项弹窗显示哪些区块的选项,合并模式下同样可用。
</details>
<details>
<summary><b>2026-09-18</b> · 0.6.0 · 新增 5 · 样式 1</summary>
**新增**
@@ -88,15 +97,6 @@ CPU 不分性能核与能效核,部分功耗与频率读数可能不显示。
</details>
<details>
<summary><b>2026-09-17</b> · 0.5.0 · 调整 1</summary>
**调整**
- 程序坞默认不再显示 OpenStats 图标:以前打开主窗口(比如从风扇弹窗进入温度与风扇页)时,图标会出现在程序坞与 ⌘Tab 里,关窗后才消失。现在和其他菜单栏工具一样始终只在菜单栏运行,主窗口照常打开并置于最前。想从程序坞或 ⌘Tab 切回主窗口的,可以在「设置 · 通用」打开“在程序坞显示图标”,开关立即生效。
</details>
<!-- changelog:end -->
## 活跃度
+1 -1
View File
@@ -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