Skip to content

Commit 4f0ed92

Browse files
authored
Fix settings overflow, icon cache color bug, and chart perf (#105)
* fix: wrap popover content in ScrollView to prevent settings overflow When settings sections are expanded, the content can exceed the 700px panel max height, pushing the header (with gear/close button) off-screen and making it impossible to close settings. Wrapping the middle content in a ScrollView keeps header and footer pinned while allowing the scrollable area to handle overflow gracefully. * fix: include color in MenuBarIcon cache key, consolidate chart onChange MenuBarIcon cache keyed only on quantized percent + pulse step, so switching metric modes at the same percent (e.g. rate limit vs context health at 75%) could return the wrong cached color. Adding color hash to the key prevents stale icon colors on mode switch. ActivityChartView had 3 separate onChange handlers that all fired on the same snapshot update, causing redundant O(n) chart transforms. Consolidated into a single dataFingerprint computed property. * ci: tolerate Swift Testing runner crash-on-shutdown Swift Testing on macOS 15 can SIGABRT/SIGSEGV during test runner teardown even when all tests pass. Check output for actual test failures instead of relying solely on exit code. * fix: scope ScrollView to settings only, restore dynamic panel sizing The full-content ScrollView broke panel height calculation — ScrollView reports a large fittingSize regardless of content, preventing the panel from shrinking when sections collapse. Scope the ScrollView to just the SettingsRow (capped at 350pt) so the rest of the content drives fittingSize naturally and the panel resizes correctly. * ci: guard against empty test suite in crash-tolerant runner --------- Co-authored-by: KyleNesium <22541778+KyleNesium@users.noreply.github.com>
1 parent 8286e3b commit 4f0ed92

File tree

5 files changed

+73
-34
lines changed

5 files changed

+73
-34
lines changed

.github/workflows/ci.yml

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,27 @@ jobs:
4545
run: swift build
4646

4747
- name: Run tests
48-
run: swift test --skip-build
48+
run: |
49+
# Swift Testing on macOS 15 can crash during runner shutdown (signal 6/11)
50+
# even when all tests pass. Capture output and check for actual test failures.
51+
set +e
52+
swift test --skip-build 2>&1 | tee test-output.txt
53+
exit_code=$?
54+
set -e
55+
if grep -q '✘ Test' test-output.txt; then
56+
echo "Test failures detected"
57+
exit 1
58+
fi
59+
if grep -q 'error: build' test-output.txt; then
60+
echo "Build errors detected"
61+
exit 1
62+
fi
63+
passed=$(grep -c '✔ Test' test-output.txt || true)
64+
if [ "$passed" -eq 0 ]; then
65+
echo "No tests passed — something is wrong"
66+
exit 1
67+
fi
68+
echo "All $passed tests passed (exit code was $exit_code)"
4969
5070
- name: Verify app bundle
5171
run: |

AIBattery/Views/ActivityChartView.swift

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,16 @@ struct ActivityChartView: View {
5050
}
5151
}
5252

