Files

172 lines
7.2 KiB
Swift

import Metal
import AppKit
import simd
final class TaskListWidget: FramedHUDWidget {
var origin: SIMD2<Float> = Theme.Layout.taskList.origin
var size: SIMD2<Float> = Theme.Layout.taskList.size
private struct Uniforms {
var resolution: SIMD2<Float>
var origin: SIMD2<Float>
var size: SIMD2<Float>
var tint: SIMD4<Float>
var frameAlpha: Float
var textAlpha: Float
var time: Float
var flashAge: Float = 999
}
private struct Task {
var title: String
var status: String // PENDING / ACTIVE / BLOCKED / DONE
var progress: Float // 0..1 — only meaningful when ACTIVE
}
private var tasks: [Task] = [
Task(title: "Wire renderer pipeline state", status: "DONE", progress: 1.0),
Task(title: "Compose orb shader", status: "DONE", progress: 1.0),
Task(title: "Implement widget pipeline", status: "DONE", progress: 1.0),
Task(title: "Build log widget", status: "ACTIVE", progress: 0.62),
Task(title: "Wire schedule countdown", status: "PENDING", progress: 0.0),
Task(title: "Spectrum bars FFT mock", status: "PENDING", progress: 0.0),
Task(title: "Status ticker scrolling logic", status: "BLOCKED", progress: 0.0),
Task(title: "Visual acceptance test pass", status: "PENDING", progress: 0.0),
]
var revealDelay: Float = 0
private var revealStart: Float = -1
private var timeSinceFlip: Float = 0
private let flipInterval: Float = Theme.Tick.taskShuffleIntervalSec
private var lastChangeTime: Float = -999
private var textTexture: MTLTexture?
private var textDirty = true
func update(context: FrameContext) {
if revealStart < 0 { revealStart = context.time + revealDelay }
if context.time < revealStart { return }
timeSinceFlip += context.deltaTime
if timeSinceFlip >= flipInterval {
timeSinceFlip = 0
shuffleStatus()
textDirty = true
}
// Progress animates continuously on the active task.
if let activeIdx = tasks.firstIndex(where: { $0.status == "ACTIVE" }) {
tasks[activeIdx].progress = min(1.0, tasks[activeIdx].progress + context.deltaTime * Theme.Tick.taskActiveProgressPerSec)
if tasks[activeIdx].progress >= 1.0 {
tasks[activeIdx].status = "DONE"
if let nextIdx = tasks.firstIndex(where: { $0.status == "PENDING" }) {
tasks[nextIdx].status = "ACTIVE"
tasks[nextIdx].progress = 0
}
textDirty = true
}
// Always redraw progress at 4 Hz to keep the percentage feeling alive.
if Int(context.time * 4) != lastRenderedSecondTick {
lastRenderedSecondTick = Int(context.time * 4)
textDirty = true
}
}
if textDirty {
rebuildText(context: context)
lastChangeTime = context.time
textDirty = false
}
}
private var lastRenderedSecondTick: Int = -1
private func shuffleStatus() {
// 50% chance: revive a DONE task back to PENDING
if Bool.random() {
if let doneIdx = tasks.indices.filter({ tasks[$0].status == "DONE" }).randomElement() {
tasks[doneIdx].status = "PENDING"
tasks[doneIdx].progress = 0
}
}
// Existing logic (PENDING↔BLOCKED swap)
guard let pending = tasks.indices.filter({ tasks[$0].status == "PENDING" }).randomElement(),
let blocked = tasks.indices.filter({ tasks[$0].status == "BLOCKED" }).randomElement() else { return }
if Bool.random() {
tasks[blocked].status = "PENDING"
tasks[pending].status = "BLOCKED"
}
}
private func rebuildText(context: FrameContext) {
let widthPts = CGFloat(size.x) * CGFloat(context.resolution.x) / CGFloat(context.scaleFactor)
let heightPts = CGFloat(size.y) * CGFloat(context.resolution.y) / CGFloat(context.scaleFactor)
let font = Theme.Font.body(size: Theme.Font.taskBodySize)
let paragraph = NSMutableParagraphStyle()
paragraph.lineSpacing = 3
let attributed = NSMutableAttributedString()
let header: [NSAttributedString.Key: Any] = [
.font: Theme.Font.title(size: Theme.Font.widgetTitleSize),
.foregroundColor: Theme.Palette.textWhite,
.paragraphStyle: paragraph
]
attributed.append(NSAttributedString(string: "[ OPEN TASKS ]\n", attributes: header))
for task in tasks {
let color: NSColor
switch task.status {
case "DONE": color = Theme.Palette.textTaskDone
case "ACTIVE": color = Theme.Palette.textTaskActive
case "BLOCKED": color = Theme.Palette.textTaskBlocked
default: color = Theme.Palette.textWhite
}
let dot: String
switch task.status {
case "DONE": dot = "●"
case "ACTIVE": dot = "◉"
case "BLOCKED": dot = "◍"
default: dot = "○"
}
let tag = "[\(task.status)]".padding(toLength: 9, withPad: " ", startingAt: 0)
let progressLabel = task.status == "ACTIVE" ? String(format: " %3d%%", Int(task.progress * 100)) : ""
let attrs: [NSAttributedString.Key: Any] = [
.font: font, .foregroundColor: color, .paragraphStyle: paragraph
]
let line = "\(dot) \(tag)\(progressLabel) \(task.title)\n"
attributed.append(NSAttributedString(string: line, attributes: attrs))
}
let microID = String(format: "0x%04X", abs(ObjectIdentifier(self).hashValue) & 0xFFFF)
let microAttrs: [NSAttributedString.Key: Any] = [
.font: Theme.Font.body(size: Theme.Font.microReadoutSize),
.foregroundColor: Theme.Palette.textMicroReadout,
.paragraphStyle: { let p = NSMutableParagraphStyle(); p.alignment = .right; return p }()
]
attributed.append(NSAttributedString(string: "\n\(microID)", attributes: microAttrs))
textTexture = context.textRasterizer.rasterize(
attributed,
maxSize: CGSize(width: widthPts, height: heightPts),
scale: CGFloat(context.scaleFactor)
)
}
func encode(into encoder: MTLRenderCommandEncoder, context: FrameContext) {
guard let texture = textTexture else { return }
var uniforms = Uniforms(
resolution: context.resolution,
origin: origin,
size: size,
tint: SIMD4<Float>(1, 1, 1, 1),
frameAlpha: 0.8,
textAlpha: 1.0,
time: context.time,
flashAge: context.time - lastChangeTime
)
encoder.setRenderPipelineState(context.pipelines.widget)
encoder.setVertexBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentBytes(&uniforms, length: MemoryLayout<Uniforms>.stride, index: 0)
encoder.setFragmentTexture(texture, index: 0)
encoder.drawPrimitives(type: .triangle, vertexStart: 0, vertexCount: 6)
}
}