Files
2026-10-04 07:23:49 -04:00

126 lines
5.3 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
/// Hope's square LED presence, matching the web's 21 × 21 dot matrix.
/// One Canvas replaces hundreds of subviews. The adjacent status text is the
/// single accessible description; individual LEDs are never focusable.
struct LightwellView: View {
enum Phase: Equatable {
case idle, connecting, waiting, thinking, tearingDown, failed, readyNoInput
case listening(level: Double)
case speaking(level: Double)
}
let phase: Phase
var compact = false
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@Environment(\.colorSchemeContrast) private var contrast
@Environment(\.scenePhase) private var scenePhase
@Environment(\.dynamicTypeSize) private var typeSize
private var side: CGFloat {
if compact { return typeSize.isAccessibilitySize ? 80 : 112 }
return typeSize.isAccessibilitySize ? 136 : 184
}
private var animated: Bool {
guard !reduceMotion, scenePhase == .active else { return false }
switch phase {
case .connecting, .waiting, .thinking, .listening, .speaking: return true
default: return false
}
}
var body: some View {
Group {
if animated {
TimelineView(.animation(minimumInterval: 1.0 / 20.0)) { timeline in
matrix(time: timeline.date.timeIntervalSinceReferenceDate)
}
} else {
matrix(time: 0)
}
}
.frame(width: side, height: side)
.background(Nearlight.personaSurface, in: RoundedRectangle(cornerRadius: Nearlight.radiusDock))
.overlay {
RoundedRectangle(cornerRadius: Nearlight.radiusDock)
.strokeBorder(contrast == .increased ? Nearlight.quiet : Nearlight.separator, lineWidth: 1)
}
.accessibilityHidden(true)
}
private var tint: Color {
switch phase {
case .failed: return Nearlight.alert
case .tearingDown: return Nearlight.quiet
default: return Nearlight.rosewood
}
}
private func matrix(time: Double) -> some View {
Canvas { context, size in
let spacing = (min(size.width, size.height) - 28) / 20
for row in 0..<21 {
for column in 0..<21 {
let dot = Self.dot(row: row, column: column, phase: phase,
time: time, reduceMotion: reduceMotion)
let diameter = max(1.6, spacing * 0.28) * dot.scale
let center = CGPoint(x: 14 + CGFloat(column) * spacing,
y: 14 + CGFloat(row) * spacing)
let rect = CGRect(x: center.x - diameter / 2, y: center.y - diameter / 2,
width: diameter, height: diameter)
// Increased contrast brightens the whole lattice; state is
// encoded in its pattern and adjacent text, never color alone.
let opacity = contrast == .increased ? min(1, dot.opacity * 1.25 + 0.16) : dot.opacity
context.fill(Path(ellipseIn: rect), with: .color(tint.opacity(opacity)))
}
}
}
}
struct Dot: Equatable {
let opacity: Double
let scale: Double
}
/// Deterministic sampling is shared with offline tests. Reduce Motion freezes
/// waves and signal-driven geometry, while keeping each state's pattern clear.
static func dot(row: Int, column: Int, phase: Phase, time: Double, reduceMotion: Bool) -> Dot {
let x = Double(column - 10) / 10.5
let y = Double(row - 10) / 10.5
let radius = sqrt(x * x + y * y)
let t = reduceMotion ? 0 : time
let baseline = max(0.08, 0.20 - radius * 0.075)
let offset = sin(Double(row) * 12.9898 + Double(column) * 78.233)
var brightness = baseline
var scale = 1.0
switch phase {
case .idle, .tearingDown:
break
case .readyNoInput:
brightness += 0.28 * exp(-radius * radius * 3)
case .failed:
// A stable diagonal cross rather than an animated alarm.
if abs(row - column) <= 1 || abs(row + column - 20) <= 1 { brightness = 0.65 }
case .connecting, .waiting:
let ring = exp(-pow((radius - 0.53) * 7, 2))
let sweep = reduceMotion ? 0.75 : 0.55 + 0.45 * cos(atan2(y, x) - t * 2)
brightness += ring * sweep * 0.65
case .thinking:
let bandCenter = reduceMotion ? 0 : sin(t * 1.8) * 0.5
let band = exp(-pow((y - bandCenter) * 4, 2)) * exp(-x * x * 2)
brightness += band * 0.65
case .listening(let raw), .speaking(let raw):
let level = reduceMotion ? 0.35 : (raw.isFinite ? min(max(raw, 0), 1) : 0)
let speaking: Bool
if case .speaking = phase { speaking = true } else { speaking = false }
let signal = min(1, level * 1.3)
let bloom = exp(-radius * radius * (3.0 - signal * 1.5))
let ripple = reduceMotion ? 1 : 0.85 + 0.15 * sin(t * 4 + offset)
brightness += bloom * (0.25 + signal * 0.7) * ripple * (speaking ? 1 : 0.65)
scale += max(0, 1 - radius) * (speaking ? 1.4 : 0.85) * signal
}
return Dot(opacity: min(max(brightness, 0.02), 1), scale: scale)
}
}