Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@
"Keep the percentage and time remaining visible without hovering." = "Keep the percentage and time remaining visible without hovering.";
"Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1.";
"Preview" = "Preview";
"Pace guide" = "Pace guide";
"resets in %d hours" = "resets in %d hours";
"resets in %d minutes" = "resets in %d minutes";
"Show a small marker for expected usage before reset." = "Show a small marker for expected usage before reset.";
"Swipe to change pages." = "Swipe to change pages.";
"Tightens the gap between logos when the island is on a screen without a hardware notch." = "Tightens the gap between logos when the island is on a screen without a hardware notch.";
"used %d%%, guide %d%%" = "used %d%%, guide %d%%";
3 changes: 3 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,10 @@
"Keep the percentage and time remaining visible without hovering." = "无需悬停即可始终查看用量百分比和剩余时间。";
"Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "注入测试百分比。仅在使用 CODEXISLAND_DEBUG=1 启动时显示。";
"Preview" = "预览";
"Pace guide" = "进度参考线";
"resets in %d hours" = "%d 小时后重置";
"resets in %d minutes" = "%d 分钟后重置";
"Show a small marker for expected usage before reset." = "显示重置前预期用量的小标记。";
"Swipe to change pages." = "滑动切换页面。";
"Tightens the gap between logos when the island is on a screen without a hardware notch." = "在没有硬件刘海的屏幕上收紧 logo 之间的间距。";
"used %d%%, guide %d%%" = "已用 %d%%,建议 %d%%";
20 changes: 20 additions & 0 deletions Sources/Model/PaceGuideStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import Foundation

