From 72ac56896c028d31d4cf3d12c84684eac03295ff Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 21:32:51 +0200 Subject: [PATCH 01/14] fix(GenesisKit, Clicky): the key sound wires its audio graph on the start queue, so the settings face no longer waits half a second for CoreAudio at launch (cherry picked from commit 187a6c27495ea796cdd96b30a7c12e5e16f5d103) --- .../GenesisKit/Clicky/ClickyAudio.swift | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift index 70a0cd7b8c..537e450e43 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift @@ -32,7 +32,7 @@ public enum ClickySynthesis { final class ClickyAudio { private let engine: AVAudioEngine private let format = AVAudioFormat(standardFormatWithSampleRate: ClickySynthesis.sampleRate, channels: 1)! - private var voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] = [] + private let voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] private var buffers: [String: AVAudioPCMBuffer] = [:] private var cursor = 0 private var idleStop: DispatchWorkItem? @@ -50,10 +50,35 @@ final class ClickyAudio { private var stopAfterStart = false /// A hardware change stopped the engine while it was starting; the start then finished on a stopped engine. private var configChangedWhileStarting = false + /// The engine and its voices, used only on the start queue (or, rendering offline, on the caller's thread). private final class EngineBox: @unchecked Sendable { let engine: AVAudioEngine - init(_ engine: AVAudioEngine) { self.engine = engine } + let voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] + let format: AVAudioFormat + private var wired = false + + init(engine: AVAudioEngine, voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)], format: AVAudioFormat) { + self.engine = engine + self.voices = voices + self.format = format + } + + /// Wires the voices once, then starts. The first `mainMixerNode` read creates the output unit, which waits for + /// CoreAudio: 0.5 s on the main thread when the settings face launched (HangWatch, 2026-10-23 21:30). + func start() throws { + if !wired { + for (player, rate) in voices { + engine.attach(player) + engine.attach(rate) + engine.connect(player, to: rate, format: format) + engine.connect(rate, to: engine.mainMixerNode, format: format) + } + wired = true + } + try engine.start() + } } + private let box: EngineBox private struct PackBank { let reference: ClickyPackReference let manifestHash: String @@ -128,15 +153,9 @@ final class ClickyAudio { init(engine: AVAudioEngine = AVAudioEngine()) { self.engine = engine - for _ in 0..<12 { - let player = AVAudioPlayerNode() - let rate = AVAudioUnitVarispeed() - engine.attach(player) - engine.attach(rate) - engine.connect(player, to: rate, format: format) - engine.connect(rate, to: engine.mainMixerNode, format: format) - voices.append((player, rate)) - } + let voices = (0..<12).map { _ in (AVAudioPlayerNode(), AVAudioUnitVarispeed()) } + self.voices = voices + box = EngineBox(engine: engine, voices: voices, format: format) for profile in ClickySwitch.allCases { for release in [false, true] { for variant in 0..<3 { @@ -150,7 +169,6 @@ final class ClickyAudio { } } } - engine.prepare() // A hardware change stops the engine without telling `running`; the next click must start it again. NotificationCenter.default.addObserver(forName: .AVAudioEngineConfigurationChange, object: engine, queue: .main) { [weak self] _ in MainActor.assumeIsolated { @@ -171,7 +189,7 @@ final class ClickyAudio { idleStop?.cancel() // Offline rendering (tests) starts at once: no hardware to wake. if !running, !starting, engine.isInManualRenderingMode { - try engine.start() + try box.start() running = true } guard running else { @@ -189,11 +207,11 @@ final class ClickyAudio { stopAfterStart = false guard !starting else { return } starting = true - let box = EngineBox(engine) + let box = box startQueue.async { let begun = Date() var failure: Error? - do { try box.engine.start() } catch { failure = error } + do { try box.start() } catch { failure = error } let ms = Date().timeIntervalSince(begun) * 1000 DispatchQueue.main.async { [weak self] in MainActor.assumeIsolated { @@ -253,6 +271,8 @@ final class ClickyAudio { stopAfterStart = true return } + // Never started: the voices are not wired yet, and a player outside an engine raises on stop. + guard running else { return } running = false for voice in voices { voice.0.stop() } engine.stop() From 9f63ef0e6c5512b30c42008cbc3747da67748c7f Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 25 Oct 2026 14:10:39 +0100 Subject: [PATCH 02/14] feat(Clicky): Clicky picks its audio output: a device of your choice or rules per system output, built on a shared GenesisKit AudioOutputs layer that reads each output's delay from CoreAudio, and a prompt that never takes focus offers a faster output when the current one is late (an AirPlay HomePod reports 2 s) (cherry picked from commit 98849d8fdb15404a774f6b4408a675dde263b188) --- .../GenesisKit/Audio/AudioOutputs.swift | 164 ++++++++++++++++++ .../GenesisKit/Clicky/ClickyAudio.swift | 15 ++ .../GenesisKit/Clicky/ClickyDelayPrompt.swift | 104 +++++++++++ .../GenesisKit/Clicky/ClickyModel.swift | 91 +++++++++- .../GenesisKit/Clicky/ClickyOutput.swift | 58 +++++++ .../Clicky/ClickyOutputSettings.swift | 121 +++++++++++++ .../GenesisKit/Clicky/ClickySettings.swift | 41 +++++ .../GenesisKit/Clicky/ClickyViews.swift | 1 + .../GenesisKitTests/ClickyOutputTests.swift | 125 +++++++++++++ 9 files changed, 716 insertions(+), 4 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift create mode 100644 src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift b/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift new file mode 100644 index 0000000000..bf36ac5fe2 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Audio/AudioOutputs.swift @@ -0,0 +1,164 @@ +import AVFoundation +import AudioToolbox +import CoreAudio +import Foundation + +/// One audio output of this Mac, with the delay macOS reports for it. +public struct AudioOutputDevice: Identifiable, Hashable, Sendable { + public let id: AudioDeviceID + public let uid: String + public let name: String + public let transport: UInt32 + /// Device latency + safety offset + stream latency + one IO buffer, as CoreAudio reports them, in seconds. An AirPlay + /// output (a HomePod) reports about 2 s; built-in speakers about 0.03 s (measured 2026-10-25). + public let delay: TimeInterval + + public init(id: AudioDeviceID, uid: String, name: String, transport: UInt32, delay: TimeInterval) { + self.id = id + self.uid = uid + self.name = name + self.transport = transport + self.delay = delay + } + + public var isVirtual: Bool { + transport == kAudioDeviceTransportTypeVirtual || transport == kAudioDeviceTransportTypeAggregate + || transport == kAudioDeviceTransportTypeAutoAggregate + } + + public var isBuiltIn: Bool { transport == kAudioDeviceTransportTypeBuiltIn } + + /// "2.0 s late" or "27 ms late". + public var delayText: String { Self.delayText(delay) } + + public static func delayText(_ seconds: TimeInterval) -> String { + seconds >= 1 ? String(format: "%.1f s late", seconds) : "\(Int((seconds * 1000).rounded())) ms late" + } +} + +/// The Mac's audio outputs, read from CoreAudio: the list, the system output, a faster alternative, and pinning an +/// `AVAudioEngine` to one device. Clicky plays through it; any part that plays sound can. +public enum AudioOutputs { + /// Every device with at least one output stream. + public static func outputs() -> [AudioOutputDevice] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDevices, mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + let system = AudioObjectID(kAudioObjectSystemObject) + guard AudioObjectGetPropertyDataSize(system, &address, 0, nil, &size) == noErr else { return [] } + var ids = [AudioDeviceID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData(system, &address, 0, nil, &size, &ids) == noErr else { return [] } + return ids.compactMap(device(for:)) + } + + public static func systemDefaultID() -> AudioDeviceID? { + var address = AudioObjectPropertyAddress( + mSelector: kAudioHardwarePropertyDefaultOutputDevice, mScope: kAudioObjectPropertyScopeGlobal, + mElement: kAudioObjectPropertyElementMain) + var id = AudioDeviceID(0) + var size = UInt32(MemoryLayout.size) + guard AudioObjectGetPropertyData(AudioObjectID(kAudioObjectSystemObject), &address, 0, nil, &size, &id) == noErr, + id != 0 else { return nil } + return id + } + + /// An output to offer instead of a late one: the built-in speakers, else the connected output with the least delay. + /// Virtual and aggregate devices are never offered: they may not reach a speaker. + public static func suggestion(instead late: AudioOutputDevice, outputs: [AudioOutputDevice], + limit: TimeInterval) -> AudioOutputDevice? { + let candidates = outputs.filter { $0.uid != late.uid && !$0.isVirtual && $0.delay < limit } + return candidates.first(where: \.isBuiltIn) ?? candidates.min { $0.delay < $1.delay } + } + + /// Makes `engine` play on `device` from its next start (call it while the engine is stopped). Returns false when + /// CoreAudio refused or the engine renders offline. + @discardableResult + public static func pin(_ engine: AVAudioEngine, to device: AudioDeviceID) -> Bool { + guard !engine.isInManualRenderingMode, let unit = engine.outputNode.audioUnit else { return false } + var device = device + return AudioUnitSetProperty(unit, kAudioOutputUnitProperty_CurrentDevice, kAudioUnitScope_Global, 0, &device, + UInt32(MemoryLayout.size)) == noErr + } + + // MARK: - CoreAudio + + private static func device(for id: AudioDeviceID) -> AudioOutputDevice? { + let streams = outputStreams(id) + guard !streams.isEmpty, let uid = string(id, kAudioDevicePropertyDeviceUID) else { return nil } + let output = kAudioObjectPropertyScopeOutput + let global = kAudioObjectPropertyScopeGlobal + let rate = value(id, kAudioDevicePropertyNominalSampleRate, global, Float64(0)) ?? 0 + let frames = (value(id, kAudioDevicePropertyLatency, output, UInt32(0)) ?? 0) + + (value(id, kAudioDevicePropertySafetyOffset, output, UInt32(0)) ?? 0) + + (value(id, kAudioDevicePropertyBufferFrameSize, global, UInt32(0)) ?? 0) + + (value(streams[0], kAudioStreamPropertyLatency, global, UInt32(0)) ?? 0) + return AudioOutputDevice( + id: id, uid: uid, name: string(id, kAudioDevicePropertyDeviceNameCFString) ?? "Output", + transport: value(id, kAudioDevicePropertyTransportType, global, UInt32(0)) ?? 0, + delay: rate > 0 ? Double(frames) / rate : 0) + } + + private static func outputStreams(_ id: AudioDeviceID) -> [AudioStreamID] { + var address = AudioObjectPropertyAddress( + mSelector: kAudioDevicePropertyStreams, mScope: kAudioObjectPropertyScopeOutput, + mElement: kAudioObjectPropertyElementMain) + var size: UInt32 = 0 + guard AudioObjectGetPropertyDataSize(id, &address, 0, nil, &size) == noErr, size > 0 else { return [] } + var streams = [AudioStreamID](repeating: 0, count: Int(size) / MemoryLayout.size) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &streams) == noErr else { return [] } + return streams + } + + private static func value(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector, + _ scope: AudioObjectPropertyScope, _ initial: T) -> T? { + var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain) + var result = initial + var size = UInt32(MemoryLayout.size) + return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &result) == noErr ? result : nil + } + + private static func value(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector, + _ scope: AudioObjectPropertyScope, _ initial: Float64) -> Float64? { + var address = AudioObjectPropertyAddress(mSelector: selector, mScope: scope, mElement: kAudioObjectPropertyElementMain) + var result = initial + var size = UInt32(MemoryLayout.size) + return AudioObjectGetPropertyData(id, &address, 0, nil, &size, &result) == noErr ? result : nil + } + + private static func string(_ id: AudioObjectID, _ selector: AudioObjectPropertySelector) -> String? { + var address = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + var text: Unmanaged? + var size = UInt32(MemoryLayout?>.size) + guard AudioObjectGetPropertyData(id, &address, 0, nil, &size, &text) == noErr, let text else { return nil } + return text.takeRetainedValue() as String + } +} + +/// Calls `changed` on the main queue when the system output or the list of devices changes. +@MainActor +public final class AudioOutputMonitor { + private var listeners: [(AudioObjectPropertyAddress, AudioObjectPropertyListenerBlock)] = [] + + public init(changed: @escaping @MainActor () -> Void) { + for selector in [kAudioHardwarePropertyDefaultOutputDevice, kAudioHardwarePropertyDevices] { + var address = AudioObjectPropertyAddress( + mSelector: selector, mScope: kAudioObjectPropertyScopeGlobal, mElement: kAudioObjectPropertyElementMain) + let block: AudioObjectPropertyListenerBlock = { _, _ in + MainActor.assumeIsolated { changed() } + } + if AudioObjectAddPropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block) == noErr { + listeners.append((address, block)) + } + } + } + + public func stop() { + for (address, block) in listeners { + var address = address + AudioObjectRemovePropertyListenerBlock(AudioObjectID(kAudioObjectSystemObject), &address, .main, block) + } + listeners.removeAll() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift index 537e450e43..42193916df 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyAudio.swift @@ -56,6 +56,8 @@ final class ClickyAudio { let voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)] let format: AVAudioFormat private var wired = false + /// The output device the next start plays on; nil keeps the engine's own choice (the system output). + var device: AudioDeviceID? init(engine: AVAudioEngine, voices: [(AVAudioPlayerNode, AVAudioUnitVarispeed)], format: AVAudioFormat) { self.engine = engine @@ -66,6 +68,9 @@ final class ClickyAudio { /// Wires the voices once, then starts. The first `mainMixerNode` read creates the output unit, which waits for /// CoreAudio: 0.5 s on the main thread when the settings face launched (HangWatch, 2026-10-23 21:30). func start() throws { + if let device, !AudioOutputs.pin(engine, to: device) { + PerfLog.mark("clicky.audio.output \(device) refused") + } if !wired { for (player, rate) in voices { engine.attach(player) @@ -79,6 +84,16 @@ final class ClickyAudio { } } private let box: EngineBox + private var outputDevice: AudioDeviceID? + + /// Plays on `device` from the next click on. A change stops the engine, so the next click starts it on the new device. + func setOutput(_ device: AudioDeviceID?) { + guard device != outputDevice else { return } + outputDevice = device + let box = box + startQueue.async { box.device = device } + stopVoices() + } private struct PackBank { let reference: ClickyPackReference let manifestHash: String diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift new file mode 100644 index 0000000000..7e1ac8fe3c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyDelayPrompt.swift @@ -0,0 +1,104 @@ +import AppKit +import SwiftUI + +/// The prompt Clicky shows when its output is late (an AirPlay HomePod is about 2 s late): a small panel at the top +/// right of the screen under the pointer. It never takes the keyboard (a non-activating panel ordered front without +/// becoming key), and it closes by itself after `holdSeconds`. +@MainActor +final class ClickyDelayPrompt { + static let shared = ClickyDelayPrompt() + static let holdSeconds: TimeInterval = 45 + + private var panel: NSPanel? + private var generation = 0 + + func show(late: AudioOutputDevice, suggestion: AudioOutputDevice, reduceMotion: Bool, + onSwitch: @escaping () -> Void, onKeep: @escaping () -> Void) { + generation += 1 + let shown = generation + let view = ClickyDelayPromptView( + late: late, suggestion: suggestion, + onSwitch: { [weak self] in onSwitch(); self?.close(shown) }, + onKeep: { [weak self] in onKeep(); self?.close(shown) }, + onClose: { [weak self] in self?.close(shown) }) + let host = NSHostingView(rootView: view) + let panel = self.panel ?? Self.makePanel() + self.panel = panel + panel.contentView = host + let size = host.fittingSize + let pointer = NSEvent.mouseLocation + let screen = (NSScreen.screens.first { $0.frame.contains(pointer) } ?? NSScreen.main)?.visibleFrame + ?? CGRect(x: 0, y: 0, width: 1440, height: 900) + panel.setFrame(CGRect(x: screen.maxX - size.width - 16, y: screen.maxY - size.height - 12, + width: size.width, height: size.height), display: true) + panel.alphaValue = reduceMotion ? 1 : 0 + panel.orderFrontRegardless() + if !reduceMotion { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.18 + panel.animator().alphaValue = 1 + } + } + DispatchQueue.main.asyncAfter(deadline: .now() + Self.holdSeconds) { [weak self] in + MainActor.assumeIsolated { self?.close(shown) } + } + } + + private func close(_ shown: Int) { + guard shown == generation, let panel else { return } + panel.orderOut(nil) + panel.contentView = nil + } + + private static func makePanel() -> NSPanel { + let panel = NSPanel(contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, + defer: true) + panel.isOpaque = false + panel.backgroundColor = .clear + panel.hasShadow = true + panel.hidesOnDeactivate = false + panel.becomesKeyOnlyIfNeeded = true + panel.isReleasedWhenClosed = false + panel.level = .floating + panel.collectionBehavior = [.transient, .ignoresCycle, .fullScreenAuxiliary, .canJoinAllSpaces] + return panel + } +} + +struct ClickyDelayPromptView: View { + let late: AudioOutputDevice + let suggestion: AudioOutputDevice + let onSwitch: () -> Void + let onKeep: () -> Void + let onClose: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "speaker.wave.2.bubble.left.fill").foregroundStyle(.orange) + Text("Clicky sounds \(late.delayText) on \(late.name)") + .font(.system(size: 13, weight: .semibold)) + Spacer(minLength: 8) + IconButton(systemName: "xmark", tooltip: "Close; Clicky asks again after the next restart", action: onClose) + } + Text("Every key sound reaches you \(late.delayText). \(suggestion.name) is \(suggestion.delayText). Play Clicky there while the system plays on \(late.name)?") + .font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { + Spacer() + Button("Keep \(late.name)", action: onKeep) + .accessibilityIdentifier("clicky.delay.keep") + Button("Play there", action: onSwitch) + .keyboardShortcut(.defaultAction) + .accessibilityIdentifier("clicky.delay.switch") + } + .controlSize(.regular) + } + .padding(14) + .frame(width: 360) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + .overlay(RoundedRectangle(cornerRadius: 12, style: .continuous).stroke(Color.primary.opacity(0.1))) + .accessibilityElement(children: .contain) + .accessibilityLabel("Clicky output is late") + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift index 1bcce74733..5f82191df0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyModel.swift @@ -54,6 +54,12 @@ public final class ClickyModel: ObservableObject { private var syncingAppearance = false private let log = Logger(subsystem: "dev.genesis.tools", category: "Clicky") private var audio: ClickyAudio? + /// The connected outputs and where Clicky plays, refreshed when the system output or the device list changes. + @Published public private(set) var outputs: [AudioOutputDevice] = [] + @Published public private(set) var output: ClickyOutputResolution? + private var outputMonitor: AudioOutputMonitor? + /// Late outputs already offered an alternative in this run: the prompt shows once per output and launch. + private var delayPrompted: Set = [] private var previewGeneration = 0 private let inputMonitor: any ClickyInputMonitoring private var observers: [NSObjectProtocol] = [] @@ -111,7 +117,7 @@ public final class ClickyModel: ObservableObject { } soundLibrary.install = { [weak self] reference, pack in guard let self else { throw ClickyPackError.invalid("Clicky is no longer available.") } - if self.audio == nil { self.audio = ClickyAudio() } + if self.audio == nil { self.makeAudio() } try self.audio?.install(reference: reference, pack: pack) self.previewGeneration &+= 1 self.inputState.clear() @@ -122,6 +128,8 @@ public final class ClickyModel: ObservableObject { } soundLibrary.restore(preferences.selectedPack) guard !previewOnly && observeSystemEvents else { return } + outputMonitor = AudioOutputMonitor { [weak self] in self?.refreshOutput() } + refreshOutput() let workspace = NSWorkspace.shared.notificationCenter for name in [ NSWorkspace.didActivateApplicationNotification, NSWorkspace.willSleepNotification, @@ -203,7 +211,7 @@ public final class ClickyModel: ObservableObject { return case .started: break } - if audio == nil { audio = ClickyAudio() } + if audio == nil { makeAudio() } enabled = true if !restoring { defaults.set(true, forKey: Self.enabledKey) } if preferences.collectStats && statisticsLoadError == nil { @@ -212,6 +220,7 @@ public final class ClickyModel: ObservableObject { pendingStats = true } refreshContext() + offerFasterOutputIfLate() if !restoring { notify("Clicky enabled", body: "Your selected switch is \(selectedSoundName).") } log.notice("Input feedback activated\(restoring ? " (restored at launch)" : ""); no input content is retained") } @@ -261,6 +270,8 @@ public final class ClickyModel: ObservableObject { } public func shutdown() { + outputMonitor?.stop() + outputMonitor = nil soundLibrary.stop() deactivate(remember: false) boundaryTimer?.invalidate() @@ -446,9 +457,81 @@ public final class ClickyModel: ObservableObject { } } if let data = try? JSONEncoder().encode(preferences) { defaults.set(data, forKey: "clicky.preferences.v1") } + if previous.outputDeviceUID != preferences.outputDeviceUID || previous.outputRules != preferences.outputRules + || previous.delayWarning != preferences.delayWarning + || previous.delayWarningMilliseconds != preferences.delayWarningMilliseconds { + refreshOutput() + } refreshContext() } + // MARK: - Output + + private func makeAudio() { + audio = ClickyAudio() + audio?.setOutput(output?.effective?.id) + } + + /// Reads the outputs again, decides where Clicky plays, and offers a faster output when that one is late. + public func refreshOutput() { + outputs = AudioOutputs.outputs() + let resolved = ClickyOutputRouting.resolve(preferences: preferences, outputs: outputs, + systemID: AudioOutputs.systemDefaultID()) + if resolved != output, let effective = resolved.effective { + log.notice("Output \(effective.name, privacy: .public), \(effective.delayText, privacy: .public): \(resolved.reason, privacy: .public)") + } + output = resolved + audio?.setOutput(resolved.effective?.id) + offerFasterOutputIfLate() + } + + private var delayLimit: TimeInterval { Double(preferences.delayWarningMilliseconds) / 1000 } + + private func offerFasterOutputIfLate() { + guard enabled, !previewOnly, preferences.delayWarning, let late = output?.effective, late.delay > delayLimit, + !preferences.delayAcceptedUIDs.contains(late.uid), !delayPrompted.contains(late.uid), + let faster = AudioOutputs.suggestion(instead: late, outputs: outputs, limit: delayLimit) + else { return } + delayPrompted.insert(late.uid) + ClickyDelayPrompt.shared.show( + late: late, suggestion: faster, reduceMotion: appearance.reduceMotion, + onSwitch: { [weak self] in self?.playLateOutputOn(faster, instead: late) }, + onKeep: { [weak self] in self?.preferences.delayAcceptedUIDs.append(late.uid) }) + } + + /// The prompt's answer: a hand-picked late output is replaced; a late system output gets a rule. + private func playLateOutputOn(_ faster: AudioOutputDevice, instead late: AudioOutputDevice) { + if preferences.outputDeviceUID == late.uid { + pickOutput(faster) + } else { + setRule(when: late, playOn: faster) + } + } + + /// nil follows the system output. + public func pickOutput(_ device: AudioOutputDevice?) { + preferences.outputDeviceUID = device?.uid + preferences.outputDeviceName = device?.name + } + + public func setRule(when system: AudioOutputDevice, playOn target: AudioOutputDevice) { + var rules = preferences.outputRules.filter { $0.whenUID != system.uid } + rules.append(ClickyOutputRule(whenUID: system.uid, whenName: system.name, playOnUID: target.uid, + playOnName: target.name)) + preferences.outputRules = rules + } + + public func removeRule(_ id: ClickyOutputRule.ID) { + preferences.outputRules.removeAll { $0.id == id } + } + + /// Late outputs the user kept are offered an alternative again. + public func askAgainAboutLateOutputs() { + preferences.delayAcceptedUIDs = [] + delayPrompted = [] + offerFasterOutputIfLate() + } + private func flushStatistics() { analytics.flush(statistics) guard statisticsLoadError == nil, pendingStats else { return } @@ -576,7 +659,7 @@ public final class ClickyModel: ObservableObject { } private func playSelected(keyCode: UInt16, release: Bool, preview: Bool, pan: Float) { - if audio == nil { audio = ClickyAudio() } + if audio == nil { makeAudio() } do { let imported = try audio?.playSelected(keyCode: keyCode, release: release, preview: preview, preferences: preferences, pan: pan) ?? false @@ -594,7 +677,7 @@ public final class ClickyModel: ObservableObject { } private func play(profile: ClickySwitch, release: Bool, pan: Float) { - if audio == nil { audio = ClickyAudio() } + if audio == nil { makeAudio() } do { try audio?.play(profile: profile, release: release, preferences: preferences, pan: pan) if preferences.visualizer { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift new file mode 100644 index 0000000000..09e949acc7 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutput.swift @@ -0,0 +1,58 @@ +import CoreAudio +import Foundation + +/// "When the system plays on `whenUID`, Clicky plays on `playOnUID`." The names are kept for the settings list while a +/// device is not connected. +public struct ClickyOutputRule: Codable, Hashable, Identifiable, Sendable { + public var id = UUID() + public var whenUID: String + public var whenName: String + public var playOnUID: String + public var playOnName: String + + public init(whenUID: String, whenName: String, playOnUID: String, playOnName: String) { + self.whenUID = whenUID + self.whenName = whenName + self.playOnUID = playOnUID + self.playOnName = playOnName + } +} + +/// Where Clicky plays and why. `device` nil means the system output. +public struct ClickyOutputResolution: Equatable, Sendable { + public let device: AudioOutputDevice? + public let system: AudioOutputDevice? + public let reason: String + + /// The output the sound really comes out of. + public var effective: AudioOutputDevice? { device ?? system } +} + +public enum ClickyOutputRouting { + /// Which device Clicky plays on. A device picked by hand wins; otherwise the first rule for the current system output; + /// otherwise the system output. A picked or ruled device that is not connected falls back to the system output. + public static func resolve(preferences: ClickyPreferences, outputs: [AudioOutputDevice], + systemID: AudioDeviceID?) -> ClickyOutputResolution { + let system = outputs.first { $0.id == systemID } + if let uid = preferences.outputDeviceUID { + if let picked = outputs.first(where: { $0.uid == uid }) { + return ClickyOutputResolution(device: picked, system: system, reason: "You picked \(picked.name).") + } + return ClickyOutputResolution( + device: nil, system: system, + reason: "\(preferences.outputDeviceName ?? "The picked output") is not connected, so Clicky plays on the system output.") + } + if let system, let rule = preferences.outputRules.first(where: { $0.whenUID == system.uid }) { + if let target = outputs.first(where: { $0.uid == rule.playOnUID }) { + return ClickyOutputResolution( + device: target, system: system, + reason: "A rule plays Clicky on \(target.name) while the system plays on \(system.name).") + } + return ClickyOutputResolution( + device: nil, system: system, + reason: "The rule for \(system.name) names \(rule.playOnName), which is not connected.") + } + return ClickyOutputResolution(device: nil, system: system, reason: "Clicky follows the system output.") + } + +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift new file mode 100644 index 0000000000..d471ba595c --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyOutputSettings.swift @@ -0,0 +1,121 @@ +import SwiftUI + +/// Clicky's "Output" card: the device it plays on, the rules per system output, and the late-output prompt. +struct ClickyOutputCard: View { + @ObservedObject var model: ClickyModel + + var body: some View { + NativeSettingsCard("Output", subtitle: nowPlaying) { + NativeSettingsPicker( + "Play Clicky on", detail: model.output?.reason, identifier: "clicky.output.device", + options: deviceOptions, selection: deviceSelection, width: 260) + if model.preferences.outputDeviceUID == nil { + Divider() + rules + } + Divider() + NativeSettingsToggle( + "Suggest a faster output", + detail: "When Clicky's output is late by more than the limit below, a small prompt offers a faster one. It never takes the keyboard.", + identifier: "clicky.output.delayWarning", isOn: $model.preferences.delayWarning) + if model.preferences.delayWarning { + NativeSettingsSegmented( + "Late after", detail: "AirPlay speakers such as a HomePod are about 2 s late; built-in speakers about 30 ms.", + identifier: "clicky.output.delayLimit", + options: [100, 150, 300, 500].map { NativeSettingsOption($0, "\($0) ms") }, + selection: $model.preferences.delayWarningMilliseconds) + } + if !model.preferences.delayAcceptedUIDs.isEmpty { + NativeSettingsRow("Kept although late", detail: keptNames) { + Button("Ask again", action: model.askAgainAboutLateOutputs) + .accessibilityIdentifier("clicky.output.askAgain") + } + } + } + } + + private var nowPlaying: String? { + guard let effective = model.output?.effective else { return nil } + return "Now playing on \(effective.name), \(effective.delayText)." + } + + private var deviceOptions: [NativeSettingsOption] { + var options = [NativeSettingsOption("", "Same as the system output", detail: nil, symbol: "speaker.wave.2")] + options += model.outputs.filter { !$0.isVirtual || $0.uid == model.preferences.outputDeviceUID }.map { + NativeSettingsOption($0.uid, "\($0.name) (\($0.delayText))") + } + if let uid = model.preferences.outputDeviceUID, !model.outputs.contains(where: { $0.uid == uid }) { + options.append(NativeSettingsOption(uid, "\(model.preferences.outputDeviceName ?? "Output") (not connected)")) + } + return options + } + + private var deviceSelection: Binding { + Binding( + get: { model.preferences.outputDeviceUID ?? "" }, + set: { uid in + if uid.isEmpty { + model.pickOutput(nil) + } else if let device = model.outputs.first(where: { $0.uid == uid }) { + model.pickOutput(device) + } + }) + } + + private var keptNames: String { + model.preferences.delayAcceptedUIDs.map { uid in model.outputs.first { $0.uid == uid }?.name ?? "A disconnected output" } + .joined(separator: ", ") + } + + @ViewBuilder private var rules: some View { + VStack(alignment: .leading, spacing: 8) { + Text("Rules").font(.system(size: 12, weight: .semibold)) + Text("While the system plays on one output, Clicky can play on another one.") + .font(.system(size: 11)).foregroundStyle(.secondary) + ForEach(model.preferences.outputRules) { rule in + ClickyOutputRuleRow(model: model, rule: rule) + } + if let system = model.output?.system, !model.preferences.outputRules.contains(where: { $0.whenUID == system.uid }) { + Button("Add a rule for \(system.name)") { + let target = AudioOutputs.suggestion(instead: system, outputs: model.outputs, limit: .infinity) + model.setRule(when: system, playOn: target ?? system) + } + .accessibilityIdentifier("clicky.output.addRule") + } + } + } +} + +private struct ClickyOutputRuleRow: View { + @ObservedObject var model: ClickyModel + let rule: ClickyOutputRule + + var body: some View { + HStack(spacing: 8) { + Text("When the system plays on \(rule.whenName), play on").font(.system(size: 12)) + Picker("Play on", selection: target) { + ForEach(model.outputs.filter { !$0.isVirtual }) { device in + Text("\(device.name) (\(device.delayText))").tag(device.uid) + } + if !model.outputs.contains(where: { $0.uid == rule.playOnUID }) { + Text("\(rule.playOnName) (not connected)").tag(rule.playOnUID) + } + } + .labelsHidden().fixedSize() + Spacer(minLength: 0) + IconButton(systemName: "trash", tooltip: "Remove this rule") { model.removeRule(rule.id) } + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("clicky.output.rule.\(rule.whenUID)") + } + + private var target: Binding { + Binding( + get: { rule.playOnUID }, + set: { uid in + guard let device = model.outputs.first(where: { $0.uid == uid }) else { return } + let system = AudioOutputDevice(id: 0, uid: rule.whenUID, name: rule.whenName, transport: 0, delay: 0) + model.setRule(when: system, playOn: device) + }) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift index a15a036477..852f5670d0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift @@ -56,14 +56,55 @@ public struct ClickyPreferences: Codable, Equatable { public var collectStats = true public var reduceMotion = false public var reduceTransparency = false + /// The output Clicky plays on; nil follows the system output (and its rules). + public var outputDeviceUID: String? + public var outputDeviceName: String? + public var outputRules: [ClickyOutputRule] = [] + /// Offer another output when the one Clicky plays on is late by more than `delayWarningMilliseconds`. + public var delayWarning = true + public var delayWarningMilliseconds = 150 + /// Outputs the user chose to keep although they are late: never offered again. + public var delayAcceptedUIDs: [String] = [] public init() {} + /// Every key is optional, so preferences saved by an older build keep their values and take the new defaults. + public init(from decoder: Decoder) throws { + self.init() + let values = try decoder.container(keyedBy: CodingKeys.self) + func read(_ key: CodingKeys, into field: inout T) throws { + if let value = try values.decodeIfPresent(T.self, forKey: key) { field = value } + } + try read(.selectedSwitch, into: &selectedSwitch) + selectedPack = try values.decodeIfPresent(ClickyPackReference.self, forKey: .selectedPack) + try read(.volume, into: &volume) + try read(.releaseSounds, into: &releaseSounds) + try read(.randomizedPitch, into: &randomizedPitch) + try read(.spatialAudio, into: &spatialAudio) + try read(.repeatSounds, into: &repeatSounds) + try read(.quietHours, into: &quietHours) + try read(.quietStart, into: &quietStart) + try read(.quietEnd, into: &quietEnd) + try read(.excludedApplications, into: &excludedApplications) + try read(.visualizer, into: &visualizer) + try read(.notifications, into: ¬ifications) + try read(.collectStats, into: &collectStats) + try read(.reduceMotion, into: &reduceMotion) + try read(.reduceTransparency, into: &reduceTransparency) + outputDeviceUID = try values.decodeIfPresent(String.self, forKey: .outputDeviceUID) + outputDeviceName = try values.decodeIfPresent(String.self, forKey: .outputDeviceName) + try read(.outputRules, into: &outputRules) + try read(.delayWarning, into: &delayWarning) + try read(.delayWarningMilliseconds, into: &delayWarningMilliseconds) + try read(.delayAcceptedUIDs, into: &delayAcceptedUIDs) + } + public mutating func normalize() { volume = volume.isFinite ? min(1, max(0, volume)) : 0.45 quietStart = min(1439, max(0, quietStart)) quietEnd = min(1439, max(0, quietEnd)) excludedApplications = Array(Set(excludedApplications.filter { !$0.isEmpty })).sorted() + delayWarningMilliseconds = min(5000, max(20, delayWarningMilliseconds)) } public func isQuiet(at date: Date, calendar: Calendar = .current) -> Bool { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift index 30bad7fab2..e2329173af 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyViews.swift @@ -255,6 +255,7 @@ private struct ClickySettingsPageContent: View { } } ClickySoundLibraryView(model: model, library: model.soundLibrary) + ClickyOutputCard(model: model) card("Sound behavior") { setting( "Key release sounds", detail: "A separate sound when each key comes back up.", diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift new file mode 100644 index 0000000000..d54d7a8122 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyOutputTests.swift @@ -0,0 +1,125 @@ +import AppKit +import CoreAudio +import SwiftUI +import XCTest + +@testable import GenesisKit + +/// Where Clicky plays: a picked device, a rule for the system output, or the system output; and which output the +/// late-output prompt offers. +@MainActor +final class ClickyOutputTests: XCTestCase { + private let speakers = AudioOutputDevice(id: 1, uid: "BuiltInSpeakerDevice", name: "MacBook Pro Speakers", + transport: kAudioDeviceTransportTypeBuiltIn, delay: 0.027) + private let homePod = AudioOutputDevice(id: 2, uid: "airplay-work", name: "AirPlay", + transport: kAudioDeviceTransportTypeAirPlay, delay: 2.012) + private let monitor = AudioOutputDevice(id: 3, uid: "hdmi-monitor", name: "Monitor", + transport: kAudioDeviceTransportTypeHDMI, delay: 0.019) + private let loopback = AudioOutputDevice(id: 4, uid: "virtual", name: "Loopback", + transport: kAudioDeviceTransportTypeVirtual, delay: 0.011) + private var all: [AudioOutputDevice] { [speakers, homePod, monitor, loopback] } + + func testWithNoChoiceClickyFollowsTheSystemOutput() { + let resolved = ClickyOutputRouting.resolve(preferences: ClickyPreferences(), outputs: all, systemID: homePod.id) + XCTAssertNil(resolved.device) + XCTAssertEqual(resolved.effective, homePod) + } + + func testARuleForTheSystemOutputPlaysOnItsTarget() { + var preferences = ClickyPreferences() + preferences.outputRules = [ClickyOutputRule(whenUID: homePod.uid, whenName: homePod.name, + playOnUID: speakers.uid, playOnName: speakers.name)] + XCTAssertEqual(ClickyOutputRouting.resolve(preferences: preferences, outputs: all, systemID: homePod.id).effective, + speakers) + XCTAssertEqual(ClickyOutputRouting.resolve(preferences: preferences, outputs: all, systemID: monitor.id).effective, + monitor, "a rule applies only while the system plays on its output") + } + + func testAPickedDeviceWinsOverRulesAndFallsBackWhenGone() { + var preferences = ClickyPreferences() + preferences.outputDeviceUID = monitor.uid + preferences.outputDeviceName = monitor.name + preferences.outputRules = [ClickyOutputRule(whenUID: homePod.uid, whenName: homePod.name, + playOnUID: speakers.uid, playOnName: speakers.name)] + XCTAssertEqual(ClickyOutputRouting.resolve(preferences: preferences, outputs: all, systemID: homePod.id).effective, + monitor) + let gone = ClickyOutputRouting.resolve(preferences: preferences, outputs: [speakers, homePod], systemID: homePod.id) + XCTAssertEqual(gone.effective, homePod) + XCTAssertTrue(gone.reason.contains("not connected"), gone.reason) + } + + func testTheSuggestionPrefersBuiltInSpeakersAndNeverAVirtualDevice() { + XCTAssertEqual(AudioOutputs.suggestion(instead: homePod, outputs: all, limit: 0.15), speakers) + XCTAssertEqual(AudioOutputs.suggestion(instead: homePod, outputs: [homePod, monitor, loopback], limit: 0.15), + monitor) + XCTAssertNil(AudioOutputs.suggestion(instead: homePod, outputs: [homePod, loopback], limit: 0.15)) + } + + func testPreferencesSavedBeforeOutputsKeepTheirValues() throws { + let old = #"{"selectedSwitch":"ember","volume":0.8,"releaseSounds":false,"randomizedPitch":true,"spatialAudio":true,"repeatSounds":false,"quietHours":false,"quietStart":1320,"quietEnd":480,"excludedApplications":[],"visualizer":true,"notifications":false,"collectStats":true,"reduceMotion":false,"reduceTransparency":false}"# + let decoded = try JSONDecoder().decode(ClickyPreferences.self, from: Data(old.utf8)) + XCTAssertEqual(decoded.selectedSwitch, .ember) + XCTAssertEqual(decoded.volume, 0.8) + XCTAssertFalse(decoded.releaseSounds) + XCTAssertNil(decoded.outputDeviceUID) + XCTAssertTrue(decoded.delayWarning) + XCTAssertEqual(decoded.delayWarningMilliseconds, 150) + var changed = decoded + changed.outputRules = [ClickyOutputRule(whenUID: "a", whenName: "A", playOnUID: "b", playOnName: "B")] + let again = try JSONDecoder().decode(ClickyPreferences.self, from: JSONEncoder().encode(changed)) + XCTAssertEqual(again, changed) + } + + func testDelayText() { + XCTAssertEqual(homePod.delayText, "2.0 s late") + XCTAssertEqual(speakers.delayText, "27 ms late") + } + + /// Renders the prompt for the report: `CLICKY_PROMPT_SHOT=`. + @MainActor + func testThePromptRendersItsQuestion() throws { + let view = ClickyDelayPromptView(late: homePod, suggestion: speakers, onSwitch: {}, onKeep: {}, onClose: {}) + .environment(\.colorScheme, .dark) + let host = NSHostingView(rootView: view) + host.frame = CGRect(origin: .zero, size: host.fittingSize) + XCTAssertGreaterThan(host.fittingSize.height, 80) + guard let path = ProcessInfo.processInfo.environment["CLICKY_PROMPT_SHOT"] else { return } + let window = NSWindow(contentRect: CGRect(x: -3000, y: -3000, width: host.frame.width + 40, height: host.frame.height + 40), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.backgroundColor = NSColor(white: 0.18, alpha: 1) + let container = NSView(frame: CGRect(origin: .zero, size: window.frame.size)) + host.frame.origin = CGPoint(x: 20, y: 20) + container.addSubview(host) + window.contentView = container + window.orderFront(nil) + defer { window.close() } + container.layoutSubtreeIfNeeded() + let rep = try XCTUnwrap(container.bitmapImageRepForCachingDisplay(in: container.bounds)) + container.cacheDisplay(in: container.bounds, to: rep) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path)) + } + + /// Renders the settings card with this Mac's real outputs: `CLICKY_OUTPUT_CARD_SHOT=`. + @MainActor + func testTheOutputCardRendersTheDevicesAndARule() throws { + guard let path = ProcessInfo.processInfo.environment["CLICKY_OUTPUT_CARD_SHOT"] else { return } + let suite = "dev.genesis.clicky.output.\(UUID().uuidString)" + let defaults = try XCTUnwrap(UserDefaults(suiteName: suite)) + defer { defaults.removePersistentDomain(forName: suite) } + let appearance = NativeSettingsAppearance(defaults: defaults, notificationNamespace: suite, observeExternalChanges: false) + let model = ClickyModel(defaults: defaults, appearance: appearance, observeSystemEvents: false) + model.refreshOutput() + if let system = model.output?.system, + let faster = AudioOutputs.suggestion(instead: system, outputs: model.outputs, limit: .infinity) { + model.setRule(when: system, playOn: faster) + } + let view = ClickyOutputCard(model: model).padding(24) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + let rep = try KitShots.render(view, size: CGSize(width: 680, height: 560), name: "clicky-output-card", settle: 0.6) + try XCTUnwrap(rep.representation(using: .png, properties: [:])).write(to: URL(fileURLWithPath: path)) + } +} From 2d34a57c16db5067e05f13409631d511f9ccb243 Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 25 Oct 2026 15:01:06 +0100 Subject: [PATCH 03/14] fix(Clicky): the performance page reaches back 2 years: minutes past 30 days roll into hourly totals, ranges add 90 days, 1 year and All time with a grouping that fits each, and the dictation estimate counts setup once per typing session instead of per 5-second burst, which made dictation look no faster than typing (cherry picked from commit 66777d4fe21e14c34ce9a732bfd148c8c9cbed97) --- .../GenesisKit/Clicky/ClickyPerformance.swift | 68 +++++++++++++++++-- .../Clicky/ClickyPerformanceView.swift | 38 +++++++++-- .../GenesisKit/Clicky/ClickySettings.swift | 10 ++- .../Tests/GenesisKitTests/ClickyTests.swift | 48 ++++++++++++- 4 files changed, 145 insertions(+), 19 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift index 0e667c4523..866df1e0d1 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformance.swift @@ -16,14 +16,34 @@ public struct ClickyPerformanceBucket: Codable, Equatable, Sendable { public var activeSeconds = 0.0 public var bursts = 0 public var carriedBurst = false + /// Typing sessions that start in this bucket: text after a minute or more without text. Kept only in the hourly + /// roll-up; a minute's session is worked out from its neighbour when the report is built. + public var sessions = 0 public init() {} + private enum CodingKeys: String, CodingKey { + case presses, characters, corrections, activeSeconds, bursts, carriedBurst, sessions + } + + /// Every key is optional, so buckets saved before `sessions` existed still load. + public init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + presses = try values.decodeIfPresent(Int.self, forKey: .presses) ?? 0 + characters = try values.decodeIfPresent(Int.self, forKey: .characters) ?? 0 + corrections = try values.decodeIfPresent(Int.self, forKey: .corrections) ?? 0 + activeSeconds = try values.decodeIfPresent(Double.self, forKey: .activeSeconds) ?? 0 + bursts = try values.decodeIfPresent(Int.self, forKey: .bursts) ?? 0 + carriedBurst = try values.decodeIfPresent(Bool.self, forKey: .carriedBurst) ?? false + sessions = try values.decodeIfPresent(Int.self, forKey: .sessions) ?? 0 + } + mutating func add(_ other: Self) { presses += other.presses characters += other.characters corrections += other.corrections activeSeconds += other.activeSeconds bursts += other.bursts + sessions += other.sessions } var estimatedWords: Double { Double(characters) / 5 } var estimatedRetainedWords: Double { Double(max(0, characters - corrections)) / 5 } @@ -42,6 +62,31 @@ extension ClickyStatistics { public mutating func breakTypingBurst() { lastTypingAt = nil } + /// Minute detail is kept for this long; older minutes are rolled into `performanceHours`. + static let performanceMinuteDays = 30 + /// The hourly roll-up is kept for this long. + static let performanceHourDays = 730 + + /// Whether `minute` starts a typing session: it has text and the minute before it has none. + static func startsSession(_ minute: Int, in minutes: [Int: ClickyPerformanceBucket]) -> Bool { + (minutes[minute]?.characters ?? 0) > 0 && (minutes[minute - 1]?.characters ?? 0) == 0 + } + + /// Rolls the minutes older than `horizon` into hourly buckets, then drops them and hours past their retention. + mutating func rollUpPerformance(olderThan horizon: Int) { + let old = performanceMinutes.filter { $0.key < horizon } + guard !old.isEmpty else { return } + for (minute, source) in old { + var bucket = source + bucket.carriedBurst = false + bucket.sessions = Self.startsSession(minute, in: performanceMinutes) ? 1 : 0 + performanceHours[Int(floor(Double(minute) / 60)), default: ClickyPerformanceBucket()].add(bucket) + } + performanceMinutes = performanceMinutes.filter { $0.key >= horizon } + let oldestHour = horizon / 60 - Self.performanceHourDays * 24 + performanceHours = performanceHours.filter { $0.key >= oldestHour } + } + mutating func recordPerformance(keyCode: UInt16, shortcut: Bool, at date: Date) { let minute = Int(floor(date.timeIntervalSince1970 / 60)) performanceMinutes[minute, default: ClickyPerformanceBucket()].presses += 1 @@ -100,7 +145,7 @@ struct ClickyPerformanceFilter: Equatable, Sendable { } enum ClickyPerformanceGrouping: Int, CaseIterable, Identifiable, Sendable { - case minute = 60, fiveMinutes = 300, fifteenMinutes = 900, hour = 3600, day = 86400 + case minute = 60, fiveMinutes = 300, fifteenMinutes = 900, hour = 3600, day = 86400, week = 604800 var id: Self { self } var label: String { switch self { @@ -109,6 +154,7 @@ enum ClickyPerformanceGrouping: Int, CaseIterable, Identifiable, Sendable { case .fifteenMinutes: return "15 minutes" case .hour: return "Hour" case .day: return "Day" + case .week: return "Week" } } } @@ -149,8 +195,14 @@ struct ClickyPerformanceReport: Sendable { init(statistics: ClickyStatistics, filter: ClickyPerformanceFilter, grouping: ClickyPerformanceGrouping, calendar: Calendar = .current) { var groups: [Date: ClickyPerformanceBucket] = [:] - for (minute, source) in statistics.performanceMinutes { - let date = Date(timeIntervalSince1970: Double(minute) * 60) + let minuteBuckets = statistics.performanceMinutes.map { minute, source -> (Date, ClickyPerformanceBucket) in + var bucket = source + bucket.sessions = ClickyStatistics.startsSession(minute, in: statistics.performanceMinutes) ? 1 : 0 + return (Date(timeIntervalSince1970: Double(minute) * 60), bucket) + } + // Rolled-up hours hold only minutes that are no longer kept, so the two never count the same press twice. + let hourBuckets = statistics.performanceHours.map { (Date(timeIntervalSince1970: Double($0.key) * 3600), $0.value) } + for (date, source) in minuteBuckets + hourBuckets { guard filter.includes(date, calendar: calendar) else { continue } var bucket = source if bucket.carriedBurst && !filter.includes(date.addingTimeInterval(-60), calendar: calendar) { @@ -159,7 +211,8 @@ struct ClickyPerformanceReport: Sendable { total.add(bucket) hours[calendar.component(.hour, from: date)].add(bucket) weekdays[(calendar.component(.weekday, from: date) + 5) % 7].add(bucket) - let key = grouping == .day ? calendar.startOfDay(for: date) + let key = grouping == .week ? (calendar.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date)) + : grouping == .day ? calendar.startOfDay(for: date) : Date(timeIntervalSince1970: floor(date.timeIntervalSince1970 / Double(grouping.rawValue)) * Double(grouping.rawValue)) groups[key, default: ClickyPerformanceBucket()].add(bucket) } @@ -174,9 +227,12 @@ struct ClickyDictationEstimate { var dictationSeconds: Double { speakingSeconds + setupSeconds } var differenceSeconds: Double { typingSeconds - dictationSeconds } - init(totals: ClickyPerformanceBucket, wordsPerMinute: Int, setupPerBurst: Int) { + /// One dictation covers a whole typing session, so its setup counts once per session, not per 5-second burst: + /// with setup per burst, 8,042 bursts in a week cost 6.7 h of setup and the estimate said dictation saved nothing + /// (Martin's data, 2026-10-25: 718 sessions in the same week). + init(totals: ClickyPerformanceBucket, wordsPerMinute: Int, setupPerSession: Int) { typingSeconds = totals.activeSeconds speakingSeconds = totals.estimatedRetainedWords * 60 / Double(max(1, wordsPerMinute)) - setupSeconds = Double(totals.bursts * max(0, setupPerBurst)) + setupSeconds = Double(totals.sessions * max(0, setupPerSession)) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift index f0c94e85f9..c4c7a9419c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickyPerformanceView.swift @@ -7,7 +7,7 @@ public struct ClickyPerformanceView: View { @State private var range = "7 days" @State private var start = Calendar.current.startOfDay(for: Date()) @State private var end = Date() - @State private var grouping: ClickyPerformanceGrouping = .fifteenMinutes + @State private var grouping: ClickyPerformanceGrouping = ClickyPerformanceView.grouping(for: "7 days") @State private var weekdays = Set(0..<7) @State private var fromHour = 0 @State private var untilHour = 24 @@ -34,6 +34,9 @@ public struct ClickyPerformanceView: View { switch range { case "Today": first = calendar.startOfDay(for: now) case "30 days": first = calendar.date(byAdding: .day, value: -29, to: calendar.startOfDay(for: now)) ?? now + case "90 days": first = calendar.date(byAdding: .day, value: -89, to: calendar.startOfDay(for: now)) ?? now + case "1 year": first = calendar.date(byAdding: .year, value: -1, to: calendar.startOfDay(for: now)) ?? now + case "All time": first = calendar.startOfDay(for: earliest) case "Custom": first = calendar.startOfDay(for: start) default: first = calendar.date(byAdding: .day, value: -6, to: calendar.startOfDay(for: now)) ?? now } @@ -42,6 +45,26 @@ public struct ClickyPerformanceView: View { return ClickyPerformanceFilter(start: first, end: last, weekdays: weekdays, fromHour: fromHour, untilHour: untilHour) } + /// The oldest timed activity: the first hourly roll-up, else the first kept minute. + private var earliest: Date { + let hour = data.performanceHours.keys.min().map { Date(timeIntervalSince1970: Double($0) * 3600) } + let minute = data.performanceMinutes.keys.min().map { Date(timeIntervalSince1970: Double($0) * 60) } + return [hour, minute, data.performanceStartedAt].compactMap { $0 }.min() ?? now + } + + nonisolated static let ranges = ["Today", "7 days", "30 days", "90 days", "1 year", "All time", "Custom"] + + /// The timeline grouping a range opens with: about 50 to 400 bars, never one bar per minute over a year. + nonisolated static func grouping(for range: String) -> ClickyPerformanceGrouping { + switch range { + case "Today": return .fifteenMinutes + case "7 days": return .hour + case "30 days", "90 days": return .day + case "1 year", "All time": return .week + default: return .day + } + } + private struct Query: Equatable { let revision: UInt64 let range: String @@ -109,7 +132,7 @@ public struct ClickyPerformanceView: View { NativeSettingsCard("How the estimates work") { Text("Only intervals between text-like key presses of 5 seconds or less count as active typing. Longer gaps add no time; isolated presses have no measurable duration. Key releases, modifiers and Command/Control shortcuts add no words. Navigation and shortcuts end a typing burst.") Text("Words are a keystroke convention, not captured text. Code, pasted text, autocomplete, non-Latin input and editing can change the result. Correction share counts delete keys; it is not an error-rate score. No text, key sequence or application history is retained.") - Text("Performance retains 30 days of minute aggregates. Filters use your current local calendar; rates divide combined words by combined active seconds, rather than averaging bucket speeds. Date and hour filters have minute precision.") + Text("Performance keeps minute detail for 30 days and hourly totals for 2 years, so ranges past 30 days count whole hours. Filters use your current local calendar; rates divide combined words by combined active seconds, rather than averaging bucket speeds. Date and hour filters have minute precision.") if let since = data.performanceStartedAt { Text("Timing history since \(since.formatted(date: .abbreviated, time: .shortened))") } @@ -130,9 +153,10 @@ public struct ClickyPerformanceView: View { NativeSettingsCard("Time and filters") { NativeSettingsRow("Date range") { Picker("Date range", selection: $range) { - ForEach(["Today", "7 days", "30 days", "Custom"], id: \.self) { Text($0).tag($0) } + ForEach(Self.ranges, id: \.self) { Text($0).tag($0) } }.labelsHidden().frame(width: 150, alignment: .trailing) .accessibilityIdentifier("clicky.performance.range") + .onChange(of: range) { _, next in grouping = Self.grouping(for: next) } } if range == "Custom" { HStack { @@ -192,7 +216,7 @@ public struct ClickyPerformanceView: View { } private func dictation(_ totals: ClickyPerformanceBucket) -> some View { - let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: spokenWPM, setupPerBurst: setupSeconds) + let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: spokenWPM, setupPerSession: setupSeconds) return NativeSettingsCard("Estimated time lost by writing instead of dictating", subtitle: "A configurable what-if comparison, not a measurement of actual dictation.") { HStack(alignment: .firstTextBaseline) { @@ -217,11 +241,11 @@ public struct ClickyPerformanceView: View { HStack { NativeSettingsNumberPicker("Spoken words per minute", range: 60..<241, identifier: "clicky.performance.speech", value: $spokenWPM); Text("WPM").font(.caption) } } - NativeSettingsRow("Setup per burst", detail: "One dictation activation for every typing burst") { - HStack { NativeSettingsNumberPicker("Setup seconds per burst", range: 0..<16, + NativeSettingsRow("Setup per session", detail: "One dictation activation per typing session; a minute or more without text starts a new session") { + HStack { NativeSettingsNumberPicker("Setup seconds per session", range: 0..<16, identifier: "clicky.performance.setup", value: $setupSeconds); Text("sec").font(.caption) } } - Text("\(totals.bursts.formatted()) bursts × \(setupSeconds)s setup + \(duration(estimate.speakingSeconds)) speaking. Retained-word estimate: max(0, text-like presses − delete presses) ÷ 5; deleting a selection or undoing cannot be inferred. It excludes thinking time, transcription delay and review/corrections after dictation; it cannot tell prose from code.") + Text("\(totals.sessions.formatted()) sessions × \(setupSeconds)s setup + \(duration(estimate.speakingSeconds)) speaking. Retained-word estimate: max(0, text-like presses − delete presses) ÷ 5; deleting a selection or undoing cannot be inferred. It excludes thinking time, transcription delay and review/corrections after dictation; it cannot tell prose from code.") .font(.caption).foregroundStyle(.secondary) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift index 852f5670d0..83bbdb7596 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Clicky/ClickySettings.swift @@ -142,6 +142,8 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { public var days: [Int: ClickyActivityBucket] = [:] public var keys: [Int: Int] = [:] public var performanceMinutes: [Int: ClickyPerformanceBucket] = [:] + /// Hourly totals of the minutes older than `performanceMinuteDays`, kept for `performanceHourDays`. + public var performanceHours: [Int: ClickyPerformanceBucket] = [:] public var performanceStartedAt: Date? var lastTypingAt: Date? private var latestMinute = 0 @@ -150,7 +152,7 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { private enum CodingKeys: String, CodingKey { case presses, releases, sessions, startedAt, historyStartedAt, minutes, hours, days, keys - case performanceMinutes, performanceStartedAt + case performanceMinutes, performanceHours, performanceStartedAt } public init(from decoder: Decoder) throws { @@ -165,6 +167,7 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { days = try values.decodeIfPresent([Int: ClickyActivityBucket].self, forKey: .days) ?? [:] keys = try values.decodeIfPresent([Int: Int].self, forKey: .keys) ?? [:] performanceMinutes = try values.decodeIfPresent([Int: ClickyPerformanceBucket].self, forKey: .performanceMinutes) ?? [:] + performanceHours = try values.decodeIfPresent([Int: ClickyPerformanceBucket].self, forKey: .performanceHours) ?? [:] performanceStartedAt = try values.decodeIfPresent(Date.self, forKey: .performanceStartedAt) latestMinute = minutes.keys.max() ?? 0 } @@ -173,7 +176,8 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { lhs.presses == rhs.presses && lhs.releases == rhs.releases && lhs.sessions == rhs.sessions && lhs.startedAt == rhs.startedAt && lhs.historyStartedAt == rhs.historyStartedAt && lhs.minutes == rhs.minutes && lhs.hours == rhs.hours && lhs.days == rhs.days && lhs.keys == rhs.keys - && lhs.performanceMinutes == rhs.performanceMinutes && lhs.performanceStartedAt == rhs.performanceStartedAt + && lhs.performanceMinutes == rhs.performanceMinutes && lhs.performanceHours == rhs.performanceHours + && lhs.performanceStartedAt == rhs.performanceStartedAt } public mutating func record(keyCode: UInt16, release: Bool, at date: Date = Date(), calendar: Calendar = .current, @@ -191,7 +195,7 @@ public struct ClickyStatistics: Codable, Equatable, Sendable { let retentionHour = latestMinute / 60 if prunedHour != retentionHour { minutes = minutes.filter { $0.key >= latestMinute - 30 * 24 * 60 } - performanceMinutes = performanceMinutes.filter { $0.key >= latestMinute - 30 * 24 * 60 } + rollUpPerformance(olderThan: latestMinute - Self.performanceMinuteDays * 24 * 60) hours = hours.filter { $0.key >= retentionHour - 366 * 24 } let oldestDay = calendar.date(byAdding: .day, value: -730, to: date) ?? date days = days.filter { $0.key >= Int(oldestDay.timeIntervalSince1970) } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift index e5fd375b13..68a0304ed9 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ClickyTests.swift @@ -2376,15 +2376,57 @@ extension ClickyTests { func testDictationEstimateUsesRetainedWordConventionAndCanFavorTyping() { var totals = ClickyPerformanceBucket() - totals.characters = 750; totals.corrections = 50; totals.activeSeconds = 180; totals.bursts = 2 - let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerBurst: 3) + totals.characters = 750; totals.corrections = 50; totals.activeSeconds = 180; totals.bursts = 9; totals.sessions = 2 + let estimate = ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerSession: 3) XCTAssertEqual(totals.estimatedWords, 150) XCTAssertEqual(totals.estimatedRetainedWords, 140) XCTAssertEqual(estimate.speakingSeconds, 60) XCTAssertEqual(estimate.setupSeconds, 6) XCTAssertEqual(estimate.differenceSeconds, 114) totals.activeSeconds = 20 - XCTAssertEqual(ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerBurst: 3).differenceSeconds, -46) + XCTAssertEqual(ClickyDictationEstimate(totals: totals, wordsPerMinute: 140, setupPerSession: 3).differenceSeconds, -46) + } + + /// A session starts after a minute without text; minutes past 30 days roll into hours that the long ranges still count. + func testOldMinutesRollIntoHoursAndLongRangesCountThemOnce() { + var stats = ClickyStatistics() + func put(_ minute: Int, characters: Int, seconds: Double) { + var bucket = ClickyPerformanceBucket() + bucket.presses = characters; bucket.characters = characters; bucket.activeSeconds = seconds; bucket.bursts = 1 + stats.performanceMinutes[minute] = bucket + } + let base = 29_000_000 // a whole hour: 483,333 h 20 min, so use an hour-aligned start below + let hourStart = (base / 60) * 60 + put(hourStart, characters: 50, seconds: 20) // starts a session + put(hourStart + 1, characters: 40, seconds: 15) // same session + put(hourStart + 5, characters: 30, seconds: 10) // a new session after 3 empty minutes + let recent = hourStart + 40 * 24 * 60 + put(recent, characters: 25, seconds: 9) + stats.rollUpPerformance(olderThan: recent - 30 * 24 * 60) + XCTAssertEqual(stats.performanceMinutes.keys.sorted(), [recent], "only minutes inside 30 days stay") + let hour = stats.performanceHours[hourStart / 60] + XCTAssertEqual(hour?.characters, 120) + XCTAssertEqual(hour?.sessions, 2) + let all = ClickyPerformanceFilter(start: Date(timeIntervalSince1970: 0), end: Date(timeIntervalSince1970: Double(recent + 60) * 60)) + let report = ClickyPerformanceReport(statistics: stats, filter: all, grouping: .week, calendar: calendar) + XCTAssertEqual(report.total.characters, 145, "hours and minutes are disjoint") + XCTAssertEqual(report.total.sessions, 3, "two rolled-up sessions and the recent minute's own") + XCTAssertEqual(report.total.activeSeconds, 54, accuracy: 0.001) + } + + func testEachRangeOpensWithAGroupingThatKeepsTheTimelineReadable() { + XCTAssertEqual(ClickyPerformanceView.grouping(for: "Today"), .fifteenMinutes) + XCTAssertEqual(ClickyPerformanceView.grouping(for: "7 days"), .hour) + XCTAssertEqual(ClickyPerformanceView.grouping(for: "90 days"), .day) + XCTAssertEqual(ClickyPerformanceView.grouping(for: "All time"), .week) + XCTAssertTrue(ClickyPerformanceView.ranges.contains("1 year")) + } + + func testABucketSavedBeforeSessionsLoads() throws { + let old = #"{"presses":3,"characters":2,"corrections":0,"activeSeconds":1.5,"bursts":1,"carriedBurst":false}"# + let bucket = try JSONDecoder().decode(ClickyPerformanceBucket.self, from: Data(old.utf8)) + XCTAssertEqual(bucket.characters, 2) + XCTAssertEqual(bucket.sessions, 0) } func testPreparedPerformanceReportPreservesTotalsAndRejectsCancellation() async { From 87c2ccc9899c014ab21a39a527af72e08fbad1ab Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 25 Oct 2026 15:22:37 +0100 Subject: [PATCH 04/14] fix(Clicky): charts open on the whole picked range and zoom out to it, week bars start on your calendar's first weekday, the hover names the span of a bar, durations read as hours and minutes, and Earlier, Later and the arrow keys pan (cherry picked from commit 18149c8958197e49d61afbda5ee9ed21ebc6c0be) --- .../GenesisKit/Charts/NativeCharts.swift | 222 +++++++++++++++--- 1 file changed, 183 insertions(+), 39 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift index d8f1e2c20e..3cc79e3294 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Charts/NativeCharts.swift @@ -33,29 +33,74 @@ public enum NativeChartSampling { return .dateTime.hour().minute() } + /// How a bin combines the points that fall into it: counts add up, rates and shares average. + public enum Combine: Sendable { case sum, average } + + /// A calendar bin (a day or a week) follows the calendar: weeks start on the user's first weekday and a day + /// can be 23 or 25 hours long. Every other step is a fixed grid. + static func calendarComponent(for step: TimeInterval) -> Calendar.Component? { + step == 86400 ? .day : step == 604800 ? .weekOfYear : nil + } + + /// The width of one drawn bin: `step`, or a multiple of it when the span would need more than 1,200 bins. + public static func binInterval(start: Date, end: Date, step: TimeInterval) -> TimeInterval { + if calendarComponent(for: step) != nil { return step } + return max(step, ceil(end.timeIntervalSince(start) / (step * 1200)) * step) + } + + /// The span a bin covers, as a reader names it: "Mon, Oct 19 to Sun, Oct 25", "Mon, Oct 19, 2026", + /// "Oct 19, 14:00 to 15:00". + public static func spanText(_ date: Date, step: TimeInterval, calendar: Calendar = .current) -> String { + let day = Date.FormatStyle.dateTime.weekday(.abbreviated).month(.abbreviated).day() + if step >= 604800 { + let days = max(1, Int((step / 86400).rounded())) + let last = calendar.date(byAdding: .day, value: days - 1, to: date) ?? date + return "\(date.formatted(day)) to \(last.formatted(day))" + } + if step >= 86400 { return date.formatted(day.year()) } + let end = date.addingTimeInterval(step) + return "\(date.formatted(.dateTime.month(.abbreviated).day().hour().minute())) to \(end.formatted(.dateTime.hour().minute()))" + } + public static func bins(points: [NativeTimePoint], start: Date, end: Date, step: TimeInterval, - calendar: Calendar = .current) -> [NativeTimePoint] { + calendar: Calendar = .current, combine: Combine = .sum) -> [NativeTimePoint] { guard let first = points.first, end > start, step > 0 else { return [] } - if step == 86400 { - var day = calendar.startOfDay(for: max(first.date, start)) - let values = Dictionary(points.filter { $0.date >= day && $0.date < end }.map { - (calendar.startOfDay(for: $0.date), $0.value) - }, uniquingKeysWith: +) + if let component = calendarComponent(for: step) { + func key(_ date: Date) -> Date { + component == .day ? calendar.startOfDay(for: date) + : calendar.dateInterval(of: .weekOfYear, for: date)?.start ?? calendar.startOfDay(for: date) + } + var cursor = key(max(first.date, start)) + var sums: [Date: Double] = [:] + var counts: [Date: Int] = [:] + for point in points where point.date >= cursor && point.date < end { + let bin = key(point.date) + sums[bin, default: 0] += point.value + counts[bin, default: 0] += 1 + } var result: [NativeTimePoint] = [] - while day < end && result.count < 1201 { - result.append(NativeTimePoint(date: day, value: values[day, default: 0])) - guard let next = calendar.date(byAdding: .day, value: 1, to: day), next > day else { break } - day = next + var visited = 0 + while cursor < end && visited < 1201 { + visited += 1 + if let count = counts[cursor] { + let sum = sums[cursor, default: 0] + result.append(NativeTimePoint(date: cursor, value: combine == .sum ? sum : sum / Double(count))) + } else if combine == .sum { + result.append(NativeTimePoint(date: cursor, value: 0)) + } + guard let next = calendar.date(byAdding: component, value: 1, to: cursor), next > cursor else { break } + cursor = next } return result } - let interval = max(step, ceil(end.timeIntervalSince(start) / (step * 1200)) * step) + let interval = binInterval(start: start, end: end, step: step) // Both bounds on the same interval grid, so a bin's time and count do not depend on where the view starts. let lower = max(floor(first.date.timeIntervalSince1970 / interval), floor(start.timeIntervalSince1970 / interval)) * interval let upper = end.timeIntervalSince1970 guard lower < upper else { return [] } let count = min(1201, Int(ceil((upper - lower) / interval))) var values = Array(repeating: 0.0, count: count) + var counts = Array(repeating: 0, count: count) var low = 0 var high = points.count while low < high { @@ -66,15 +111,28 @@ public enum NativeChartSampling { let time = point.date.timeIntervalSince1970 if time >= upper { break } let index = Int((time - lower) / interval) - if values.indices.contains(index) { values[index] += point.value } + if values.indices.contains(index) { + values[index] += point.value + counts[index] += 1 + } + } + return values.indices.compactMap { index in + let date = Date(timeIntervalSince1970: lower + Double(index) * interval) + switch combine { + case .sum: return NativeTimePoint(date: date, value: values[index]) + case .average: + return counts[index] > 0 ? NativeTimePoint(date: date, value: values[index] / Double(counts[index])) : nil + } } - return values.enumerated().map { NativeTimePoint( - date: Date(timeIntervalSince1970: lower + Double($0.offset) * interval), value: $0.element) } } } +/// A pannable, zoomable time series. Two ways to open: +/// - `rangeStart: nil`: on the latest `initialWindow`, zoomable from a quarter to 8 times that window. +/// - `rangeStart` set: fitted to the whole `rangeStart...end`; zoom goes from that whole range down to 5 bins, and +/// "Whole range" returns to it. public struct NativeTimeSeriesChart: View { - public enum Style: String, CaseIterable, Identifiable { + public enum Style: String, CaseIterable, Identifiable, Sendable { case line = "Line", area = "Area", bars = "Bars" public var id: Self { self } } @@ -83,7 +141,11 @@ public struct NativeTimeSeriesChart: View { private let initialWindow: TimeInterval private let valueLabel: String private let end: Date - @State private var style: Style = .area + private let rangeStart: Date? + private let combine: NativeChartSampling.Combine + private let valueFormat: ((Double) -> String)? + private let styleBinding: Binding