126 lines
5.3 KiB
Swift
126 lines
5.3 KiB
Swift
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)
|
||
}
|
||
}
|