菜单栏合并时点击弹出状态总览,可切到各项详情(#2)
- 合并模式点击图标不再直接打开主窗口,改为弹出面板:顶部标签在总览与菜单栏开启的各项之间切换 - 总览每项一行:数值、状态、走势,偏高时变色;点一行进入该项完整详情,与每项独立时的弹窗共用 PopoverDetail - 切到某一项时 openPopover 跟着切换,采样范围与单项弹窗一致;面板打开期间每秒刷新 - 合并面板最小高度减半,项目少时不留空白;设置里的弹窗区块选项在合并模式下也可用 - 电池状态文案合成 BatteryStatus.stateText;--show-panel overview 用于调试
This commit is contained in:
1 parent
7095a9022f
commit
b5c1a330c7
17 files changed
+500
-71
No files matched your search
+4
-1
@@ -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,
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user