diff --git a/Resources/en.lproj/Localizable.strings b/Resources/en.lproj/Localizable.strings index cdff36e..9f6beba 100644 --- a/Resources/en.lproj/Localizable.strings +++ b/Resources/en.lproj/Localizable.strings @@ -161,6 +161,9 @@ "Show usage as used or remaining quota." = "Show usage as used or remaining quota."; "Usage display" = "Usage display"; "Used" = "Used"; +"Quota left" = "Left"; +"Reset" = "Reset"; +"Window" = "Window"; "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"; diff --git a/Resources/zh-Hans.lproj/Localizable.strings b/Resources/zh-Hans.lproj/Localizable.strings index 628337f..916c28f 100644 --- a/Resources/zh-Hans.lproj/Localizable.strings +++ b/Resources/zh-Hans.lproj/Localizable.strings @@ -161,6 +161,9 @@ "Show usage as used or remaining quota." = "将用量百分比显示为已用或剩余额度。"; "Usage display" = "用量显示"; "Used" = "已用"; +"Quota left" = "剩余"; +"Reset" = "重置"; +"Window" = "周期"; "Keep the percentage and time remaining visible without hovering." = "无需悬停即可始终查看用量百分比和剩余时间。"; "Inject test percentages. Visible only when launched with CODEXISLAND_DEBUG=1." = "注入测试百分比。仅在使用 CODEXISLAND_DEBUG=1 启动时显示。"; "Preview" = "预览"; diff --git a/Sources/Model/IslandModel.swift b/Sources/Model/IslandModel.swift index fc118c7..59dc573 100644 --- a/Sources/Model/IslandModel.swift +++ b/Sources/Model/IslandModel.swift @@ -26,14 +26,9 @@ final class IslandModel: ObservableObject { /// Side extension that houses each brand logo in compact state. let tabWidth: CGFloat = 38 - /// Per-side outboard slot that houses the peek-state percentage pill. - /// Sized for "100% · Nd Nh" worst case at the chosen pill typography - /// (weekly Codex windows can land at e.g. `6d 23h`). Fixed (not - /// text-measured) so percentage updates don't jitter the silhouette - /// width during refresh. Grown symmetrically on both sides regardless - /// of which provider is visible — keeps the silhouette balanced over - /// the physical notch. - let pillSlotWidth: CGFloat = 96 + /// Stacked labels share the same fixed space as a single-provider gauge, + /// so changing providers or live readings never shifts the silhouette. + let pillSlotWidth: CGFloat = 56 /// Visible expanded panel width. private let expandedWidth: CGFloat = 800 diff --git a/Sources/Views/CompactQuotaGauge.swift b/Sources/Views/CompactQuotaGauge.swift new file mode 100644 index 0000000..043b891 --- /dev/null +++ b/Sources/Views/CompactQuotaGauge.swift @@ -0,0 +1,65 @@ +import SwiftUI + +struct CompactQuotaGauge: View { + let usage: WindowUsage + let mode: UsageDisplayMode + let tint: Color + let progress: CGFloat + let height: CGFloat + + private let expandedWidth: CGFloat = 76 + + var body: some View { + let fraction = min(1, max(0, usage.displayedFraction(mode: mode))) + let amount = min(1, max(0, progress)) + let shape = UnrollingQuotaShape(progress: progress) + ZStack(alignment: .topLeading) { + if usage.hasReading { + shape.stroke(tint.opacity(0.24), style: StrokeStyle(lineWidth: 2 + amount, lineCap: .round, lineJoin: .round)) + shape.trim(from: 0, to: fraction) + .stroke(tint, style: StrokeStyle(lineWidth: 2 + amount, lineCap: .round, lineJoin: .round)) + .opacity(fraction > 0 ? 1 : 0) + } else { + shape.stroke(.white.opacity(0.35), style: StrokeStyle(lineWidth: 2 + amount, lineCap: .round, lineJoin: .round, dash: [2, 3])) + } + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(usage.hasReading ? "\(usage.displayedPercentInt(mode: mode))%" : "-%") + .font(Typography.bodyNumber) + .foregroundStyle(.white.opacity(usage.hasReading ? 0.82 : 0.40)) + Spacer(minLength: 0) + Text(L10n.tr(mode == .used ? "Used" : "Quota left")) + .font(Typography.micro) + .foregroundStyle(.white.opacity(0.55)) + } + .lineLimit(1) + .frame(width: expandedWidth) + .position(x: expandedWidth / 2, y: max(7, height / 2 - 5.5)) + .modifier(PeekContentReveal(progress: progress, start: 0.6, end: 0.9)) + } + .frame(width: expandedWidth, height: height) + .animation(PeekMotion.animation(opening: progress > 0), value: progress) + .animation(.strongEaseOut, value: fraction) + } +} + +enum PeekMotion { + static func animation(opening: Bool) -> Animation { + .timingCurve(0.25, 0.1, 0.25, 1, duration: opening ? 0.44 : 0.24) + } +} + +struct PeekContentReveal: AnimatableModifier { + var progress: CGFloat + let start: CGFloat + let end: CGFloat + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + func body(content: Content) -> some View { + let amount = min(1, max(0, (progress - start) / (end - start))) + content.opacity(amount * amount * (3 - 2 * amount)) + } +} diff --git a/Sources/Views/IslandRootView.swift b/Sources/Views/IslandRootView.swift index d4e6700..3af0284 100644 --- a/Sources/Views/IslandRootView.swift +++ b/Sources/Views/IslandRootView.swift @@ -82,22 +82,39 @@ struct IslandRootView: View { } } .overlay(alignment: .topTrailing) { - if model.state != .expanded, let right = visibility.right { - ProviderMark(provider: right) - .padding(.trailing, logoEdgePadding) - .padding(.top, max(0, (model.notch.height - 20) / 2)) + if model.state != .expanded { + if let right = visibility.right { + ProviderMark(provider: right) + .padding(.trailing, logoEdgePadding) + .padding(.top, max(0, (model.notch.height - 20) / 2)) + } else { + PeekPillOverlay(provider: visibility.left, isLeft: false, + topPadding: 0, pillsVisible: true, + contents: .gauge, edgePadding: logoEdgePadding, slotWidth: 20, + availableHeight: model.notch.height, + gaugeProgress: model.state == .peek ? 1 : 0) + } } } .overlay(alignment: .topLeading) { - if model.state != .compact { + if model.state != .expanded { PeekPillOverlay(provider: visibility.left, isLeft: true, - topPadding: max(0, (model.notch.height - 14) / 2), pillsVisible: pillsVisible) + topPadding: 0, pillsVisible: true, + contents: visibility.right == nil ? .reset : .stacked, + edgePadding: 9, + slotWidth: model.state == .peek ? model.pillSlotWidth - 14 : 0, + availableHeight: model.notch.height, + showsResetCaption: model.notch.height >= 32, + revealProgress: model.state == .peek ? 1 : 0) } } .overlay(alignment: .topTrailing) { - if model.state != .compact, let right = visibility.right { + if model.state != .expanded, let right = visibility.right { PeekPillOverlay(provider: right, isLeft: false, - topPadding: max(0, (model.notch.height - 14) / 2), pillsVisible: pillsVisible) + topPadding: 0, pillsVisible: true, contents: .stacked, edgePadding: 9, + slotWidth: model.state == .peek ? model.pillSlotWidth - 14 : 0, + availableHeight: model.notch.height, + revealProgress: model.state == .peek ? 1 : 0) } } .contentShape(IslandShape()) @@ -427,6 +444,13 @@ private struct PeekPillOverlay: View { let isLeft: Bool let topPadding: CGFloat let pillsVisible: Bool + var contents: NotchPeekPill.Contents = .combined + var edgePadding: CGFloat = 14 + var slotWidth: CGFloat? = nil + var availableHeight: CGFloat? = nil + var gaugeProgress: CGFloat = 0 + var showsResetCaption = false + var revealProgress: CGFloat = 1 @ObservedObject private var visibility = ProviderVisibilityStore.shared @ObservedObject private var connections = ProviderConnectionStore.shared @@ -436,15 +460,30 @@ private struct PeekPillOverlay: View { var body: some View { let window = currentWindow - NotchPeekPill( + let pill = NotchPeekPill( usage: window, loading: provider.usesLegacyUsage ? usageStore.loading : connections.loading.contains(provider), tint: tint, alignment: isLeft ? .leading : .trailing, severity: severity, - windowLengthFallback: provider.usesLegacyUsage ? (currentWindowIsWeekly ? "7d" : "5h") : "" + windowLengthFallback: provider.usesLegacyUsage ? (currentWindowIsWeekly ? "7d" : "5h") : "", + contents: contents, + gaugeProgress: gaugeProgress, + gaugeHeight: availableHeight ?? 38, + showsResetCaption: showsResetCaption ) - .padding(isLeft ? .leading : .trailing, 14) + Group { + if contents == .reset || contents == .stacked { + pill + .modifier(PeekContentReveal(progress: revealProgress, start: 0.45, end: 1)) + .animation(PeekMotion.animation(opening: revealProgress > 0), value: revealProgress) + .frame(width: slotWidth, height: availableHeight, alignment: isLeft ? .trailing : .leading) + .mask { Rectangle().padding(isLeft ? .leading : .trailing, -edgePadding) } + } else { + pill.frame(width: slotWidth, height: availableHeight, alignment: isLeft ? .trailing : .leading) + } + } + .padding(isLeft ? .leading : .trailing, edgePadding) .padding(.top, topPadding) // Two opacity bindings stack: // - `pillsVisible` is the peek lifecycle (hover-in / hover-out). @@ -456,12 +495,14 @@ private struct PeekPillOverlay: View { .animation(.openMorph, value: isVisible) .offset(x: pillsVisible ? 0 : (isLeft ? -6 : 6)) .allowsHitTesting(false) + .accessibilityElement(children: .ignore) .accessibilityLabel(peekLabel(for: window, provider: providerLabel, weekly: currentWindowIsWeekly)) // Mirror the visual opacity gate exactly — both `pillsVisible` and // `isVisible` must be true for the pill to render. Keying the // accessibility hide on only `isVisible` lets VoiceOver reach a // pill that is visually invisible during the peek-out lifecycle. - .accessibilityHidden(!(pillsVisible && isVisible)) + .accessibilityHidden(!(pillsVisible && isVisible) || contents == .reset || contents == .percentage + || (contents == .stacked && revealProgress == 0)) } private var isVisible: Bool { diff --git a/Sources/Views/NotchPeekPill.swift b/Sources/Views/NotchPeekPill.swift index e7e26e7..d9922ae 100644 --- a/Sources/Views/NotchPeekPill.swift +++ b/Sources/Views/NotchPeekPill.swift @@ -8,11 +8,15 @@ import SwiftUI /// • value: "32% · 2h" / "0% · 6d 23h" (active countdown) or /// "0% · 5h" (window-length fallback at lower opacity when no /// active resetAt is known) -/// • loading: small pulsing dot (only when `loading && usedPercent == 0`) +/// • loading: small pulsing dot while the first reading is unavailable /// • errored: "—%" (when error is set and we have no value) /// /// Stateless — pure function of inputs. The parent owns visibility/animation. struct NotchPeekPill: View { + enum Contents { + case combined, reset, percentage, ring, gauge, stacked + } + let usage: WindowUsage let loading: Bool let tint: Color @@ -22,16 +26,51 @@ struct NotchPeekPill: View { /// match the window actually displayed ("5h", or "7d" for the Codex /// weekly fallback on weekly-only plans). var windowLengthFallback: String = "5h" + var contents: Contents = .combined + var gaugeProgress: CGFloat = 0 + var gaugeHeight: CGFloat = 38 + var showsResetCaption = false @ObservedObject private var usageDisplay = UsageDisplayModeStore.shared + @Environment(\.accessibilityReduceMotion) private var reduceMotion var body: some View { Group { - if showSpinner { + if contents == .gauge { + CompactQuotaGauge(usage: usage, mode: usageDisplay.mode, tint: effectiveTint, + progress: gaugeProgress, height: gaugeHeight) + } else if contents == .stacked { + stackedContent + } else if contents == .ring { + quotaRing + } else if contents == .reset, showsResetCaption { + VStack(alignment: .trailing, spacing: 1) { + if showSpinner { + LoadingDot() + } else if showDash { + Text("-").font(Typography.bodyNumber).foregroundStyle(.white.opacity(0.40)) + } else { + Text(resetText ?? (windowLengthFallback.isEmpty ? "-" : windowLengthFallback)) + .font(Typography.bodyNumber) + .foregroundStyle(.white.opacity(resetText == nil ? 0.45 : 0.82)) + } + if usage.hasReading, resetText != nil || !windowLengthFallback.isEmpty { + Text(L10n.tr(resetText == nil ? "Window" : "Reset")) + .font(Typography.micro).foregroundStyle(.white.opacity(0.55)) + } + } + } else if showSpinner { LoadingDot() } else if showDash { - Text("—%") + Text(contents == .reset ? "-" : "-%") .font(Typography.bodyNumber) .foregroundStyle(.white.opacity(0.40)) + } else if contents == .reset { + resetLabel + } else if contents == .percentage { + HStack(spacing: 4) { + percentLabel + if severity != .none { warningGlyph } + } } else { HStack(spacing: 4) { if alignment == .leading { @@ -57,6 +96,45 @@ struct NotchPeekPill: View { .fixedSize() } + private var stackedContent: some View { + VStack(alignment: alignment == .leading ? .trailing : .leading, spacing: 1) { + HStack(spacing: 4) { + if showSpinner { + LoadingDot() + } else if showDash { + Text("-%").font(Typography.bodyNumber).foregroundStyle(.white.opacity(0.40)) + } else { + if alignment == .leading, severity != .none { warningGlyph } + percentLabel + if alignment == .trailing, severity != .none { warningGlyph } + } + } + .frame(height: 12) + Text(usage.hasReading ? (resetText ?? (windowLengthFallback.isEmpty ? "-" : windowLengthFallback)) : "-") + .font(Typography.caption) + .foregroundStyle(.white.opacity(!usage.hasReading ? 0.40 : (resetText == nil ? 0.45 : 0.70))) + .frame(height: 11) + } + } + + private var quotaRing: some View { + let fraction = min(1, max(0, usage.displayedFraction(mode: usageDisplay.mode))) + return ZStack { + if usage.hasReading { + Circle().strokeBorder(effectiveTint.opacity(0.24), lineWidth: 2) + Circle().inset(by: 1) + .trim(from: 0, to: fraction) + .stroke(effectiveTint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .animation(reduceMotion ? nil : .strongEaseOut, value: fraction) + } else { + Circle().inset(by: 1) + .stroke(.white.opacity(0.35), style: StrokeStyle(lineWidth: 2, dash: [2, 3])) + } + } + .frame(width: 20, height: 20) + } + private var warningGlyph: some View { Text("⚠") .font(Typography.bodyNumber) @@ -79,9 +157,9 @@ struct NotchPeekPill: View { /// window" label from an active "5h until reset" countdown — same /// glyph shape, weaker visual presence. private var resetLabel: some View { - Text(resetText ?? windowLengthFallback) + Text(resetText ?? (windowLengthFallback.isEmpty ? "-" : windowLengthFallback)) .font(Typography.bodyNumber) - .foregroundStyle(.white.opacity(resetText == nil ? 0.45 : 0.70)) + .foregroundStyle(.white.opacity(resetText == nil ? 0.45 : (contents == .reset ? 0.82 : 0.70))) } /// Brand tint by default; alert color when above threshold so the @@ -94,11 +172,9 @@ struct NotchPeekPill: View { } } - /// Spinner only fires for the cold-start case (loading AND we have nothing - /// to show). If we have a prior value, keep showing it during refresh — - /// same principle as UsageStore.isErrorOnly's "don't blank the panel" rule. + /// A measured zero stays visible during refresh, just like any other reading. private var showSpinner: Bool { - loading && usage.usedPercent == 0 && usage.error == nil + loading && usage.isUnreported } private var showDash: Bool { diff --git a/Sources/Views/UnrollingQuotaShape.swift b/Sources/Views/UnrollingQuotaShape.swift new file mode 100644 index 0000000..5911317 --- /dev/null +++ b/Sources/Views/UnrollingQuotaShape.swift @@ -0,0 +1,42 @@ +import SwiftUI + +struct UnrollingQuotaShape: Shape { + var progress: CGFloat + + var animatableData: CGFloat { + get { progress } + set { progress = newValue } + } + + func path(in rect: CGRect) -> Path { + let amount = min(1, max(0, progress)) + let stroke = 2 + amount + let centerY = rect.height / 2 + let barY = min(rect.height - 3, centerY + 8.5) + if amount >= 0.99999 { + var path = Path() + path.move(to: CGPoint(x: rect.minX + 1.5, y: rect.minY + barY)) + path.addLine(to: CGPoint(x: rect.maxX - 1.5, y: rect.minY + barY)) + return path + } + + // Keep the curl small while a tangent grows into the bar. Stretching + // the entire arc makes the quota appear to spin during the hover. + let turn = 2 * CGFloat.pi * (1 - amount) + let radius = 9 * (1 - amount) + let startX = rect.minX + stroke / 2 + radius + let tangentX = startX + (rect.width - 3) * amount + let topY = rect.minY + (centerY - 9) * (1 - amount) + barY * amount + var path = Path() + path.move(to: CGPoint(x: startX, y: topY)) + path.addLine(to: CGPoint(x: tangentX, y: topY)) + for index in 1...64 { + let angle = turn * CGFloat(index) / 64 + path.addLine(to: CGPoint( + x: tangentX + radius * sin(angle), + y: topY + radius * (1 - cos(angle)) + )) + } + return path + } +} diff --git a/Tests/QuotaGaugeGeometryTests.swift b/Tests/QuotaGaugeGeometryTests.swift new file mode 100644 index 0000000..bbc5360 --- /dev/null +++ b/Tests/QuotaGaugeGeometryTests.swift @@ -0,0 +1,64 @@ +import SwiftUI + +@main +struct QuotaGaugeGeometryTests { + static func main() { + var checks = 0 + func check(_ condition: Bool, _ message: String) { + precondition(condition, message) + checks += 1 + } + for height: CGFloat in [24, 38] { + for origin in [CGPoint.zero, CGPoint(x: 12, y: 30)] { + let rect = CGRect(origin: origin, size: CGSize(width: 76, height: height)) + for step in 0...100 { + let amount = CGFloat(step) / 100 + let shape = UnrollingQuotaShape(progress: amount) + let path = shape.path(in: rect) + let ink = path.boundingRect.insetBy(dx: -(2 + amount) / 2, dy: -(2 + amount) / 2) + check(rect.insetBy(dx: -0.001, dy: -0.001).contains(ink), "Gauge escapes its bounds at \(amount), height \(height)") + let points = vertices(path) + check(points.allSatisfy { $0.x.isFinite && $0.y.isFinite }, "Gauge contains a non-finite point") + check(!selfIntersects(points), "Unrolling arc crosses itself at \(amount)") + } + let circle = UnrollingQuotaShape(progress: 0).path(in: rect) + check(abs(circle.boundingRect.width - 18) < 0.001 && abs(circle.boundingRect.height - 18) < 0.001, "Compact circle must retain its 20 pt footprint") + let start = vertices(circle)[0] + let end = circle.cgPath.currentPoint + check(hypot(start.x - end.x, start.y - end.y) < 0.001, "Compact ring has an open seam") + let bar = UnrollingQuotaShape(progress: 1).path(in: rect) + check(abs(bar.boundingRect.width - 73) < 0.001 && bar.boundingRect.height == 0, "Peek must settle into a straight 76 pt bar including caps") + let almost = UnrollingQuotaShape(progress: 0.9999).path(in: rect).boundingRect + check(abs(almost.maxY - bar.boundingRect.maxY) < 0.01, "Final unroll frame jumps") + } + } + print("PASS \(checks) quota gauge geometry checks") + } + + private static func vertices(_ path: Path) -> [CGPoint] { + var points: [CGPoint] = [] + path.cgPath.applyWithBlock { element in + switch element.pointee.type { + case .moveToPoint, .addLineToPoint: points.append(element.pointee.points[0]) + default: break + } + } + return points + } + + private static func selfIntersects(_ points: [CGPoint]) -> Bool { + guard points.count >= 4 else { return false } + func side(_ a: CGPoint, _ b: CGPoint, _ c: CGPoint) -> CGFloat { + (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x) + } + for first in 0..<(points.count - 3) { + for second in (first + 2)..<(points.count - 1) { + let a = points[first], b = points[first + 1] + let c = points[second], d = points[second + 1] + if side(a, b, c) * side(a, b, d) < -0.000001, + side(c, d, a) * side(c, d, b) < -0.000001 { return true } + } + } + return false + } +} diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 359b785..f2cec3c 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -37,6 +37,11 @@ swiftc \ "$OUT_DIR/notch-height-tests" +swiftc -parse-as-library -o "$OUT_DIR/quota-gauge-tests" \ + Sources/Views/UnrollingQuotaShape.swift \ + Tests/QuotaGaugeGeometryTests.swift +"$OUT_DIR/quota-gauge-tests" + swiftc \ -parse-as-library \ -o "$OUT_DIR/usage-merge-tests" \