mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-19 18:23:41 +00:00
feat(menubar): optional second row in the macOS menu bar item
The menu bar item could show one figure, so seeing subscription quota and today's local usage at the same time meant opening the popover. This adds an optional second line, off by default, configured from the existing Settings > General > Display section: a "Second row" toggle plus a picker for what it shows. All four metrics are figures the app already computes for the popover, so nothing here adds a fetch or a refresh cadence: quota remaining with its reset countdown for whichever connected provider is nearest its limit (the same provider the flame already tints for), today's all-provider cost, today's total tokens, or running sessions. The rows are one attributed title. A paragraph style clamps text line height but not an attachment, so the inline flame at the text's own point size dragged the title to 24pt and AppKit clipped the second line away; the flame is requested at 8pt and seated back inside the line, which puts every row combination at exactly 20pt inside the standard 22pt menu bar, centred with 1pt of slack above and below. Measured with NSAttributedString.boundingRect and asserted in the tests, since those four numbers only work together. With the setting off, or whenever the chosen metric has no data yet (no connected provider with a quota window, no today payload, a CLI that predates the liveSessions block), the title falls back to the previous single-row composition unchanged: same font, same attributes, no paragraph style. The row formatting is a pure type over the row settings and a plain-value payload snapshot, so it is covered without an AppStore or a status item. This is a deliberately reduced slice of the request. Not included: the layout editor, presets, live preview, a third row, multiple items or aligned columns per row, per-item provider/period/scope, optional labels, decimal precision, reordering and text size. Keeping the formatter pure leaves room for those later without touching the rendering path. Closes #1252
This commit is contained in:
parent
ef86348673
commit
016b8edaef
7 changed files with 826 additions and 14 deletions
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added (macOS)
|
||||
- **The macOS menubar item can show a second line.** Settings → General → Display gains a "Second row" switch, off by default, and a picker for what that line shows: quota remaining with its reset countdown for whichever connected provider is nearest its limit, today's all-provider cost, today's total tokens, or the number of running sessions. Both lines render as one attributed title at 9pt with their line height clamped to 10pt, so the pair fits the standard 22pt menu bar, and the second line hides itself whenever its metric has no data yet, leaving the existing single-row figure exactly as it was. This is a deliberately small first slice of the multi-row layout request: no layout editor, no presets, no live preview, no per-item provider or period scoping. The setting persists as `CodeBurnMenubarSecondRowEnabled` and `CodeBurnMenubarSecondRowMetric` in the app's own defaults domain alongside the existing menubar period, scope and metric keys. (#1252)
|
||||
|
||||
### Fixed
|
||||
- **Copilot live quota works for GitHub Enterprise Cloud enterprises on a `*.ghe.com` host.** Both readers hardcoded `https://api.github.com/copilot_internal/user` and threw away the host their credential came from, so a data-residency enterprise signed in on `<tenant>.ghe.com` could only ever report `available: false` with "Temporarily unavailable". A discovered credential now carries its host — `hosts.json` is keyed by host and newer `apps.json` files key by `<host>:<app id>` — and the request follows it: `api.github.com` for `github.com` and for any rung that carries no host of its own (an app-name `apps.json` key, `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` / `GITHUB_TOKEN`, `gh auth token`, a pasted token), and `https://api.<tenant>.ghe.com/copilot_internal/user` for an enterprise host. The token and the host always come from the same entry, with `github.com` preferred when several hosts are signed in and otherwise the first `.ghe.com` tenant in sorted order; a host neither rule can address, such as a self-hosted GitHub Enterprise Server install, fails with a message naming that host instead of sending the credential to dotcom, and unreachable-host and HTTP failures name the host that was tried. The macOS Settings connection row now says which host answered. (#1286)
|
||||
|
||||
|
|
|
|||
|
|
@ -332,6 +332,15 @@ defaults write org.agentseal.codeburn-menubar CodeBurnMenubarCompact -bool true
|
|||
|
||||
Relaunch the app to apply. To revert: `defaults delete org.agentseal.codeburn-menubar CodeBurnMenubarCompact`.
|
||||
|
||||
**Second row** adds an optional smaller line under the menubar figure. Turn it on in Settings → General → Display and pick what it shows: quota remaining with its reset countdown (for whichever connected provider is nearest its limit), today's cost, today's tokens, or running sessions. It is off by default, and the line hides itself while the chosen metric has no data, so the item falls back to its single-row figure. From Terminal:
|
||||
|
||||
```bash
|
||||
defaults write org.agentseal.codeburn-menubar CodeBurnMenubarSecondRowEnabled -bool true
|
||||
defaults write org.agentseal.codeburn-menubar CodeBurnMenubarSecondRowMetric -string todayCost
|
||||
```
|
||||
|
||||
Allowed metric values are `quotaRemaining`, `todayCost`, `todayTokens`, and `activeSessions`. Relaunch the app to apply external defaults changes.
|
||||
|
||||
**Refresh cadence** is set in Settings under Usage Refresh. Auto (the default) refreshes every 30 seconds on AC power and backs off on battery, in Low Power Mode, and while the display sleeps; fixed 1, 5, or 15 minute cadences and a Manual mode (refresh only when you open the popover or click Refresh Now) are also available. From Terminal:
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -85,6 +85,20 @@ final class AppStore {
|
|||
var displayMetric: DisplayMetric = DisplayMetric(rawValue: UserDefaults.standard.string(forKey: "CodeBurnDisplayMetric") ?? "") ?? .cost {
|
||||
didSet { UserDefaults.standard.set(displayMetric.rawValue, forKey: "CodeBurnDisplayMetric") }
|
||||
}
|
||||
/// Optional second menu-bar line. Off by default, so the status item keeps
|
||||
/// rendering exactly the historical single-row title until asked otherwise.
|
||||
var menubarSecondRowEnabled: Bool = MenubarRowPreferences.load().isSecondRowEnabled {
|
||||
didSet { MenubarRowPreferences.setSecondRowEnabled(menubarSecondRowEnabled) }
|
||||
}
|
||||
var menubarSecondRowMetric: MenubarSecondRowMetric = MenubarRowPreferences.load().secondRowMetric {
|
||||
didSet { MenubarRowPreferences.setSecondRowMetric(menubarSecondRowMetric) }
|
||||
}
|
||||
var menubarRowSettings: MenubarRowSettings {
|
||||
MenubarRowSettings(
|
||||
isSecondRowEnabled: menubarSecondRowEnabled,
|
||||
secondRowMetric: menubarSecondRowMetric
|
||||
)
|
||||
}
|
||||
var dailyBudget: Double = UserDefaults.standard.double(forKey: "CodeBurnDailyBudget") {
|
||||
didSet { UserDefaults.standard.set(dailyBudget, forKey: "CodeBurnDailyBudget") }
|
||||
}
|
||||
|
|
@ -2033,6 +2047,54 @@ final class AppStore {
|
|||
return todayPayload?.current
|
||||
}
|
||||
|
||||
/// Connected providers that report a headline quota window, as plain values.
|
||||
/// Bounded on purpose: the six adapters with a native quota path, plus the
|
||||
/// CodeBurn-owned adapters whose summary has already been fetched. Nothing
|
||||
/// here starts a fetch, so the menu-bar title stays a pure read.
|
||||
var menubarQuotaCandidates: [MenubarQuotaCandidate] {
|
||||
var candidates: [MenubarQuotaCandidate] = []
|
||||
var seen: Set<String> = []
|
||||
|
||||
func append(label: String, summary: QuotaSummary?) {
|
||||
guard let summary,
|
||||
summary.connection == .connected || summary.connection == .stale,
|
||||
let window = summary.headlineWindow,
|
||||
window.percent.isFinite,
|
||||
seen.insert(label).inserted else { return }
|
||||
candidates.append(
|
||||
MenubarQuotaCandidate(
|
||||
label: label,
|
||||
percentUsed: window.percent,
|
||||
resetsAt: window.resetsAt
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
for provider in CapacityDockPreferences.supportedProviders {
|
||||
guard let filter = provider.legacyFilter else { continue }
|
||||
append(label: provider.displayName, summary: quotaSummary(for: filter))
|
||||
}
|
||||
for (id, summary) in capacityDockProviderSummaries {
|
||||
guard let provider = CapacityDockProvider(rawValue: id) else { continue }
|
||||
append(label: provider.displayName, summary: summary)
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
/// Plain-value snapshot the second menu-bar row formats. Reads only figures
|
||||
/// the app already keeps for the popover.
|
||||
var menubarRowSnapshot: MenubarRowSnapshot {
|
||||
let today = capacityDockToday
|
||||
return MenubarRowSnapshot(
|
||||
quota: MenubarQuotaRowSelection.primary(from: menubarQuotaCandidates),
|
||||
todayCost: today?.cost,
|
||||
todayTotalTokens: today.map { $0.inputTokens + $0.outputTokens },
|
||||
activeSessionCount: menubarPayload?.liveSessions?.sessions.count,
|
||||
currencySymbol: CurrencyState.shared.symbol,
|
||||
currencyRate: CurrencyState.shared.rate
|
||||
)
|
||||
}
|
||||
|
||||
/// Today's totals for ONE provider's glance card. The card is provider
|
||||
/// scoped, so the machine-wide `capacityDockToday` block is the wrong number
|
||||
/// in it; `providerDetails` carries the per-provider row.
|
||||
|
|
|
|||
|
|
@ -989,6 +989,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
// currency switch instead of waiting for the next 30s payload tick.
|
||||
_ = self.store.currency
|
||||
_ = self.store.displayMetric
|
||||
// Second-row settings: the title has to re-render the moment the
|
||||
// toggle or its metric changes, not on the next payload tick.
|
||||
_ = self.store.menubarSecondRowEnabled
|
||||
_ = self.store.menubarSecondRowMetric
|
||||
_ = self.store.dailyBudget
|
||||
_ = self.store.dailyTokenBudget
|
||||
// Read the derived flag so the flame re-tints when today's usage
|
||||
|
|
@ -1247,8 +1251,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
/// menubar text point size. With no tint it stays a template image so the system
|
||||
/// auto-adapts to the menu bar; a tint returns a recolored non-template copy for
|
||||
/// the budget/quota warning states.
|
||||
private static func menubarFlameImage(tint: NSColor?) -> NSImage? {
|
||||
let config = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
private static func menubarFlameImage(
|
||||
tint: NSColor?,
|
||||
pointSize: CGFloat = menubarTitleFontSize
|
||||
) -> NSImage? {
|
||||
let config = NSImage.SymbolConfiguration(pointSize: pointSize, weight: .medium)
|
||||
guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(config) else { return nil }
|
||||
guard let tint else {
|
||||
|
|
@ -1265,6 +1272,35 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
return recolored
|
||||
}
|
||||
|
||||
/// Typography for one rendering of the status-item title. `singleRow` holds
|
||||
/// the historical constants, so the off state of the second-row setting
|
||||
/// produces exactly the string this app has always rendered.
|
||||
private struct MenubarTitleStyle {
|
||||
let fontSize: CGFloat
|
||||
let attachmentPointSize: CGFloat
|
||||
let baselineOffset: CGFloat
|
||||
let attachmentVerticalOffset: CGFloat
|
||||
|
||||
static let singleRow = MenubarTitleStyle(
|
||||
fontSize: menubarTitleFontSize,
|
||||
attachmentPointSize: menubarTitleFontSize,
|
||||
baselineOffset: -1.0,
|
||||
attachmentVerticalOffset: -3
|
||||
)
|
||||
static let twoRow = MenubarTitleStyle(
|
||||
fontSize: MenubarRowTypography.twoRowFontSize,
|
||||
attachmentPointSize: MenubarRowTypography.twoRowAttachmentPointSize,
|
||||
baselineOffset: MenubarRowTypography.twoRowBaselineOffset,
|
||||
attachmentVerticalOffset: MenubarRowTypography.twoRowAttachmentVerticalOffset
|
||||
)
|
||||
}
|
||||
|
||||
/// The button cell's single-line state as AppKit handed it to us, captured
|
||||
/// before the two-row path ever touches it so turning the second row back
|
||||
/// off restores the original rendering rather than a guessed default.
|
||||
private var defaultTitleUsesSingleLineMode: Bool?
|
||||
private var defaultTitleWraps: Bool?
|
||||
|
||||
private func refreshStatusButton() {
|
||||
guard let button = statusItem.button else { return }
|
||||
// Skip while the popover is anchored to this button. Rewriting the
|
||||
|
|
@ -1279,7 +1315,55 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
button.image = nil
|
||||
button.imagePosition = .noImage
|
||||
|
||||
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .regular)
|
||||
// nil whenever the setting is off or the chosen metric has no data, in
|
||||
// which case the title falls back to the single-row composition.
|
||||
let secondRow = MenubarRowFormatter.secondRow(
|
||||
settings: store.menubarRowSettings,
|
||||
snapshot: store.menubarRowSnapshot
|
||||
)
|
||||
applyTitleLineMode(to: button, multiline: secondRow != nil)
|
||||
button.attributedTitle = composeStatusTitle(
|
||||
style: secondRow == nil ? .singleRow : .twoRow,
|
||||
secondRow: secondRow
|
||||
)
|
||||
|
||||
let menubarPeriod = store.menubarPeriod
|
||||
if let shortfall = store.menubarBadgeDeviceShortfall {
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting"
|
||||
} else {
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
|
||||
}
|
||||
|
||||
persistBadgeStatusFile()
|
||||
}
|
||||
|
||||
/// A multi-line attributed title only renders once the cell stops forcing a
|
||||
/// single line. Both states are applied explicitly so toggling the setting
|
||||
/// at runtime never leaves the cell in the other mode.
|
||||
private func applyTitleLineMode(to button: NSStatusBarButton, multiline: Bool) {
|
||||
guard let cell = button.cell else { return }
|
||||
if defaultTitleUsesSingleLineMode == nil {
|
||||
defaultTitleUsesSingleLineMode = cell.usesSingleLineMode
|
||||
defaultTitleWraps = cell.wraps
|
||||
}
|
||||
if multiline {
|
||||
cell.usesSingleLineMode = false
|
||||
cell.wraps = true
|
||||
} else {
|
||||
cell.usesSingleLineMode = defaultTitleUsesSingleLineMode ?? false
|
||||
cell.wraps = defaultTitleWraps ?? false
|
||||
}
|
||||
}
|
||||
|
||||
/// Composes the status-item title. With `secondRow` nil this is the original
|
||||
/// single-row string, byte for byte; with a second row it renders the same
|
||||
/// first line at the two-row point size and appends the extra line under a
|
||||
/// paragraph style that clamps both lines into the menu bar's height.
|
||||
private func composeStatusTitle(
|
||||
style: MenubarTitleStyle,
|
||||
secondRow: String?
|
||||
) -> NSAttributedString {
|
||||
let font = NSFont.monospacedDigitSystemFont(ofSize: style.fontSize, weight: .regular)
|
||||
// Tint the flame based on the worst-affected connected provider's quota.
|
||||
// Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
|
||||
// warning/critical/danger override with a fixed palette color so the
|
||||
|
|
@ -1289,12 +1373,17 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
if tint == nil, store.isOverDailyBudget {
|
||||
tint = NSColor.systemYellow
|
||||
}
|
||||
let flame = Self.menubarFlameImage(tint: tint)
|
||||
let flame = Self.menubarFlameImage(tint: tint, pointSize: style.attachmentPointSize)
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = flame
|
||||
if let size = flame?.size {
|
||||
attachment.bounds = CGRect(x: 0, y: -3, width: size.width, height: size.height)
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: style.attachmentVerticalOffset,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
)
|
||||
}
|
||||
|
||||
let menubarPeriod = store.menubarPeriod
|
||||
|
|
@ -1334,7 +1423,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
: " " + (cost?.asCompactCurrency() ?? fallback) + suffix
|
||||
}
|
||||
|
||||
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: -1.0]
|
||||
var textAttrs: [NSAttributedString.Key: Any] = [.font: font, .baselineOffset: style.baselineOffset]
|
||||
if !hasPayload {
|
||||
textAttrs[.foregroundColor] = NSColor.secondaryLabelColor
|
||||
}
|
||||
|
|
@ -1347,21 +1436,38 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
let marker = " · \(shortfall.reachable)/\(shortfall.total)"
|
||||
let markerAttrs: [NSAttributedString.Key: Any] = [
|
||||
.font: font,
|
||||
.baselineOffset: -1.0,
|
||||
.baselineOffset: style.baselineOffset,
|
||||
.foregroundColor: NSColor.secondaryLabelColor,
|
||||
]
|
||||
composed.append(NSAttributedString(string: marker, attributes: markerAttrs))
|
||||
}
|
||||
}
|
||||
|
||||
button.attributedTitle = composed
|
||||
if let shortfall = store.menubarBadgeDeviceShortfall {
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel) · \(shortfall.reachable) of \(shortfall.total) devices reporting"
|
||||
} else {
|
||||
button.toolTip = "CodeBurn \(menubarPeriod.menubarMetricLabel)"
|
||||
}
|
||||
guard let secondRow else { return composed }
|
||||
|
||||
persistBadgeStatusFile()
|
||||
var secondRowAttrs: [NSAttributedString.Key: Any] = [.font: font]
|
||||
if style.baselineOffset != 0 {
|
||||
secondRowAttrs[.baselineOffset] = style.baselineOffset
|
||||
}
|
||||
composed.append(NSAttributedString(string: "\n" + secondRow, attributes: secondRowAttrs))
|
||||
|
||||
// Centre both lines against each other and clamp their height: the
|
||||
// status item is only as tall as the menu bar, and an unclamped 9pt
|
||||
// line box (with its natural leading) pushes the pair past 22pt, which
|
||||
// AppKit resolves by clipping the second line away.
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = .center
|
||||
paragraph.lineBreakMode = .byClipping
|
||||
paragraph.lineSpacing = 0
|
||||
paragraph.paragraphSpacing = 0
|
||||
paragraph.minimumLineHeight = MenubarRowTypography.twoRowLineHeight
|
||||
paragraph.maximumLineHeight = MenubarRowTypography.twoRowLineHeight
|
||||
composed.addAttribute(
|
||||
.paragraphStyle,
|
||||
value: paragraph,
|
||||
range: NSRange(location: 0, length: composed.length)
|
||||
)
|
||||
return composed
|
||||
}
|
||||
|
||||
private var lastWrittenBadgeGenerated: String?
|
||||
|
|
|
|||
246
mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift
Normal file
246
mac/Sources/CodeBurnMenubar/MenubarSecondRow.swift
Normal file
|
|
@ -0,0 +1,246 @@
|
|||
import Foundation
|
||||
import CoreGraphics
|
||||
|
||||
/// What the optional second menu-bar row shows. Every case is a figure the app
|
||||
/// already computes for the popover — the second row only re-renders it, so no
|
||||
/// case can introduce a new fetch or a new refresh cadence.
|
||||
enum MenubarSecondRowMetric: String, CaseIterable, Identifiable, Sendable {
|
||||
/// Quota remaining plus reset countdown for the primary connected provider.
|
||||
case quotaRemaining
|
||||
/// Today's all-provider cost.
|
||||
case todayCost
|
||||
/// Today's all-provider input + output tokens.
|
||||
case todayTokens
|
||||
/// Sessions the CLI reported as live in its liveness window.
|
||||
case activeSessions
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
/// Settings picker label.
|
||||
var settingsLabel: String {
|
||||
switch self {
|
||||
case .quotaRemaining: "Quota remaining"
|
||||
case .todayCost: "Today's cost"
|
||||
case .todayTokens: "Today's tokens"
|
||||
case .activeSessions: "Active sessions"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The persisted row configuration. The first row is never configurable here:
|
||||
/// it stays the existing Metric/Period/Scope badge, so an off setting leaves
|
||||
/// the historical single-row title untouched.
|
||||
struct MenubarRowSettings: Equatable, Sendable {
|
||||
var isSecondRowEnabled: Bool
|
||||
var secondRowMetric: MenubarSecondRowMetric
|
||||
|
||||
static let `default` = MenubarRowSettings(
|
||||
isSecondRowEnabled: false,
|
||||
secondRowMetric: .quotaRemaining
|
||||
)
|
||||
|
||||
init(
|
||||
isSecondRowEnabled: Bool = false,
|
||||
secondRowMetric: MenubarSecondRowMetric = .quotaRemaining
|
||||
) {
|
||||
self.isSecondRowEnabled = isSecondRowEnabled
|
||||
self.secondRowMetric = secondRowMetric
|
||||
}
|
||||
}
|
||||
|
||||
/// UserDefaults storage for the row settings, in the same shape as the other
|
||||
/// menubar preferences (`CodeBurnMenubarPeriod`, `CodeBurnMenubarScope`,
|
||||
/// `CodeBurnDisplayMetric`): a string-keyed value in the app's own domain, read
|
||||
/// through a small loader so an unknown stored value degrades to the default
|
||||
/// rather than failing.
|
||||
enum MenubarRowPreferences {
|
||||
static let secondRowEnabledKey = "CodeBurnMenubarSecondRowEnabled"
|
||||
static let secondRowMetricKey = "CodeBurnMenubarSecondRowMetric"
|
||||
|
||||
static func load(defaults: UserDefaults = .standard) -> MenubarRowSettings {
|
||||
MenubarRowSettings(
|
||||
isSecondRowEnabled: defaults.bool(forKey: secondRowEnabledKey),
|
||||
secondRowMetric: defaults.string(forKey: secondRowMetricKey)
|
||||
.flatMap(MenubarSecondRowMetric.init(rawValue:))
|
||||
?? MenubarRowSettings.default.secondRowMetric
|
||||
)
|
||||
}
|
||||
|
||||
static func setSecondRowEnabled(_ enabled: Bool, defaults: UserDefaults = .standard) {
|
||||
defaults.set(enabled, forKey: secondRowEnabledKey)
|
||||
}
|
||||
|
||||
static func setSecondRowMetric(
|
||||
_ metric: MenubarSecondRowMetric,
|
||||
defaults: UserDefaults = .standard
|
||||
) {
|
||||
defaults.set(metric.rawValue, forKey: secondRowMetricKey)
|
||||
}
|
||||
}
|
||||
|
||||
/// One connected provider's headline quota window, reduced to plain values so
|
||||
/// the row selection and formatting stay testable without an AppStore.
|
||||
struct MenubarQuotaCandidate: Equatable, Sendable {
|
||||
let label: String
|
||||
/// Fraction of the window consumed, 0...1.
|
||||
let percentUsed: Double
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
enum MenubarQuotaRowSelection {
|
||||
/// The "primary" connected provider for the second row: the one nearest its
|
||||
/// limit, which is the same provider the menu-bar flame already tints for.
|
||||
/// Ties break on label so the row does not flip between equal providers
|
||||
/// from one refresh to the next.
|
||||
static func primary(from candidates: [MenubarQuotaCandidate]) -> MenubarQuotaCandidate? {
|
||||
candidates
|
||||
.sorted { lhs, rhs in
|
||||
if lhs.percentUsed != rhs.percentUsed { return lhs.percentUsed > rhs.percentUsed }
|
||||
return lhs.label < rhs.label
|
||||
}
|
||||
.first
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the second row can read, captured as plain values. A nil field
|
||||
/// means "no data" for that metric and makes the row degrade to one line; it
|
||||
/// never renders as a zero.
|
||||
struct MenubarRowSnapshot: Equatable, Sendable {
|
||||
var quota: MenubarQuotaCandidate?
|
||||
var todayCost: Double?
|
||||
var todayTotalTokens: Int?
|
||||
/// Nil when the payload carries no live-session block at all (a CLI that
|
||||
/// predates it), which is unknown rather than "nothing running".
|
||||
var activeSessionCount: Int?
|
||||
var currencySymbol: String
|
||||
var currencyRate: Double
|
||||
|
||||
init(
|
||||
quota: MenubarQuotaCandidate? = nil,
|
||||
todayCost: Double? = nil,
|
||||
todayTotalTokens: Int? = nil,
|
||||
activeSessionCount: Int? = nil,
|
||||
currencySymbol: String = "$",
|
||||
currencyRate: Double = 1
|
||||
) {
|
||||
self.quota = quota
|
||||
self.todayCost = todayCost
|
||||
self.todayTotalTokens = todayTotalTokens
|
||||
self.activeSessionCount = activeSessionCount
|
||||
self.currencySymbol = currencySymbol
|
||||
self.currencyRate = currencyRate
|
||||
}
|
||||
}
|
||||
|
||||
/// Pure composition of the menu-bar rows. Returns one string when the second
|
||||
/// row is off or its metric has no data, two when it has something to say.
|
||||
enum MenubarRowFormatter {
|
||||
static func rows(
|
||||
firstRow: String,
|
||||
settings: MenubarRowSettings,
|
||||
snapshot: MenubarRowSnapshot,
|
||||
now: Date = Date()
|
||||
) -> [String] {
|
||||
guard let second = secondRow(settings: settings, snapshot: snapshot, now: now) else {
|
||||
return [firstRow]
|
||||
}
|
||||
return [firstRow, second]
|
||||
}
|
||||
|
||||
/// The second row's text, or nil when the setting is off or the chosen
|
||||
/// metric has no data yet.
|
||||
static func secondRow(
|
||||
settings: MenubarRowSettings,
|
||||
snapshot: MenubarRowSnapshot,
|
||||
now: Date = Date()
|
||||
) -> String? {
|
||||
guard settings.isSecondRowEnabled else { return nil }
|
||||
switch settings.secondRowMetric {
|
||||
case .quotaRemaining:
|
||||
return quotaRow(snapshot.quota, now: now)
|
||||
case .todayCost:
|
||||
guard let cost = snapshot.todayCost, cost.isFinite else { return nil }
|
||||
let converted = cost * snapshot.currencyRate
|
||||
return String(format: "\(snapshot.currencySymbol)%.2f today", converted)
|
||||
case .todayTokens:
|
||||
guard let tokens = snapshot.todayTotalTokens else { return nil }
|
||||
return "\(compactTokens(Double(tokens))) tok today"
|
||||
case .activeSessions:
|
||||
guard let count = snapshot.activeSessionCount else { return nil }
|
||||
// Live sessions are identity-derived, so the exact phrasing applies.
|
||||
return SessionCountLabel.compact(sessions: count, basis: "identity")
|
||||
}
|
||||
}
|
||||
|
||||
/// "Claude 42% left · 3h 12m". The countdown is dropped when the provider
|
||||
/// reports no reset instant; the whole row is dropped when there is no
|
||||
/// usable percentage.
|
||||
private static func quotaRow(_ quota: MenubarQuotaCandidate?, now: Date) -> String? {
|
||||
guard let quota, quota.percentUsed.isFinite else { return nil }
|
||||
let remaining = min(max(1 - quota.percentUsed, 0), 1)
|
||||
let percent = Int((remaining * 100).rounded())
|
||||
var row = quota.label.isEmpty ? "\(percent)% left" : "\(quota.label) \(percent)% left"
|
||||
if let countdown = resetCountdown(quota.resetsAt, now: now) {
|
||||
row += " · \(countdown)"
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
/// Same shape as the popover's quota rows (`QuotaSummary.Window.resetsInLabel`),
|
||||
/// with an injectable clock so the row is testable.
|
||||
static func resetCountdown(_ resetsAt: Date?, now: Date) -> String? {
|
||||
guard let resetsAt else { return nil }
|
||||
let seconds = max(0, resetsAt.timeIntervalSince(now))
|
||||
if seconds < 60 { return "now" }
|
||||
let minutes = Int(seconds / 60)
|
||||
let hours = minutes / 60
|
||||
let days = hours / 24
|
||||
if days > 0 { return "\(days)d \(hours % 24)h" }
|
||||
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
/// Menu-bar token shorthand, matching `Double.asCompactTokens()`. Kept here
|
||||
/// so the formatter stays free of the main actor.
|
||||
static func compactTokens(_ n: Double) -> String {
|
||||
if n >= 1_000_000_000 { return String(format: "%.1fB", n / 1_000_000_000) }
|
||||
if n >= 1_000_000 { return String(format: "%.1fM", n / 1_000_000) }
|
||||
if n >= 1_000 { return String(format: "%.0fK", n / 1_000) }
|
||||
return String(format: "%.0f", n)
|
||||
}
|
||||
}
|
||||
|
||||
/// Geometry for the two-line title. The menu bar is 22pt on every standard
|
||||
/// display (`NSStatusBar.system.thickness`), so the two lines get 10pt each and
|
||||
/// the remaining 2pt is the slack AppKit's button cell centres the block in.
|
||||
///
|
||||
/// The inline flame has to shrink too, and by more than the text does: a
|
||||
/// paragraph style clamps *text* line height but not an attachment, so a flame
|
||||
/// whose image is taller than the clamp drags line one — and the whole title —
|
||||
/// past the menu bar. These numbers were measured with
|
||||
/// `NSAttributedString.boundingRect` over every first-row and second-row
|
||||
/// combination the app renders; all of them come out at exactly
|
||||
/// `twoRowMeasuredHeight`. Raising the font size, the line height or the flame's
|
||||
/// point size breaks that, so change them together and re-measure.
|
||||
enum MenubarRowTypography {
|
||||
/// The historical single-row text size, for reference in tests.
|
||||
static let singleRowFontSize: CGFloat = 13
|
||||
static let twoRowFontSize: CGFloat = 9
|
||||
static let twoRowLineHeight: CGFloat = 10
|
||||
static let standardMenuBarThickness: CGFloat = 22
|
||||
static let twoRowBaselineOffset: CGFloat = 0
|
||||
/// 8pt renders a 10pt-tall flame, which the -3pt offset seats inside the
|
||||
/// clamped first line instead of pushing it taller.
|
||||
static let twoRowAttachmentPointSize: CGFloat = 8
|
||||
static let twoRowAttachmentVerticalOffset: CGFloat = -3
|
||||
|
||||
static var twoRowTextHeight: CGFloat { twoRowLineHeight * 2 }
|
||||
|
||||
/// The height AppKit actually lays the two rows out at.
|
||||
static let twoRowMeasuredHeight: CGFloat = 20
|
||||
|
||||
/// True when both clamped lines fit inside a menu bar of this thickness.
|
||||
static func fitsMenuBar(thickness: CGFloat = standardMenuBarThickness) -> Bool {
|
||||
thickness > 0 && twoRowMeasuredHeight <= thickness
|
||||
}
|
||||
}
|
||||
|
|
@ -457,6 +457,26 @@ private struct GeneralSettingsTab: View {
|
|||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
// Optional second menu-bar line. Off by default, so the status
|
||||
// item keeps its existing single-row figure untouched.
|
||||
Toggle("Second row", isOn: Binding(
|
||||
get: { store.menubarSecondRowEnabled },
|
||||
set: { store.menubarSecondRowEnabled = $0 }
|
||||
))
|
||||
if store.menubarSecondRowEnabled {
|
||||
Picker("Second row shows", selection: Binding(
|
||||
get: { store.menubarSecondRowMetric },
|
||||
set: { store.menubarSecondRowMetric = $0 }
|
||||
)) {
|
||||
ForEach(MenubarSecondRowMetric.allCases) { metric in
|
||||
Text(metric.settingsLabel).tag(metric)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
Text("Adds a smaller second line under the menubar figure. Quota remaining tracks whichever connected provider is nearest its limit. The line hides itself while the chosen metric has no data.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Picker("Accent", selection: Binding(
|
||||
get: { store.accentPreset },
|
||||
set: { store.accentPreset = $0 }
|
||||
|
|
|
|||
366
mac/Tests/CodeBurnMenubarTests/MenubarSecondRowTests.swift
Normal file
366
mac/Tests/CodeBurnMenubarTests/MenubarSecondRowTests.swift
Normal file
|
|
@ -0,0 +1,366 @@
|
|||
import AppKit
|
||||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("Menubar second row")
|
||||
struct MenubarSecondRowTests {
|
||||
private let now = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
|
||||
private func snapshot(
|
||||
quota: MenubarQuotaCandidate? = nil,
|
||||
todayCost: Double? = nil,
|
||||
todayTotalTokens: Int? = nil,
|
||||
activeSessionCount: Int? = nil,
|
||||
currencySymbol: String = "$",
|
||||
currencyRate: Double = 1
|
||||
) -> MenubarRowSnapshot {
|
||||
MenubarRowSnapshot(
|
||||
quota: quota,
|
||||
todayCost: todayCost,
|
||||
todayTotalTokens: todayTotalTokens,
|
||||
activeSessionCount: activeSessionCount,
|
||||
currencySymbol: currencySymbol,
|
||||
currencyRate: currencyRate
|
||||
)
|
||||
}
|
||||
|
||||
private func settings(
|
||||
_ enabled: Bool,
|
||||
_ metric: MenubarSecondRowMetric = .quotaRemaining
|
||||
) -> MenubarRowSettings {
|
||||
MenubarRowSettings(isSecondRowEnabled: enabled, secondRowMetric: metric)
|
||||
}
|
||||
|
||||
// MARK: - Off state
|
||||
|
||||
@Test("off state returns the first row alone, whatever the snapshot holds")
|
||||
func offStateRendersOneRow() {
|
||||
let full = snapshot(
|
||||
quota: MenubarQuotaCandidate(label: "Claude", percentUsed: 0.4, resetsAt: now.addingTimeInterval(3600)),
|
||||
todayCost: 12.34,
|
||||
todayTotalTokens: 1_500_000,
|
||||
activeSessionCount: 3
|
||||
)
|
||||
for metric in MenubarSecondRowMetric.allCases {
|
||||
let off = settings(false, metric)
|
||||
#expect(MenubarRowFormatter.secondRow(settings: off, snapshot: full, now: now) == nil)
|
||||
#expect(MenubarRowFormatter.rows(firstRow: "$12.34", settings: off, snapshot: full, now: now) == ["$12.34"])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("default settings are off and never produce a second row")
|
||||
func defaultSettingsAreOff() {
|
||||
#expect(MenubarRowSettings.default.isSecondRowEnabled == false)
|
||||
#expect(MenubarRowSettings().isSecondRowEnabled == false)
|
||||
#expect(MenubarRowSettings.default.secondRowMetric == .quotaRemaining)
|
||||
#expect(
|
||||
MenubarRowFormatter.rows(
|
||||
firstRow: "$1.00",
|
||||
settings: .default,
|
||||
snapshot: snapshot(todayCost: 1, activeSessionCount: 2),
|
||||
now: now
|
||||
) == ["$1.00"]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Quota remaining
|
||||
|
||||
@Test("quota row pairs remaining percent with the reset countdown")
|
||||
func quotaRowRendersRemainingAndCountdown() {
|
||||
let rows = MenubarRowFormatter.rows(
|
||||
firstRow: "$12.34",
|
||||
settings: settings(true, .quotaRemaining),
|
||||
snapshot: snapshot(
|
||||
quota: MenubarQuotaCandidate(
|
||||
label: "Claude",
|
||||
percentUsed: 0.58,
|
||||
resetsAt: now.addingTimeInterval(3 * 3600 + 12 * 60)
|
||||
)
|
||||
),
|
||||
now: now
|
||||
)
|
||||
#expect(rows == ["$12.34", "Claude 42% left · 3h 12m"])
|
||||
}
|
||||
|
||||
@Test("quota row drops the countdown when the provider reports no reset")
|
||||
func quotaRowWithoutReset() {
|
||||
let row = MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .quotaRemaining),
|
||||
snapshot: snapshot(
|
||||
quota: MenubarQuotaCandidate(label: "Codex", percentUsed: 0.0, resetsAt: nil)
|
||||
),
|
||||
now: now
|
||||
)
|
||||
#expect(row == "Codex 100% left")
|
||||
}
|
||||
|
||||
@Test("quota row clamps an over-limit window to zero remaining")
|
||||
func quotaRowClampsOverLimit() {
|
||||
let row = MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .quotaRemaining),
|
||||
snapshot: snapshot(
|
||||
quota: MenubarQuotaCandidate(label: "Gemini", percentUsed: 1.4, resetsAt: nil)
|
||||
),
|
||||
now: now
|
||||
)
|
||||
#expect(row == "Gemini 0% left")
|
||||
}
|
||||
|
||||
@Test("no connected provider quota degrades to one line")
|
||||
func quotaRowUnavailable() {
|
||||
let settings = settings(true, .quotaRemaining)
|
||||
#expect(MenubarRowFormatter.secondRow(settings: settings, snapshot: snapshot(), now: now) == nil)
|
||||
#expect(
|
||||
MenubarRowFormatter.rows(
|
||||
firstRow: "$12.34",
|
||||
settings: settings,
|
||||
snapshot: snapshot(),
|
||||
now: now
|
||||
) == ["$12.34"]
|
||||
)
|
||||
}
|
||||
|
||||
@Test("reset countdown uses the same shape as the popover quota rows")
|
||||
func resetCountdownShape() {
|
||||
#expect(MenubarRowFormatter.resetCountdown(nil, now: now) == nil)
|
||||
#expect(MenubarRowFormatter.resetCountdown(now.addingTimeInterval(30), now: now) == "now")
|
||||
#expect(MenubarRowFormatter.resetCountdown(now.addingTimeInterval(-600), now: now) == "now")
|
||||
#expect(MenubarRowFormatter.resetCountdown(now.addingTimeInterval(45 * 60), now: now) == "45m")
|
||||
#expect(MenubarRowFormatter.resetCountdown(now.addingTimeInterval(2 * 3600 + 11 * 60), now: now) == "2h 11m")
|
||||
#expect(MenubarRowFormatter.resetCountdown(now.addingTimeInterval(3 * 86400 + 14 * 3600), now: now) == "3d 14h")
|
||||
}
|
||||
|
||||
// MARK: - Today's cost
|
||||
|
||||
@Test("today cost row formats in the display currency")
|
||||
func todayCostRow() {
|
||||
#expect(
|
||||
MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .todayCost),
|
||||
snapshot: snapshot(todayCost: 12.3456),
|
||||
now: now
|
||||
) == "$12.35 today"
|
||||
)
|
||||
#expect(
|
||||
MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .todayCost),
|
||||
snapshot: snapshot(todayCost: 10, currencySymbol: "€", currencyRate: 0.9),
|
||||
now: now
|
||||
) == "€9.00 today"
|
||||
)
|
||||
#expect(
|
||||
MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .todayCost),
|
||||
snapshot: snapshot(todayCost: 0),
|
||||
now: now
|
||||
) == "$0.00 today"
|
||||
)
|
||||
}
|
||||
|
||||
@Test("today cost row degrades to one line before the payload lands")
|
||||
func todayCostRowUnavailable() {
|
||||
#expect(
|
||||
MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .todayCost),
|
||||
snapshot: snapshot(),
|
||||
now: now
|
||||
) == nil
|
||||
)
|
||||
#expect(
|
||||
MenubarRowFormatter.secondRow(
|
||||
settings: settings(true, .todayCost),
|
||||
snapshot: snapshot(todayCost: .nan),
|
||||
now: now
|
||||
) == nil
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Today's tokens
|
||||
|
||||
@Test("today tokens row uses the menubar token shorthand")
|
||||
func todayTokensRow() {
|
||||
let enabled = settings(true, .todayTokens)
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(todayTotalTokens: 940), now: now) == "940 tok today")
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(todayTotalTokens: 12_400), now: now) == "12K tok today")
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(todayTotalTokens: 1_540_000), now: now) == "1.5M tok today")
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(todayTotalTokens: 0), now: now) == "0 tok today")
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(), now: now) == nil)
|
||||
}
|
||||
|
||||
@Test("token shorthand matches the badge's own thresholds")
|
||||
func compactTokensThresholds() {
|
||||
#expect(MenubarRowFormatter.compactTokens(999) == "999")
|
||||
#expect(MenubarRowFormatter.compactTokens(1_000) == "1K")
|
||||
#expect(MenubarRowFormatter.compactTokens(1_000_000) == "1.0M")
|
||||
#expect(MenubarRowFormatter.compactTokens(2_500_000_000) == "2.5B")
|
||||
}
|
||||
|
||||
// MARK: - Active sessions
|
||||
|
||||
@Test("active sessions row reuses the shared compact session phrasing")
|
||||
func activeSessionsRow() {
|
||||
let enabled = settings(true, .activeSessions)
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(activeSessionCount: 3), now: now) == "3 sess")
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(activeSessionCount: 1), now: now) == "1 sess")
|
||||
#expect(MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(activeSessionCount: 0), now: now) == "0 sess")
|
||||
#expect(
|
||||
MenubarRowFormatter.secondRow(settings: enabled, snapshot: snapshot(activeSessionCount: 4), now: now)
|
||||
== SessionCountLabel.compact(sessions: 4, basis: "identity")
|
||||
)
|
||||
}
|
||||
|
||||
@Test("a CLI with no live-session block degrades to one line")
|
||||
func activeSessionsUnavailable() {
|
||||
#expect(
|
||||
MenubarRowFormatter.rows(
|
||||
firstRow: "$12.34",
|
||||
settings: settings(true, .activeSessions),
|
||||
snapshot: snapshot(todayCost: 12.34),
|
||||
now: now
|
||||
) == ["$12.34"]
|
||||
)
|
||||
}
|
||||
|
||||
// MARK: - Primary provider selection
|
||||
|
||||
@Test("primary quota is the connected provider nearest its limit")
|
||||
func primaryQuotaSelection() {
|
||||
let candidates = [
|
||||
MenubarQuotaCandidate(label: "Claude", percentUsed: 0.42, resetsAt: nil),
|
||||
MenubarQuotaCandidate(label: "Codex", percentUsed: 0.91, resetsAt: nil),
|
||||
MenubarQuotaCandidate(label: "Gemini", percentUsed: 0.12, resetsAt: nil),
|
||||
]
|
||||
#expect(MenubarQuotaRowSelection.primary(from: candidates)?.label == "Codex")
|
||||
#expect(MenubarQuotaRowSelection.primary(from: []) == nil)
|
||||
}
|
||||
|
||||
@Test("equal utilization breaks on label so the row does not flip")
|
||||
func primaryQuotaTieBreak() {
|
||||
let candidates = [
|
||||
MenubarQuotaCandidate(label: "Codex", percentUsed: 0.5, resetsAt: nil),
|
||||
MenubarQuotaCandidate(label: "Claude", percentUsed: 0.5, resetsAt: nil),
|
||||
]
|
||||
#expect(MenubarQuotaRowSelection.primary(from: candidates)?.label == "Claude")
|
||||
#expect(MenubarQuotaRowSelection.primary(from: candidates.reversed())?.label == "Claude")
|
||||
}
|
||||
|
||||
// MARK: - Preferences
|
||||
|
||||
@Test("settings default to off and round-trip through UserDefaults")
|
||||
func preferencesRoundTrip() {
|
||||
let suiteName = "CodeBurnMenubarTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defer { defaults.removePersistentDomain(forName: suiteName) }
|
||||
|
||||
#expect(MenubarRowPreferences.load(defaults: defaults) == MenubarRowSettings.default)
|
||||
|
||||
MenubarRowPreferences.setSecondRowEnabled(true, defaults: defaults)
|
||||
MenubarRowPreferences.setSecondRowMetric(.todayTokens, defaults: defaults)
|
||||
#expect(defaults.bool(forKey: "CodeBurnMenubarSecondRowEnabled"))
|
||||
#expect(defaults.string(forKey: "CodeBurnMenubarSecondRowMetric") == "todayTokens")
|
||||
let loaded = MenubarRowPreferences.load(defaults: defaults)
|
||||
#expect(loaded.isSecondRowEnabled)
|
||||
#expect(loaded.secondRowMetric == .todayTokens)
|
||||
|
||||
// An unknown stored metric (older build, manual `defaults write`) falls
|
||||
// back to the default rather than dropping the setting.
|
||||
defaults.set("bogus", forKey: MenubarRowPreferences.secondRowMetricKey)
|
||||
#expect(MenubarRowPreferences.load(defaults: defaults).secondRowMetric == .quotaRemaining)
|
||||
#expect(MenubarRowPreferences.load(defaults: defaults).isSecondRowEnabled)
|
||||
|
||||
MenubarRowPreferences.setSecondRowEnabled(false, defaults: defaults)
|
||||
#expect(!MenubarRowPreferences.load(defaults: defaults).isSecondRowEnabled)
|
||||
}
|
||||
|
||||
@Test("every metric is reachable from the settings picker with a label")
|
||||
func metricsAreSelectable() {
|
||||
#expect(MenubarSecondRowMetric.allCases == [.quotaRemaining, .todayCost, .todayTokens, .activeSessions])
|
||||
for metric in MenubarSecondRowMetric.allCases {
|
||||
#expect(!metric.settingsLabel.isEmpty)
|
||||
#expect(MenubarSecondRowMetric(rawValue: metric.rawValue) == metric)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Menu bar geometry
|
||||
|
||||
@Test("two clamped lines fit the standard 22pt menu bar")
|
||||
func twoRowsFitMenuBar() {
|
||||
#expect(MenubarRowTypography.standardMenuBarThickness == 22)
|
||||
#expect(MenubarRowTypography.twoRowTextHeight == 20)
|
||||
// The measured layout height must still equal the clamped text height:
|
||||
// anything larger means the inline flame is dragging line one taller.
|
||||
#expect(MenubarRowTypography.twoRowMeasuredHeight == MenubarRowTypography.twoRowTextHeight)
|
||||
#expect(MenubarRowTypography.fitsMenuBar())
|
||||
#expect(MenubarRowTypography.fitsMenuBar(thickness: 20))
|
||||
#expect(!MenubarRowTypography.fitsMenuBar(thickness: 19))
|
||||
#expect(!MenubarRowTypography.fitsMenuBar(thickness: 0))
|
||||
// The two-row text must be smaller than the single-row figure, or the
|
||||
// pair cannot be centred inside the menu bar at all.
|
||||
#expect(MenubarRowTypography.twoRowFontSize < MenubarRowTypography.singleRowFontSize)
|
||||
#expect(MenubarRowTypography.twoRowFontSize <= MenubarRowTypography.twoRowLineHeight)
|
||||
// The flame is clamped by nothing, so it must be requested smaller than
|
||||
// the text and seated back inside the line by a negative offset.
|
||||
#expect(MenubarRowTypography.twoRowAttachmentPointSize < MenubarRowTypography.twoRowFontSize)
|
||||
#expect(MenubarRowTypography.twoRowAttachmentVerticalOffset < 0)
|
||||
}
|
||||
|
||||
@Test("the two rows AppKit lays out measure 20pt for every row combination")
|
||||
func twoRowsMeasureTwentyPoints() {
|
||||
// Same composition the status item renders: an inline flame attachment at
|
||||
// the two-row point size, the badge text, then the second row under a
|
||||
// paragraph style that clamps both line heights.
|
||||
func measuredHeight(first: String, second: String) -> CGFloat {
|
||||
let font = NSFont.monospacedDigitSystemFont(
|
||||
ofSize: MenubarRowTypography.twoRowFontSize,
|
||||
weight: .regular
|
||||
)
|
||||
let configuration = NSImage.SymbolConfiguration(
|
||||
pointSize: MenubarRowTypography.twoRowAttachmentPointSize,
|
||||
weight: .medium
|
||||
)
|
||||
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(configuration)
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = flame
|
||||
if let size = flame?.size {
|
||||
attachment.bounds = CGRect(
|
||||
x: 0,
|
||||
y: MenubarRowTypography.twoRowAttachmentVerticalOffset,
|
||||
width: size.width,
|
||||
height: size.height
|
||||
)
|
||||
}
|
||||
let composed = NSMutableAttributedString()
|
||||
composed.append(NSAttributedString(attachment: attachment))
|
||||
composed.append(NSAttributedString(string: first, attributes: [.font: font]))
|
||||
composed.append(NSAttributedString(string: "\n" + second, attributes: [.font: font]))
|
||||
let paragraph = NSMutableParagraphStyle()
|
||||
paragraph.alignment = .center
|
||||
paragraph.lineBreakMode = .byClipping
|
||||
paragraph.lineSpacing = 0
|
||||
paragraph.paragraphSpacing = 0
|
||||
paragraph.minimumLineHeight = MenubarRowTypography.twoRowLineHeight
|
||||
paragraph.maximumLineHeight = MenubarRowTypography.twoRowLineHeight
|
||||
composed.addAttribute(
|
||||
.paragraphStyle,
|
||||
value: paragraph,
|
||||
range: NSRange(location: 0, length: composed.length)
|
||||
)
|
||||
return composed.boundingRect(
|
||||
with: NSSize(width: 600, height: 200),
|
||||
options: [.usesLineFragmentOrigin, .usesFontLeading]
|
||||
).height
|
||||
}
|
||||
|
||||
let firstRows = [" $12.34", " ↑1.2M ↓340K / wk", " 2.4M / mo", ""]
|
||||
let secondRows = ["Claude 42% left · 3h 12m", "$0.00 today", "12 sess", "1.5M tok today"]
|
||||
for first in firstRows {
|
||||
for second in secondRows {
|
||||
let height = measuredHeight(first: first, second: second)
|
||||
#expect(height == MenubarRowTypography.twoRowMeasuredHeight)
|
||||
#expect(height <= MenubarRowTypography.standardMenuBarThickness)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue