mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-23 23:29:35 +00:00
Some checks are pending
CI / semgrep (push) Waiting to run
macOS Menubar CI / test (push) Waiting to run
Tests / desktop (push) Waiting to run
Tests / test (22) (push) Waiting to run
Tests / test (22.13.0) (push) Waiting to run
Tests / test (24) (push) Waiting to run
Tests / test (26) (push) Waiting to run
Tests / test-platforms (macos-latest) (push) Waiting to run
Tests / test-platforms (windows-latest) (push) Waiting to run
Windows Menubar CI / check (windows-latest) (push) Waiting to run
Windows Menubar CI / check (ubuntu-latest) (push) Waiting to run
* fix: open menu bar Settings centered on the active screen
The window was positioned at a 520x380 placeholder while SwiftUI's own
minimum is 880x620, so the grown window kept its top-left corner and ran
off the bottom-right edge. Size the window from the view's minimum, place
it with a tested pure function, and honour a saved frame only while it
still fits on one of today's screens.
* fix: say where the telemetry toggle is managed when the desktop app owns it
A desktop telemetry.v1.json with enabled:false locks the menu bar toggle
off, so clicking it does nothing. Name the desktop app's Privacy & data
pane in the caption, and pin the four desktop/veto combinations plus the
standalone case in tests.
* fix(desktop): export the provider id, and never swallow a rejected setter
The export pane built its provider list from `current.providers`, which is
keyed on the lowercased display name. Five of the seventeen providers on a
real corpus ("cursor agent", "grok build", "kimi code", "hermes agent",
"grok bot") are not valid ids, so vProvider rejected them as bad-args.
`exportNow` had a try/finally and no catch, so the rejection produced no
toast and no file: the button did nothing. Use detectedProviders, which
already resolves ids from providerDetails, and toast on rejection.
The telemetry consent toggle swallowed its rejection the same way.
* fix(desktop): warm only the selected period, and only while visible
The post-paint sweep warmed all six standard periods x seven reports plus
the provider overviews: up to 48 serve-routed queries, ~18 CPU-seconds a
minute for five minutes and 2.79 GB peak RSS on a real corpus, and it ran
with the window hidden. It now warms the selected period's reports (its
own overview is already under that memo key from the visible poll) and
the other providers' headlines for that same period, holding before every
request while a user fetch is in flight or the window is hidden. Other
periods take the ordinary on-demand path.
Also corrects two comments that claimed background priority lets a click
jump ahead: spawnCli takes the serve fast path before priority is read,
and the resident child answers FIFO.
* fix(desktop): answer the click, report the write, tell the truth about the Claude login
Nine bridge setters in Settings attached only .then(), so a rejected
envelope produced no toast and the control read as if it had ignored the
click. One shared helper toasts the rejection, per pane fallback wording.
Telemetry save() swallowed write errors while setEnabled returned the
in-memory status, so the UI could show ON while the file the macOS menu
bar app reads still said OFF. The setters now carry persisted:boolean and
the renderer toasts when it is false; the write goes through the existing
atomic writeFileAtomic (temp file, rename) so a crash cannot tear it.
The Claude card called a logged-in Claude Code 2.x user "disconnected"
forever, because the credential lives only in the keychain and the first
poll never reads it. A new darwin-only keychainUnchecked state says we
have not looked yet and offers the forced read, keeping the login
instructions for the genuinely-logged-out case. ConnectAffordance's
toggle is renamed to what it does: it discloses instructions.
* fix: retry tax is the cost of retried edit turns, not a multiple of spend
* stop the change guard watching a Warp path it can never stat
* apply a menu bar language change in place instead of relaunching
* keep honoring WARP_DB_PATH in the change guard
* restart a menu bar too old to watch AppleLanguages
* one rebuild per language change, and no global bundle in tests
* fix(desktop): a forced quota refresh must not join the poll it just aborted
Check now / Refresh / ⌘R invalidated the quota service and then handed the
caller the background fetch it had just aborted, so every provider failed
its generation check and resolved disconnected: the click repainted every
card as logged out without ever reading the keychain, and Claude showed
"Not connected. Log in with the Claude CLI." A call now joins an in-flight
run only when that run is at least as privileged, so a keychain-allowed
call starts its own; a second forced click still joins the first, which
keeps the single macOS prompt. A superseded run hands its caller the run
that replaced it instead of its own abort artefacts.
retainOnFailure also holds accessDenied through a keychainUnchecked poll,
so the "Allow in keychain" guidance is not replaced by "Check now" one
cycle after the user denied the prompt.
* fix(desktop): stop the sweep churning, and never spend a query on an abandoned one
The prefetch effect depended on the detected-provider entries, whose costs
move on every overview poll, so it was torn down and restarted each cadence
tick: a warm slower than the interval was discarded and re-issued forever.
It now depends on the provider ids alone, and a finished warm is kept for
its own period/provider key even if the effect was torn down meanwhile —
only a config-generation change makes a result wrong.
Both loops re-test cancellation and the memo after the hold, which has no
bound, so an abandoned sweep issues nothing and a section the user visited
meanwhile is not parsed again. The hidden half of the hold waits on
visibilitychange instead of waking the renderer every two seconds for a
window nobody is looking at.
* fix(desktop): never offer a provider id the CLI will reject, and keep the keychain note
detectedProviders applied the round-trip filter only to the legacy
providers map; a providerDetails id with a space reached the picker and the
export as a --provider value the main process rejects.
The Plans settings row showed the "not read your login yet" line without
the note that macOS may ask once for keychain access, which the Plans card
does show.
1815 lines
78 KiB
Swift
1815 lines
78 KiB
Swift
import AppKit
|
|
import Observation
|
|
import SwiftUI
|
|
|
|
private extension String {
|
|
/// `asCompactTokens` prints an uppercase K; the glance popover uses the
|
|
/// lowercase form (182k) beside the uppercase M and B.
|
|
func lowercasedThousands() -> String { replacingOccurrences(of: "K", with: "k") }
|
|
}
|
|
|
|
private extension View {
|
|
/// Hairline between glance sections, drawn as an overlay so it never adds a
|
|
/// point the computed panel height did not reserve.
|
|
func dividerBelow() -> some View {
|
|
overlay(alignment: .bottom) {
|
|
Rectangle()
|
|
.fill(Color.white.opacity(0.08))
|
|
.frame(height: 0.5)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset)
|
|
}
|
|
}
|
|
}
|
|
|
|
private extension Color {
|
|
/// Warm off-white for Capacity Dock text: a very mild orange tint so bright
|
|
/// labels on the dark card read softer than pure white and do not stress the eyes.
|
|
static let capacityDockText = Color(red: 0.98, green: 0.95, blue: 0.90)
|
|
}
|
|
|
|
enum CapacityDockMetrics {
|
|
private static let baseRailWidth: CGFloat = 88
|
|
// Horizontal rails stack the ring above its label, so their cross-extent (the
|
|
// pill height) needs more room than a vertical rail's width to give the same
|
|
// top/bottom breathing space around the gauge.
|
|
private static let baseHorizontalRailWidth: CGFloat = 106
|
|
private static let baseEdgeFlareWidth: CGFloat = 22
|
|
private static let baseEdgeShoulderDepth: CGFloat = 52
|
|
private static let baseRowHeight: CGFloat = 84
|
|
private static let baseRowSpacing: CGFloat = 12
|
|
private static let baseRailAlongPad: CGFloat = 20
|
|
private static let baseRailCrossPad: CGFloat = 12
|
|
private static let baseRingSize: CGFloat = 52
|
|
private static let baseRingStrokeWidth: CGFloat = 4
|
|
private static let baseRingLabelSpacing: CGFloat = 6
|
|
private static let baseProviderIconSize: CGFloat = 26
|
|
private static let basePercentageTextSize: CGFloat = 17
|
|
private static let baseDetailWidth: CGFloat = 350
|
|
|
|
/// Every dock dimension lands on a whole point. Fractional sizes (85%
|
|
/// of 88 is 74.8) made SwiftUI's fitted content disagree with the
|
|
/// pixel-aligned panel frame on every layout pass, so the hosting view
|
|
/// re-laid itself out at display cadence forever: 5 to 7 percent idle CPU
|
|
/// at any scale except 100%.
|
|
private static func points(_ base: CGFloat, _ scale: CGFloat) -> CGFloat {
|
|
max(1, (base * scale).rounded())
|
|
}
|
|
|
|
static func railWidth(scale: CGFloat) -> CGFloat { points(baseRailWidth, scale) }
|
|
static func horizontalRailWidth(scale: CGFloat) -> CGFloat { points(baseHorizontalRailWidth, scale) }
|
|
static func edgeFlareWidth(scale: CGFloat) -> CGFloat { points(baseEdgeFlareWidth, scale) }
|
|
static func edgeShoulderDepth(scale: CGFloat) -> CGFloat { points(baseEdgeShoulderDepth, scale) }
|
|
static func rowHeight(scale: CGFloat) -> CGFloat { points(baseRowHeight, scale) }
|
|
static func rowSpacing(scale: CGFloat) -> CGFloat { points(baseRowSpacing, scale) }
|
|
static func railAlongPad(scale: CGFloat) -> CGFloat { points(baseRailAlongPad, scale) }
|
|
static func railCrossPad(scale: CGFloat) -> CGFloat { points(baseRailCrossPad, scale) }
|
|
static func ringSize(scale: CGFloat) -> CGFloat { points(baseRingSize, scale) }
|
|
static func ringStrokeWidth(scale: CGFloat) -> CGFloat { points(baseRingStrokeWidth, scale) }
|
|
static func ringLabelSpacing(scale: CGFloat) -> CGFloat { points(baseRingLabelSpacing, scale) }
|
|
static func providerIconSize(scale: CGFloat) -> CGFloat { points(baseProviderIconSize, scale) }
|
|
static func percentageTextSize(scale: CGFloat) -> CGFloat { points(basePercentageTextSize, scale) }
|
|
static func detailWidth(scale: CGFloat) -> CGFloat { points(baseDetailWidth, scale) }
|
|
|
|
static func railHeight(providerCount: Int, alongPad: CGFloat, scale: CGFloat) -> CGFloat {
|
|
let count = max(providerCount, 1)
|
|
return alongPad
|
|
+ CGFloat(count) * rowHeight(scale: scale)
|
|
+ CGFloat(max(0, count - 1)) * rowSpacing(scale: scale)
|
|
+ alongPad
|
|
}
|
|
|
|
/// The glance popover's height is the sum of its blocks, not a fitted size:
|
|
/// the hosting view has intrinsic sizing off, so every block below carries
|
|
/// the same explicit frame height in `CapacityDockDetailView`. Keep the two
|
|
/// in step, and keep every term a whole number of points (see `points`).
|
|
static func detailHeight(
|
|
quota: QuotaSummary?,
|
|
provider: CapacityDockProvider,
|
|
sessionCount: Int?,
|
|
hasToday: Bool,
|
|
tailEdge: CapacityDockEdge,
|
|
scale: CGFloat
|
|
) -> CGFloat {
|
|
guard let quota else {
|
|
let card = CapacityDockGlance.connectCardHeight(
|
|
provider: provider,
|
|
width: baseDetailWidth - 2 * CapacityDockGlance.contentInset
|
|
)
|
|
return (card * scale).rounded()
|
|
}
|
|
// Each section carries its own padding, so the panel adds none.
|
|
var height = CapacityDockGlance.headerHeight
|
|
// The tail only eats vertical room when it points up or down.
|
|
if !tailEdge.isVertical { height += CapacityDockGlance.tailAllowance }
|
|
if let sessionCount { height += CapacityDockGlance.sessionsHeight(count: sessionCount) }
|
|
if hasToday { height += CapacityDockGlance.todayHeight }
|
|
if CapacityDockGlance.drawsWindows(quota) {
|
|
height += CapacityDockGlance.windows(quota).isEmpty
|
|
? CapacityDockGlance.windowsEmptyHeight
|
|
: CapacityDockGlance.windowsHeight(for: quota)
|
|
}
|
|
height += CapacityDockConnectionAction.resolve(quota: quota) == nil
|
|
? 0
|
|
: CapacityDockGlance.actionRowHeight
|
|
// Measured rather than reserved at worst case: every block here is a
|
|
// padded section, and the panel is sized to their sum. The connection
|
|
// block is measured at the drawn scale and added after the body scales,
|
|
// so its fixed-size reconnect text keeps its reserve at the 0.9 floor.
|
|
let connection = CapacityDockGlance.connectionBlockHeight(
|
|
quota.connection,
|
|
provider: provider,
|
|
width: baseDetailWidth - 2 * CapacityDockGlance.contentInset,
|
|
scale: scale
|
|
)
|
|
return ((height * scale).rounded() + connection).rounded()
|
|
}
|
|
}
|
|
|
|
/// Fixed block heights shared by the glance popover's layout and its panel
|
|
/// size, plus the ramps its numbers and rings use. Every section owns its own
|
|
/// padding, so the panel itself only insets horizontally.
|
|
enum CapacityDockGlance {
|
|
/// The panel's padding on all four sides. Horizontally it is applied by each
|
|
/// section rather than the panel, so group fills can still run edge to edge.
|
|
static let contentInset: CGFloat = 16
|
|
static let tailAllowance: CGFloat = 18
|
|
|
|
/// The title row's own line box.
|
|
static let headerRow: CGFloat = 20
|
|
/// 16 top + 20 title + 8 bottom.
|
|
static let headerHeight: CGFloat = contentInset + headerRow + 8
|
|
/// Gap between the title and the guidance paragraph on the no-quota card.
|
|
static let connectGuidanceGap: CGFloat = 11
|
|
static let sectionPadTop: CGFloat = 8
|
|
static let sectionPadBottom: CGFloat = 10
|
|
/// A 10.5pt caption's line box, shared by every section header.
|
|
static let captionLine: CGFloat = 13
|
|
/// Content driven: 8 padding + a 12pt line + 2 + a 10.5pt line + 8 padding.
|
|
/// Held as a constant because the panel frame is computed, not fitted.
|
|
static let pillHeight: CGFloat = 46
|
|
static let pillGap: CGFloat = 6
|
|
/// Four stacked lines, 13 + 13 + 13 + 12, with three 3pt gaps. Taller than the
|
|
/// 17pt burned figure beside it, so it sets the row. The fourth line is the
|
|
/// cache-read figure, which drops out when the payload carries no
|
|
/// provider-scoped cache accounting — the reserved height does not move with
|
|
/// it, exactly like the input/output pair above it.
|
|
static let todayContentHeight: CGFloat = 60
|
|
/// 8 top + 24 percent + 2 + 13 label + 2 + 12 reset + 16 bottom.
|
|
static let windowsHeight: CGFloat = 77
|
|
/// The column content height inside `windowsHeight`: the section's top and
|
|
/// bottom padding are owned by `windowsSection`, while a grid owns the rows.
|
|
static let windowContentHeight: CGFloat = windowsHeight - sectionPadTop - contentInset
|
|
/// A real gap keeps adjacent scope labels and captions visually separate.
|
|
/// The old zero-spacing HStack let intrinsic Text widths bleed across columns.
|
|
static let windowsColumnGap: CGFloat = 8
|
|
static let windowsRowGap: CGFloat = 6
|
|
/// 8 top + one secondary line + 16 bottom.
|
|
static let windowsEmptyHeight: CGFloat = 37
|
|
|
|
/// The windows row's height for this quota.
|
|
static func windowsHeight(for quota: QuotaSummary) -> CGFloat {
|
|
let shown = windows(quota)
|
|
guard !shown.isEmpty else { return windowsEmptyHeight }
|
|
return (
|
|
sectionPadTop
|
|
+ windowsGridHeight(for: quota)
|
|
+ contentInset
|
|
).rounded()
|
|
}
|
|
|
|
/// Up to three windows share one compact row; a fourth starts a second row. This is shared by `detailHeight` and the actual SwiftUI grid.
|
|
static func windowColumnCount(for windowCount: Int) -> Int {
|
|
guard windowCount > 0 else { return 0 }
|
|
return min(windowCount, 3)
|
|
}
|
|
|
|
static func windowRowCount(for windowCount: Int) -> Int {
|
|
let columns = windowColumnCount(for: windowCount)
|
|
guard columns > 0 else { return 0 }
|
|
return (windowCount + columns - 1) / columns
|
|
}
|
|
|
|
/// Height of the grid alone, excluding this section's top and bottom pads.
|
|
static func windowsGridHeight(for quota: QuotaSummary) -> CGFloat {
|
|
let count = windows(quota).count
|
|
guard count > 0 else { return 0 }
|
|
let rows = windowRowCount(for: count)
|
|
return (
|
|
CGFloat(rows) * windowContentHeight
|
|
+ CGFloat(max(0, rows - 1)) * windowsRowGap
|
|
).rounded()
|
|
}
|
|
/// The staleness or reconnect line under the header. It is a section like any
|
|
/// other, so the panel has to reserve its height: the frame is computed, not
|
|
/// fitted, and an unreserved line squeezes every block below it.
|
|
/// 6 top + a 10pt line box + 8 bottom.
|
|
static let noticeTopPad: CGFloat = 6
|
|
static let noticeBottomPad: CGFloat = 8
|
|
/// Spacing between the notice block's own lines.
|
|
static let noticeRowGap: CGFloat = 3
|
|
static let noticeHeight: CGFloat = noticeTopPad + captionLine + noticeBottomPad
|
|
/// 8 above the action row, the button's own line box, then the panel inset.
|
|
static let actionRowHeight: CGFloat = sectionPadTop + 21 + contentInset
|
|
|
|
/// The no-quota card: title row, the guidance paragraph as it wraps, and the
|
|
/// action row. Measured for the same reason the reconnect block is — a
|
|
/// constant leaves its surplus as dead space above the button.
|
|
static func connectCardHeight(provider: CapacityDockProvider, width: CGFloat) -> CGFloat {
|
|
// The Copilot card swaps in a longer paragraph once the user has
|
|
// explicitly disconnected; the store is not in reach here, so reserve
|
|
// whichever of its two paragraphs is taller.
|
|
let paragraphs = [
|
|
ProviderConnectionGuidance.dockInstruction(for: provider),
|
|
provider == .copilot ? CopilotQuotaPresentation.disconnectedSettingsDetail : nil,
|
|
].compactMap { $0 }
|
|
let guidance = paragraphs
|
|
.map { textHeight($0, size: 12, weight: .regular, width: width, maxLines: .max) }
|
|
.max() ?? 0
|
|
return contentInset + headerRow + connectGuidanceGap + guidance + actionRowHeight
|
|
}
|
|
|
|
/// How tall the reconnect or disconnected block actually draws. The panel
|
|
/// frame is computed rather than fitted, so a worst-case reserve does not
|
|
/// shrink the panel — it parks its surplus above the action row as a gap.
|
|
/// `scale` measures the reconnect block at the font size and content width
|
|
/// it is actually drawn at, so the reserve tracks the scaled text instead of
|
|
/// shrinking below a fixed-size draw and clipping at the 0.9 floor. The
|
|
/// transient notices keep their single-caption reserve.
|
|
static func connectionBlockHeight(
|
|
_ connection: QuotaSummary.Connection,
|
|
provider: CapacityDockProvider,
|
|
width: CGFloat,
|
|
scale: CGFloat = 1
|
|
) -> CGFloat {
|
|
let w = width * scale
|
|
switch connection {
|
|
case .connected: return 0
|
|
case .loading, .stale, .transientFailure: return noticeHeight * scale
|
|
case .disconnected:
|
|
return (noticeTopPad + noticeBottomPad) * scale
|
|
+ textHeight(L("Not connected"), size: 11 * scale, weight: .regular, width: w, maxLines: 1)
|
|
case .terminalFailure(let reason):
|
|
var height = noticeTopPad * scale
|
|
+ textHeight(L("Reconnect required"), size: 11 * scale, weight: .semibold, width: w, maxLines: 1)
|
|
if let reason, !reason.isEmpty {
|
|
height += noticeRowGap * scale
|
|
+ textHeight(reason, size: 10 * scale, weight: .regular, width: w, maxLines: 2)
|
|
}
|
|
height += noticeRowGap * scale + textHeight(
|
|
ProviderConnectionGuidance.dockInstruction(for: provider),
|
|
size: 10 * scale,
|
|
weight: .regular,
|
|
width: w,
|
|
maxLines: 3
|
|
)
|
|
return height + noticeBottomPad * scale
|
|
}
|
|
}
|
|
|
|
/// Whole line boxes, capped at the `Text`'s own line limit, so the reserve
|
|
/// can never be shorter than what SwiftUI draws in the same width.
|
|
private static func textHeight(
|
|
_ string: String,
|
|
size: CGFloat,
|
|
weight: NSFont.Weight,
|
|
width: CGFloat,
|
|
maxLines: Int
|
|
) -> CGFloat {
|
|
guard !string.isEmpty, width > 0 else { return 0 }
|
|
let font = NSFont.systemFont(ofSize: size, weight: weight)
|
|
// Two line boxes on purpose: `boundingRect` wraps by the font's own
|
|
// extent, so that is what counts the lines, but SwiftUI draws each line
|
|
// in the taller box `lineBox` returns.
|
|
let wrapLine = (font.ascender - font.descender + font.leading).rounded(.up)
|
|
let measured = (string as NSString).boundingRect(
|
|
with: CGSize(width: width, height: .greatestFiniteMagnitude),
|
|
options: [.usesLineFragmentOrigin, .usesFontLeading],
|
|
attributes: [.font: font]
|
|
).height
|
|
let lines = min(maxLines, max(1, Int((measured / wrapLine).rounded(.up))))
|
|
return CGFloat(lines) * lineBox(font)
|
|
}
|
|
|
|
/// The height SwiftUI gives one line of `.system(size:)`, which is neither
|
|
/// the font's ceil'd extent nor `NSLayoutManager.defaultLineHeight` — at 10
|
|
/// and 11pt it is a point taller than both, because it rounds the ascent and
|
|
/// the descent up separately. The larger of the two formulas matches SwiftUI
|
|
/// exactly at every size the glance uses and stays an upper bound elsewhere,
|
|
/// so a measured reserve can never be shorter than what is drawn.
|
|
private static func lineBox(_ font: NSFont) -> CGFloat {
|
|
max(
|
|
(font.ascender - font.descender + font.leading).rounded(.up),
|
|
font.ascender.rounded(.up) + (-font.descender).rounded(.up)
|
|
)
|
|
}
|
|
|
|
/// The plan to show beside the title, or nil when it only repeats the
|
|
/// provider's own name — several services use that name as their fallback.
|
|
static func headerPlanLabel(_ plan: String?, provider: CapacityDockProvider) -> String? {
|
|
guard let plan else { return nil }
|
|
let trimmed = plan.trimmingCharacters(in: .whitespaces)
|
|
guard !trimmed.isEmpty,
|
|
trimmed.caseInsensitiveCompare(provider.displayName) != .orderedSame
|
|
else { return nil }
|
|
return trimmed
|
|
}
|
|
|
|
/// Whether the band is drawn as its own padded section. Disconnected and
|
|
/// reconnect draw their own blocks with an action button instead.
|
|
static func drawsNotice(_ connection: QuotaSummary.Connection) -> Bool {
|
|
switch connection {
|
|
case .connected, .disconnected, .terminalFailure: return false
|
|
case .loading, .stale, .transientFailure: return true
|
|
}
|
|
}
|
|
|
|
/// The list scrolls past this many pills rather than truncating.
|
|
static let maxVisibleSessionRows = 4
|
|
static let maxWindowColumns = 4
|
|
|
|
/// Height of the scrolling pill list: every pill up to the visible cap, then
|
|
/// the list scrolls inside that.
|
|
static func sessionListHeight(count: Int) -> CGFloat {
|
|
let visible = min(count, maxVisibleSessionRows)
|
|
guard visible > 0 else { return 0 }
|
|
return CGFloat(visible) * pillHeight + CGFloat(visible - 1) * pillGap
|
|
}
|
|
|
|
static func sessionsHeight(count: Int) -> CGFloat {
|
|
var height = sectionPadTop + captionLine + sectionPadBottom
|
|
if count > 0 { height += pillGap + sessionListHeight(count: count) }
|
|
return height
|
|
}
|
|
|
|
static let todayHeight: CGFloat = sectionPadTop + captionLine + pillGap
|
|
+ todayContentHeight + sectionPadBottom
|
|
|
|
/// The menu-bar flame's bands, so the rail and the flame never disagree
|
|
/// about a window. Rings start green because a ring with no colour reads
|
|
/// as broken.
|
|
static func severityColor(_ fraction: Double) -> Color {
|
|
switch QuotaSummary.severity(for: fraction) {
|
|
case .normal: .green
|
|
case .warning: .yellow
|
|
case .critical: .orange
|
|
case .danger: .red
|
|
}
|
|
}
|
|
|
|
/// Share of the pill the tint covers. Anything outside 0...1 is a bad
|
|
/// context reading, not a reason to paint past the pill.
|
|
static func gaugeFillFraction(_ fraction: Double) -> Double {
|
|
min(max(fraction, 0), 1)
|
|
}
|
|
|
|
/// Where the tint starts fading, as a share of the filled band. The fade is
|
|
/// a fixed 12pt tail, so a short band fades over its whole length instead of
|
|
/// inverting.
|
|
static func pillFadeStart(filledWidth: CGFloat, scale: CGFloat) -> CGFloat {
|
|
let fade = 12 * scale
|
|
guard filledWidth > fade else { return 0 }
|
|
return (filledWidth - fade) / filledWidth
|
|
}
|
|
|
|
/// How far the reveal edge leans, as a share of the text's height.
|
|
static let gaugeSlantRatio: CGFloat = 0.35
|
|
|
|
/// Where the slanted reveal edge crosses the top and bottom of the text box.
|
|
/// The edge starts fully off the left and ends fully past the right, so 0
|
|
/// reveals nothing and 1 fills every glyph. Both values are clamped into the
|
|
/// box, which turns the parallelogram into a triangle at the two extremes
|
|
/// rather than letting it fold over itself.
|
|
static func gaugeRevealEdge(
|
|
fraction: Double,
|
|
width: CGFloat,
|
|
height: CGFloat
|
|
) -> (top: CGFloat, bottom: CGFloat) {
|
|
let clamped = min(max(fraction, 0), 1)
|
|
let slant = height * gaugeSlantRatio
|
|
let bottom = (width + slant) * clamped
|
|
let clamp = { (value: CGFloat) in min(max(value, 0), width) }
|
|
return (clamp(bottom - slant), clamp(bottom))
|
|
}
|
|
|
|
/// A provider that is not connected has no quota and no spend to stand in for
|
|
/// one, so the windows row gives way to the reconnect guidance entirely.
|
|
static func drawsWindows(_ quota: QuotaSummary) -> Bool {
|
|
switch quota.connection {
|
|
case .disconnected, .terminalFailure: return false
|
|
case .connected, .loading, .stale, .transientFailure: return true
|
|
}
|
|
}
|
|
|
|
/// The windows the row draws, in the order the provider service reported them.
|
|
static func windows(_ quota: QuotaSummary) -> [QuotaSummary.Window] {
|
|
let ordered = quota.details.isEmpty ? [quota.primary].compactMap { $0 } : quota.details
|
|
return Array(ordered.prefix(maxWindowColumns))
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
@Observable
|
|
final class CapacityDockViewModel {
|
|
var preferences: CapacityDockPreferences.Snapshot
|
|
var interaction = CapacityDockInteractionState()
|
|
var hoveredProvider: CapacityDockProvider?
|
|
var detailHeight: CGFloat = 164
|
|
var isRailPresentationExpanded = false
|
|
var railPresentationProgress: CGFloat = 0
|
|
var dockedEdge: CapacityDockEdge?
|
|
var attachmentEdge: CapacityDockEdge
|
|
var attachmentProgress: CGFloat
|
|
var detailTailEdge: CapacityDockEdge = .right
|
|
var detailTailPosition: CGFloat = 0.5
|
|
var expansionAnchor: CapacityDockExpansionAnchor = .start
|
|
|
|
init(preferences: CapacityDockPreferences.Snapshot) {
|
|
self.preferences = preferences
|
|
self.dockedEdge = preferences.dockedEdge
|
|
self.attachmentEdge = preferences.attachmentEdge
|
|
self.attachmentProgress = preferences.dockedEdge == nil ? 0 : 1
|
|
}
|
|
|
|
var displayedProviders: [CapacityDockProvider] {
|
|
guard isRailPresentationExpanded else { return [preferences.preferredProvider] }
|
|
let preferred = preferences.preferredProvider
|
|
let providers = [preferred] + preferences.selectedProviders.filter { $0 != preferred }
|
|
return expansionAnchor == .start ? providers : providers.reversed()
|
|
}
|
|
|
|
var restingBodyLength: CGFloat {
|
|
CapacityDockMetrics.railHeight(providerCount: 1, alongPad: railAlongPad, scale: scale)
|
|
}
|
|
var expandedBodyLength: CGFloat {
|
|
CapacityDockMetrics.railHeight(
|
|
providerCount: preferences.selectedProviders.count,
|
|
alongPad: railAlongPad,
|
|
scale: scale
|
|
)
|
|
}
|
|
var targetBodyLength: CGFloat {
|
|
interaction.isExpanded ? expandedBodyLength : restingBodyLength
|
|
}
|
|
var bodyLength: CGFloat {
|
|
restingBodyLength
|
|
+ (expandedBodyLength - restingBodyLength)
|
|
* min(max(railPresentationProgress, 0), 1)
|
|
}
|
|
|
|
var scale: CGFloat { CGFloat(preferences.scale) }
|
|
var detailScale: CGFloat { max(scale, 0.9) }
|
|
var railWidth: CGFloat {
|
|
isVertical
|
|
? CapacityDockMetrics.railWidth(scale: scale)
|
|
: CapacityDockMetrics.horizontalRailWidth(scale: scale)
|
|
}
|
|
var edgeFlareWidth: CGFloat { CapacityDockMetrics.edgeFlareWidth(scale: scale) }
|
|
var isDocked: Bool { dockedEdge != nil }
|
|
var isVertical: Bool { attachmentEdge.isVertical }
|
|
var bodySize: CGSize {
|
|
isVertical
|
|
? CGSize(width: railWidth, height: bodyLength)
|
|
: CGSize(width: bodyLength, height: railWidth)
|
|
}
|
|
var panelSize: CGSize {
|
|
panelSize(forAttachmentProgress: attachmentProgress)
|
|
}
|
|
func panelSize(forAttachmentProgress progress: CGFloat) -> CGSize {
|
|
panelSize(bodyLength: bodyLength, attachmentProgress: progress)
|
|
}
|
|
func targetPanelSize(forAttachmentProgress progress: CGFloat) -> CGSize {
|
|
panelSize(bodyLength: targetBodyLength, attachmentProgress: progress)
|
|
}
|
|
private func panelSize(bodyLength: CGFloat, attachmentProgress progress: CGFloat) -> CGSize {
|
|
return isVertical
|
|
? CGSize(width: railWidth, height: bodyLength)
|
|
: CGSize(width: bodyLength, height: railWidth)
|
|
}
|
|
var rowHeight: CGFloat { CapacityDockMetrics.rowHeight(scale: scale) }
|
|
var rowSpacing: CGFloat { CapacityDockMetrics.rowSpacing(scale: scale) }
|
|
// Along-axis content padding: small when floating, plus the docked concave
|
|
// flare depth so content never crowds a necked edge. Cross-axis is a small
|
|
// fixed margin. railTop/BottomPadding stay as the names the controller's
|
|
// detail-tail math reads.
|
|
var flareCompensation: CGFloat {
|
|
let p = min(max(attachmentProgress, 0), 1)
|
|
let eased = p * p * (3 - 2 * p)
|
|
return CapacityDockMetrics.edgeShoulderDepth(scale: scale) * 0.6 * eased
|
|
}
|
|
// Rounded after the flare is added, not before: the panel's length is built from this
|
|
// padding, and a fractional length re-runs the window's layout at display cadence for
|
|
// as long as the dock is up. `points` rounds the base, the eased flare then reintroduced
|
|
// the fraction for the whole attach animation.
|
|
var railAlongPad: CGFloat {
|
|
(CapacityDockMetrics.railAlongPad(scale: scale) + flareCompensation).rounded()
|
|
}
|
|
var railCrossPad: CGFloat { CapacityDockMetrics.railCrossPad(scale: scale) }
|
|
var railTopPadding: CGFloat { railAlongPad }
|
|
var railBottomPadding: CGFloat { railAlongPad }
|
|
var detailWidth: CGFloat { CapacityDockMetrics.detailWidth(scale: detailScale) }
|
|
|
|
func presentationOpacity(for provider: CapacityDockProvider) -> CGFloat {
|
|
provider == preferences.preferredProvider ? 1 : railPresentationProgress
|
|
}
|
|
}
|
|
|
|
struct CapacityDockView: View {
|
|
let model: CapacityDockViewModel
|
|
let quota: (CapacityDockProvider) -> QuotaSummary?
|
|
let onProviderClick: (CapacityDockProvider) -> Void
|
|
let onSwitchGlanceWindow: (CapacityDockProvider) -> Void
|
|
let onHide: () -> Void
|
|
let onDock: (CapacityDockEdge) -> Void
|
|
let onDragChanged: (CGPoint, CGSize) -> Void
|
|
let onDragEnded: () -> Void
|
|
|
|
var body: some View {
|
|
let railShape = CapacityDockRailShape(
|
|
bodyWidth: model.railWidth,
|
|
bodyLength: model.bodyLength,
|
|
shoulderDepth: CapacityDockMetrics.edgeShoulderDepth(scale: model.scale),
|
|
attachmentProgress: model.attachmentProgress,
|
|
edge: model.attachmentEdge
|
|
)
|
|
let providerLayout = model.isVertical
|
|
? AnyLayout(VStackLayout(spacing: model.rowSpacing))
|
|
: AnyLayout(HStackLayout(spacing: model.rowSpacing))
|
|
providerLayout {
|
|
ForEach(Array(model.displayedProviders.enumerated()), id: \.element.id) { index, provider in
|
|
CapacityDockProviderRow(
|
|
provider: provider,
|
|
quota: quota(provider),
|
|
scale: model.scale,
|
|
gaugeShape: model.preferences.gaugeShape,
|
|
glanceWindow: model.preferences.glanceWindow(for: provider),
|
|
onClick: { onProviderClick(provider) },
|
|
onSwitchGlanceWindow: { onSwitchGlanceWindow(provider) }
|
|
)
|
|
.frame(
|
|
width: model.isVertical ? model.railWidth : model.rowHeight,
|
|
height: model.isVertical ? model.rowHeight : model.railWidth
|
|
)
|
|
.opacity(model.presentationOpacity(for: provider))
|
|
.offset(
|
|
x: model.isVertical || provider == model.preferences.preferredProvider
|
|
? 0
|
|
: -8 * model.scale * (1 - model.railPresentationProgress),
|
|
y: !model.isVertical || provider == model.preferences.preferredProvider
|
|
? 0
|
|
: -8 * model.scale * (1 - model.railPresentationProgress)
|
|
)
|
|
}
|
|
}
|
|
.padding(.top, model.isVertical ? model.railAlongPad : model.railCrossPad)
|
|
.padding(.bottom, model.isVertical ? model.railAlongPad : model.railCrossPad)
|
|
.padding(.leading, model.isVertical ? model.railCrossPad : model.railAlongPad)
|
|
.padding(.trailing, model.isVertical ? model.railCrossPad : model.railAlongPad)
|
|
// Keep the preferred row pinned to the reveal origin. Without an
|
|
// explicit axis alignment, SwiftUI centers the already-expanded stack
|
|
// inside the interpolating frame and makes the first ring look as if it
|
|
// is being redrawn from the middle with the incoming rows.
|
|
.frame(
|
|
width: model.bodySize.width,
|
|
height: model.bodySize.height,
|
|
alignment: revealAlignment
|
|
)
|
|
.frame(
|
|
width: model.panelSize.width,
|
|
height: model.panelSize.height,
|
|
alignment: contentAlignment
|
|
)
|
|
.background(CapacityDockSurface(shape: railShape, theme: model.preferences.theme))
|
|
.clipShape(railShape)
|
|
.overlay {
|
|
if model.preferences.theme == .graphite {
|
|
railShape
|
|
.stroke(
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: .white.opacity(0.05), location: 0),
|
|
.init(color: .white.opacity(0.09), location: 0.55),
|
|
.init(color: .white.opacity(0.14), location: 0.86),
|
|
.init(color: .white.opacity(0.08), location: 1),
|
|
],
|
|
startPoint: .top,
|
|
endPoint: .bottom
|
|
),
|
|
lineWidth: max(0.6, model.scale * 0.8)
|
|
)
|
|
}
|
|
}
|
|
.clipShape(railShape)
|
|
.contentShape(railShape)
|
|
.contextMenu {
|
|
Menu(L("Dock to Edge")) {
|
|
Button(L("Left")) { onDock(.left) }
|
|
Button(L("Right")) { onDock(.right) }
|
|
Button(L("Top")) { onDock(.top) }
|
|
Button(L("Bottom")) { onDock(.bottom) }
|
|
}
|
|
Button(L("Hide Capacity Dock"), action: onHide)
|
|
}
|
|
.simultaneousGesture(
|
|
DragGesture(minimumDistance: 3, coordinateSpace: .global)
|
|
.onChanged { onDragChanged(NSEvent.mouseLocation, $0.translation) }
|
|
.onEnded { _ in onDragEnded() }
|
|
)
|
|
.accessibilityElement(children: .contain)
|
|
.accessibilityLabel(L("Capacity Dock"))
|
|
// The rail panel is built once and kept, so its labels only follow a
|
|
// language change if the whole subtree is rebuilt. The detail bubble is
|
|
// torn down after every hover and needs nothing.
|
|
.id(LanguageGeneration.shared.value)
|
|
}
|
|
|
|
private var contentAlignment: Alignment {
|
|
switch model.attachmentEdge {
|
|
case .left: .trailing
|
|
case .right: .leading
|
|
case .top: .bottom
|
|
case .bottom: .top
|
|
}
|
|
}
|
|
|
|
private var revealAlignment: Alignment {
|
|
if model.isVertical {
|
|
return model.expansionAnchor == .start ? .top : .bottom
|
|
}
|
|
return model.expansionAnchor == .start ? .leading : .trailing
|
|
}
|
|
}
|
|
|
|
private struct CapacityDockProviderRow: View {
|
|
let provider: CapacityDockProvider
|
|
let quota: QuotaSummary?
|
|
let scale: CGFloat
|
|
let gaugeShape: CapacityDockGaugeShape
|
|
let glanceWindow: CapacityDockGlanceWindowKind
|
|
let onClick: () -> Void
|
|
let onSwitchGlanceWindow: () -> Void
|
|
|
|
private var headline: QuotaSummary.Window? {
|
|
CapacityDockGlanceWindow.resolvedWindow(preferred: glanceWindow, quota: quota)
|
|
}
|
|
private var percent: Double? { headline?.percent }
|
|
|
|
var body: some View {
|
|
Button(action: onClick) {
|
|
VStack(spacing: CapacityDockMetrics.ringLabelSpacing(scale: scale)) {
|
|
ZStack {
|
|
CapacityDockUsageRing(
|
|
progress: percent,
|
|
color: headlineRingColor,
|
|
scale: scale,
|
|
gaugeShape: gaugeShape
|
|
)
|
|
|
|
if let image = ProviderIconCache.image(named: provider.iconName) {
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
.foregroundStyle(.white)
|
|
.frame(
|
|
width: CapacityDockMetrics.providerIconSize(scale: scale),
|
|
height: CapacityDockMetrics.providerIconSize(scale: scale)
|
|
)
|
|
} else {
|
|
Image(systemName: "circle.dotted")
|
|
.font(.system(size: 21 * scale, weight: .medium))
|
|
.foregroundStyle(.white)
|
|
}
|
|
|
|
if case .terminalFailure = quota?.connection {
|
|
Image(systemName: "exclamationmark.circle.fill")
|
|
.font(.system(size: 12 * scale, weight: .bold))
|
|
.foregroundStyle(.red)
|
|
.background(Circle().fill(.black))
|
|
.offset(x: 19 * scale, y: -19 * scale)
|
|
}
|
|
}
|
|
.frame(
|
|
width: CapacityDockMetrics.ringSize(scale: scale),
|
|
height: CapacityDockMetrics.ringSize(scale: scale)
|
|
)
|
|
|
|
Text(headline?.percentLabel ?? "--")
|
|
.font(.system(
|
|
size: CapacityDockMetrics.percentageTextSize(scale: scale),
|
|
weight: .medium
|
|
))
|
|
.monospacedDigit()
|
|
.foregroundStyle(headlinePercentColor)
|
|
.contentTransition(.numericText())
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(L("%@ usage", provider.displayName))
|
|
.accessibilityValue(accessibilityValue)
|
|
.accessibilityHint(accessibilityHint)
|
|
// The gauge is the switch, and a click is a pointer gesture. Keyboard
|
|
// and VoiceOver users reach the same horizon change through a named
|
|
// action on the row. It is offered unconditionally because quota
|
|
// arrives asynchronously, and an action that appears and disappears as
|
|
// the first fetch lands is worse than one that no-ops for a provider
|
|
// with a single window.
|
|
.accessibilityAction(named: switchActionName, onSwitchGlanceWindow)
|
|
}
|
|
|
|
private var isSwitchable: Bool {
|
|
CapacityDockGlanceWindow.isSwitchable(quota: quota)
|
|
}
|
|
|
|
/// Names the horizon as well as the number: the ring looks identical
|
|
/// whichever window feeds it, so the window is the part a screen reader
|
|
/// has to say out loud.
|
|
private var accessibilityValue: String {
|
|
guard let headline else { return L("Unknown") }
|
|
return "\(localizedWindowLabel(headline.label)) \(headline.percentLabel)"
|
|
}
|
|
|
|
private var accessibilityHint: String {
|
|
isSwitchable
|
|
? L("Click to switch between this provider's usage windows")
|
|
: L("Click to keep Capacity Dock expanded")
|
|
}
|
|
|
|
private var switchActionName: String {
|
|
let next = CapacityDockGlanceWindow.next(after: glanceWindow, quota: quota)
|
|
let label = CapacityDockGlanceWindow.window(next, quota: quota)?.label
|
|
?? next.displayName
|
|
return L("Show %@ usage", localizedWindowLabel(label))
|
|
}
|
|
|
|
private var headlinePercentColor: Color {
|
|
guard let percent else { return Color.capacityDockText.opacity(0.72) }
|
|
switch QuotaSummary.severity(for: percent) {
|
|
case .normal: return Color.capacityDockText
|
|
case .warning: return .yellow
|
|
case .critical: return .orange
|
|
case .danger: return .red
|
|
}
|
|
}
|
|
|
|
// The ring reflects the weekly (else monthly) limit's status, not a brand
|
|
// colour: green while there is headroom, stepping to red as it is exhausted.
|
|
private var headlineRingColor: Color {
|
|
guard let percent else { return Color.capacityDockText.opacity(0.35) }
|
|
switch QuotaSummary.severity(for: percent) {
|
|
case .normal: return .green
|
|
case .warning: return .yellow
|
|
case .critical: return .orange
|
|
case .danger: return .red
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct CapacityDockUsageRing: View {
|
|
let progress: Double?
|
|
let color: Color
|
|
let scale: CGFloat
|
|
let gaugeShape: CapacityDockGaugeShape
|
|
|
|
private var strokeWidth: CGFloat {
|
|
CapacityDockMetrics.ringStrokeWidth(scale: scale)
|
|
}
|
|
|
|
var body: some View {
|
|
ZStack {
|
|
// A recessed track makes the progress read as light filling a
|
|
// physical channel instead of a flat vector stroke.
|
|
CapacityDockGaugePath(kind: gaugeShape)
|
|
.stroke(Color.black.opacity(0.74), lineWidth: strokeWidth + 2 * scale)
|
|
CapacityDockGaugePath(kind: gaugeShape)
|
|
.stroke(
|
|
LinearGradient(
|
|
colors: [.white.opacity(0.16), .white.opacity(0.07), .white.opacity(0.12)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
),
|
|
lineWidth: strokeWidth + 0.6 * scale
|
|
)
|
|
|
|
if let progress {
|
|
let amount = min(max(progress, 0), 1)
|
|
// Plain solid progress arc, no neon glow or gradient sheen.
|
|
CapacityDockGaugePath(kind: gaugeShape)
|
|
.trim(from: 0, to: amount)
|
|
.stroke(
|
|
color,
|
|
style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round)
|
|
)
|
|
.rotationEffect(.degrees(-90))
|
|
} else {
|
|
CapacityDockGaugePath(kind: gaugeShape)
|
|
.stroke(
|
|
Color.white.opacity(0.24),
|
|
style: StrokeStyle(
|
|
lineWidth: 2 * scale,
|
|
dash: [3 * scale, 4 * scale]
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CapacityDockGaugePath: Shape {
|
|
let kind: CapacityDockGaugeShape
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
switch kind {
|
|
case .circle:
|
|
Path(ellipseIn: rect)
|
|
case .squircle:
|
|
RoundedRectangle(
|
|
cornerRadius: min(rect.width, rect.height) * 0.30,
|
|
style: .continuous
|
|
)
|
|
.path(in: rect)
|
|
}
|
|
}
|
|
}
|
|
|
|
enum CapacityDockQuotaPresentation {
|
|
static func displayLabel(_ label: String) -> String {
|
|
localizedWindowLabel(
|
|
label
|
|
.replacingOccurrences(of: "Claude and GPT models", with: "Claude + GPT", options: .caseInsensitive)
|
|
.replacingOccurrences(of: "Gemini Models", with: "Gemini", options: .caseInsensitive)
|
|
.replacingOccurrences(of: "Five-hour", with: "5-hour", options: .caseInsensitive)
|
|
)
|
|
}
|
|
|
|
static func visibleFooterLines(
|
|
_ lines: [String],
|
|
connection: QuotaSummary.Connection
|
|
) -> [String] {
|
|
guard case let .terminalFailure(reason) = connection,
|
|
let reason,
|
|
!reason.isEmpty else { return lines }
|
|
let normalizedReason = reason.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
return lines.filter {
|
|
$0.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
.localizedCaseInsensitiveCompare(normalizedReason) != .orderedSame
|
|
}
|
|
}
|
|
}
|
|
|
|
struct CapacityDockDetailView: View {
|
|
let model: CapacityDockViewModel
|
|
let quota: (CapacityDockProvider) -> QuotaSummary?
|
|
let onConnect: (CapacityDockProvider) -> Void
|
|
@Environment(AppStore.self) private var store
|
|
/// Session id -> the terminal its process was found in. A row missing from
|
|
/// here stays inert; resolution reads files and the process table, so it
|
|
/// happens once per session list rather than per render.
|
|
@State private var sessionFocusTargets: [String: SessionFocusTarget] = [:]
|
|
|
|
var body: some View {
|
|
let bubbleShape = CapacityDockBubbleShape(
|
|
tailEdge: model.detailTailEdge,
|
|
tailPosition: model.detailTailPosition
|
|
)
|
|
Group {
|
|
if let provider = model.hoveredProvider {
|
|
detail(for: provider, quota: quota(provider))
|
|
}
|
|
}
|
|
.padding(detailInsets)
|
|
.frame(
|
|
width: model.detailWidth,
|
|
height: model.detailHeight,
|
|
alignment: .topLeading
|
|
)
|
|
.background(CapacityDockSurface(shape: bubbleShape, theme: model.preferences.theme))
|
|
.overlay {
|
|
if model.preferences.theme == .graphite {
|
|
bubbleShape
|
|
.stroke(Color.white.opacity(0.09), lineWidth: max(0.5, model.detailScale))
|
|
}
|
|
}
|
|
.contentShape(bubbleShape)
|
|
.accessibilityElement(children: .contain)
|
|
}
|
|
|
|
private var detailInsets: EdgeInsets {
|
|
let tailAllowance = CapacityDockGlance.tailAllowance * model.detailScale
|
|
// Every section carries its own padding, so the panel adds only the tail's
|
|
// allowance on whichever side the tail points.
|
|
return EdgeInsets(
|
|
top: model.detailTailEdge == .top ? tailAllowance : 0,
|
|
leading: model.detailTailEdge == .left ? tailAllowance : 0,
|
|
bottom: model.detailTailEdge == .bottom ? tailAllowance : 0,
|
|
trailing: model.detailTailEdge == .right ? tailAllowance : 0
|
|
)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func detail(for provider: CapacityDockProvider, quota: QuotaSummary?) -> some View {
|
|
if let quota {
|
|
glance(for: provider, quota: quota)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
VStack(alignment: .leading, spacing: CapacityDockGlance.connectGuidanceGap * model.detailScale) {
|
|
header(provider, plan: nil)
|
|
Text(
|
|
provider == .copilot
|
|
&& CopilotExplicitDisconnect.isSet(defaults: store.copilotQuotaRuntime.defaults)
|
|
? CopilotQuotaPresentation.disconnectedSettingsDetail
|
|
: ProviderConnectionGuidance.dockInstruction(for: provider)
|
|
)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.62))
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
.padding(.top, CapacityDockGlance.contentInset * model.detailScale)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * model.detailScale)
|
|
Spacer(minLength: 0)
|
|
connectButton(provider, quota: nil)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sections stack at fixed heights that mirror `CapacityDockMetrics.detailHeight`,
|
|
/// because the panel frame is computed rather than fitted. Separators are
|
|
/// overlays so a hairline never adds a point the height did not reserve.
|
|
@ViewBuilder
|
|
private func glance(for provider: CapacityDockProvider, quota: QuotaSummary) -> some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
headerSection(provider, plan: quota.planLabel).dividerBelow()
|
|
noticeSection(quota.connection, provider: provider)
|
|
if let sessions = store.capacityDockLiveSessions(for: provider) {
|
|
sessionsSection(sessions).dividerBelow()
|
|
}
|
|
if let today = store.capacityDockToday(for: provider) {
|
|
todaySection(today).dividerBelow()
|
|
}
|
|
if CapacityDockGlance.drawsWindows(quota) { windowsSection(quota) }
|
|
Spacer(minLength: 0)
|
|
connectButton(provider, quota: quota)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func headerSection(_ provider: CapacityDockProvider, plan: String?) -> some View {
|
|
let s = model.detailScale
|
|
header(provider, plan: plan)
|
|
.padding(.top, CapacityDockGlance.contentInset * s)
|
|
.padding(.bottom, 8 * s)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
|
}
|
|
|
|
/// The notice carries the same horizontal inset as every other section, its own
|
|
/// vertical padding, and a divider, so it reads as a band rather than as text
|
|
/// crammed against the header.
|
|
@ViewBuilder
|
|
private func noticeSection(
|
|
_ connection: QuotaSummary.Connection,
|
|
provider: CapacityDockProvider
|
|
) -> some View {
|
|
let s = model.detailScale
|
|
if case .connected = connection {
|
|
EmptyView()
|
|
} else {
|
|
let band = connectionLabel(connection, provider: provider)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, CapacityDockGlance.noticeTopPad * s)
|
|
.padding(.bottom, CapacityDockGlance.noticeBottomPad * s)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
|
if CapacityDockGlance.drawsNotice(connection) {
|
|
band.dividerBelow()
|
|
} else {
|
|
band
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func header(_ provider: CapacityDockProvider, plan: String?) -> some View {
|
|
let s = model.detailScale
|
|
HStack(spacing: 8 * s) {
|
|
if let image = ProviderIconCache.image(named: provider.iconName) {
|
|
Image(nsImage: image)
|
|
.resizable()
|
|
.scaledToFit()
|
|
// Matches the 17pt title's cap height plus a little, so the
|
|
// mark reads as its equal rather than as a bullet.
|
|
.frame(width: 18 * s, height: 18 * s)
|
|
}
|
|
Text(provider.displayName)
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.foregroundStyle(Color.capacityDockText)
|
|
Spacer(minLength: 6)
|
|
// Several providers fall back to their own name as the plan label
|
|
// when no plan is known, which draws the title twice in one row.
|
|
if let plan = CapacityDockGlance.headerPlanLabel(plan, provider: provider) {
|
|
Text(plan)
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
.frame(height: 20 * s)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sectionCaption(_ title: String, trailing: String?) -> some View {
|
|
let s = model.detailScale
|
|
HStack(spacing: 8 * s) {
|
|
Text(title)
|
|
.font(.system(size: 10.5, weight: .semibold))
|
|
if let trailing {
|
|
Spacer(minLength: 8)
|
|
Text(trailing)
|
|
.font(.system(size: 10.5))
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
.frame(height: CapacityDockGlance.captionLine * s)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sessionsSection(_ sessions: [LiveSession]) -> some View {
|
|
let s = model.detailScale
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sectionCaption(L("Sessions"), trailing: sessionsTrailing(sessions.count))
|
|
if !sessions.isEmpty {
|
|
ScrollView(.vertical) {
|
|
VStack(spacing: CapacityDockGlance.pillGap * s) {
|
|
ForEach(sessions) { session in
|
|
sessionPill(session)
|
|
}
|
|
}
|
|
}
|
|
.scrollIndicators(.automatic)
|
|
.frame(height: CapacityDockGlance.sessionListHeight(count: sessions.count) * s)
|
|
.padding(.top, CapacityDockGlance.pillGap * s)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, CapacityDockGlance.sectionPadTop * s)
|
|
.padding(.bottom, CapacityDockGlance.sectionPadBottom * s)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
|
.task(id: sessions.map(\.id)) {
|
|
sessionFocusTargets = await Task.detached(priority: .utility) {
|
|
sessions.reduce(into: [String: SessionFocusTarget]()) { targets, session in
|
|
targets[session.id] = SessionFocus.target(for: session)
|
|
}
|
|
}.value
|
|
}
|
|
}
|
|
|
|
private func sessionsTrailing(_ count: Int) -> String {
|
|
switch count {
|
|
case 0: return L("none running")
|
|
case 1: return L("1 running")
|
|
default: return L("%lld running", count)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func sessionPill(_ session: LiveSession) -> some View {
|
|
let s = model.detailScale
|
|
let fraction = session.contextFraction
|
|
HStack(spacing: 0) {
|
|
VStack(alignment: .leading, spacing: 2 * s) {
|
|
Text(session.title)
|
|
.font(.system(size: 12))
|
|
.foregroundStyle(Color.capacityDockText)
|
|
.lineLimit(1)
|
|
.truncationMode(.tail)
|
|
.frame(height: 15 * s)
|
|
Text(sessionSubtitle(session))
|
|
.font(.system(size: 10.5))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
.lineLimit(1)
|
|
.frame(height: 13 * s)
|
|
// The model name identifies the row, so the project above it
|
|
// gives up width first.
|
|
.layoutPriority(1)
|
|
}
|
|
Spacer(minLength: 12)
|
|
if let fraction {
|
|
VStack(alignment: .trailing, spacing: 2 * s) {
|
|
PercentGaugeText(
|
|
label: "\(Int((fraction * 100).rounded()))%",
|
|
fraction: fraction,
|
|
font: .system(size: 12)
|
|
)
|
|
.frame(height: 15 * s)
|
|
if let remaining = session.contextRemaining {
|
|
Text(L("%@ left", Double(remaining).asCompactTokens().lowercasedThousands()))
|
|
.font(.system(size: 10))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
.frame(height: 13 * s)
|
|
}
|
|
}
|
|
.layoutPriority(1)
|
|
}
|
|
}
|
|
.padding(.vertical, 8 * s)
|
|
.padding(.horizontal, 12 * s)
|
|
.frame(height: CapacityDockGlance.pillHeight * s)
|
|
.background(
|
|
ZStack(alignment: .leading) {
|
|
Color.white.opacity(0.06)
|
|
if let fraction {
|
|
GeometryReader { geometry in
|
|
pillFill(fraction: fraction, width: geometry.size.width, scale: s)
|
|
}
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 8 * s, style: .continuous))
|
|
)
|
|
// A session waiting on the user recedes; one that is generating does not.
|
|
.opacity(session.isIdle ? 0.55 : 1)
|
|
.modifier(SessionFocusAffordance(target: sessionFocusTargets[session.id]))
|
|
}
|
|
|
|
/// The pill itself is the context gauge: a tinted band over the first N% of
|
|
/// its width, faded out at the leading edge so the boundary reads as a
|
|
/// gradient rather than a rule.
|
|
@ViewBuilder
|
|
private func pillFill(fraction: Double, width: CGFloat, scale: CGFloat) -> some View {
|
|
let filled = width * CapacityDockGlance.gaugeFillFraction(fraction)
|
|
let colour = CapacityDockGlance.severityColor(fraction)
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: colour.opacity(0.14), location: 0),
|
|
.init(
|
|
color: colour.opacity(0.14),
|
|
location: CapacityDockGlance.pillFadeStart(filledWidth: filled, scale: scale)
|
|
),
|
|
.init(color: colour.opacity(0), location: 1),
|
|
],
|
|
startPoint: .leading,
|
|
endPoint: .trailing
|
|
)
|
|
.frame(width: filled)
|
|
}
|
|
|
|
private func sessionSubtitle(_ session: LiveSession) -> String {
|
|
[session.model, session.elapsedLabel().isEmpty ? nil : session.elapsedLabel()]
|
|
.compactMap { $0 }
|
|
.joined(separator: " · ")
|
|
}
|
|
|
|
@ViewBuilder
|
|
/// Provider scoped: the row belongs to the hovered provider, not the machine.
|
|
/// Its height is the same whether or not the payload carried that provider's
|
|
/// tokens — the column drops out of a fixed frame, so the panel's reserved
|
|
/// `todayHeight` keeps matching what is drawn.
|
|
private func todaySection(_ today: ProviderDetail) -> some View {
|
|
let s = model.detailScale
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
sectionCaption(L("Today"), trailing: nil)
|
|
HStack(alignment: .firstTextBaseline, spacing: 8 * s) {
|
|
HStack(alignment: .firstTextBaseline, spacing: 5 * s) {
|
|
Text(today.cost.asUSD())
|
|
.font(.system(size: 17, weight: .semibold))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText)
|
|
Text(L("burned"))
|
|
.font(.system(size: 11.5))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
}
|
|
Spacer(minLength: 8)
|
|
VStack(alignment: .trailing, spacing: 3 * s) {
|
|
// Absent on a CLI that predates per-provider tokens. Showing
|
|
// the machine-wide pair here would be a wrong number, so the
|
|
// arrows drop out instead.
|
|
if let input = today.inputTokens { tokenLine("arrow.down", Double(input)) }
|
|
if let output = today.outputTokens { tokenLine("arrow.up", Double(output)) }
|
|
// Same absence rule as the arrows: cache read is shown only
|
|
// when the payload carries it for this provider, so a legacy
|
|
// CLI reads as unknown rather than as a fabricated zero.
|
|
if let cacheRead = today.cacheReadTokens {
|
|
cacheReadLine(Double(cacheRead))
|
|
}
|
|
Text(L("%@ calls", today.calls.asThousandsSeparated()))
|
|
.font(.system(size: 10))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
.frame(height: 12 * s)
|
|
}
|
|
}
|
|
.frame(height: CapacityDockGlance.todayContentHeight * s)
|
|
.padding(.top, CapacityDockGlance.pillGap * s)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, CapacityDockGlance.sectionPadTop * s)
|
|
.padding(.bottom, CapacityDockGlance.sectionPadBottom * s)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tokenLine(_ symbol: String, _ value: Double) -> some View {
|
|
let s = model.detailScale
|
|
HStack(spacing: 4 * s) {
|
|
Image(systemName: symbol)
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
Text(value.asCompactTokens().lowercasedThousands())
|
|
.font(.system(size: 10.5))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText)
|
|
}
|
|
.frame(height: 13 * s)
|
|
}
|
|
|
|
/// Reused input tokens, kept apart from the `arrow.down` fresh-input figure
|
|
/// and from cache writes: this is the part of the prompt the provider served
|
|
/// from its cache at the discounted rate that `cost` already includes.
|
|
@ViewBuilder
|
|
private func cacheReadLine(_ value: Double) -> some View {
|
|
let s = model.detailScale
|
|
let explanation = L(
|
|
"Input tokens reused from this provider's prompt cache today — not fresh input (arrow.down), not cache writes, and already priced at the cache-read rate inside the burned figure."
|
|
)
|
|
HStack(spacing: 4 * s) {
|
|
Image(systemName: "arrow.triangle.2.circlepath")
|
|
.font(.system(size: 9, weight: .semibold))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
Text(value.asCompactTokens().lowercasedThousands())
|
|
.font(.system(size: 10.5))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText)
|
|
Text(L("cache read"))
|
|
.font(.system(size: 9.5))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
}
|
|
.frame(height: 13 * s)
|
|
.help(explanation)
|
|
.accessibilityElement(children: .ignore)
|
|
.accessibilityLabel(L("Cache read: %@ tokens", value.asCompactTokens().lowercasedThousands()))
|
|
.accessibilityHint(explanation)
|
|
}
|
|
|
|
/// One cell per quota window, in the order the provider reported them.
|
|
/// Up to three windows share a compact row; four use two rows. Cell width
|
|
/// comes from the actual content geometry, including its inter-column gap.
|
|
@ViewBuilder
|
|
private func windowsSection(_ quota: QuotaSummary) -> some View {
|
|
let s = model.detailScale
|
|
let windows = CapacityDockGlance.windows(quota)
|
|
Group {
|
|
if windows.isEmpty {
|
|
budgetLine()
|
|
.frame(height: CapacityDockGlance.captionLine * s)
|
|
} else {
|
|
windowGrid(windows, scale: s)
|
|
.frame(height: CapacityDockGlance.windowsGridHeight(for: quota) * s)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.top, CapacityDockGlance.sectionPadTop * s)
|
|
.padding(.bottom, CapacityDockGlance.contentInset * s)
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * s)
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func windowGrid(
|
|
_ windows: [QuotaSummary.Window],
|
|
scale: CGFloat
|
|
) -> some View {
|
|
let columnCount = CapacityDockGlance.windowColumnCount(for: windows.count)
|
|
let rowCount = CapacityDockGlance.windowRowCount(for: windows.count)
|
|
let alignment: HorizontalAlignment = windows.count == 1 ? .leading : .center
|
|
GeometryReader { geometry in
|
|
let columnSpacing = columnCount > 1
|
|
? CapacityDockGlance.windowsColumnGap * scale
|
|
: 0
|
|
let columnWidth = max(
|
|
0,
|
|
(geometry.size.width - columnSpacing * CGFloat(max(0, columnCount - 1)))
|
|
/ CGFloat(max(columnCount, 1))
|
|
)
|
|
VStack(spacing: rowCount > 1 ? CapacityDockGlance.windowsRowGap * scale : 0) {
|
|
ForEach(0..<rowCount, id: \.self) { row in
|
|
HStack(spacing: columnSpacing) {
|
|
ForEach(0..<columnCount, id: \.self) { column in
|
|
let index = row * columnCount + column
|
|
if index < windows.count {
|
|
windowColumn(
|
|
windows[index],
|
|
width: columnWidth,
|
|
alignment: alignment,
|
|
scale: scale
|
|
)
|
|
} else {
|
|
// Keep an odd final row's first cell in the same
|
|
// geometry as every other row without inventing a
|
|
// quota window or shifting its identity.
|
|
Color.clear
|
|
.frame(width: columnWidth)
|
|
}
|
|
}
|
|
}
|
|
.frame(width: geometry.size.width, alignment: .leading)
|
|
}
|
|
}
|
|
.frame(width: geometry.size.width, height: geometry.size.height, alignment: .top)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func windowColumn(
|
|
_ window: QuotaSummary.Window,
|
|
width: CGFloat,
|
|
alignment: HorizontalAlignment,
|
|
scale: CGFloat
|
|
) -> some View {
|
|
VStack(alignment: alignment, spacing: 0) {
|
|
PercentGaugeText(
|
|
label: window.percentLabel,
|
|
fraction: window.percent,
|
|
font: .system(size: 20 * scale, weight: .semibold)
|
|
)
|
|
.frame(width: width, height: 24 * scale, alignment: alignment == .leading ? .leading : .center)
|
|
Text(CapacityDockQuotaPresentation.displayLabel(window.label))
|
|
.font(.system(size: 11 * scale))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
.lineLimit(1)
|
|
.minimumScaleFactor(0.7)
|
|
.truncationMode(.middle)
|
|
.frame(
|
|
width: width,
|
|
height: CapacityDockGlance.captionLine * scale,
|
|
alignment: alignment == .leading ? .leading : .center
|
|
)
|
|
.padding(.top, 2 * scale)
|
|
.help(window.label)
|
|
.accessibilityLabel(L("Quota window %@", window.label))
|
|
Text(window.resetsInLabel)
|
|
.font(.system(size: 10 * scale))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.3))
|
|
// Reset labels come from the validated countdown formatter and
|
|
// are short (for example, "3d 11h"). Let the complete value
|
|
// keep its natural one-line width at the small 0.9x card scale;
|
|
// the grid cells are substantially wider than these labels.
|
|
.fixedSize(horizontal: true, vertical: true)
|
|
.frame(
|
|
width: width,
|
|
height: 12 * scale,
|
|
alignment: alignment == .leading ? .leading : .center
|
|
)
|
|
.padding(.top, 2 * scale)
|
|
.accessibilityLabel(L("Resets %@", window.resetsInLabel))
|
|
}
|
|
.frame(
|
|
width: width,
|
|
alignment: alignment == .leading ? .leading : .center
|
|
)
|
|
}
|
|
|
|
/// No quota window exists for this provider, so money is the capacity.
|
|
@ViewBuilder
|
|
private func budgetLine() -> some View {
|
|
let spend = store.capacityDockToday?.cost ?? 0
|
|
let budget = store.activeDailyBudget
|
|
Text(budget > 0 ? L("today %1$@ of %2$@", spend.asUSD(), budget.asUSD()) : L("no budget set"))
|
|
.font(.system(size: 11))
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func connectButton(_ provider: CapacityDockProvider, quota: QuotaSummary?) -> some View {
|
|
if provider.catalogEntry.hasLiveCodeBurnQuotaAdapter,
|
|
let action = CapacityDockConnectionAction.resolve(quota: quota) {
|
|
let title = action.title(for: provider)
|
|
Button(title) { onConnect(provider) }
|
|
.buttonStyle(.borderedProminent)
|
|
.tint(provider.ringColor)
|
|
.controlSize(.small)
|
|
.accessibilityLabel("\(title) \(provider.displayName)")
|
|
.padding(.horizontal, CapacityDockGlance.contentInset * model.detailScale)
|
|
.padding(.bottom, CapacityDockGlance.contentInset * model.detailScale)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func connectionLabel(
|
|
_ connection: QuotaSummary.Connection,
|
|
provider: CapacityDockProvider
|
|
) -> some View {
|
|
// The reconnect block's reserve scales with the panel, so its fixed-size
|
|
// text has to scale too or it clips at the 0.9 floor. The transient
|
|
// notices are a single caption line whose reserve already covers them.
|
|
let s = model.detailScale
|
|
switch connection {
|
|
case .connected:
|
|
EmptyView()
|
|
case .loading:
|
|
Text(L("Refreshing…"))
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.52))
|
|
case .stale:
|
|
Text(store.quotaRefreshIsInFlight(for: provider)
|
|
? L("Last known usage · refreshing")
|
|
: L("Last known usage"))
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.yellow.opacity(0.82))
|
|
case .transientFailure:
|
|
Text(L("Last known usage · retrying"))
|
|
.font(.system(size: 10))
|
|
.foregroundStyle(.orange.opacity(0.86))
|
|
case .disconnected:
|
|
Text(L("Not connected"))
|
|
.font(.system(size: 11 * s))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.6))
|
|
case .terminalFailure(let reason):
|
|
VStack(alignment: .leading, spacing: CapacityDockGlance.noticeRowGap * s) {
|
|
Text(L("Reconnect required"))
|
|
.font(.system(size: 11 * s, weight: .semibold))
|
|
.foregroundStyle(.red)
|
|
if let reason, !reason.isEmpty {
|
|
Text(reason)
|
|
.font(.system(size: 10 * s))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.58))
|
|
.lineLimit(2)
|
|
}
|
|
Text(ProviderConnectionGuidance.dockInstruction(for: provider))
|
|
.font(.system(size: 10 * s))
|
|
.foregroundStyle(Color.capacityDockText.opacity(0.72))
|
|
.lineLimit(3)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Click-to-raise on a session row whose process was found. A row that resolved
|
|
/// to nothing keeps exactly the layout, the cursor and the inertness it had, so
|
|
/// the affordance never promises a window that is not there.
|
|
private struct SessionFocusAffordance: ViewModifier {
|
|
let target: SessionFocusTarget?
|
|
|
|
/// A row can vanish while the pointer is still over it — the session leaves
|
|
/// the ten-minute window, or the dock hides — and no hover exit ever fires,
|
|
/// so the pushed cursor has to be popped on the way out instead.
|
|
@State private var isHovering = false
|
|
|
|
@ViewBuilder
|
|
func body(content: Content) -> some View {
|
|
if let target {
|
|
content
|
|
.contentShape(Rectangle())
|
|
.help(target.tooltip)
|
|
.accessibilityAddTraits(.isButton)
|
|
.accessibilityHint(target.tooltip)
|
|
.onHover { hovering in
|
|
guard hovering != isHovering else { return }
|
|
isHovering = hovering
|
|
if hovering { NSCursor.pointingHand.push() } else { NSCursor.pop() }
|
|
}
|
|
.onDisappear {
|
|
if isHovering {
|
|
isHovering = false
|
|
NSCursor.pop()
|
|
}
|
|
}
|
|
.onTapGesture { SessionFocus.raise(target) }
|
|
} else {
|
|
content
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A percentage drawn as its own gauge: the glyphs sit dim, and the value's own
|
|
/// severity colour fills them left to right up to the value, wiped off on a
|
|
/// slant. One band colour, so the fill says how bad it is and the wipe says how
|
|
/// far along it is.
|
|
private struct PercentGaugeText: View {
|
|
let label: String
|
|
let fraction: Double
|
|
let font: Font
|
|
|
|
var body: some View {
|
|
Text(label)
|
|
.font(font)
|
|
.monospacedDigit()
|
|
.foregroundStyle(Color.white.opacity(0.3))
|
|
.overlay {
|
|
CapacityDockGlance.severityColor(fraction)
|
|
.clipShape(PercentGaugeReveal(fraction: fraction))
|
|
.mask {
|
|
Text(label)
|
|
.font(font)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
.accessibilityLabel(label)
|
|
}
|
|
}
|
|
|
|
/// The slanted wipe. An overlay clip only, so it never changes layout size.
|
|
private struct PercentGaugeReveal: Shape {
|
|
var fraction: Double
|
|
|
|
var animatableData: Double {
|
|
get { fraction }
|
|
set { fraction = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let edge = CapacityDockGlance.gaugeRevealEdge(
|
|
fraction: fraction,
|
|
width: rect.width,
|
|
height: rect.height
|
|
)
|
|
guard edge.bottom > 0 else { return Path() }
|
|
var path = Path()
|
|
path.move(to: CGPoint(x: rect.minX, y: rect.minY))
|
|
path.addLine(to: CGPoint(x: rect.minX + edge.top, y: rect.minY))
|
|
path.addLine(to: CGPoint(x: rect.minX + edge.bottom, y: rect.maxY))
|
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY))
|
|
path.closeSubpath()
|
|
return path
|
|
}
|
|
}
|
|
|
|
private struct CapacityDockSurface<S: Shape>: View {
|
|
let shape: S
|
|
let theme: CapacityDockTheme
|
|
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
|
|
|
|
@ViewBuilder
|
|
var body: some View {
|
|
if theme == .liquidGlass, !reduceTransparency {
|
|
if #available(macOS 26.0, *) {
|
|
CapacityDockNativeGlassSurface(shape: shape)
|
|
} else {
|
|
shape
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(shape.fill(Color.black.opacity(0.16)))
|
|
}
|
|
} else {
|
|
ZStack {
|
|
shape.fill(
|
|
LinearGradient(
|
|
stops: [
|
|
.init(color: Color(red: 0.075, green: 0.078, blue: 0.085), location: 0),
|
|
.init(color: Color(red: 0.034, green: 0.035, blue: 0.040), location: 0.46),
|
|
.init(color: Color(red: 0.012, green: 0.013, blue: 0.016), location: 1),
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
shape.fill(
|
|
RadialGradient(
|
|
colors: [.white.opacity(0.055), .clear],
|
|
center: .topLeading,
|
|
startRadius: 0,
|
|
endRadius: 180
|
|
)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(macOS 26.0, *)
|
|
private struct CapacityDockNativeGlassSurface<S: Shape>: View {
|
|
let shape: S
|
|
|
|
var body: some View {
|
|
Color.clear
|
|
.glassEffect(.regular.interactive(), in: shape)
|
|
// Native glass tracks the wallpaper, so over a light background it
|
|
// turns pale and the light text disappears. A gentle dark scrim keeps
|
|
// the surface dark enough for the labels on any background while still
|
|
// reading as glass.
|
|
.overlay(shape.fill(Color.black.opacity(0.24)))
|
|
}
|
|
}
|
|
|
|
struct CapacityDockRailShape: Shape {
|
|
var bodyWidth: CGFloat
|
|
var bodyLength: CGFloat? = nil
|
|
var shoulderDepth: CGFloat = 34
|
|
var attachmentProgress: CGFloat
|
|
var edge: CapacityDockEdge
|
|
|
|
var animatableData: CGFloat {
|
|
get { attachmentProgress }
|
|
set { attachmentProgress = newValue }
|
|
}
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let canonicalRect = CGRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: edge.isVertical ? rect.width : rect.height,
|
|
height: edge.isVertical ? rect.height : rect.width
|
|
)
|
|
let canonical = rightFlarePath(in: canonicalRect)
|
|
let transform: CGAffineTransform
|
|
switch edge {
|
|
case .right:
|
|
transform = CGAffineTransform(translationX: rect.minX, y: rect.minY)
|
|
case .left:
|
|
transform = CGAffineTransform(
|
|
a: -1,
|
|
b: 0,
|
|
c: 0,
|
|
d: 1,
|
|
tx: canonicalRect.width + rect.minX,
|
|
ty: rect.minY
|
|
)
|
|
case .bottom:
|
|
transform = CGAffineTransform(
|
|
a: 0,
|
|
b: 1,
|
|
c: 1,
|
|
d: 0,
|
|
tx: rect.minX,
|
|
ty: rect.minY
|
|
)
|
|
case .top:
|
|
transform = CGAffineTransform(
|
|
a: 0,
|
|
b: -1,
|
|
c: 1,
|
|
d: 0,
|
|
tx: rect.minX,
|
|
ty: canonicalRect.width + rect.minY
|
|
)
|
|
}
|
|
return canonical.applying(transform)
|
|
}
|
|
|
|
private func rightFlarePath(in rect: CGRect) -> Path {
|
|
let progress = min(max(attachmentProgress, 0), 1)
|
|
let eased = progress * progress * (3 - 2 * progress)
|
|
// The system-notch technique (Helm / notchi): one quad curve per corner,
|
|
// control point at the corner. Free (left) side has convex rounded
|
|
// corners; the contact (right) side necks concavely into the touched
|
|
// edge when docked. Depth scales with panel length and is clamped below
|
|
// half of it, so a short single-item rail necks gently and never lets the
|
|
// two shoulders meet or swallow the gauge.
|
|
// freeR: convex rounded corners on the free (left) side. contactR: the
|
|
// small concave flare where the body necks out to the flush contact
|
|
// (right) edge — the body is inset from top and bottom by contactR, and
|
|
// the flare connects that inset to the flush corner (Helm's structure).
|
|
let freeR = min(22, rect.height / 2, bodyWidth * 0.45)
|
|
// Not attached to an edge: a plain rounded pill, every corner rounded.
|
|
// The concave contact-edge flares only exist once docked.
|
|
if eased < 0.5 {
|
|
return Path(roundedRect: rect, cornerRadius: freeR)
|
|
}
|
|
let contactR = min(shoulderDepth * 0.6, rect.height * 0.22, max(0, rect.height / 2 - freeR)) * eased
|
|
|
|
var path = Path()
|
|
// Flush top-right corner, then concave flare into the inset body top
|
|
path.move(to: CGPoint(x: rect.maxX, y: rect.minY))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: rect.maxX - contactR, y: rect.minY + contactR),
|
|
control: CGPoint(x: rect.maxX, y: rect.minY + contactR)
|
|
)
|
|
// Body top edge to the free-side top corner (convex)
|
|
path.addLine(to: CGPoint(x: rect.minX + freeR, y: rect.minY + contactR))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: rect.minX, y: rect.minY + contactR + freeR),
|
|
control: CGPoint(x: rect.minX, y: rect.minY + contactR)
|
|
)
|
|
// Free (left) edge down to the bottom-left corner (convex)
|
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.maxY - contactR - freeR))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: rect.minX + freeR, y: rect.maxY - contactR),
|
|
control: CGPoint(x: rect.minX, y: rect.maxY - contactR)
|
|
)
|
|
// Body bottom edge, then concave flare out to the flush bottom-right
|
|
path.addLine(to: CGPoint(x: rect.maxX - contactR, y: rect.maxY - contactR))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: rect.maxX, y: rect.maxY),
|
|
control: CGPoint(x: rect.maxX, y: rect.maxY - contactR)
|
|
)
|
|
// Flush contact (right) edge back up to the start
|
|
path.closeSubpath()
|
|
return path
|
|
}
|
|
}
|
|
|
|
struct CapacityDockBubbleShape: Shape {
|
|
let tailEdge: CapacityDockEdge
|
|
var tailPosition: CGFloat = 0.5
|
|
|
|
func path(in rect: CGRect) -> Path {
|
|
let canonicalRect = CGRect(
|
|
x: 0,
|
|
y: 0,
|
|
width: tailEdge.isVertical ? rect.width : rect.height,
|
|
height: tailEdge.isVertical ? rect.height : rect.width
|
|
)
|
|
let canonical = rightTailPath(in: canonicalRect)
|
|
let transform: CGAffineTransform
|
|
switch tailEdge {
|
|
case .right:
|
|
transform = CGAffineTransform(translationX: rect.minX, y: rect.minY)
|
|
case .left:
|
|
transform = CGAffineTransform(
|
|
a: -1,
|
|
b: 0,
|
|
c: 0,
|
|
d: 1,
|
|
tx: canonicalRect.width + rect.minX,
|
|
ty: rect.minY
|
|
)
|
|
case .bottom:
|
|
transform = CGAffineTransform(
|
|
a: 0,
|
|
b: 1,
|
|
c: 1,
|
|
d: 0,
|
|
tx: rect.minX,
|
|
ty: rect.minY
|
|
)
|
|
case .top:
|
|
transform = CGAffineTransform(
|
|
a: 0,
|
|
b: -1,
|
|
c: 1,
|
|
d: 0,
|
|
tx: rect.minX,
|
|
ty: canonicalRect.width + rect.minY
|
|
)
|
|
}
|
|
return canonical.applying(transform)
|
|
}
|
|
|
|
private func rightTailPath(in rect: CGRect) -> Path {
|
|
var path = Path()
|
|
let tailWidth = min(22, max(14, rect.width * 0.055))
|
|
let bodyRight = rect.maxX - tailWidth
|
|
let radius = min(20, rect.height * 0.18)
|
|
let midY = rect.minY + rect.height * min(max(tailPosition, 0.18), 0.82)
|
|
let neckHalfHeight = min(32, rect.height * 0.19)
|
|
|
|
path.move(to: CGPoint(x: radius, y: rect.minY))
|
|
path.addLine(to: CGPoint(x: bodyRight - radius, y: rect.minY))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: bodyRight, y: rect.minY + radius),
|
|
control: CGPoint(x: bodyRight, y: rect.minY)
|
|
)
|
|
path.addLine(to: CGPoint(x: bodyRight, y: midY - neckHalfHeight))
|
|
path.addCurve(
|
|
to: CGPoint(x: rect.maxX, y: midY),
|
|
control1: CGPoint(x: bodyRight, y: midY - neckHalfHeight * 0.55),
|
|
control2: CGPoint(x: rect.maxX, y: midY - tailWidth * 0.42)
|
|
)
|
|
path.addCurve(
|
|
to: CGPoint(x: bodyRight, y: midY + neckHalfHeight),
|
|
control1: CGPoint(x: rect.maxX, y: midY + tailWidth * 0.42),
|
|
control2: CGPoint(x: bodyRight, y: midY + neckHalfHeight * 0.55)
|
|
)
|
|
path.addLine(to: CGPoint(x: bodyRight, y: rect.maxY - radius))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: bodyRight - radius, y: rect.maxY),
|
|
control: CGPoint(x: bodyRight, y: rect.maxY)
|
|
)
|
|
path.addLine(to: CGPoint(x: radius, y: rect.maxY))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: rect.minX, y: rect.maxY - radius),
|
|
control: CGPoint(x: rect.minX, y: rect.maxY)
|
|
)
|
|
path.addLine(to: CGPoint(x: rect.minX, y: rect.minY + radius))
|
|
path.addQuadCurve(
|
|
to: CGPoint(x: radius, y: rect.minY),
|
|
control: CGPoint(x: rect.minX, y: rect.minY)
|
|
)
|
|
path.closeSubpath()
|
|
return path
|
|
}
|
|
}
|
|
|
|
private extension CapacityDockProvider {
|
|
var ringColor: Color {
|
|
switch self {
|
|
case .claude: return Color(red: 0.98, green: 0.31, blue: 0.08)
|
|
case .codex: return Color(red: 0.12, green: 0.87, blue: 0.55)
|
|
case .gemini: return Color(red: 0.28, green: 0.55, blue: 0.98)
|
|
case .copilot: return Color(red: 0.58, green: 0.48, blue: 0.96)
|
|
case .kimiCode: return Color(red: 0.90, green: 0.94, blue: 0.08)
|
|
case .antigravity: return Color(red: 1.0, green: 0.48, blue: 0.27)
|
|
default:
|
|
// Stable CodeBurn-owned accents keep generated provider sigils
|
|
// recognizable without importing a branding registry.
|
|
let seed = rawValue.utf8.reduce(UInt64(2_166_136_261)) { value, byte in
|
|
(value ^ UInt64(byte)) &* 16_777_619
|
|
}
|
|
return Color(
|
|
hue: Double(seed % 360) / 360,
|
|
saturation: 0.72,
|
|
brightness: 0.94
|
|
)
|
|
}
|
|
}
|
|
}
|