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 @@ -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";
Expand Down
3 changes: 3 additions & 0 deletions Resources/zh-Hans.lproj/Localizable.strings
Original file line number Diff line number Diff line change
Expand Up @@ -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" = "预览";
Expand Down
11 changes: 3 additions & 8 deletions Sources/Model/IslandModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
65 changes: 65 additions & 0 deletions Sources/Views/CompactQuotaGauge.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
65 changes: 53 additions & 12 deletions Sources/Views/IslandRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand All @@ -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).
Expand All @@ -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 {
Expand Down
94 changes: 85 additions & 9 deletions Sources/Views/NotchPeekPill.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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)
Expand All @@ -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
Expand All @@ -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 {
Expand Down
Loading