Skip to content

Commit 1bf1713

Browse files
committed
feat(menubar): show up to 3 quota rings again, with 5h/7d arc swap
Round 2 collapsed the menu bar to a single most-constrained ring per the exposure experiment. Direct product feedback restores multi-provider visibility: the menu bar now shows up to three visible providers ranked by 5-hour usage (most constrained first), each as a 21pt DualRing. DualRing arcs are swapped from the old layout: OUTER now carries the 7-day fraction (dimmer, 0.65 base opacity) and INNER carries the 5-hour fraction (bright base, thickened when near-limit), so the urgent 5h signal sits at the center of the glyph and stays the visually dominant one. 7-day is not dropped — both windows remain visible per provider. - AppDelegate.menuBarProviders: unconditional mostConstrained(count: 3) - statusItemLength: restored pre-round-2 ring formula (26pt/ring, up to 3) - QuotaStore.mostConstrained(in:count:) is now the production path - QuotaStoreTests: keep the ranked-list tests for the production API - Prototype widget-style toggle scaffolding removed entirely
1 parent 4b4d486 commit 1bf1713

4 files changed

Lines changed: 96 additions & 33 deletions

File tree

Sources/Kaji/AppDelegate.swift

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -261,14 +261,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
261261
store.providers.filter { prefs.isVisible($0.id) }
262262
}
263263

264-
/// What the menubar actually draws: every visible ring on the legacy
265-
/// rollback path, or exactly the single most-constrained provider in
266-
/// treatment. Empty means no provider has data yet — StatusItemView then
267-
/// falls back to its placeholder single ring.
264+
/// What the menubar draws: up to three visible providers, most-constrained
265+
/// first (5-hour used fraction). Restores multi-provider visibility that
266+
/// round 2 collapsed to a single ring. Empty means no provider has data
267+
/// yet — StatusItemView then falls back to its placeholder single ring.
268268
private var menuBarProviders: [ProviderView] {
269-
guard !usesLegacyExposure else { return visibleProviders }
270-
guard let leader = QuotaStore.mostConstrained(in: visibleProviders) else { return [] }
271-
return [leader]
269+
QuotaStore.mostConstrained(in: visibleProviders, count: 3)
272270
}
273271

274272
func applicationWillTerminate(_ notification: Notification) {
@@ -385,8 +383,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
385383
}
386384