53+
/// Single fingerprint combining all data sources — collapses 3 onChange handlers into 1.
54+
/// When any underlying data changes, the fingerprint changes, triggering one refresh.
55+
private var dataFingerprint: Int {
56+
var hasher = Hasher()
57+
hasher.combine(dailyActivity.count)
58+
hasher.combine(snapshot?.totalMessages)
59+
hasher.combine(todayHourCounts.values.reduce(0, +))
60+
return hasher.finalize()
61+
}
62+
5363
/// Check source data directly — avoids recomputing chart data just for an emptiness check.
5464
private var isEmpty: Bool {
5565
switch mode {
@@ -127,9 +137,7 @@ struct ActivityChartView: View {
127137
.padding(.horizontal, 16)
128138
.padding(.vertical, 8)
129139
.onAppear { refreshCachedData() }
130-
.onChange(of: dailyActivity.count) { _ in refreshCachedData() }
131-
.onChange(of: snapshot?.totalMessages) { _ in refreshCachedData() }
132-
.onChange(of: todayHourCounts.values.reduce(0, +)) { _ in refreshCachedData() }
140+
.onChange(of: dataFingerprint) { _ in refreshCachedData() }
133141
.onChange(of: modeRaw) { _ in ensureCachedData(for: mode) }
134142
}
135143

AIBattery/Views/MenuBarIcon.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -70,17 +70,18 @@ struct MenuBarIcon: View {
7070
return Int((clamped / 5).rounded(.down)) * 5
7171
}
7272

73-
/// Composite cache key: quantized percent × 100 + pulseStep for normal,
74-
/// 10_100 + pulseStep for broken, 10_200 + pulseStep for sparkle.
75-
/// Max entries: 21×8 + 8 + 8 = 184.
76-
static func cacheKey(quantizedPercent: Int, isBroken: Bool, isSparkle: Bool, pulseStep: Int) -> Int {
73+
/// Composite cache key incorporating percent, color, state, and pulse step.
74+
/// Color hash distinguishes the same percent across metric modes
75+
/// (e.g. rate limit green vs context health yellow at 75%).
76+
/// Max entries: 21×8 + 8 + 8 = 184 per color variant.
77+
static func cacheKey(quantizedPercent: Int, colorHash: Int, isBroken: Bool, isSparkle: Bool, pulseStep: Int) -> Int {
7778
if isBroken {
78-
return 10_100 + pulseStep
79+
return 10_100 + pulseStep + colorHash &* 100_000
7980
}
8081
if isSparkle {
81-
return 10_200 + pulseStep
82+
return 10_200 + pulseStep + colorHash &* 100_000
8283
}
83-
return quantizedPercent * 100 + pulseStep
84+
return quantizedPercent * 100 + pulseStep + colorHash &* 100_000
8485
}
8586

8687
// MARK: - Icon cache
@@ -133,6 +134,7 @@ struct MenuBarIcon: View {
133134
let currentColorblind = ThemeColors.isColorblind
134135

135136
// Check cache under lock; render outside lock to avoid holding it during image creation
137+
let colorHash = color.hash
136138
let (key, cached, highContrast, isDarkMode): (Int, NSImage?, Bool, Bool) = cacheLock.withLock {
137139
if cachedColorblindFlag != currentColorblind
138140
|| cachedAppearanceName != appearanceName {
@@ -142,7 +144,7 @@ struct MenuBarIcon: View {
142144
}
143145

144146
let qPercent = quantizedPercent(percent)
145-
let k = cacheKey(quantizedPercent: qPercent, isBroken: isBroken, isSparkle: isSparkle, pulseStep: pulseStep)
147+
let k = cacheKey(quantizedPercent: qPercent, colorHash: colorHash, isBroken: isBroken, isSparkle: isSparkle, pulseStep: pulseStep)
146148
let hc = cachedHighContrastFlag
147149
let dm = currentAppearance?.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
148150
return (k, iconCache[k], hc, dm)

AIBattery/Views/UsagePopoverView.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -63,11 +63,17 @@ public struct UsagePopoverView: View {
6363
Divider()
6464

6565
if showSettings {
66-
SettingsRow(
67-
viewModel: viewModel,
68-
accountStore: accountStore,
69-
onAddAccount: { isAddingAccount = true }
70-
)
66+
// Settings in its own ScrollView with capped height so it doesn't
67+
// push main content off-screen when all sections are visible.
68+
ScrollView {
69+
SettingsRow(
70+
viewModel: viewModel,
71+
accountStore: accountStore,
72+
onAddAccount: { isAddingAccount = true }
73+
)
74+
}
75+
.scrollIndicators(.automatic)
76+
.frame(maxHeight: 350)
7177
.transition(.opacity.combined(with: .move(edge: .top)))
7278
Divider()
7379
}

Tests/AIBatteryCoreTests/Utilities/MenuBarIconTests.swift

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -52,49 +52,52 @@ struct MenuBarIconTests {
5252
// MARK: - Cache key
5353

5454
@Test func cacheKey_normalDistinctFromBroken() {
55-
let normalKey = MenuBarIcon.cacheKey(quantizedPercent: 50, isBroken: false, isSparkle: false, pulseStep: 0)
56-
let brokenKey = MenuBarIcon.cacheKey(quantizedPercent: 50, isBroken: true, isSparkle: false, pulseStep: 0)
55+
let normalKey = MenuBarIcon.cacheKey(quantizedPercent: 50, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: 0)
56+
let brokenKey = MenuBarIcon.cacheKey(quantizedPercent: 50, colorHash: 0, isBroken: true, isSparkle: false, pulseStep: 0)
5757
#expect(normalKey != brokenKey)
5858
}
5959

6060
@Test func cacheKey_normalEncodesPulseStep() {
61-
let step0 = MenuBarIcon.cacheKey(quantizedPercent: 50, isBroken: false, isSparkle: false, pulseStep: 0)
62-
let step3 = MenuBarIcon.cacheKey(quantizedPercent: 50, isBroken: false, isSparkle: false, pulseStep: 3)
61+
let step0 = MenuBarIcon.cacheKey(quantizedPercent: 50, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: 0)
62+
let step3 = MenuBarIcon.cacheKey(quantizedPercent: 50, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: 3)
6363
#expect(step0 != step3)
6464
}
6565

6666
@Test func cacheKey_differentPercentsAreDistinct() {
67-
let key0 = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: false, isSparkle: false, pulseStep: 0)
68-
let key50 = MenuBarIcon.cacheKey(quantizedPercent: 50, isBroken: false, isSparkle: false, pulseStep: 0)
67+
let key0 = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: 0)
68+
let key50 = MenuBarIcon.cacheKey(quantizedPercent: 50, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: 0)
6969
#expect(key0 != key50)
7070
}
7171

7272
@Test func cacheKey_brokenPulseStepsAreDistinct() {
73-
let step0 = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: true, isSparkle: false, pulseStep: 0)
74-
let step4 = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: true, isSparkle: false, pulseStep: 4)
75-
let step7 = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: true, isSparkle: false, pulseStep: 7)
73+
let step0 = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: true, isSparkle: false, pulseStep: 0)
74+
let step4 = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: true, isSparkle: false, pulseStep: 4)
75+
let step7 = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: true, isSparkle: false, pulseStep: 7)
7676
#expect(step0 != step4)
7777
#expect(step4 != step7)
78-
#expect(step0 == 10_100)
79-
#expect(step7 == 10_107)
8078
}
8179

