Skip to content

Commit fa28046

Browse files
authored
Merge pull request robinebers#989 from robinebers/codex/provider-header-copy-feedback
Refine provider header copy controls
2 parents 4154074 + f0d7b59 commit fa28046

6 files changed

Lines changed: 88 additions & 89 deletions

File tree

Sources/OpenUsage/Support/LiquidGlassFallbacks.swift

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,17 +130,3 @@ extension View {
130130
}
131131
}
132132
}
133-
134-
/// A share button's icon with built-in "copied" feedback: while `copied` is set, the share arrow
135-
/// becomes a checkmark — a hard swap, deliberately with no symbol transition (on macOS 26 the
136-
/// Replace transition defaults to Magic Replace, which draws the checkmark on stroke by stroke;
137-
/// tried and rejected) — and the checkmark bounces once as it lands. Flip `copied` inside
138-
/// `withAnimation`.
139-
struct ShareFeedbackIcon: View {
140-
let copied: Bool
141-
142-
var body: some View {
143-
Image(systemName: copied ? "checkmark" : "square.and.arrow.up")
144-
.symbolEffect(.bounce, value: copied)
145-
}
146-
}

Sources/OpenUsage/Support/ShareCardRenderer.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,12 +63,13 @@ enum ShareCardRenderer {
6363
/// rows read density via `@AppStorage`, so the saved value is swapped to `.regular` for the duration
6464
/// of the render and restored on exit (synchronously), keeping the exported card consistent without
6565
/// disturbing the live popover.
66+
@discardableResult
6667
static func share(
6768
group: ProviderGroup,
6869
dataStore: WidgetDataStore,
6970
layout: LayoutStore,
7071
appearance: ColorScheme
71-
) {
72+
) -> Bool {
7273
let isExpanded = layout.isProviderExpanded(group.provider.id)
7374
let alwaysRows = group.alwaysShownWidgets.compactMap { widget -> WidgetData? in
7475
guard let descriptor = layout.descriptor(for: widget) else { return nil }
@@ -86,7 +87,7 @@ enum ShareCardRenderer {
8687
appearance: appearance,
8788
expandBoundaryIndex: isExpanded ? alwaysRows.count : nil
8889
)
89-
renderAndCopy(view, label: group.provider.id, layout: layout)
90+
return renderAndCopy(view, label: group.provider.id, layout: layout)
9091
}
9192

9293
/// The Total Spend counterpart to `share(group:…)`: renders the aggregate ring card for the
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import SwiftUI
2+
3+
/// The shared screenshot-copy control used by dashboard headers. The glyph stays compact, but the
4+
/// button owns a larger pointer target; a successful copy swaps it for a green, bouncing checkmark
5+
/// before returning to the copy symbol. Keeping the state and timer here makes every copy action use
6+
/// the same feedback instead of rebuilding the interaction at each call site.
7+
struct CopyFeedbackButton: View {
8+
let accessibilityLabel: String
9+
var isRevealed = true
10+
let action: () -> Bool
11+
12+
@State private var copied = false
13+
@State private var resetTask: Task<Void, Never>?
14+
15+
var body: some View {
16+
Button {
17+
guard action() else { return }
18+
19+
withAnimation(Motion.spring) { copied = true }
20+
resetTask?.cancel()
21+
resetTask = Task {
22+
try? await Task.sleep(for: .seconds(1.4))
23+
guard !Task.isCancelled else { return }
24+
withAnimation(.easeOut(duration: 0.18)) { copied = false }
25+
}
26+
} label: {
27+
Image(systemName: copied ? "checkmark" : "doc.on.doc")
28+
.font(.system(size: 11, weight: .semibold))
29+
.foregroundStyle(copied ? Color.green : Color.secondary)
30+
.symbolEffect(.bounce, value: copied)
31+
.frame(width: 28, height: 28)
32+
.contentShape(Rectangle())
33+
}
34+
.buttonStyle(.plain)
35+
// Preserve the header's compact 16pt layout slot while the button's 28pt hit rectangle extends
36+
// around it. The visible glyph therefore aligns with the old provider-mark position instead of
37+
// being pushed inward by the larger interaction target.
38+
.padding(-6)
39+
.opacity(isRevealed || copied ? 1 : 0)
40+
.allowsHitTesting(isRevealed || copied)
41+
.animation(.easeOut(duration: 0.12), value: isRevealed)
42+
.accessibilityLabel(accessibilityLabel)
43+
.onDisappear {
44+
resetTask?.cancel()
45+
resetTask = nil
46+
}
47+
}
48+
}

Sources/OpenUsage/Views/ProviderSectionHeader.swift

Lines changed: 29 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import SwiftUI
22

33
/// Shared provider section header used by the dashboard and its lifted provider-reorder preview.
4-
/// The name leads with the optional plan badge beside it; the provider mark sits at the trailing
5-
/// edge. Callers supply an optional `warning` — the latest refresh error, rendered as a small amber
4+
/// The provider mark and name lead, followed by the optional plan badge. Dashboard callers supply a
5+
/// screenshot-copy action, revealed at the trailing edge while the header is hovered. Callers can also
6+
/// supply an optional `warning` — the latest refresh error, rendered as a small amber
67
/// triangle beside the name whose hover tooltip carries the message (e.g. "Not logged in. Run `codex`
78
/// to authenticate."). The
89
/// optional `staleness` is the dashboard-only hint that the values shown are an aged snapshot still
@@ -20,28 +21,40 @@ struct ProviderSectionHeader: View {
2021
/// (dashboard only; `nil` in the reorder preview, which never surfaces staleness). Its tooltip carries
2122
/// the precise age.
2223
var staleness: StalenessHint?
24+
/// Dashboard-only screenshot action. The reorder preview omits it, while Customize uses its own
25+
/// row type and is unaffected by this header.
26+
var onCopyScreenshot: (() -> Bool)?
2327

2428
/// Header type and icon track the density setting like the rows do, so Compact shrinks the
2529
/// whole section anatomy — not just the rows under it.
2630
@AppStorage(DensitySetting.key) private var density = DensitySetting.regular
2731
/// Party easter egg: pulse the provider mark. Off by default everywhere else.
2832
@Environment(\.popoverPartyMode) private var partyMode
33+
@State private var isHovered = false
2934

30-
init(provider: Provider, plan: String? = nil, warning: String? = nil, refreshing: Bool = false, staleness: StalenessHint? = nil) {
35+
init(
36+
provider: Provider,
37+
plan: String? = nil,
38+
warning: String? = nil,
39+
refreshing: Bool = false,
40+
staleness: StalenessHint? = nil,
41+
onCopyScreenshot: (() -> Bool)? = nil
42+
) {
3143
self.provider = provider
3244
self.plan = plan
3345
self.warning = warning
3446
self.refreshing = refreshing
3547
self.staleness = staleness
48+
self.onCopyScreenshot = onCopyScreenshot
3649
}
3750

3851
var body: some View {
3952
HStack(spacing: 5) {
40-
// A grip leading the name marks the header line as draggable to reorder providers. The
41-
// whole line still carries the gesture; this is only the visual affordance. The trailing
42-
// gap keeps it from crowding the provider name beside it.
43-
DragHandleGrip()
44-
.padding(.trailing, 4)
53+
// The provider mark replaces the dashboard's visual drag grip. Reordering still belongs
54+
// to the whole header at the caller, so the logo itself stays presentational.
55+
ProviderIcon(source: provider.icon, inset: 0.04)
56+
.frame(width: density.headerIconSize, height: density.headerIconSize)
57+
.partyPulse(partyMode)
4558
// Baseline-aligned pair: the plan badge (and stale tag) are smaller type and sit on the
4659
// name's text baseline, so the words line up along the bottom rather than floating centered.
4760
HStack(alignment: .firstTextBaseline, spacing: 5) {
@@ -81,18 +94,19 @@ struct ProviderSectionHeader: View {
8194
.accessibilityLabel(warning)
8295
}
8396
Spacer(minLength: 8)
84-
// Match the menu-bar strip glyph: a near-zero inset lets the mark fill its box so the
85-
// header logo reads at the same scale as the tray, instead of floating small inside the
86-
// default list-context padding.
87-
ProviderIcon(source: provider.icon, inset: 0.04)
88-
.frame(width: density.headerIconSize, height: density.headerIconSize)
89-
.partyPulse(partyMode)
97+
if let onCopyScreenshot {
98+
CopyFeedbackButton(
99+
accessibilityLabel: "Copy \(provider.displayName) Screenshot",
100+
isRevealed: isHovered,
101+
action: onCopyScreenshot
102+
)
103+
}
90104
}
91-
// Shave the left inset so the leading grip sits a touch closer to the card's edge.
92105
.padding(.leading, 2)
93106
.padding(.trailing, 4)
94107
.padding(.vertical, 2)
95108
.contentShape(Rectangle())
109+
.onHover { isHovered = $0 }
96110
}
97111
}
98112

@@ -121,28 +135,3 @@ struct ReorderGrip: View {
121135
.contentShape(Rectangle())
122136
}
123137
}
124-
125-
/// The 2×3 dot-grid grip that leads the dashboard provider name. Kept quiet (tertiary) so it reads
126-
/// as an affordance hinting the header line can be dragged to reorder providers, not as a control
127-
/// competing with the name beside it.
128-
struct DragHandleGrip: View {
129-
var body: some View {
130-
VStack(spacing: 2) {
131-
ForEach(0..<3) { _ in
132-
HStack(spacing: 2) {
133-
dot
134-
dot
135-
}
136-
}
137-
}
138-
.frame(height: 22)
139-
.contentShape(Rectangle())
140-
.accessibilityHidden(true)
141-
}
142-
143-
private var dot: some View {
144-
Circle()
145-
.fill(.tertiary)
146-
.frame(width: 1.75, height: 1.75)
147-
}
148-
}

Sources/OpenUsage/Views/TotalSpendCard.swift

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,6 @@ struct TotalSpendCard: View {
2020
@AppStorage("openusage.totalSpend.metric") private var metricRawValue = TotalSpendMetric.cost.rawValue
2121
@AppStorage(DensitySetting.key) private var density = DensitySetting.regular
2222

23-
/// True briefly after a successful copy: the share arrow becomes a checkmark, then reverts.
24-
@State private var shareCopied = false
25-
/// The pending revert, kept so a rapid second click restarts the window instead of cutting the
26-
/// fresh checkmark short.
27-
@State private var shareRevertTask: Task<Void, Never>?
28-
2923
private var period: TotalSpendPeriod {
3024
TotalSpendPeriod(rawValue: periodRawValue) ?? .today
3125
}
@@ -115,33 +109,14 @@ struct TotalSpendCard: View {
115109
}
116110

117111
private var shareButton: some View {
118-
Button {
119-
// The checkmark only appears when the PNG actually landed on the pasteboard — a failed
120-
// render/copy beeps (inside the renderer) and the icon stays a share arrow.
121-
guard ShareCardRenderer.shareTotalSpend(
112+
CopyFeedbackButton(accessibilityLabel: "Copy \(metric.title) Screenshot") {
113+
ShareCardRenderer.shareTotalSpend(
122114
total: total,
123115
metric: metric,
124116
appearance: colorScheme,
125117
layout: layout
126-
) else { return }
127-
withAnimation(Motion.spring) { shareCopied = true }
128-
shareRevertTask?.cancel()
129-
shareRevertTask = Task {
130-
try? await Task.sleep(for: .seconds(1.4))
131-
guard !Task.isCancelled else { return }
132-
withAnimation(Motion.spring) { shareCopied = false }
133-
}
134-
} label: {
135-
ShareFeedbackIcon(copied: shareCopied)
136-
.font(.system(size: 11, weight: .semibold))
137-
.foregroundStyle(.secondary)
138-
// Same height as the provider headers' trailing mark, so the header row measures the
139-
// same and the arrow centers vertically exactly like the provider icons do.
140-
.frame(width: 16, height: density.headerIconSize)
141-
.contentShape(Rectangle())
118+
)
142119
}
143-
.buttonStyle(.plain)
144-
.accessibilityLabel("Share \(metric.title) Screenshot")
145120
}
146121

147122
// MARK: - Card

Sources/OpenUsage/Views/WidgetGroupedListView.swift

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -50,10 +50,10 @@ struct WidgetGroupedListView: View {
5050
plan: dataStore.plan(for: group.provider.id),
5151
warning: dataStore.headerNotice(for: group.provider.id),
5252
refreshing: dataStore.refreshingProviderIDs.contains(group.provider.id),
53-
staleness: dataStore.stalenessHint(for: group.provider.id)
53+
staleness: dataStore.stalenessHint(for: group.provider.id),
54+
onCopyScreenshot: { shareCard(group) }
5455
)
55-
// 8pt (+ 4pt internal) on both sides: insets the drag grip off the card's left edge and
56-
// lines the provider mark up with the card's right content edge.
56+
// Keep the provider mark and hover-revealed copy control aligned with the card's content edges.
5757
.padding(.horizontal, 8)
5858
.highPriorityGesture(providerDragGesture(for: group))
5959
.contextMenu {
@@ -70,7 +70,7 @@ struct WidgetGroupedListView: View {
7070
openCustomize(for: group.provider.id)
7171
}
7272
Divider()
73-
Button("Share Screenshot") { shareCard(group) }
73+
Button("Share Screenshot") { _ = shareCard(group) }
7474
}
7575
}
7676

@@ -80,7 +80,7 @@ struct WidgetGroupedListView: View {
8080
/// the export matches the card on screen instead of guessing from `NSApp.effectiveAppearance`. The
8181
/// same render path backs the footer's "Share Screenshot" submenu, which reaches it without a
8282
/// right-click.
83-
private func shareCard(_ group: ProviderGroup) {
83+
private func shareCard(_ group: ProviderGroup) -> Bool {
8484
ShareCardRenderer.share(
8585
group: group,
8686
dataStore: dataStore,

0 commit comments

Comments
 (0)