Skip to content

Commit 7e577ce

Browse files
committed
Smooth dashboard scrolling
1 parent 7c4c49d commit 7e577ce

5 files changed

Lines changed: 166 additions & 53 deletions

File tree

Sources/TokenMeter/ChartViews.swift

Lines changed: 104 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AppKit
12
import SwiftUI
23
import TokenMeterCore
34

@@ -6,6 +7,88 @@ enum ChartMode: Equatable {
67
case byTokenKind(TokenSource)
78
}
89

10+
private struct ChartHoverTrackingView: NSViewRepresentable {
11+
var onHover: (CGPoint?) -> Void
12+
13+
func makeCoordinator() -> Coordinator {
14+
Coordinator()
15+
}
16+
17+
func makeNSView(context: Context) -> TrackingView {
18+
let view = TrackingView()
19+
view.onHover = onHover
20+
context.coordinator.onHover = onHover
21+
view.coordinator = context.coordinator
22+
return view
23+
}
24+
25+
func updateNSView(_ view: TrackingView, context: Context) {
26+
view.onHover = onHover
27+
view.coordinator = context.coordinator
28+
context.coordinator.onHover = onHover
29+
}
30+
31+
func sizeThatFits(_ proposal: ProposedViewSize, nsView: TrackingView, context: Context) -> CGSize? {
32+
CGSize(width: proposal.width ?? 0, height: proposal.height ?? 0)
33+
}
34+
35+
final class Coordinator {
36+
var onHover: ((CGPoint?) -> Void)?
37+
private var eventMonitor: Any?
38+
39+
deinit {
40+
if let eventMonitor {
41+
NSEvent.removeMonitor(eventMonitor)
42+
}
43+
}
44+
45+
@MainActor
46+
func updateEventMonitor(for view: TrackingView) {
47+
removeEventMonitor()
48+
guard let window = view.window else { return }
49+
onHover = view.onHover
50+
51+
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.mouseMoved, .scrollWheel]) { [weak self, weak view, weak window] event in
52+
MainActor.assumeIsolated {
53+
guard let self, let view, event.window === window else { return }
54+
if event.type == .scrollWheel {
55+
self.onHover?(nil)
56+
return
57+
}
58+
59+
let point = view.convert(event.locationInWindow, from: nil)
60+
self.onHover?(view.bounds.contains(point) ? point : nil)
61+
}
62+
return event
63+
}
64+
}
65+
66+
@MainActor
67+
func removeEventMonitor() {
68+
guard let eventMonitor else { return }
69+
NSEvent.removeMonitor(eventMonitor)
70+
self.eventMonitor = nil
71+
}
72+
}
73+
74+
final class TrackingView: NSView {
75+
var onHover: ((CGPoint?) -> Void)?
76+
var coordinator: Coordinator?
77+
78+
override var isFlipped: Bool { true }
79+
80+
override func viewDidMoveToWindow() {
81+
super.viewDidMoveToWindow()
82+
window?.acceptsMouseMovedEvents = true
83+
if window == nil {
84+
coordinator?.removeEventMonitor()
85+
} else {
86+
coordinator?.updateEventMonitor(for: self)
87+
}
88+
}
89+
}
90+
}
91+
992
struct TokenBarChart: View {
1093
let buckets: [TimeBucket]
1194
let range: TimeRangePreset
@@ -29,23 +112,27 @@ struct TokenBarChart: View {
29112
sparseTimeline: sparseTimeline
30113
)
31114
}
32-
.contentShape(Rectangle())
33-
.onContinuousHover { phase in
34-
switch phase {
35-
case .active(let point):
36-
let bucket = hoveredBucket(
37-
at: point,
38-
in: proxy.size,
39-
buckets: visibleBuckets,
40-
sparseTimeline: sparseTimeline
41-
)
42-
if hoveredBucket?.id != bucket?.id {
43-
hoveredBucket = bucket
44-
}
45-
case .ended:
46-
if hoveredBucket != nil {
47-
hoveredBucket = nil
115+
.overlay {
116+
GeometryReader { hoverProxy in
117+
ChartHoverTrackingView { point in
118+
guard let point else {
119+
if hoveredBucket != nil {
120+
hoveredBucket = nil
121+
}
122+
return
123+
}
124+
125+
let bucket = hoveredBucket(
126+
at: point,
127+
in: proxy.size,
128+
buckets: visibleBuckets,
129+
sparseTimeline: sparseTimeline
130+
)
131+
if hoveredBucket?.id != bucket?.id {
132+
hoveredBucket = bucket
133+
}
48134
}
135+
.frame(width: hoverProxy.size.width, height: hoverProxy.size.height)
49136
}
50137
}
51138
}
@@ -1044,7 +1131,7 @@ struct UsageTable: View {
10441131
Text(keyFormatter(row.key))
10451132
.lineLimit(1)
10461133
.foregroundStyle(TokenMeterTheme.secondaryText)
1047-
.help(row.key)
1134+
.accessibilityValue(row.key)
10481135
.frame(maxWidth: .infinity, alignment: .leading)
10491136
tokenCell(row.usage.total, width: totalColumnWidth)
10501137
if density == .regular {

Sources/TokenMeter/DashboardModel.swift

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,12 +120,16 @@ final class DashboardModel: ObservableObject {
120120
private static let allDevicesFilterId = "all-devices"
121121
private static let deviceIdKey = "tokenMeter.localDeviceId"
122122
private static let syncFolderPathKey = "tokenMeter.syncFolderPath"
123+
private static let postScrollRefreshDelayNanoseconds: UInt64 = 1_200_000_000
123124

124125
private let defaults: UserDefaults
125126
private let scanner: TokenLogScanner
126127
private var scanTask: Task<Void, Never>?
127128
private var scanGeneration = 0
128129
private var scanCanDeferForScroll = false
130+
private var isScrollActive = false
131+
private var refreshAfterScroll = false
132+
private var deferredRefreshTask: Task<Void, Never>?
129133
private var loadedEventWindowStart: Date?
130134
private var hasLoadedAllEvents = false
131135
private var eventRevision = 0
@@ -150,6 +154,7 @@ final class DashboardModel: ObservableObject {
150154
}
151155

152156
func refresh(restartInProgress: Bool = false, fullSync: Bool = false) {
157+
cancelDeferredRefresh()
153158
let windowStart = fullSync ? nil : scanWindowStart(for: range)
154159
startRefresh(
155160
fileModifiedAfter: windowStart,
@@ -171,6 +176,35 @@ final class DashboardModel: ObservableObject {
171176
)
172177
}
173178

179+
func refreshRecentChangesWhenIdle() {
180+
guard !isScrollActive else {
181+
refreshAfterScroll = true
182+
return
183+
}
184+
refreshRecentChanges()
185+
}
186+
187+
func scrollActivityChanged(_ isActive: Bool) {
188+
guard isScrollActive != isActive else { return }
189+
isScrollActive = isActive
190+
191+
if isActive {
192+
if deferredRefreshTask != nil {
193+
refreshAfterScroll = true
194+
}
195+
cancelDeferredRefresh()
196+
if deferInProgressRefreshForScroll() {
197+
refreshAfterScroll = true
198+
}
199+
return
200+
}
201+
202+
if refreshAfterScroll {
203+
refreshAfterScroll = false
204+
scheduleRefreshAfterScroll()
205+
}
206+
}
207+
174208
func deferInProgressRefreshForScroll() -> Bool {
175209
guard isScanning, scanCanDeferForScroll else { return false }
176210
scanTask?.cancel()
@@ -570,6 +604,25 @@ final class DashboardModel: ObservableObject {
570604
loadedEventWindowStart = min(loadedEventWindowStart ?? eventAfter, eventAfter)
571605
}
572606

607+
private func scheduleRefreshAfterScroll() {
608+
cancelDeferredRefresh()
609+
deferredRefreshTask = Task { @MainActor [weak self] in
610+
try? await Task.sleep(nanoseconds: Self.postScrollRefreshDelayNanoseconds)
611+
guard let self, !Task.isCancelled else { return }
612+
self.deferredRefreshTask = nil
613+
guard !self.isScrollActive else {
614+
self.refreshAfterScroll = true
615+
return
616+
}
617+
self.refreshRecentChanges()
618+
}
619+
}
620+
621+
private func cancelDeferredRefresh() {
622+
deferredRefreshTask?.cancel()
623+
deferredRefreshTask = nil
624+
}
625+
573626
private func scanWindowStart(for range: TimeRangePreset) -> Date? {
574627
switch range {
575628
case .today, .yesterday, .last30Minutes, .last1Hour, .last3Hours, .last6Hours, .last12Hours, .last24Hours, .last7Days, .last30Days:

Sources/TokenMeter/DashboardView.swift

Lines changed: 6 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ struct DashboardView: View {
88
@State private var showingFilters = false
99
@State private var showingDetails = false
1010
@State private var showingSyncSettings = false
11-
@State private var isScrollActive = false
12-
@State private var refreshAfterScroll = false
1311
@AppStorage("showFullTokenNumbers") private var showFullTokenNumbers = false
1412

1513
private let refreshTimer = Timer.publish(every: 60, on: .main, in: .common).autoconnect()
@@ -20,7 +18,7 @@ struct DashboardView: View {
2018
.padding(.horizontal, 18)
2119
.padding(.top, 14)
2220
.padding(.bottom, 8)
23-
TokenSmoothScrollView(onScrollActivityChanged: handleScrollActivityChanged) {
21+
TokenSmoothScrollView(onScrollActivityChanged: model.scrollActivityChanged) {
2422
VStack(alignment: .leading, spacing: 18) {
2523
if updates.updateLabel != nil {
2624
UpdateAvailableBanner()
@@ -70,7 +68,7 @@ struct DashboardView: View {
7068
.environmentObject(updates)
7169
}
7270
.onReceive(refreshTimer) { _ in
73-
refreshRecentChangesWhenIdle()
71+
model.refreshRecentChangesWhenIdle()
7472
}
7573
.onChange(of: model.range) { _ in
7674
model.rangeDidChange()
@@ -121,7 +119,7 @@ struct DashboardView: View {
121119
Image(systemName: "arrow.clockwise")
122120
}
123121
}
124-
.help("Refresh")
122+
.accessibilityLabel("Refresh")
125123
.buttonStyle(TokenIconButtonStyle())
126124

127125
Button {
@@ -186,7 +184,7 @@ struct DashboardView: View {
186184
Image(systemName: showFullTokenNumbers ? "number.circle.fill" : "number.circle")
187185
}
188186
.buttonStyle(TokenCompactIconButtonStyle(selected: showFullTokenNumbers))
189-
.help("Show exact token counts with separators")
187+
.accessibilityLabel("Show exact token counts with separators")
190188

191189
if model.isScanning {
192190
HStack(spacing: 7) {
@@ -256,7 +254,6 @@ struct DashboardView: View {
256254
}
257255
.font(.system(size: 12))
258256
.foregroundStyle(TokenMeterTheme.secondaryText)
259-
.help("Compared with the previous matching time period")
260257
}
261258

262259
private var comparisonText: String {
@@ -356,7 +353,6 @@ struct DashboardView: View {
356353
}
357354
.menuStyle(.borderlessButton)
358355
.fixedSize()
359-
.help("Time range")
360356
}
361357

362358
private var bucketPicker: some View {
@@ -387,7 +383,6 @@ struct DashboardView: View {
387383
}
388384
.menuStyle(.borderlessButton)
389385
.fixedSize()
390-
.help("Chart grouping")
391386
}
392387

393388
private var recentRangePresets: [TimeRangePreset] {
@@ -502,7 +497,6 @@ struct DashboardView: View {
502497
}
503498
.menuStyle(.borderlessButton)
504499
.fixedSize()
505-
.help("Device scope")
506500
}
507501

508502
private var details: some View {
@@ -656,28 +650,6 @@ struct DashboardView: View {
656650
return "The current section, time range, project, or model selection has no token events."
657651
}
658652

659-
private func refreshRecentChangesWhenIdle() {
660-
guard !isScrollActive else {
661-
refreshAfterScroll = true
662-
return
663-
}
664-
model.refreshRecentChanges()
665-
}
666-
667-
private func handleScrollActivityChanged(_ isActive: Bool) {
668-
guard isScrollActive != isActive else { return }
669-
isScrollActive = isActive
670-
671-
if isActive, model.deferInProgressRefreshForScroll() {
672-
refreshAfterScroll = true
673-
}
674-
675-
if !isActive, refreshAfterScroll {
676-
refreshAfterScroll = false
677-
model.refreshRecentChanges()
678-
}
679-
}
680-
681653
@ViewBuilder
682654
private var syncSettingsSection: some View {
683655
if model.isSyncConfigured {
@@ -872,7 +844,7 @@ struct SyncFolderPanel: View {
872844
.foregroundStyle(TokenMeterTheme.tertiaryText)
873845
.lineLimit(1)
874846
.truncationMode(.middle)
875-
.help(configuredPath ?? "")
847+
.accessibilityValue(configuredPath ?? "")
876848
}
877849

878850
Spacer()
@@ -1004,7 +976,7 @@ struct DataSourceStatusRow: View {
1004976
.foregroundStyle(TokenMeterTheme.tertiaryText)
1005977
.lineLimit(1)
1006978
.truncationMode(.middle)
1007-
.help(status.path)
979+
.accessibilityValue(status.path)
1008980
}
1009981

1010982
Spacer()

Sources/TokenMeter/DesignSystem.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ struct TokenSmoothScrollView<Content: View>: NSViewRepresentable {
127127
}
128128

129129
final class TokenHostingScrollView<Content: View>: NSScrollView {
130+
private let scrollIdleDelay: TimeInterval = 0.35
130131
private let hostingView: NSHostingView<Content>
131132
private var lastLaidOutWidth: CGFloat = 0
132133
private var cachedFittingHeight: CGFloat = 1
@@ -251,7 +252,7 @@ final class TokenHostingScrollView<Content: View>: NSScrollView {
251252
self?.endRootUpdateDeferral()
252253
}
253254
endScrollWorkItem = workItem
254-
DispatchQueue.main.asyncAfter(deadline: .now() + 0.12, execute: workItem)
255+
DispatchQueue.main.asyncAfter(deadline: .now() + scrollIdleDelay, execute: workItem)
255256
}
256257

257258
private func endRootUpdateDeferral() {

Sources/TokenMeter/UpdateSheetView.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ struct UpdateSheetView: View {
2222
Image(systemName: "xmark")
2323
}
2424
.buttonStyle(TokenCompactIconButtonStyle())
25-
.help("Close")
25+
.accessibilityLabel("Close")
2626
}
2727

2828
VStack(alignment: .leading, spacing: 10) {

0 commit comments

Comments
 (0)