mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 00:22:34 +00:00
Narrow the dropdown (380 to 360) and give it more vertical room (620 to 700). Tighten the subscription cards (less spacing/padding) since they carry only a few window rows. Make the spend chart taller (18 to 30) and let the user pick its style in Settings: chunk bars (default) or a line graph with a subtle area fill. Persist the choice in UserDefaults.
579 lines
21 KiB
Swift
579 lines
21 KiB
Swift
import SwiftUI
|
|
import AppKit
|
|
import CCSBarCore
|
|
|
|
/// Dropdown content for the menu bar: a CCS-branded header, usage analytics,
|
|
/// per-account rows + actions, an offline state when CCS isn't running, and
|
|
/// footer controls.
|
|
struct BarMenuView: View {
|
|
@ObservedObject var viewModel: BarViewModel
|
|
/// Resolved theme injected by ThemedRoot — used to tint the armed Quit control
|
|
/// with the themed red ramp so it matches the dropdown on both plates.
|
|
@Environment(\.barTheme) private var theme
|
|
/// Two-step inline quit confirm. First footer-Quit click arms it (icon swaps
|
|
/// hollow->filled, tints red); second click terminates. Reset on every popover
|
|
/// open via .onAppear so a stale armed state never carries across sessions —
|
|
/// no modal, no .confirmationDialog (those steal focus and dismiss the popover,
|
|
/// the exact fragility of BUG 1).
|
|
@State private var quitArmed = false
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
header
|
|
Divider()
|
|
|
|
if viewModel.offline {
|
|
offlineState.padding(14)
|
|
} else {
|
|
// The scroll indicator is suppressed (.never, not just .hidden) AND the
|
|
// enclosing NSScrollView's scroller is hard-disabled via ScrollerHider:
|
|
// inside a MenuBarExtra popover the SwiftUI preference alone is sometimes
|
|
// ignored and a scroller track steals width + misaligns content. With the
|
|
// reorder + collapsed spend strip the important rows fit without scrolling
|
|
// for the common 1-4 subscription setup; the scroll only engages for
|
|
// genuine pool/model overflow.
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
// (1) ALERTS first — urgent quota crossings surface above everything.
|
|
// Spend-cap alerts are opt-in OFF by default, so by default only
|
|
// quota/reauth/cooldown conditions appear here.
|
|
if !viewModel.activeAlerts.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel("Alerts")
|
|
ForEach(viewModel.activeAlerts) { alert in
|
|
AlertRow(alert: alert)
|
|
}
|
|
}
|
|
}
|
|
|
|
// (2) SUBSCRIPTIONS — the dominant section, opens here.
|
|
accountsSection
|
|
|
|
// (3) SPEND — demoted to a thin informational strip below the cockpit.
|
|
// spendChartStyle is threaded from the viewModel so a live change in
|
|
// Settings immediately updates the chart without a refresh.
|
|
if let analytics = viewModel.analytics {
|
|
Divider()
|
|
BarAnalyticsView(analytics: analytics, section: .spend,
|
|
spendChartStyle: viewModel.spendChartStyle)
|
|
}
|
|
|
|
// (4) POOL ACCOUNTS — compact generic rows, subordinate.
|
|
poolSection
|
|
|
|
// (5) BY-SURFACE / TOP MODELS — tightened detail, below the pool.
|
|
if let analytics = viewModel.analytics,
|
|
BarAnalyticsView(analytics: analytics, section: .breakdown).hasBreakdown
|
|
{
|
|
BarAnalyticsView(analytics: analytics, section: .breakdown)
|
|
}
|
|
|
|
// Zero-size AppKit bridge that disables the popover's NSScrollView
|
|
// scroller at runtime (belt-and-suspenders with .scrollIndicators).
|
|
ScrollerHider().frame(width: 0, height: 0)
|
|
}
|
|
.padding(14)
|
|
}
|
|
.scrollIndicators(.never)
|
|
// 700 gives more vertical breathing room before scroll engages — useful
|
|
// for 3-4 subscription cards each carrying multiple quota windows.
|
|
// Scroll still engages gracefully on genuine overflow.
|
|
.frame(maxHeight: 700)
|
|
}
|
|
|
|
Divider()
|
|
footer
|
|
}
|
|
// 360 is narrower than the old 380, keeping the popover compact while still
|
|
// fitting the bar-list fixed column widths (label 32 + bar 110 + pct 32 + chip 48).
|
|
.frame(width: 360)
|
|
.onAppear {
|
|
viewModel.onOpen()
|
|
// Disarm quit on every popover open so a stale armed state never persists.
|
|
quitArmed = false
|
|
}
|
|
}
|
|
|
|
/// The cockpit. Native subscriptions (Claude Code / Codex) render as detailed
|
|
/// `BarSubscriptionCard`s at the very top, ordered tightest-binding-first
|
|
/// (closest to empty on top) so the window the user is about to run out of
|
|
/// leads. CLIProxy pool accounts keep the compact generic `BarRowView` below,
|
|
/// subordinate. The two-section split is suppressed when only one kind is
|
|
/// present, preserving the single "Accounts" header for a CLIProxy-only setup.
|
|
@ViewBuilder private var accountsSection: some View {
|
|
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
if let error = viewModel.lastError {
|
|
ErrorBanner(message: error)
|
|
}
|
|
if viewModel.rows.isEmpty {
|
|
SectionLabel("Accounts")
|
|
Text("No accounts configured")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
} else if parts.subscriptions.isEmpty {
|
|
// CLIProxy-only setup: keep the single established header + generic rows.
|
|
SectionLabel("Accounts")
|
|
ForEach(parts.pool) { row in
|
|
BarRowView(row: row, viewModel: viewModel)
|
|
}
|
|
} else {
|
|
subscriptionsHeader(parts.subscriptions)
|
|
ForEach(orderedSubscriptions(parts.subscriptions)) { row in
|
|
BarSubscriptionCard(row: row)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// CLIProxy pool accounts as compact generic rows — subordinate, rendered below
|
|
/// the spend strip. Suppressed entirely when there are no pool accounts, or
|
|
/// when there are no subscriptions (the CLIProxy-only path renders pool rows
|
|
/// under the single "Accounts" header in `accountsSection` instead).
|
|
@ViewBuilder private var poolSection: some View {
|
|
let parts = BarFormatting.partitionSubscriptions(viewModel.rows)
|
|
if !parts.subscriptions.isEmpty && !parts.pool.isEmpty {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
SectionLabel("Pool accounts")
|
|
ForEach(parts.pool) { row in
|
|
BarRowView(row: row, viewModel: viewModel)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// "SUBSCRIPTIONS" header, with a right-aligned cross-tool headroom hint
|
|
/// ("most room: <X> NN%") when there are >=2 subscriptions with quota data.
|
|
/// Falls back to the bare label otherwise.
|
|
@ViewBuilder private func subscriptionsHeader(_ subs: [BarSummaryRow]) -> some View {
|
|
HStack(alignment: .firstTextBaseline) {
|
|
SectionLabel("Subscriptions")
|
|
Spacer()
|
|
if let leader = BarQuotaGauge.headroomLeader(subs) {
|
|
Text("most room: \(leader.label) \(Int(leader.remainingPercent.rounded()))%")
|
|
.font(.system(size: 10, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Order subscription cards by tightest binding window ascending (closest to
|
|
/// empty on top). Rows with no binding window (error/reauth) sink to the bottom
|
|
/// so the actionable quota always leads.
|
|
private func orderedSubscriptions(_ subs: [BarSummaryRow]) -> [BarSummaryRow] {
|
|
subs.sorted { a, b in
|
|
let ra = BarQuotaGauge.selectBindingWindow(a.quotaWindows ?? [])?.remainingPercent
|
|
let rb = BarQuotaGauge.selectBindingWindow(b.quotaWindows ?? [])?.remainingPercent
|
|
switch (ra, rb) {
|
|
case let (.some(x), .some(y)):
|
|
if x != y { return x < y }
|
|
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
|
|
case (.some, .none):
|
|
return true // a has quota, b doesn't → a first
|
|
case (.none, .some):
|
|
return false
|
|
case (.none, .none):
|
|
return (a.displayName ?? a.provider) < (b.displayName ?? b.provider)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
HStack(spacing: 8) {
|
|
Image(nsImage: MenuBarIcon.headerImage())
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("CCS").font(.headline)
|
|
Text("usage & accounts").font(.caption2).foregroundStyle(.secondary)
|
|
}
|
|
Spacer()
|
|
if viewModel.isRefreshing {
|
|
ProgressView().controlSize(.small)
|
|
}
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
}
|
|
|
|
private var offlineState: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label("CCS is not running", systemImage: "bolt.slash.fill")
|
|
.font(.body)
|
|
Text("Start CCS, then reopen this menu.")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
Button("Retry") { viewModel.reconnect(); viewModel.onOpen() }
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
openDashboard()
|
|
} label: {
|
|
Label("Dashboard", systemImage: "chart.bar.xaxis")
|
|
}
|
|
Button {
|
|
viewModel.toggleIconStyle()
|
|
} label: {
|
|
Label(
|
|
"Icon",
|
|
systemImage: viewModel.iconStyle == .color ? "paintpalette" : "circle.lefthalf.filled"
|
|
)
|
|
}
|
|
.help("Toggle the menu-bar icon between color and monochrome (does not change the bar theme)")
|
|
Button {
|
|
// Open Settings as a standalone AppKit NSWindow (NOT a .sheet on this
|
|
// popover). A sheet hosted in a .window-style MenuBarExtra popover pulls
|
|
// focus off the popover and auto-dismisses the whole bar (BUG 1). The
|
|
// window opens beside the popover and leaves it untouched.
|
|
SettingsWindowController.shared.show(viewModel: viewModel)
|
|
} label: {
|
|
Label("Settings", systemImage: "gearshape")
|
|
}
|
|
.help("Settings — appearance/theme, menu-bar glance, and alerts")
|
|
Spacer()
|
|
Button {
|
|
viewModel.onOpen()
|
|
} label: {
|
|
Image(systemName: "arrow.clockwise")
|
|
}
|
|
.help("Refresh")
|
|
// Quit confirms via a two-step INLINE arm/confirm — no modal, no sheet, no
|
|
// .confirmationDialog. Those all steal focus and auto-dismiss the popover
|
|
// (the exact fragility of BUG 1). A stray single click can no longer kill
|
|
// the app: the first click only arms; the popover stays open and responsive.
|
|
quitButton
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.font(.caption)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 11)
|
|
}
|
|
|
|
/// Two visual states in one footer slot. Disarmed: hollow power icon that arms
|
|
/// on click. Armed: filled power icon tinted themed red that terminates on
|
|
/// click. Reopening the popover disarms it (.onAppear on the root VStack).
|
|
@ViewBuilder private var quitButton: some View {
|
|
if !quitArmed {
|
|
Button {
|
|
quitArmed = true
|
|
} label: {
|
|
Image(systemName: "power")
|
|
}
|
|
.help("Quit CCS Bar (click again to confirm)")
|
|
} else {
|
|
Button {
|
|
NSApplication.shared.terminate(nil)
|
|
} label: {
|
|
Image(systemName: "power.circle.fill")
|
|
}
|
|
.help("Click to confirm quit")
|
|
.foregroundStyle(theme.bandRed)
|
|
}
|
|
}
|
|
|
|
private func openDashboard() {
|
|
// Open the dashboard if the server is up; otherwise start it via `ccs config`.
|
|
Task { await DashboardLauncher.openOrStart() }
|
|
}
|
|
}
|
|
|
|
/// One account row — the strongest section of the glance.
|
|
///
|
|
/// Top line: health dot, name, default/paused/reauth badges. Subline: provider +
|
|
/// tier chips, the honest tri-state quota label (NN% / "no quota" / "quota ?"),
|
|
/// and a per-account "Last active <date>" caption. Trailing: today's cost (or a
|
|
/// muted "no data" when unknown vs a real "$0.00"), a visible pause/resume
|
|
/// toggle, and the overflow menu (set-default / solo / tier-lock).
|
|
struct BarRowView: View {
|
|
@Environment(\.barTheme) private var theme
|
|
let row: BarSummaryRow
|
|
@ObservedObject var viewModel: BarViewModel
|
|
|
|
/// A native first-party subscription (Claude Code / Codex) — drives the
|
|
/// distinct "subscription" badge + indigo provider chip.
|
|
private var isNativeSubscription: Bool {
|
|
BarFormatting.isNativeSubscription(provider: row.provider)
|
|
}
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 10) {
|
|
Circle()
|
|
.fill(healthColor)
|
|
.frame(width: 8, height: 8)
|
|
.padding(.top, 5)
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
HStack(spacing: 7) {
|
|
Text(row.displayName ?? row.accountId)
|
|
.font(.system(.body, design: .default).weight(.medium))
|
|
.lineLimit(1)
|
|
.truncationMode(.middle)
|
|
if row.isDefault {
|
|
Chip("default", tint: theme.accent)
|
|
}
|
|
if row.paused {
|
|
Chip("paused", tint: .secondary)
|
|
}
|
|
if row.needsReauth {
|
|
Chip("reauth", tint: theme.bandRed)
|
|
}
|
|
if isNativeSubscription {
|
|
Chip("subscription", tint: theme.subscription)
|
|
}
|
|
}
|
|
HStack(spacing: 6) {
|
|
Chip(
|
|
BarFormatting.providerLabel(row.provider),
|
|
tint: isNativeSubscription ? theme.subscription : theme.accent)
|
|
if let tier = row.tier { Chip(tier, tint: .secondary) }
|
|
QuotaGaugeView(
|
|
percentage: row.quotaPercentage,
|
|
status: row.quotaStatus,
|
|
nextReset: row.nextReset)
|
|
}
|
|
if let lastActive = BarFormatting.lastActiveLabel(
|
|
iso: row.lastActivityAt, daysSince: nil)
|
|
{
|
|
Text(lastActive)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Spacer(minLength: 4)
|
|
|
|
VStack(alignment: .trailing, spacing: 3) {
|
|
costView
|
|
HStack(spacing: 2) {
|
|
pauseToggle
|
|
overflowMenu
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
.padding(.horizontal, 10)
|
|
.background(Color.primary.opacity(0.035), in: RoundedRectangle(cornerRadius: 8))
|
|
}
|
|
|
|
/// Today's cost: a real "$x.xx" when known (including a genuine $0.00), a muted
|
|
/// "no data" when the value is null (no usage record on a possibly-stale snapshot).
|
|
@ViewBuilder private var costView: some View {
|
|
if let cost = row.todayCost {
|
|
Text(BarFormatting.money(cost))
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
Text("no data")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
|
|
/// Visible primary action: one tap to pause or resume the account.
|
|
private var pauseToggle: some View {
|
|
Button {
|
|
if row.paused { viewModel.resume(row) } else { viewModel.pause(row) }
|
|
} label: {
|
|
Image(systemName: row.paused ? "play.circle" : "pause.circle")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.help(row.paused ? "Resume account" : "Pause account")
|
|
}
|
|
|
|
private var overflowMenu: some View {
|
|
Menu {
|
|
Button("Set as default") { viewModel.setDefault(row) }
|
|
Button("Solo (pause others)") { viewModel.solo(row) }
|
|
Divider()
|
|
if let tier = row.tier {
|
|
Button("Lock to \(tier)") { viewModel.tierLock(row, tier: tier) }
|
|
}
|
|
Button("Clear tier lock") { viewModel.tierLock(row, tier: nil) }
|
|
} label: {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.menuIndicator(.hidden)
|
|
.frame(width: 24)
|
|
}
|
|
|
|
/// Health dot. With the corrected backend, "unsupported" providers (ghcp/kiro)
|
|
/// arrive as health "ok" (green) — no permanent orange dot. Orange is reserved
|
|
/// for genuine transient fetch failures, red for accounts needing reauth.
|
|
private var healthColor: Color {
|
|
// Use the themed band ramp (not raw system .red/.orange/.green) so the dot
|
|
// matches the rest of the dropdown and stays legible on both plates.
|
|
switch row.health {
|
|
case "error": return theme.bandRed
|
|
case "warning": return theme.bandAmber
|
|
default: return theme.bandGreen
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Per-account quota gauge. When the row has a live "ok" quota with a percentage,
|
|
/// renders a thin colored bar (filled by the remaining fraction, tinted by the
|
|
/// severity band) plus a "resets in …" caption. When there is no live quota it
|
|
/// falls back to the honest text label ("no quota" / "quota ?"). All branch,
|
|
/// color, and countdown logic lives in the pure Core `BarQuotaGauge`; this view
|
|
/// is a thin render.
|
|
struct QuotaGaugeView: View {
|
|
@Environment(\.barTheme) private var theme
|
|
let percentage: Double?
|
|
let status: String
|
|
let nextReset: String?
|
|
|
|
var body: some View {
|
|
let band = BarQuotaGauge.band(percentage: percentage, status: status)
|
|
if band != .none, let fill = BarQuotaGauge.fillFraction(percentage: percentage, status: status) {
|
|
HStack(spacing: 5) {
|
|
gaugeBar(fill: fill, color: color(for: band))
|
|
Text(BarFormatting.quotaLabel(percentage: percentage, status: status))
|
|
.font(.system(.caption2, design: .monospaced))
|
|
.foregroundStyle(color(for: band))
|
|
if let countdown = BarQuotaGauge.resetCountdown(nextReset: nextReset, now: Date()) {
|
|
Text(countdown)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
}
|
|
} else {
|
|
// No live quota: keep the existing honest text ("no quota" / "quota ?").
|
|
Text(BarFormatting.quotaLabel(percentage: percentage, status: status))
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
private func gaugeBar(fill: Double, color: Color) -> some View {
|
|
GeometryReader { geo in
|
|
ZStack(alignment: .leading) {
|
|
Capsule()
|
|
.fill(Color.primary.opacity(0.12))
|
|
Capsule()
|
|
.fill(color)
|
|
.frame(width: max(2, geo.size.width * fill))
|
|
}
|
|
}
|
|
.frame(width: 54, height: 6)
|
|
}
|
|
|
|
private func color(for band: BarQuotaGauge.Band) -> Color {
|
|
// Themed band ramp for whole-dropdown consistency. .orange maps to the coral
|
|
// band (the warning step in the green→amber→coral→red ramp) so it stays
|
|
// distinct from the brand accent orange on both plates.
|
|
switch band {
|
|
case .green: return theme.bandGreen
|
|
case .yellow: return theme.bandAmber
|
|
case .orange: return theme.bandCoral
|
|
case .red: return theme.bandRed
|
|
case .none: return .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Inline banner surfacing the last failed action so it is visible rather than
|
|
/// silently swallowed. Success is confirmed by the default/paused badge updating.
|
|
struct ErrorBanner: View {
|
|
@Environment(\.barTheme) private var theme
|
|
let message: String
|
|
var body: some View {
|
|
HStack(spacing: 6) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.foregroundStyle(theme.accent)
|
|
Text(message)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.vertical, 5)
|
|
.padding(.horizontal, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(theme.accent.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
|
}
|
|
}
|
|
|
|
/// One in-dropdown alert row. Mirrors a delivered notification so the conditions
|
|
/// are visible even when system notifications are denied. The icon is keyed off
|
|
/// the alert kind so each rule reads at a glance.
|
|
struct AlertRow: View {
|
|
@Environment(\.barTheme) private var theme
|
|
let alert: BarNotification
|
|
|
|
var body: some View {
|
|
HStack(alignment: .top, spacing: 6) {
|
|
Image(systemName: icon)
|
|
.foregroundStyle(tint)
|
|
.font(.caption)
|
|
.padding(.top, 1)
|
|
VStack(alignment: .leading, spacing: 1) {
|
|
Text(alert.title)
|
|
.font(.caption.weight(.medium))
|
|
Text(alert.body)
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(2)
|
|
}
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.vertical, 5)
|
|
.padding(.horizontal, 8)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.background(tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 7))
|
|
}
|
|
|
|
private var icon: String {
|
|
switch alert.kind {
|
|
case .quotaRemainingBelow: return "gauge.with.dots.needle.bottom.0percent"
|
|
case .dailySpendAbove, .monthSpendAbove: return "dollarsign.circle"
|
|
case .reauthNeeded: return "key.slash"
|
|
case .accountCooldownOrPaused: return "pause.circle"
|
|
}
|
|
}
|
|
|
|
private var tint: Color {
|
|
// Themed: quota warnings take the brand accent, reauth the critical band,
|
|
// so alert chips match the rest of the dropdown on both plates.
|
|
switch alert.kind {
|
|
case .quotaRemainingBelow: return theme.accent
|
|
case .dailySpendAbove, .monthSpendAbove: return theme.accent
|
|
case .reauthNeeded: return theme.bandRed
|
|
case .accountCooldownOrPaused: return .secondary
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Small pill label used in account sublines.
|
|
struct Chip: View {
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
let text: String
|
|
let tint: Color
|
|
init(_ text: String, tint: Color) {
|
|
self.text = text
|
|
self.tint = tint
|
|
}
|
|
/// Lift the small 9pt label toward the opposite of the surface so it stays
|
|
/// legible: toward white on the dark plate (the raw indigo subscription tint
|
|
/// was too dim to read), toward black on the light plate (lifting toward white
|
|
/// there would wash the text out). The forced scheme is already in effect on
|
|
/// this subtree, so `colorScheme` reflects exactly the plate being drawn.
|
|
private var textColor: Color {
|
|
if tint == .secondary { return .secondary }
|
|
let target: NSColor = (colorScheme == .light) ? .black : .white
|
|
let lifted = NSColor(tint).blended(withFraction: 0.5, of: target) ?? NSColor(tint)
|
|
return Color(nsColor: lifted)
|
|
}
|
|
var body: some View {
|
|
Text(text)
|
|
.font(.system(size: 10, weight: .semibold))
|
|
.padding(.horizontal, 5)
|
|
.padding(.vertical, 1.5)
|
|
.background(tint.opacity(0.22), in: Capsule())
|
|
.foregroundStyle(textColor)
|
|
}
|
|
}
|