Files
james.maesandClaude Opus 4.7 3cd94535c8 History view uniform with Log Stream + proper line shader + Status panel (⌘I)
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>
2026-05-16 17:46:27 -05:00

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
}
}
}