/// User preference for showing the reset-window pace guide in usage charts.
///
/// Default off so upgrading users keep the original cleaner charts until
/// they opt in from General settings.
@MainActor
final class PaceGuideStore: ObservableObject {
static let shared = PaceGuideStore()

private static let key = "MacIsland.paceGuideEnabled"

@Published var enabled: Bool {
didSet { UserDefaults.standard.set(enabled, forKey: Self.key) }
}

private init() {
self.enabled = Pref.seededBool(key: Self.key, default: false)
}
}
8 changes: 8 additions & 0 deletions Sources/Views/Charts/BarChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct BarChart: View {
let color: Color
let label: String
let sub: String
let guide: Double?

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Expand All @@ -16,6 +17,13 @@ struct BarChart: View {
.fill(color)
.frame(width: geo.size.width * CGFloat(value / 100), height: 4)
.animation(.strongEaseOut, value: value)
if let guide {
RoundedRectangle(cornerRadius: 1)
.fill(.white.opacity(0.86))
.frame(width: 2, height: 11)
.offset(x: geo.size.width * CGFloat(guide / 100))
.animation(.strongEaseOut, value: guide)
}
// Tick marks at quartiles. Subtle (12% white) so they
// hint at scale without competing with the fill.
ForEach([0.25, 0.5, 0.75], id: \.self) { p in
Expand Down
8 changes: 8 additions & 0 deletions Sources/Views/Charts/NumericChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct NumericChart: View {
let color: Color
let label: String
let sub: String
let guide: Double?

var body: some View {
VStack(alignment: .leading, spacing: 6) {
Expand Down Expand Up @@ -39,6 +40,13 @@ struct NumericChart: View {
.frame(width: geo.size.width * CGFloat(value / 100), height: 3)
.shadow(color: color.opacity(0.7), radius: 4)
.animation(.strongEaseOut, value: value)
if let guide {
RoundedRectangle(cornerRadius: 1)
.fill(.white.opacity(0.86))
.frame(width: 2, height: 10)
.offset(x: geo.size.width * CGFloat(guide / 100))
.animation(.strongEaseOut, value: guide)
}
}
}
.frame(height: 4)
Expand Down
35 changes: 35 additions & 0 deletions Sources/Views/Charts/RingChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ struct RingChart: View {
let color: Color
let label: String
let sub: String
let guide: Double?

var body: some View {
VStack(alignment: .leading, spacing: 8) {
Expand All @@ -20,6 +21,12 @@ struct RingChart: View {
// (old → new), which makes the trim feel alive
// without ever flashing 0%.
.animation(.strongEaseOut, value: value)
if let guide {
RingGuideTick(guide: guide)
.stroke(.white.opacity(0.86),
style: StrokeStyle(lineWidth: 2, lineCap: .round))
.animation(.strongEaseOut, value: guide)
}
}
.frame(width: 56, height: 56)

Expand Down Expand Up @@ -49,3 +56,31 @@ struct RingChart: View {
}
}
}

private struct RingGuideTick: Shape {
var guide: Double

var animatableData: Double {
get { guide }
set { guide = newValue }
}

func path(in rect: CGRect) -> Path {
let radius = min(rect.width, rect.height) / 2
let center = CGPoint(x: rect.midX, y: rect.midY)
let radians = CGFloat((guide / 100) * 360 - 90) * .pi / 180
let inner = radius - 8
let outer = radius + 1

var path = Path()
path.move(to: CGPoint(
x: center.x + cos(radians) * inner,
y: center.y + sin(radians) * inner
))
path.addLine(to: CGPoint(
x: center.x + cos(radians) * outer,
y: center.y + sin(radians) * outer
))
return path
}
}
19 changes: 13 additions & 6 deletions Sources/Views/Charts/SparkChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@ struct SparkChart: View {
let label: String
let sub: String
let seed: Int
let guide: Double?

var body: some View {
VStack(alignment: .leading, spacing: 6) {
ChartHead(value: value, label: label)
SparkSVG(value: value, color: color, seed: seed)
SparkSVG(value: value, color: color, seed: seed, guide: guide)
.frame(height: 50)
.animation(.strongEaseOut, value: value)
ChartFoot(caption: sub)
Expand All @@ -22,6 +23,7 @@ private struct SparkSVG: View {
let value: Double
let color: Color
let seed: Int
let guide: Double?

/// Synthesize 36 plausible-looking historical points around the current
/// value. Real history would need a usage time-series API neither
Expand Down Expand Up @@ -60,6 +62,7 @@ private struct SparkSVG: View {
let w = geo.size.width, h = geo.size.height
let pts = generatePoints(width: w, height: h)
let baselineY = h - CGFloat(value / 100) * (h - 8) - 4
let guideY = guide.map { h - CGFloat($0 / 100) * (h - 8) - 4 }
ZStack {
// Quartile rules at 4% white, barely there.
ForEach([0.25, 0.5, 0.75], id: \.self) { p in
Expand All @@ -69,13 +72,17 @@ private struct SparkSVG: View {
}
.stroke(.white.opacity(0.04), lineWidth: 1)
}
// Dotted threshold at the current value — the line reading
// "this is now" against the synthesized history.
// Dotted threshold: guide when reset data exists; otherwise
// the current value as the old decorative baseline.
Path { path in
path.move(to: CGPoint(x: 0, y: baselineY))
path.addLine(to: CGPoint(x: w, y: baselineY))
path.move(to: CGPoint(x: 0, y: guideY ?? baselineY))
path.addLine(to: CGPoint(x: w, y: guideY ?? baselineY))
}
.stroke(color.opacity(0.25), style: StrokeStyle(lineWidth: 1, dash: [2, 3]))
.stroke(
guideY == nil ? color.opacity(0.25) : .white.opacity(0.38),
style: StrokeStyle(lineWidth: guideY == nil ? 1 : 1.5, dash: [2, 3])
)
.animation(.strongEaseOut, value: guide ?? value)

// Gradient area fill under the curve.
Path { p in
Expand Down
36 changes: 25 additions & 11 deletions Sources/Views/Charts/SteppedChart.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,37 @@ struct SteppedChart: View {
let color: Color
let label: String
let sub: String
let guide: Double?

var body: some View {
VStack(alignment: .leading, spacing: 8) {
ChartHead(value: value, label: label)
HStack(spacing: 2) {
let segments = 30
let filled = (value / 100) * Double(segments)
ForEach(0..<segments, id: \.self) { i in
RoundedRectangle(cornerRadius: 1.5)
.fill(Double(i) < floor(filled) ? color : .white.opacity(0.10))
.frame(maxWidth: .infinity)
.frame(height: 16)
// ~7ms stagger across 30 cells = ~210ms sweep when
// a new value arrives. Stays under the 300ms budget.
.animation(.strongEaseOut.delay(Double(i) * 0.007), value: value)
GeometryReader { geo in
ZStack(alignment: .leading) {
HStack(spacing: 2) {
let segments = 30
let filled = (value / 100) * Double(segments)
ForEach(0..<segments, id: \.self) { i in
RoundedRectangle(cornerRadius: 1.5)
.fill(Double(i) < floor(filled) ? color : .white.opacity(0.10))
.frame(maxWidth: .infinity)
.frame(height: 16)
// ~7ms stagger across 30 cells = ~210ms sweep when
// a new value arrives. Stays under the 300ms budget.
.animation(.strongEaseOut.delay(Double(i) * 0.007), value: value)
}
}

if let guide {
RoundedRectangle(cornerRadius: 1)
.fill(.white.opacity(0.86))
.frame(width: 2, height: 22)
.offset(x: geo.size.width * CGFloat(guide / 100))
.animation(.strongEaseOut, value: guide)
}
}
}
.frame(height: 20)
ChartFoot(caption: sub)
}
}
Expand Down
9 changes: 9 additions & 0 deletions Sources/Views/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct SettingsView: View {
@ObservedObject private var tokenMode = TokenCountModeStore.shared
@ObservedObject private var lowPower = LowPowerModeStore.shared
@ObservedObject private var alwaysShow = AlwaysShowUsageStore.shared
@ObservedObject private var paceGuide = PaceGuideStore.shared
@ObservedObject private var alertPrefs = AlertThresholdStore.shared
@ObservedObject private var spacing = IslandSpacingStore.shared
@ObservedObject private var targetDisplay = IslandTargetDisplayStore.shared
Expand Down Expand Up @@ -220,6 +221,14 @@ struct SettingsView: View {
alwaysShow.enabled.toggle()
}
}
SettingsRow(
title: "Pace guide",
subtitle: "Show a small marker for expected usage before reset."
) {
SettingsToggle(isOn: paceGuide.enabled) {
paceGuide.enabled.toggle()
}
}
SettingsRow(
title: "Low Power Mode",
subtitle: "Glow only on refresh, hover, or limit alerts."
Expand Down
42 changes: 35 additions & 7 deletions Sources/Views/UsageView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,8 @@ struct ReauthButton: View {
}

struct ChartTile: View {
@ObservedObject private var paceGuide = PaceGuideStore.shared

let style: ChartStyle
let color: Color
let labelKey: String
Expand All @@ -151,14 +153,20 @@ struct ChartTile: View {
let value = window.usedPercent * 100 // 0-100
let sub = subCaption()
let label = L10n.tr(labelKey)
let guide = paceGuidePercent()

Group {
switch style {
case .ring: RingChart(value: value, color: color, label: label, sub: sub)
case .bar: BarChart(value: value, color: color, label: label, sub: sub)
case .stepped: SteppedChart(value: value, color: color, label: label, sub: sub)
case .numeric: NumericChart(value: value, color: color, label: label, sub: compactSubCaption())
case .spark: SparkChart(value: value, color: color, label: label, sub: sub, seed: seed)
case .ring:
RingChart(value: value, color: color, label: label, sub: sub, guide: guide)
case .bar:
BarChart(value: value, color: color, label: label, sub: sub, guide: guide)
case .stepped:
SteppedChart(value: value, color: color, label: label, sub: sub, guide: guide)
case .numeric:
NumericChart(value: value, color: color, label: label, sub: compactSubCaption(), guide: guide)
case .spark:
SparkChart(value: value, color: color, label: label, sub: sub, seed: seed, guide: guide)
}
}
.id(style)
Expand All @@ -169,8 +177,28 @@ struct ChartTile: View {
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.frame(height: Self.tileHeight)
.accessibilityElement(children: .combine)
.accessibilityLabel(L10n.tr("%@, %d%%", label, Int(value)))
.accessibilityValue(subCaption())
.accessibilityLabel(L10n.tr("%@, %d%%", label, window.percentInt))
.accessibilityValue(accessibilityValue())
}

private func paceGuidePercent() -> Double? {
guard paceGuide.enabled,
window.error == nil,
let resetAt = window.resetAt
else { return nil }

let duration: TimeInterval = labelKey == "week"
? 7 * 24 * 60 * 60
: 5 * 60 * 60
let remaining = resetAt.timeIntervalSinceNow
return min(100, max(0, (1 - remaining / duration) * 100))
}

private func accessibilityValue() -> String {
guard let guide = paceGuidePercent() else { return subCaption() }
return L10n.tr("used %d%%, guide %d%%",
window.percentInt,
Int(guide.rounded()))
}

private func subCaption() -> String {
Expand Down