Three asks landing together. ## History view — now identical controls to Log Stream - Oldest-first order with auto-scroll-to-bottom on append (was newest- first, no auto-scroll). ScrollViewReader + onChange pattern lifted from LogStreamView for behavioral parity. - Toolbar now matches the Log Stream layout exactly: Category filter, Auto-scroll toggle, Pause toggle, filtered/total count, Clear button. - Poll rate bumped 1 Hz → 0.10 s so new events appear instantly. Pause toggle freezes refresh without dropping the running event collection. ## Proper line shader (much better hand + body rendering) - New `line_vertex` / `line_fragment` in Boxes.metal alongside the existing box shader. `LineUniforms` carries endpoints + thickness + soft-edge falloff + a dot-mode flag. - Line mode: emits a rotated thin quad oriented along (end - start) with width = thickness. Soft-edge antialiasing via smoothstep on the signed perpendicular UV makes diagonal skeleton segments read as actual strokes instead of axis-aligned bounding boxes. - Dot mode: emits a square around `start` and the fragment discards outside the unit-circle — produces filled disks for joint markers with sub-pixel-clean soft edges. - Pipelines gains a `lines` blended pipeline. - Renderer.drawPoses() rewritten to use the line pipeline — one draw per segment, one draw per joint, all visually consistent regardless of segment angle. Theme.Palette.bodyPose (mint) for body, handPose (amber) for hand. ## Status panel (⌘I) - New `CurrentStateStore` singleton subscribes to the event bus and retains the most recent value per concept: expression (smile / frown / neutral_face), activity (standing / sitting / etc.), per-chirality finger counts, plus a small ring of recent non-finger non-expression gestures. - New StatusView (SwiftUI) reads three sources at 4 Hz: the live ObjectDetector for FPS + DET timing + objects + faces, the CurrentStateStore for hands/expression/activity/recent-gestures, and the HistoryStore for an 8-entry tail of recent events. - New StatusWindowController hosts the SwiftUI view as a dark-aqua floating window with frame persistence (VPOC.StatusWindowFrame). - AppDelegate `openStatus(_:)` builds a DetectorSnapshot from the live windowController + detector each tick, partitioning detections into face entries (anything labeled FACE or matching an enrolled name) vs object entries. Object entries carry the same per-track color the JARVIS pane uses (via ColorHash.colorFor) so the panel's dots match the on-screen box colors. - Renderer exposes `smoothedFPSForReadout` so the panel can show the same FPS the footer does without reaching into private state. - FingerCounter now records its count per chirality via CurrentStateStore.recordFingers (in addition to the existing bus emit), using VNHumanHandPoseObservation.chirality. The panel displays "Left: 3 fingers / Right: 5 fingers" correctly. Menu structure now: - App menu: Settings (⌘,) · Log Stream (⌘L) · Status (⌘I) · Save Snapshot (⌘S) · Start/Stop Recording (⇧⌘R) · Quit - Faces menu: Enroll (⌘E) · Forget (⇧⌘E) · History (⌘H) - Camera menu: Devices ▶ · Refresh · Privacy Mode (⇧⌘P) Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
138 lines
5.1 KiB
Swift
138 lines
5.1 KiB
Swift
import SwiftUI
|
|
|
|
/// SwiftUI viewer for the Detection History ring buffer. Mirrors the Log
|
|
/// Stream viewer's behavior and controls so both windows feel identical:
|
|
/// oldest-to-newest order with auto-scroll-to-bottom on append, plus
|
|
/// Pause and Auto-scroll toggles, a Category filter, and a Clear button.
|
|
struct HistoryView: View {
|
|
@State private var entries: [HistoryEntry] = []
|
|
@State private var lastVersion: UInt64 = 0
|
|
@State private var categoryFilter: Set<String> = Set(HistoryView.allCategories)
|
|
@State private var autoScroll: Bool = true
|
|
@State private var paused: Bool = false
|
|
|
|
private let pollTimer = Timer.publish(every: 0.10, on: .main, in: .common).autoconnect()
|
|
|
|
static let allCategories: [String] = [
|
|
"object", "face-known", "face-unknown", "ocr",
|
|
"gesture", "activity", "spatial"
|
|
]
|
|
|
|
private static let timeFormatter: DateFormatter = {
|
|
let df = DateFormatter()
|
|
df.dateFormat = "HH:mm:ss.SSS"
|
|
return df
|
|
}()
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
toolbar
|
|
Divider()
|
|
list
|
|
}
|
|
.frame(minWidth: 640, idealWidth: 820, minHeight: 360, idealHeight: 540)
|
|
.background(Color(white: 0.07))
|
|
.onReceive(pollTimer) { _ in
|
|
guard !paused else { return }
|
|
let v = HistoryStore.shared.version
|
|
guard v != lastVersion else { return }
|
|
entries = HistoryStore.shared.snapshot()
|
|
lastVersion = v
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var toolbar: some View {
|
|
HStack(spacing: 12) {
|
|
Menu("Category") {
|
|
ForEach(HistoryView.allCategories, id: \.self) { cat in
|
|
Toggle(cat, isOn: Binding(
|
|
get: { categoryFilter.contains(cat) },
|
|
set: { isOn in
|
|
if isOn { categoryFilter.insert(cat) } else { categoryFilter.remove(cat) }
|
|
}
|
|
))
|
|
}
|
|
}
|
|
.menuStyle(.borderlessButton)
|
|
.fixedSize()
|
|
|
|
Toggle("Auto-scroll", isOn: $autoScroll)
|
|
.toggleStyle(.checkbox)
|
|
.controlSize(.small)
|
|
|
|
Toggle("Pause", isOn: $paused)
|
|
.toggleStyle(.checkbox)
|
|
.controlSize(.small)
|
|
|
|
Spacer()
|
|
|
|
Text("\(filteredEntries.count) / \(entries.count)")
|
|
.font(.system(.caption, design: .monospaced))
|
|
.foregroundStyle(.secondary)
|
|
|
|
Button("Clear") {
|
|
HistoryStore.shared.clear()
|
|
entries = []
|
|
lastVersion = HistoryStore.shared.version
|
|
}
|
|
.controlSize(.small)
|
|
}
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 6)
|
|
.background(Color(white: 0.10))
|
|
}
|
|
|
|
/// Oldest-first, matching the Log Stream. Newest entries arrive at the
|
|
/// bottom and the ScrollViewReader pins the viewport there when
|
|
/// auto-scroll is on.
|
|
private var filteredEntries: [HistoryEntry] {
|
|
entries.filter { categoryFilter.contains($0.category) }
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var list: some View {
|
|
ScrollViewReader { proxy in
|
|
ScrollView {
|
|
LazyVStack(alignment: .leading, spacing: 0) {
|
|
ForEach(filteredEntries) { entry in
|
|
HStack(alignment: .top, spacing: 8) {
|
|
Text(Self.timeFormatter.string(from: entry.timestamp))
|
|
.foregroundStyle(.secondary)
|
|
Text(entry.category)
|
|
.foregroundStyle(color(for: entry.category))
|
|
.frame(width: 90, alignment: .leading)
|
|
Text(entry.summary)
|
|
.foregroundStyle(.primary)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.font(.system(.caption, design: .monospaced))
|
|
.padding(.horizontal, 12)
|
|
.padding(.vertical, 2)
|
|
.id(entry.id)
|
|
}
|
|
}
|
|
}
|
|
.onChange(of: filteredEntries.last?.id) { _, newValue in
|
|
guard autoScroll, let last = newValue else { return }
|
|
withAnimation(.linear(duration: 0.06)) {
|
|
proxy.scrollTo(last, anchor: .bottom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func color(for category: String) -> Color {
|
|
switch category {
|
|
case "object": return Color(red: 0.20, green: 0.90, blue: 0.95) // cyan
|
|
case "face-known": return Color(red: 0.30, green: 1.00, blue: 0.75) // mint
|
|
case "face-unknown": return Color(white: 0.55)
|
|
case "ocr": return Color(red: 1.00, green: 0.45, blue: 0.85) // magenta
|
|
case "gesture": return Color(red: 1.00, green: 0.75, blue: 0.20) // amber
|
|
case "activity": return .yellow
|
|
case "spatial": return Color(red: 0.80, green: 0.55, blue: 1.00) // purple
|
|
default: return .secondary
|
|
}
|
|
}
|
|
}
|