387385
private var statusItemLength: CGFloat {
388-
let count = max(1, min(4, menuBarProviders.count))
389-
var length = CGFloat(count) * 26 + 6
386+
// 21pt ring + 5pt gap per visible ring (up to 3), + 6pt padding —
387+
// the pre-round-2 multi-ring formula, restored.
388+
let shown = max(1, min(3, menuBarProviders.count))
389+
var length = CGFloat(shown) * 26 + 6
390390
// Compact monospaced `MM:SS` to the right of the rings (~40pt).
391391
if presentedWorkSlotLabel != nil {
392392
length += 40

Sources/Kaji/QuotaStore.swift

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -304,10 +304,20 @@ final class QuotaStore: ObservableObject {
304304
/// The provider closest to its limit among a given set.
305305
/// Providers without five-hour data are skipped; ties go to the earlier one.
306306
static func mostConstrained(in providers: [ProviderView]) -> ProviderView? {
307-
providers.reduce(nil) { best, candidate in
308-
guard let percent = candidate.fiveHourPercent else { return best }
309-
guard let best, let bestPercent = best.fiveHourPercent else { return candidate }
310-
return percent > bestPercent ? candidate : best
311-
}
307+
mostConstrained(in: providers, count: 1).first
308+
}
309+
310+
/// This is what the menubar shows — up to three visible providers sorted
311+
/// by 5-hour used percent descending (stable, so equal percents keep their
312+
/// input order — "ties go to the earlier one"). Providers without five-hour
313+
/// data are skipped; `count` merely caps the result, it never pads it.
314+
static func mostConstrained(in providers: [ProviderView], count: Int) -> [ProviderView] {
315+
let ranked = providers
316+
.filter { $0.fiveHourPercent != nil }
317+
.sorted {
318+
guard let a = $0.fiveHourPercent, let b = $1.fiveHourPercent else { return false }
319+
return a > b
320+
}
321+
return Array(ranked.prefix(max(0, count)))
312322
}
313323
}

Sources/Kaji/StatusItemView.swift

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,12 @@ import KajiCore
44
// MARK: - StatusItemView
55
//
66
// The compact menubar indicator: one concentric DOUBLE ring per visible
7-
// provider, side by side. Each glyph carries the provider's identity AND two
8-
// quota signals at once:
9-
// - CENTER = the provider mark.
10-
// - OUTER arc = the 5-hour window.
11-
// - INNER arc = the 7-day window.
7+
// provider, side by side — up to three, most-constrained first. Each glyph
8+
// carries the provider's identity AND two quota signals at once:
9+
// - CENTER = the provider mark.
10+
// - OUTER arc = the 7-day window (dimmer — supportive context).
11+
// - INNER arc = the 5-hour window (bright, near-limit thickened — the
12+
// actionable number).
1213
// The exact % lives in the popover (click the item); the arc length already
1314
// shows roughly how full each window is.
1415
//
@@ -169,8 +170,11 @@ private struct WorkStatusSlot: View {
169170

170171
// MARK: - DualRing
171172
//
172-
// Two concentric trim arcs (outer 5h, inner 7d) around a center provider logo.
173-
// Sized for the menubar (~21pt).
173+
// Two concentric trim arcs around a center provider logo, sized for the
174+
// menubar (~21pt). OUTER = the 7-day window (dimmer — supportive context);
175+
// INNER = the 5-hour window (bright `base`, thickened when near-limit — the
176+
// actionable number). The 5-hour signal sits at the center of the glyph, so
177+
// the urgent number stays the visually dominant one.
174178
private struct DualRing: View {
175179
let provider: ProviderView?
176180
let showRemaining: Bool
@@ -183,7 +187,7 @@ private struct DualRing: View {
183187
private let gap: CGFloat = 1.3
184188

185189
private var base: Color { scheme == .dark ? .white : .black }
186-
private var innerColor: Color { base.opacity(0.65) }
190+
private var weekColor: Color { base.opacity(0.65) }
187191
private var trackColor: Color { base.opacity(0.22) }
188192

189193
private var fiveFraction: Double {
@@ -198,11 +202,13 @@ private struct DualRing: View {
198202

199203
var body: some View {
200204
ZStack {
201-
ring(inset: 0,
202-
lineWidth: nearLimit ? outerLW + 0.9 : outerLW,
205+
// OUTER = 7-day arc: dimmer, normal width.
206+
ring(inset: 0, lineWidth: outerLW,
207+
fraction: weekFraction, color: weekColor)
208+
// INNER = 5-hour arc: bright, thickened when near-limit.
209+
ring(inset: outerLW + gap,
210+
lineWidth: nearLimit ? innerLW + 0.9 : innerLW,
203211
fraction: fiveFraction, color: base)
204-
ring(inset: outerLW + gap, lineWidth: innerLW,
205-
fraction: weekFraction, color: innerColor)
206212
if let provider {
207213
ProviderLogo(key: provider.id, color: base, size: 9)
208214
}

Tests/KajiTests/QuotaStoreTests.swift

Lines changed: 54 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -41,13 +41,60 @@ final class QuotaStoreTests: XCTestCase {
4141
)
4242
}
4343

44-
func testMostConstrainedReturnsNilWithoutData() {
45-
XCTAssertNil(QuotaStore.mostConstrained(in: []))
46-
XCTAssertNil(
47-
QuotaStore.mostConstrained(in: [
48-
provider("codex", fiveHourPercent: nil),
49-
provider("claude", fiveHourPercent: nil),
50-
])
44+
// MARK: - mostConstrained(in:count:)
45+
46+
func testMostConstrainedCountReturnsRankedList() {
47+
let providers = [
48+
provider("claude", fiveHourPercent: 56),
49+
provider("codex", fiveHourPercent: 82),
50+
provider("ark", fiveHourPercent: nil),
51+
provider("cursor", fiveHourPercent: 63),
52+
]
53+
XCTAssertEqual(
54+
QuotaStore.mostConstrained(in: providers, count: 2).map(\.id),
55+
["codex", "cursor"]
56+
)
57+
// `count` only caps the result — no-data providers are dropped, never padded.
58+
XCTAssertEqual(
59+
QuotaStore.mostConstrained(in: providers, count: 4).map(\.id),
60+
["codex", "cursor", "claude"]
61+
)
62+
}
63+
64+
func testMostConstrainedCountTieBreaksToEarlierProvider() {
65+
let providers = [
66+
provider("codex", fiveHourPercent: 70),
67+
provider("claude", fiveHourPercent: 70),
68+
provider("cursor", fiveHourPercent: 50),
69+
]
70+
XCTAssertEqual(
71+
QuotaStore.mostConstrained(in: providers, count: 2).map(\.id),
72+
["codex", "claude"]
73+
)
74+
}
75+
76+
func testMostConstrainedCountBoundsAndEmpty() {
77+
let providers = [
78+
provider("codex", fiveHourPercent: 82),
79+
provider("claude", fiveHourPercent: 56),
80+
]
81+
XCTAssertEqual(QuotaStore.mostConstrained(in: providers, count: 0), [])
82+
XCTAssertEqual(QuotaStore.mostConstrained(in: [], count: 3), [])
83+
XCTAssertEqual(
84+
QuotaStore.mostConstrained(in: [provider("ark", fiveHourPercent: nil)], count: 3),
85+
[]
86+
)
87+
}
88+
89+
func testMostConstrainedCountOneMatchesLegacySingle() {
90+
let providers = [
91+
provider("claude", fiveHourPercent: 56),
92+
provider("codex", fiveHourPercent: 82),
93+
provider("ark", fiveHourPercent: nil),
94+
]
95+
XCTAssertEqual(
96+
QuotaStore.mostConstrained(in: providers, count: 1).first?.id,
97+
QuotaStore.mostConstrained(in: providers)?.id
5198
)
5299
}
53100
}

0 commit comments

Comments
 (0)