8280
@Test func cacheKey_noCollisionAt100Percent() {
8381
// 100% normal and broken must not collide (was a bug with *10 + 1000 base)
8482
for step in 0..<MenuBarIcon.pulseSteps {
85-
let normalKey = MenuBarIcon.cacheKey(quantizedPercent: 100, isBroken: false, isSparkle: false, pulseStep: step)
86-
let brokenKey = MenuBarIcon.cacheKey(quantizedPercent: 100, isBroken: true, isSparkle: false, pulseStep: step)
83+
let normalKey = MenuBarIcon.cacheKey(quantizedPercent: 100, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: step)
84+
let brokenKey = MenuBarIcon.cacheKey(quantizedPercent: 100, colorHash: 0, isBroken: true, isSparkle: false, pulseStep: step)
8785
#expect(normalKey != brokenKey)
8886
}
8987
}
9088

9189
@Test func cacheKey_sparkleDistinctFromNormalAndBroken() {
92-
let normalKey = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: false, isSparkle: false, pulseStep: 0)
93-
let brokenKey = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: true, isSparkle: false, pulseStep: 0)
94-
let sparkleKey = MenuBarIcon.cacheKey(quantizedPercent: 0, isBroken: false, isSparkle: true, pulseStep: 0)
90+
let normalKey = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: false, isSparkle: false, pulseStep: 0)
91+
let brokenKey = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: true, isSparkle: false, pulseStep: 0)
92+
let sparkleKey = MenuBarIcon.cacheKey(quantizedPercent: 0, colorHash: 0, isBroken: false, isSparkle: true, pulseStep: 0)
9593
#expect(normalKey != sparkleKey)
9694
#expect(brokenKey != sparkleKey)
97-
#expect(sparkleKey == 10_200)
95+
}
96+
97+
@Test func cacheKey_differentColorsAreDistinct() {
98+
let greenKey = MenuBarIcon.cacheKey(quantizedPercent: 75, colorHash: 42, isBroken: false, isSparkle: false, pulseStep: 0)
99+
let orangeKey = MenuBarIcon.cacheKey(quantizedPercent: 75, colorHash: 99, isBroken: false, isSparkle: false, pulseStep: 0)
100+
#expect(greenKey != orangeKey)
98101
}
99102

100103
// MARK: - Star geometry

0 commit comments

Comments
 (0)