diff --git a/slab/menuband/Package.swift b/slab/menuband/Package.swift index 40f7b9cda0..c824f53e77 100644 --- a/slab/menuband/Package.swift +++ b/slab/menuband/Package.swift @@ -8,6 +8,11 @@ let package = Package( .executableTarget( name: "MenuBand", path: "Sources/MenuBand", + exclude: [ + // Docs that live alongside source modules for discoverability — + // SwiftPM nags about "unhandled files" without an explicit exclude. + "KidLisp/README.md", + ], resources: [ .process("Resources"), // SwiftPM doesn't auto-compile .metal files in diff --git a/slab/menuband/Sources/MenuBand/AppDelegate.swift b/slab/menuband/Sources/MenuBand/AppDelegate.swift index 7bf9404305..06735b1b6a 100644 --- a/slab/menuband/Sources/MenuBand/AppDelegate.swift +++ b/slab/menuband/Sources/MenuBand/AppDelegate.swift @@ -20,6 +20,29 @@ final class AppDelegate: NSObject, NSApplicationDelegate { private var layoutToggleHotkey: GlobalHotkey? private var popoverPanel: MenuBandPopoverPanel? private var popoverVC: MenuBandPopoverViewController? + private var kidlispTVPanel: KidLispTVPanel? + /// Cached "$ pieces" feed for the TV chooser menu. Populated on + /// first popover open; refreshed lazily when stale. + private var kidlispChooserPieces: [KidLispChooserItem] = [] + private var kidlispChooserFetchInflight: Bool = false + private var kidlispChooserFetchedAt: Date? + private static let kidlispChooserCacheTTL: TimeInterval = 300 + private static let kidlispChooserURL = + "https://aesthetic.computer/api/store-kidlisp?recent=15&sortBy=hits" + private static let kidlispTVSourceDefaultsKey = "notepat.kidlispTV.source" + /// Snapshot buffer reused per-frame by the TV's amp provider so + /// every tick is a free read instead of a fresh allocation. + /// 256 mirrors WaveformView's snapshotSize. + private var kidlispAmpSamples = [Float](repeating: 0, count: 256) + /// Wall-clock of the most recent outbound noteOn (any source). + /// Drives a decaying-envelope fallback for the KidLisp `amp` value + /// when the local synth is silent — e.g. MIDI-output mode, where + /// the user hears notes through a DAW but the tap reads zeros. + private var kidlispLastNoteOnAt: CFTimeInterval = 0 + /// Envelope time constant for the noteOn-driven amp fallback. + /// ~250 ms feels right at 60 fps: one tap reads strong, sustained + /// playing reads as a fluttering attack envelope. + private static let kidlispNoteOnTau: Double = 0.25 private var isPopoverPanelShown: Bool { popoverPanel?.isVisible == true } private lazy var pianoWaveformWindowDelegate = PianoWaveformWindowDelegate(menuBand: menuBand) @@ -395,11 +418,15 @@ final class AppDelegate: NSObject, NSApplicationDelegate { self.updatePianoWaveformWindow() } } - menuBand.onMIDIEvent = { + menuBand.onMIDIEvent = { [weak self] in // Spike the square indicator to full on every // outbound noteOn; the visualizer animation tick // decays it back toward zero. KeyboardIconRenderer.midiActivityFlash = 1 + // Stamp the noteOn time so the KidLisp TV's `amp` value + // still moves in MIDI-output mode, where the local synth + // is silent and the audio tap reads zeros. + self?.kidlispLastNoteOnAt = CACurrentMediaTime() } menuBand.bootstrap() // Subscribe to mic RMS during sample-voice recording. The @@ -2156,6 +2183,25 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // false synchronously — prevents toggle paths from racing // with the in-flight fade. popoverPanel = nil + // KidLisp TV panel is a child of the popover panel, so it + // would auto-order-out with the parent — but we want it to + // fade in lockstep, so explicit teardown is cleaner. + if let tv = kidlispTVPanel { + kidlispTVPanel = nil + // Release the synth-capture refcount that the TV took out + // in showPopover. Other surfaces (WaveformView etc.) keep + // their own counts, so the capture loop only actually + // stops when everyone's released. + menuBand.setWaveformCaptureEnabled(false) + NSAnimationContext.runAnimationGroup({ ctx in + ctx.duration = 0.14 + ctx.timingFunction = CAMediaTimingFunction(name: .easeOut) + tv.animator().alphaValue = 0 + }, completionHandler: { + tv.orderOut(nil) + tv.alphaValue = 1 + }) + } if let panel = panelToFade { NSAnimationContext.runAnimationGroup({ ctx in ctx.duration = 0.14 @@ -2307,6 +2353,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate { panel.makeKeyAndOrderFront(nil) popoverPanel = panel debugLog("popover panel ordered front; visible=\(panel.isVisible) frame=\(panel.frame)") + + // KidLisp TV — skeuomorphic LCD-television panel that sits + // directly under the LEFT floating panel (PianoWaveformWindow), + // not under the popover. Child-windowed off the popover so it + // dismisses with the popover; the anchor frame still comes + // from the left panel so the TV reads as part of the player + // palette stack. KidLispTVView starts its own 30fps timer in + // viewDidMoveToWindow once the panel is on screen. // Now that the popover's live frame is available // (popoverFrameProvider can read popoverPanel.frame), // re-anchor the floating piano panel so it lands at @@ -2319,6 +2373,49 @@ final class AppDelegate: NSObject, NSApplicationDelegate { // fixing the cause here. pianoWaveformWindowDelegate.refresh() + // KidLisp TV anchors against the LEFT floating panel's + // *post-refresh* frame. Order matters: if we did this before + // refresh(), the TV would pin to the predicted position and + // the left column would then slide right, leaving the TV + // stranded to its left. + if let leftPanel = pianoWaveformWindowDelegate.activeWindow, + leftPanel.isVisible { + let tvPanel = KidLispTVPanel(width: leftPanel.frame.width) + // Attach BEFORE anchoring. addChildWindow nudges a child + // window so it sits "below" its parent if their frames + // don't intersect — which slides the TV over toward the + // popover (the parent), away from the left column. Set + // the final frame last so it's authoritative. + panel.addChildWindow(tvPanel, ordered: .below) + tvPanel.anchor(below: leftPanel.frame) + kidlispTVPanel = tvPanel + // Restore the last-picked source so the user's choice + // sticks across popover open/close cycles. + if let saved = UserDefaults.standard.string( + forKey: Self.kidlispTVSourceDefaultsKey + ), !saved.isEmpty { + tvPanel.tv.setSource(saved) + } + // Click on the screen → "$ pieces" chooser menu. Bound + // here (not in the panel init) because the chooser + // needs AppDelegate state (cache + persistence). + tvPanel.tv.onScreenClick = { [weak self] view, event in + self?.showKidLispChooser(from: view, event: event) + } + // Feed synth output amplitude into the evaluator each + // tick so pieces that read `amp`/`mic`/`amplitude` + // visualize the live MenuBand performance. Refcounted + // capture is released when closePopover tears the TV + // down so we don't keep the tap thread alive forever. + menuBand.setWaveformCaptureEnabled(true) + tvPanel.tv.ampProvider = { [weak self] in + self?.currentSynthAmp() ?? 0 + } + // Warm the cache so the first click pops a populated + // menu instead of "Loading…". + fetchKidLispChooserPieces(force: false) { } + } + // Arm local key capture so arrow keys + spacebar reach // our handler while the popover is up. The InstrumentList // used to be in the popover and grabbed keys via its @@ -2757,4 +2854,188 @@ final class AppDelegate: NSObject, NSApplicationDelegate { pianoWaveformWindowDelegate.isCollapsedPresentationSuppressed = pianoWaveformWindowDelegate.isDocked && (isPopoverPanelShown || pianoWaveformWindowDelegate.isShown) } + + // MARK: - KidLisp TV chooser + + /// Audio amplitude for the KidLisp TV, scaled to the 0–10 range + /// kidlisp.mjs uses for `amp`. Combines two sources so the viz + /// reacts in every mode: + /// 1. **Audio peak** of the synth tap (works whenever the local + /// synth is making sound). Compressed ×5 because typical + /// synth peaks live in the 0.05–0.2 band; raw RMS×10 was + /// barely visible. + /// 2. **NoteOn envelope** that decays from the most recent + /// outbound noteOn. Drives the viz in MIDI-output mode where + /// the local synth is muted and the tap reads silence. + /// Returns the louder of the two so the user's performance is + /// always visible, regardless of routing. + private func currentSynthAmp() -> Double { + menuBand.synthSnapshotWaveform(into: &kidlispAmpSamples) + var peak: Float = 0 + for s in kidlispAmpSamples { + let a = abs(s) + if a > peak { peak = a } + } + let audioAmp = Double(min(1.0, peak * 5.0)) * 10.0 + let now = CACurrentMediaTime() + let dt = now - kidlispLastNoteOnAt + let envelope = (kidlispLastNoteOnAt > 0 && dt >= 0) + ? exp(-dt / Self.kidlispNoteOnTau) + : 0 + let noteAmp = envelope * 10.0 + return max(audioAmp, noteAmp) + } + + private struct KidLispChooserItem { + let code: String + let source: String + let preview: String + let handle: String? + } + + /// Pop the "$ pieces" chooser menu at the click point. Empty cache + /// renders a "Loading…" placeholder and kicks off a fetch so the + /// next click is populated. Stale cache renders immediately and + /// refreshes in the background. + private func showKidLispChooser(from view: NSView, event: NSEvent) { + let menu = buildKidLispChooserMenu() + let local = view.convert(event.locationInWindow, from: nil) + menu.popUp(positioning: nil, at: local, in: view) + let stale = kidlispChooserFetchedAt.map { + Date().timeIntervalSince($0) > Self.kidlispChooserCacheTTL + } ?? true + if stale || kidlispChooserPieces.isEmpty { + fetchKidLispChooserPieces(force: false) { } + } + } + + private func buildKidLispChooserMenu() -> NSMenu { + let menu = NSMenu(title: "KidLisp") + menu.autoenablesItems = false + if kidlispChooserPieces.isEmpty { + let item = NSMenuItem(title: "Loading…", action: nil, keyEquivalent: "") + item.isEnabled = false + menu.addItem(item) + } else { + for piece in kidlispChooserPieces { + let item = NSMenuItem( + title: "", + action: #selector(loadKidLispPiece(_:)), + keyEquivalent: "" + ) + item.target = self + item.representedObject = piece.source + let attr = NSMutableAttributedString() + attr.append(NSAttributedString( + string: "$\(piece.code)", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .semibold) + ] + )) + let cleaned = piece.preview + .replacingOccurrences(of: "\n", with: " · ") + .replacingOccurrences(of: "\r", with: "") + attr.append(NSAttributedString( + string: " \(cleaned)", + attributes: [ + .font: NSFont.monospacedSystemFont(ofSize: 12, weight: .regular), + .foregroundColor: NSColor.secondaryLabelColor + ] + )) + item.attributedTitle = attr + menu.addItem(item) + } + } + menu.addItem(.separator()) + let reset = NSMenuItem( + title: "Reset to default", + action: #selector(resetKidLispPiece(_:)), + keyEquivalent: "" + ) + reset.target = self + menu.addItem(reset) + let reload = NSMenuItem( + title: "Reload pieces…", + action: #selector(reloadKidLispChooser(_:)), + keyEquivalent: "" + ) + reload.target = self + menu.addItem(reload) + return menu + } + + @objc private func loadKidLispPiece(_ sender: NSMenuItem) { + guard let src = sender.representedObject as? String else { return } + kidlispTVPanel?.tv.setSource(src) + UserDefaults.standard.set(src, forKey: Self.kidlispTVSourceDefaultsKey) + } + + @objc private func resetKidLispPiece(_ sender: Any?) { + kidlispTVPanel?.tv.setSource(KidLispTVPanel.defaultSource) + UserDefaults.standard.removeObject(forKey: Self.kidlispTVSourceDefaultsKey) + } + + @objc private func reloadKidLispChooser(_ sender: Any?) { + // Force-refresh; next menu open will show the new list. + fetchKidLispChooserPieces(force: true) { } + } + + private func fetchKidLispChooserPieces( + force: Bool, + completion: @escaping () -> Void + ) { + if kidlispChooserFetchInflight { + completion() + return + } + if !force, + let at = kidlispChooserFetchedAt, + Date().timeIntervalSince(at) < Self.kidlispChooserCacheTTL, + !kidlispChooserPieces.isEmpty { + completion() + return + } + guard let url = URL(string: Self.kidlispChooserURL) else { + completion() + return + } + kidlispChooserFetchInflight = true + let task = URLSession.shared.dataTask(with: url) { [weak self] data, _, error in + DispatchQueue.main.async { + guard let self = self else { completion(); return } + self.kidlispChooserFetchInflight = false + if let error = error { + NSLog("KidLisp chooser fetch failed: \(error)") + completion() + return + } + guard let data = data else { completion(); return } + struct Item: Decodable { + let code: String + let source: String + let preview: String? + let handle: String? + } + struct Response: Decodable { + let recent: [Item] + } + do { + let resp = try JSONDecoder().decode(Response.self, from: data) + self.kidlispChooserPieces = resp.recent.map { + KidLispChooserItem( + code: $0.code, + source: $0.source, + preview: $0.preview ?? "", + handle: $0.handle + ) + } + self.kidlispChooserFetchedAt = Date() + } catch { + NSLog("KidLisp chooser decode failed: \(error)") + } + completion() + } + } + task.resume() + } } diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLCLI.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLCLI.swift new file mode 100644 index 0000000000..d82625300f --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLCLI.swift @@ -0,0 +1,112 @@ +// Headless renderer for conformance testing. +// +// Usage (after `swift build -c release`): +// +// ./MenuBand --kidlisp-render "" [seed] +// +// Writes a binary PPM (P6) at the given path. Designed to be called from +// kidlisp/conformance/ harness scripts to diff against reference frames +// rendered by kidlisp.mjs. + +import Foundation + +enum KLCLI { + /// Returns true if the args contained --kidlisp-render and the run + /// completed (caller should exit). Returns false to let normal app + /// startup proceed. + /// + /// Single frame: + /// --kidlisp-render [seed] + /// + /// Multi-frame dump (persistent evaluator across frames — accumulates + /// blur, advances timing tokens, etc.): + /// --kidlisp-render-frames + /// [seed] [frame_interval_ms] + static func runIfRequested(_ args: [String]) -> Bool { + if let idx = args.firstIndex(of: "--kidlisp-render-frames") { + return runFrames(Array(args.dropFirst(idx + 1))) + } + guard let idx = args.firstIndex(of: "--kidlisp-render") else { return false } + let rest = Array(args.dropFirst(idx + 1)) + guard rest.count >= 4 else { + FileHandle.standardError.write(Data( + "usage: --kidlisp-render [seed]\n".utf8)) + exit(2) + } + let source = rest[0] + let width = Int(rest[1]) ?? 0 + let height = Int(rest[2]) ?? 0 + let outPath = rest[3] + let seed: UInt64 = rest.count > 4 ? (UInt64(rest[4]) ?? 0xC0FFEE_BABE_F00D) + : 0xC0FFEE_BABE_F00D + guard width > 0, height > 0 else { + FileHandle.standardError.write(Data("invalid width/height\n".utf8)) + exit(2) + } + let fb = KLRuntime.render(source: source, + width: width, + height: height, + frame: 0, + seed: seed) + do { + try fb.writePPM(to: URL(fileURLWithPath: outPath)) + FileHandle.standardOutput.write(Data( + "wrote \(width)x\(height) → \(outPath)\n".utf8)) + } catch { + FileHandle.standardError.write(Data( + "ppm write failed: \(error)\n".utf8)) + exit(1) + } + return true + } + + private static func runFrames(_ rest: [String]) -> Bool { + guard rest.count >= 5 else { + FileHandle.standardError.write(Data( + "usage: --kidlisp-render-frames [seed] [interval_ms]\n".utf8)) + exit(2) + } + let source = rest[0] + let width = Int(rest[1]) ?? 0 + let height = Int(rest[2]) ?? 0 + let frames = Int(rest[3]) ?? 0 + let outDir = rest[4] + let seed: UInt64 = rest.count > 5 ? (UInt64(rest[5]) ?? 0xC0FFEE_BABE_F00D) + : 0xC0FFEE_BABE_F00D + // Each "frame" advances wall-clock time by this many ms — timing + // tokens (1s..., 2s..., 0.5s) anchor on wall-clock, so we need to + // simulate elapsed time between frames for deterministic dumps. + // Default 67ms matches oven's production WebP frame interval. + let intervalMs = rest.count > 6 ? (Double(rest[6]) ?? 67.0) : 67.0 + + guard width > 0, height > 0, frames > 0 else { + FileHandle.standardError.write(Data("invalid args\n".utf8)) + exit(2) + } + let fm = FileManager.default + try? fm.createDirectory(atPath: outDir, + withIntermediateDirectories: true) + + let fb = KLFramebuffer(width: width, height: height) + let ev = KLEvaluator(fb: fb, seed: seed) + ev.simulatedClock = Date().timeIntervalSinceReferenceDate + let step = intervalMs / 1000.0 + + for i in 0.. KLColor? { + return table[name.lowercased()] + } +} diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLEvaluator.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLEvaluator.swift new file mode 100644 index 0000000000..06c1da8fa2 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLEvaluator.swift @@ -0,0 +1,535 @@ +// Tree-walking evaluator for KidLisp. +// +// Conformance target: `KidLisp Decree '26: Core + RBP-26` (in progress). +// Heads: wipe, ink, line, blur, circle, scroll, spin, zoom, contrast, +// `?` random pick, timing tokens (`Ns`, `Ns...`, `Ns!`), `fade:` gradient. +// Magic vars: width/height/w/h/w/2/h/2/frame. Magic colors: rainbow. + +import Foundation + +final class KLEvaluator { + let fb: KLFramebuffer + var rng: KLRNG + var frame: Int = 0 + + /// Audio amplitude in (roughly) 0–10 scale, matching kidlisp.mjs's + /// `globalDef.amp`. Owner pushes this in each tick from whichever + /// source feeds the visualizer (synth output, mic, etc.). Pieces + /// access via the magic var `amp` (or `mic`). + var amp: Double = 0 + + private var inkSet: Bool = false + private var ink: KLInkSpec = .rgba(.white) + + // Timing state — persists across frames so `1s...` knows when it last + // fired. Keyed by `head|args.description` (stable for a fixed source). + private var timingLast: [String: TimeInterval] = [:] + private var timingSeq: [String: Int] = [:] + + // Backdrop / `(once ...)` gating. A bare color at the top of a program + // (e.g. `purple` in $bop) is shorthand for `(once (wipe purple))` per + // kidlisp.mjs — it wipes the first frame only, then subsequent frames + // accumulate on top. Keyed by the backdrop's color/expression. + private var oncedBackdrops: Set = [] + + // Progressive blur accumulator — matches graph.mjs §blur. Each call to + // `blur N` adds N/3 to the accumulator; the kernel only fires when + // |accumulator| ≥ 0.5, then resets. Keeps `blur 5` from washing the + // whole framebuffer flat after a few frames. + private var blurAccumulator: Double = 0.0 + private let blurThreshold: Double = 0.5 + + // Spin: fractional `steps` accumulate until we have a whole step, then + // the vortex kernel fires (matches graph.mjs §spinBlockBased). + private var spinAccumulator: Double = 0.0 + + // Zoom: small zoom calls (|level - 1| < 0.1) accumulate; only fire + // every ~0.05 of total delta. Large zooms apply immediately. Matches + // graph.mjs §zoom. + private var zoomAccumulator: Double = 0.0 + + // For deterministic CLI frame dumps. When non-nil, timing tokens read + // from this instead of Date() — the harness advances it by the + // expected per-frame interval between runFrame() calls. + var simulatedClock: TimeInterval? = nil + + init(fb: KLFramebuffer, seed: UInt64 = 0xC0FFEE_BABE_F00D) { + self.fb = fb + self.rng = KLRNG(seed: seed) + } + + /// Run `source` once as a single frame. Increments the frame counter + /// and clears per-frame state (ink) at entry. + func runFrame(_ source: String) { + inkSet = false + ink = .rgba(.white) + let tokens = KLLexer(source).tokenize() + let ast = KLParser(tokens).parseProgram() + _ = eval(ast) + frame &+= 1 + } + + @discardableResult + func run(_ program: KLExpr) -> Any? { + return eval(program) + } + + // MARK: - Evaluation + + @discardableResult + private func eval(_ expr: KLExpr) -> Any? { + switch expr { + case .number(let d): return d + case .symbol(let s): + if let v = magicVar(s) { return v } + return s + case .program(let stmts): + var last: Any? = nil + for s in stmts { last = eval(s) } + return last + case .atom(let inner): + if case .symbol(let name) = inner { + if let c = resolveSymbolAsColor(name) { + onceBackdrop("color:\(name)") { fb.wipe(flattenColor(c)) } + return nil + } + if name.hasPrefix("fade:") { + onceBackdrop(name) { fb.wipeFade(parseFadeColors(name)) } + return nil + } + } + return eval(inner) + case .list(let items): + guard let first = items.first, case .symbol(let head) = first else { + return nil + } + return apply(head, Array(items.dropFirst())) + case .bareForm(let head, let args): + return apply(head, args) + } + } + + // MARK: - Magic vars + + private func magicVar(_ name: String) -> Double? { + switch name { + case "width", "w": return Double(fb.width) + case "height", "h": return Double(fb.height) + case "w/2": return Double(fb.width) / 2.0 + case "h/2": return Double(fb.height) / 2.0 + case "frame": return Double(frame) + // Audio reactivity — `amp`/`mic`/`amplitude` all return the + // same owner-supplied value. Mirrors kidlisp.mjs which + // populates `amp`, `mic`, `amplitude` separately but in this + // host we feed a single synth-output channel. + case "amp", "mic", "amplitude": return amp + default: return nil + } + } + + // MARK: - Dispatch + + private func apply(_ head: String, _ args: [KLExpr]) -> Any? { + // `?` random pick: evaluate one of the args at random. + if head == "?" { + guard !args.isEmpty else { return nil } + let idx = rng.intRange(0, args.count) + return eval(args[idx]) + } + // Timing tokens: `Ns`, `Ns...`, `Ns!` + if let timing = parseTiming(head) { + return applyTiming(timing, args: args) + } + // Fade: `fade:r-b-c-...` as a top-level bare command = implicit + // fade backdrop (one-shot, like a bare color). + if head.hasPrefix("fade:") { + onceBackdrop(head) { self.fb.wipeFade(self.parseFadeColors(head)) } + return nil + } + // Basic arithmetic — needed for `amp`-driven pieces to scale + // the 0–10 value into framebuffer coordinates. Variadic to + // match the lisp shape: `(+ a b c)`, `(* amp 6)`, `(- h/2 4)`. + if let v = applyArith(head, args) { return v } + + switch head { + case "wipe": + if args.count == 1, case .symbol(let s) = args[0], s.hasPrefix("fade:") { + fb.wipeFade(parseFadeColors(s)) + return nil + } + if let c = resolveColor(args) { + fb.wipe(flattenColor(c)) + } + return nil + + case "ink": + if args.isEmpty { + inkSet = false + } else if let c = resolveColor(args) { + ink = c + inkSet = true + } + return nil + + case "line": + applyInkIfNeeded() + let (x0, y0, x1, y1) = lineEndpoints(args) + fb.line(x0, y0, x1, y1, flattenColor(ink)) + return nil + + case "circle": + applyInkIfNeeded() + let x = Int(numericArg(args, 0) ?? Double(fb.width / 2)) + let y = Int(numericArg(args, 1) ?? Double(fb.height / 2)) + let r = Int(numericArg(args, 2) ?? 4) + fb.circle(x, y, r, flattenColor(ink)) + return nil + + case "blur": + // Strength scale is gentler than kidlisp.mjs's /3 — at the TV's + // 192×120 res each blur radius covers less screen area, so we + // can spend more accumulator per call without smearing flat. + let strength = numericArg(args, 0) ?? 1 + blurAccumulator += strength / 1.5 + if abs(blurAccumulator) >= blurThreshold { + let r = max(1, Int(abs(blurAccumulator))) + fb.blur(radius: r) + blurAccumulator = 0.0 + } + return nil + + case "scroll": + let dx = numericArg(args, 0) ?? 0 + let dy = numericArg(args, 1) ?? 0 + fb.scroll(dx: dx, dy: dy) + return nil + + case "contrast": + let f = numericArg(args, 0) ?? 1.0 + fb.contrast(factor: f) + return nil + + case "spin": + let steps = numericArg(args, 0) ?? 0 + if steps != 0 { + spinAccumulator += steps + let integerSteps = floor(spinAccumulator) + spinAccumulator -= integerSteps + if integerSteps != 0 { + fb.spinVortex(integerSteps: integerSteps) + } + } + return nil + + case "zoom": + let level = numericArg(args, 0) ?? 1.0 + if level != 1.0 { + if abs(level - 1.0) >= 0.1 { + // Large change — apply immediately, reset accumulator. + zoomAccumulator = 0 + fb.zoom(scale: level) + } else { + // Small change — accumulate, fire on 0.05-step crossings. + zoomAccumulator += (level - 1.0) + let threshold = 0.05 + if abs(zoomAccumulator) >= threshold { + let zoomDelta = (zoomAccumulator >= 0 ? 1.0 : -1.0) * threshold + let actual = 1.0 + zoomDelta + zoomAccumulator -= zoomDelta + fb.zoom(scale: actual) + } + } + } + return nil + + default: + // Implicit backdrop via a bare color name with no args — runs + // once per evaluator (per kidlisp.mjs §backdrop semantics). + if args.isEmpty, let c = KLColors.named(head) { + onceBackdrop("color:\(head)") { self.fb.wipe(c) } + } + return nil + } + } + + private func onceBackdrop(_ key: String, _ body: () -> Void) { + let full = "backdrop:" + key + if oncedBackdrops.contains(full) { return } + oncedBackdrops.insert(full) + body() + } + + /// Called when the playing source changes — clear once-set so the new + /// program's backdrops fire on its first frame. + func resetBackdrops() { + oncedBackdrops.removeAll(keepingCapacity: true) + } + + // MARK: - Timing + + private struct Timing { + let seconds: Double + let iterating: Bool // `Ns...` cycles through args + let bang: Bool // `Ns!` (reserved) + } + + private func parseTiming(_ head: String) -> Timing? { + // Match `s` optionally followed by `...` or `!`. + guard head.last != ":" else { return nil } + var idx = head.startIndex + var numStr = "" + while idx < head.endIndex, head[idx].isNumber || head[idx] == "." { + numStr.append(head[idx]); idx = head.index(after: idx) + } + guard !numStr.isEmpty, idx < head.endIndex, head[idx] == "s" else { return nil } + guard let secs = Double(numStr) else { return nil } + let tail = head[head.index(after: idx)...] + let iterating = tail == "..." + let bang = tail == "!" + if !tail.isEmpty && !iterating && !bang { return nil } + return Timing(seconds: secs, iterating: iterating, bang: bang) + } + + private func applyTiming(_ t: Timing, args: [KLExpr]) -> Any? { + guard !args.isEmpty else { return nil } + let now = simulatedClock ?? Date().timeIntervalSinceReferenceDate + let key = "\(t.seconds)|\(t.iterating)|\(args.count)|\(stableArgsKey(args))" + let last = timingLast[key] ?? (now - t.seconds) // first call fires immediately + let due = (now - last) >= t.seconds + + if t.iterating { + if due { + timingLast[key] = now + timingSeq[key] = (timingSeq[key] ?? 0) &+ 1 + } + let idx = (timingSeq[key] ?? 0) % args.count + return eval(args[idx]) + } else { + // One-shot: only eval body on a fresh tick. Between fires, do + // nothing. Returns the last evaluated expr's value (matches + // kidlisp.mjs `do { result = eval(a) } while args`). + if due { + timingLast[key] = now + var result: Any? = nil + for a in args { result = eval(a) } + return result + } + return nil + } + } + + private func stableArgsKey(_ args: [KLExpr]) -> String { + // Cheap structural hash — good enough for keying timing state + // when the source string is stable across frames. + var s = "" + for a in args { s += "|" + shortDesc(a) } + return s + } + private func shortDesc(_ e: KLExpr) -> String { + switch e { + case .number(let d): return "n\(d)" + case .symbol(let s): return "s\(s)" + case .list(let xs): return "(" + xs.map(shortDesc).joined(separator: " ") + ")" + case .bareForm(let h, let xs): return "b\(h){" + xs.map(shortDesc).joined() + "}" + case .atom(let i): return "a" + shortDesc(i) + case .program(let xs): return "p[" + xs.map(shortDesc).joined() + "]" + } + } + + // MARK: - Ink + line + + private func applyInkIfNeeded() { + if !inkSet { + let r = UInt8(rng.intRange(0, 256)) + let g = UInt8(rng.intRange(0, 256)) + let b = UInt8(rng.intRange(0, 256)) + ink = .rgba(KLColor(r, g, b)) + } + } + + private func lineEndpoints(_ args: [KLExpr]) -> (Int, Int, Int, Int) { + if args.count >= 4 { + let x0 = Int(numericArg(args, 0) ?? 0) + let y0 = Int(numericArg(args, 1) ?? 0) + let x1 = Int(numericArg(args, 2) ?? 0) + let y1 = Int(numericArg(args, 3) ?? 0) + return (x0, y0, x1, y1) + } + return ( + rng.intRange(0, fb.width), + rng.intRange(0, fb.height), + rng.intRange(0, fb.width), + rng.intRange(0, fb.height) + ) + } + + // MARK: - Numerics + + private func numericArg(_ args: [KLExpr], _ idx: Int) -> Double? { + guard idx < args.count else { return nil } + let v = eval(args[idx]) + if let d = v as? Double { return d } + if let s = v as? String, let d = Double(s) { return d } + return nil + } + + /// Evaluate +, -, *, / as variadic numeric folds. Returns nil for + /// any other head so the main dispatch falls through unchanged. + /// Single-arg `-` negates (`(- amp)` = `-amp`); single-arg `/` + /// reciprocates (`(/ 2)` = `0.5`). + private func applyArith(_ head: String, _ args: [KLExpr]) -> Double? { + switch head { + case "+": + var s: Double = 0 + for i in 0.. KLInkSpec? { + if args.count >= 3 { + let r = UInt8(clamping: Int(numericArg(args, 0) ?? 0)) + let g = UInt8(clamping: Int(numericArg(args, 1) ?? 0)) + let b = UInt8(clamping: Int(numericArg(args, 2) ?? 0)) + let a = UInt8(clamping: Int(numericArg(args, 3) ?? 255)) + return .rgba(KLColor(r, g, b, a)) + } + guard !args.isEmpty else { return nil } + + // Evaluate the first arg — may be a list/`?`/timing token resolving + // to a symbol or number. + let v0 = eval(args[0]) + let alphaArg: UInt8? = args.count >= 2 + ? UInt8(clamping: Int(numericArg(args, 1) ?? 255)) + : nil + + if let name = v0 as? String { + if let c = resolveSymbolAsColor(name) { + if let a = alphaArg { return withAlpha(c, a) } + return c + } + if name.hasPrefix("fade:") { + return .fade(parseFadeColors(name)) + } + } + if let d = v0 as? Double { + let g = UInt8(clamping: Int(d)) + let a = alphaArg ?? 255 + return .rgba(KLColor(g, g, g, a)) + } + return nil + } + + private func resolveSymbolAsColor(_ name: String) -> KLInkSpec? { + if name == "rainbow" { + return .rgba(rainbowAt(frame: frame)) + } + if let c = KLColors.named(name) { + return .rgba(c) + } + return nil + } + + private func withAlpha(_ spec: KLInkSpec, _ a: UInt8) -> KLInkSpec { + switch spec { + case .rgba(let c): return .rgba(KLColor(c.r, c.g, c.b, a)) + case .fade(let cs): + return .fade(cs.map { KLColor($0.r, $0.g, $0.b, a) }) + } + } + + private func flattenColor(_ spec: KLInkSpec) -> KLColor { + // Strokes use the first stop of fade as a fallback — proper + // gradient strokes land when we wire fade into draw primitives. + switch spec { + case .rgba(let c): return c + case .fade(let cs): return cs.first ?? .white + } + } + + private func parseFadeColors(_ raw: String) -> [KLColor] { + // raw is `fade:red-blue-black-blue-red` or `fade:red-blue:frame` — + // we ignore the trailing `:frame` (animation suffix) for now and + // just take the first two segments delimited by `:`. + var s = raw + if s.hasPrefix("fade:") { s.removeFirst("fade:".count) } + if let colon = s.firstIndex(of: ":") { s = String(s[.. KLColor { + // Slow hue sweep — full cycle every ~180 frames (≈6 sec @30fps). + let hue = (Double(f).truncatingRemainder(dividingBy: 180.0)) / 180.0 + let (r, g, b) = hsvToRgb(h: hue, s: 1.0, v: 1.0) + return KLColor(UInt8(r * 255), UInt8(g * 255), UInt8(b * 255)) + } + + private func hsvToRgb(h: Double, s: Double, v: Double) -> (Double, Double, Double) { + let i = floor(h * 6) + let f = h * 6 - i + let p = v * (1 - s) + let q = v * (1 - f * s) + let t = v * (1 - (1 - f) * s) + switch Int(i.truncatingRemainder(dividingBy: 6)) { + case 0: return (v, t, p) + case 1: return (q, v, p) + case 2: return (p, v, t) + case 3: return (p, q, v) + case 4: return (t, p, v) + default: return (v, p, q) + } + } +} + +/// Ink can be either a flat RGBA or a fade gradient. Fade applies to +/// wipes today; future work threads it through draw primitives too. +enum KLInkSpec { + case rgba(KLColor) + case fade([KLColor]) +} diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLExpr.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLExpr.swift new file mode 100644 index 0000000000..05155641cf --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLExpr.swift @@ -0,0 +1,22 @@ +// KidLisp AST. + +import Foundation + +indirect enum KLExpr { + case number(Double) + case symbol(String) + case list([KLExpr]) // paren form: (head arg ...) — first elem is the head + case bareForm(String, [KLExpr]) // top-level non-paren form: `ink red` or `blur 5` + case atom(KLExpr) // top-level lone atom: `blue` — implicit wipe + case program([KLExpr]) // top-level sequence + + var asDouble: Double? { + if case .number(let d) = self { return d } + return nil + } + + var asSymbol: String? { + if case .symbol(let s) = self { return s } + return nil + } +} diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLFramebuffer.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLFramebuffer.swift new file mode 100644 index 0000000000..13cb6dfab8 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLFramebuffer.swift @@ -0,0 +1,313 @@ +// CPU framebuffer + immediate-mode primitives. +// +// Pixel format: RGBA8, row-major. (0,0) is top-left. +// This is the layer the Metal blit will consume — keep it +// Foundation-only so it can run from a CLI without AppKit. + +import Foundation + +final class KLFramebuffer { + let width: Int + let height: Int + var pixels: [UInt8] // length = width * height * 4, RGBA + + init(width: Int, height: Int) { + self.width = max(1, width) + self.height = max(1, height) + self.pixels = [UInt8](repeating: 0, count: self.width * self.height * 4) + } + + @inline(__always) + private func index(_ x: Int, _ y: Int) -> Int { + return (y * width + x) * 4 + } + + /// Standard "over" composite: `dest = src.rgb * α + dest.rgb * (1-α)`. + /// The framebuffer stays at alpha=255 throughout — partial alpha lives + /// only on the incoming ink color. This matches kidlisp.mjs's draw + /// semantics for `ink … N` where N is alpha. + @inline(__always) + func setPixel(_ x: Int, _ y: Int, _ c: KLColor) { + guard x >= 0, y >= 0, x < width, y < height else { return } + let i = index(x, y) + if c.a == 255 { + pixels[i] = c.r + pixels[i + 1] = c.g + pixels[i + 2] = c.b + pixels[i + 3] = 255 + return + } + let alpha = Double(c.a) / 255.0 + let inv = 1.0 - alpha + let dr = Double(pixels[i]), sr = Double(c.r) + let dg = Double(pixels[i + 1]), sg = Double(c.g) + let db = Double(pixels[i + 2]), sb = Double(c.b) + pixels[i] = UInt8(max(0.0, min(255.0, sr * alpha + dr * inv))) + pixels[i + 1] = UInt8(max(0.0, min(255.0, sg * alpha + dg * inv))) + pixels[i + 2] = UInt8(max(0.0, min(255.0, sb * alpha + db * inv))) + pixels[i + 3] = 255 // canvas stays opaque + } + + func wipe(_ c: KLColor) { + let total = width * height + var i = 0 + for _ in 0..= dy { err += dy; x0 += sx } + if e2 <= dx { err += dx; y0 += sy } + } + } + + /// Separable Gaussian blur of pixel radius `r`. Kernel = 2r+1, σ = r/3. + /// Identity at r ≤ 0. Mirrors graph.mjs Gaussian variant (the + /// `applyHorizontalBlur` / `applyVerticalBlur` pair) so visuals stay + /// consistent across runtimes. + func blur(radius r: Int) { + guard r > 0 else { return } + let w = width, h = height + let kernelSize = min(2 * r + 1, 15) + let radius = kernelSize / 2 + let sigma = max(0.25, Double(radius) / 3.0) + var weights = [Double](repeating: 0, count: kernelSize) + var wsum = 0.0 + for i in 0.. tmp + for y in 0.. pixels + for y in 0.. 0 else { setPixel(cx, cy, c); return } + for dy in -r...r { + let span = Int(Double(r * r - dy * dy).squareRoot().rounded()) + let y = cy + dy + for dx in -span...span { + setPixel(cx + dx, y, c) + } + } + } + + /// Wrap-around scroll by float pixels. Fractional offsets snap to int. + func scroll(dx: Double, dy: Double) { + let w = width, h = height + let ix = ((Int(dx.rounded()) % w) + w) % w + let iy = ((Int(dy.rounded()) % h) + h) % h + if ix == 0 && iy == 0 { return } + var dst = [UInt8](repeating: 0, count: w * h * 4) + for y in 0.. 0 else { return } + let w = width, h = height + var i = 0 + for _ in 0..<(w * h) { + for c in 0..<3 { + let v = Double(pixels[i + c]) + let stretched = (v - 128.0) * f + 128.0 + pixels[i + c] = UInt8(max(0.0, min(255.0, stretched))) + } + i += 4 + } + } + + /// Vortex spin (matches graph.mjs §spinBlockBased). `integerSteps` is + /// the already-accumulated whole-pixel step count — the evaluator does + /// the fractional accumulation. Per-pixel angle change = `steps / + /// distance_from_center`, so near-center pixels twist more than edges, + /// producing spiral arms. Source coords wrap (modulo) for the tiled- + /// tunnel look $roz depends on. + func spinVortex(integerSteps: Double) { + guard integerSteps != 0 else { return } + let w = width, h = height + let cx = Double(w) / 2.0, cy = Double(h) / 2.0 + var dst = [UInt8](repeating: 0, count: w * h * 4) + let twoPi = 2.0 * .pi + for y in 0.. 0, scale != 1.0 else { return } + let w = width, h = height + let ax = Double(w) * anchorX + let ay = Double(h) * anchorY + let inv = 1.0 / scale + var dst = [UInt8](repeating: 0, count: w * h * 4) + for y in 0.. 1 else { wipe(colors[0]); return } + let stops = colors.count - 1 + let h = height + for y in 0.. [KLToken] { + var out: [KLToken] = [] + while i < count { + let c = chars[i] + if c == ";" { + while i < count && chars[i] != "\n" { i += 1 } + continue + } + if c == "\n" { + out.append(KLToken(kind: .newline, pos: i)) + i += 1 + continue + } + if c.isWhitespace { i += 1; continue } + if c == "(" { out.append(KLToken(kind: .lparen, pos: i)); i += 1; continue } + if c == ")" { out.append(KLToken(kind: .rparen, pos: i)); i += 1; continue } + if c == "," { out.append(KLToken(kind: .comma, pos: i)); i += 1; continue } + if c == "-" || c == "+" || c.isNumber || c == "." { + if let tok = readNumber() { out.append(tok); continue } + } + if let tok = readIdent() { out.append(tok); continue } + // unknown — skip + i += 1 + } + out.append(KLToken(kind: .eof, pos: i)) + return out + } + + private func readNumber() -> KLToken? { + let start = i + var s = "" + if chars[i] == "+" || chars[i] == "-" { + // Only treat as number sign if followed by digit/dot AND prior token wasn't an ident. + let next = i + 1 + guard next < count, chars[next].isNumber || chars[next] == "." else { + // Treat as ident-y operator instead. + s.append(chars[i]); i += 1 + return KLToken(kind: .ident(s), pos: start) + } + s.append(chars[i]); i += 1 + } + var sawDot = false + var sawDigit = false + while i < count { + let c = chars[i] + if c.isNumber { sawDigit = true; s.append(c); i += 1; continue } + if c == "." && !sawDot { sawDot = true; s.append(c); i += 1; continue } + break + } + guard sawDigit, Double(s) != nil else { + i = start + return nil + } + // Timing token check: a number immediately followed by `s` (possibly + // `s...` or `s!`) is a single identifier like `2s...`, `0.5s`, `1s!` + // — `readNumber` shouldn't bite off just the number and leave the + // `s` for the next ident pass. + if i < count, chars[i] == "s" { + s.append(chars[i]); i += 1 + // `...` iterating suffix + while i < count, chars[i] == "." { + s.append(chars[i]); i += 1 + } + // `!` bang suffix + if i < count, chars[i] == "!" { + s.append(chars[i]); i += 1 + } + return KLToken(kind: .ident(s), pos: start) + } + guard let d = Double(s) else { + i = start + return nil + } + return KLToken(kind: .number(d), pos: start) + } + + private func readIdent() -> KLToken? { + let start = i + var s = "" + // Idents may contain almost anything that isn't a structural char. + while i < count { + let c = chars[i] + if c.isWhitespace || c == "(" || c == ")" || c == "," || c == ";" { break } + s.append(c); i += 1 + } + if s.isEmpty { return nil } + return KLToken(kind: .ident(s), pos: start) + } +} diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLParser.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLParser.swift new file mode 100644 index 0000000000..02027be852 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLParser.swift @@ -0,0 +1,110 @@ +// Parser: tokens → KLExpr tree. +// +// Top level is a sequence of statements separated by commas or newlines. +// A statement is either: +// * a paren form (wipe blue) +// * a bare-command form ink red / line / blur 5 +// * a lone atom blue — wraps as implicit wipe later +// +// Inside parens, the standard Lisp rules apply. + +import Foundation + +final class KLParser { + private let tokens: [KLToken] + private var i: Int = 0 + + init(_ tokens: [KLToken]) { + self.tokens = tokens + } + + func parseProgram() -> KLExpr { + var stmts: [KLExpr] = [] + while !atEnd() { + skipSeparators() + if atEnd() { break } + if let stmt = parseStatement() { + stmts.append(stmt) + } + } + return .program(stmts) + } + + private func parseStatement() -> KLExpr? { + if case .lparen = peek().kind { + return parseList() + } + // Bare-command form: collect atoms until comma/newline/EOF. + var atoms: [KLExpr] = [] + while !atEnd() { + let t = peek() + if case .comma = t.kind { break } + if case .newline = t.kind { break } + if case .lparen = t.kind { + if let l = parseList() { atoms.append(l) } + continue + } + if case .rparen = t.kind { break } // shouldn't happen at top level + if let a = parseAtom() { atoms.append(a) } + } + guard !atoms.isEmpty else { return nil } + // Symbol-headed statements always dispatch as bare commands — + // a lone `ink` or `line` is a zero-arg invocation, not an atom. + // The evaluator falls back to "implicit wipe" if the head turns + // out to be a color name with no args. + if case .symbol(let head) = atoms[0] { + return .bareForm(head, Array(atoms.dropFirst())) + } + if atoms.count == 1 { + return .atom(atoms[0]) + } + return .list(atoms) + } + + private func parseList() -> KLExpr? { + guard case .lparen = peek().kind else { return nil } + advance() + var items: [KLExpr] = [] + while !atEnd() { + let t = peek() + if case .rparen = t.kind { advance(); break } + if case .newline = t.kind { advance(); continue } // newlines inside parens are whitespace + if case .comma = t.kind { advance(); continue } + if case .lparen = t.kind { + if let inner = parseList() { items.append(inner) } + continue + } + if let a = parseAtom() { items.append(a) } + } + return .list(items) + } + + private func parseAtom() -> KLExpr? { + let t = peek() + switch t.kind { + case .number(let d): + advance(); return .number(d) + case .ident(let s): + advance(); return .symbol(s) + default: + advance() + return nil + } + } + + private func skipSeparators() { + while !atEnd() { + let t = peek() + if case .comma = t.kind { advance(); continue } + if case .newline = t.kind { advance(); continue } + break + } + } + + private func peek() -> KLToken { tokens[i] } + @discardableResult private func advance() -> KLToken { defer { i += 1 }; return tokens[i] } + private func atEnd() -> Bool { + if case .eof = tokens[i].kind { return true } + return false + } +} diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLRNG.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLRNG.swift new file mode 100644 index 0000000000..e21b63911b --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLRNG.swift @@ -0,0 +1,34 @@ +// Seedable PCG32 — gives deterministic conformance frames. + +import Foundation + +struct KLRNG { + private var state: UInt64 + private let inc: UInt64 + + init(seed: UInt64 = 0x4d595df4d0f33173) { + self.state = 0 + self.inc = (seed << 1) | 1 + _ = nextUInt32() + self.state &+= seed + _ = nextUInt32() + } + + mutating func nextUInt32() -> UInt32 { + let old = state + state = old &* 6364136223846793005 &+ inc + let xorshifted = UInt32(truncatingIfNeeded: ((old >> 18) ^ old) >> 27) + let rot = UInt32(truncatingIfNeeded: old >> 59) + return (xorshifted >> rot) | (xorshifted << ((0 &- rot) & 31)) + } + + mutating func nextDouble() -> Double { + return Double(nextUInt32()) / Double(UInt32.max) + } + + /// Integer in [low, high) — matches kidlisp.mjs `randIntRange(low, high)`. + mutating func intRange(_ low: Int, _ high: Int) -> Int { + guard high > low else { return low } + return low + Int(nextUInt32() % UInt32(high - low)) + } +} diff --git a/slab/menuband/Sources/MenuBand/KidLisp/KLRuntime.swift b/slab/menuband/Sources/MenuBand/KidLisp/KLRuntime.swift new file mode 100644 index 0000000000..9f02fb1ff8 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLisp/KLRuntime.swift @@ -0,0 +1,29 @@ +// Top-level facade. Two call shapes: +// +// • One-shot (CLI / conformance): +// let fb = KLRuntime.render(source: "(wipe blue)", width: 64, height: 32) +// +// • Persistent (live preview surfaces like the popover TV): +// let ev = KLEvaluator(fb: persistentFB) +// ev.runFrame(source) // every tick +// +// `seed` lets conformance tests pin the RNG so frames are reproducible. + +import Foundation + +enum KLRuntime { + static func render(source: String, + width: Int, + height: Int, + frame: Int = 0, + seed: UInt64 = 0xC0FFEE_BABE_F00D) -> KLFramebuffer { + let fb = KLFramebuffer(width: width, height: height) + let ev = KLEvaluator(fb: fb, seed: seed) + // Catch up the frame counter for callers that want a specific + // frame value visible via the `frame` magic var. Runs no work — + // just advances the counter without re-evaluating the source. + for _ in 0..' [seed] +``` + +Examples (phase 1 corpus): + +```sh +./.build/debug/MenuBand --kidlisp-render 'blue' 256 256 /tmp/4xa.ppm +./.build/debug/MenuBand --kidlisp-render '(wipe blue)' 256 256 /tmp/wib.ppm +./.build/debug/MenuBand --kidlisp-render 'purple, ink, line, blur 5' 256 256 /tmp/bop.ppm +``` + +## Status + +| Phase | Corpus | Heads | Status | +|---|---|---|---| +| 1 | `$bop` · `$wib` · `$4xa` | wipe, ink, line, blur, bare-color-wipe | ✅ done | +| 2 | `$pie` · `$39i` | `(fps N)`, timing tokens, magic vars (w/h/frame), scroll, flood, circle, zoom, contrast | not started | +| 3 | `$roz` · `$ceo` | `fade:` gradients, `:frame` animation, spin, coat, `?`/`...` | claims `RBP-26` when done | +| 4 | `$cow` | `$code` embed (recursive eval + source fetch) | not started | +| 5 | `$4bb` | `bake`/`burn` offscreen pages | not started | + +When phase 3 lands, we can publish `KidLisp Decree '26: Core + Render + RBP-26`. + +## Not yet ported from kidlisp.mjs + +- Audio API (`amp`, `mic`, `melody`, `overtone`) — Decree Audio level +- 3D primitives (`cube`, `form`, `trans`) +- Text rendering (`write`, font tables) +- All transformation effects beyond `blur` — `scroll`/`spin`/`zoom`/`contrast`/`suck` +- `bake`/`burn` offscreen page model (Decree §6 Render) +- Embed resolution (`$code` → fetch + recursive render) +- The `?` and `...` choice/cycle operators + +These land in phases 2–5. diff --git a/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift b/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift new file mode 100644 index 0000000000..0fa3b71a27 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLispTVPanel.swift @@ -0,0 +1,146 @@ +// Floating panel that hosts the KidLisp TV. +// +// Skeuomorphic LCD-television look: thick black bezel around a recessed +// screen area, with a subtle glossy inner border + a tiny power LED at +// the bottom-right corner. The framebuffer is upscaled nearest-neighbor +// inside the screen so pixels stay sharp. +// +// Positioning: child window of the popover, anchored directly under +// the LEFT floating panel (PianoWaveformWindow) so the TV reads as part +// of the player palette, not the settings popover. See +// AppDelegate.showPopover / closePopover for lifecycle. + +import AppKit + +final class KidLispTVPanel: NSPanel { + + let tv: KidLispTVView + private let bezel: KidLispTVBezelView + + static let bezelThickness: CGFloat = 14 + static let screenAspect: CGFloat = 192.0 / 120.0 + static let panelInset: CGFloat = 3 // gap below the anchor panel + + /// Default piece — a simple amp-reactive twin-line waveform that + /// spreads from the center as MenuBand's synth output gets louder. + /// Picked over `$roz` so first-time openers see the audio→visual + /// loop wired up: tap a piano key, the lines snap apart. + /// `amp` is the magic var (matches kidlisp.mjs's `amp` global, + /// ~0–10 scale). Replace via the "$ pieces" chooser on click. + static let defaultSource: String = """ + wipe black + ink rainbow + line 0 (- h/2 (* amp 5)) w (- h/2 (* amp 5)) + line 0 (+ h/2 (* amp 5)) w (+ h/2 (* amp 5)) + """ + + init(width: CGFloat) { + let screenWidth = width - 2 * Self.bezelThickness + let screenHeight = (screenWidth / Self.screenAspect).rounded() + let panelHeight = screenHeight + 2 * Self.bezelThickness + self.tv = KidLispTVView(source: Self.defaultSource, + resWidth: 192, resHeight: 120) + self.bezel = KidLispTVBezelView(frame: NSRect(x: 0, y: 0, + width: width, + height: panelHeight)) + super.init( + contentRect: NSRect(x: 0, y: 0, width: width, height: panelHeight), + styleMask: [.borderless, .nonactivatingPanel], + backing: .buffered, + defer: false + ) + isOpaque = false + backgroundColor = .clear + hasShadow = true + level = .popUpMenu + animationBehavior = .none + collectionBehavior = [.transient, .ignoresCycle] + hidesOnDeactivate = false + canHide = false + isMovableByWindowBackground = false + titleVisibility = .hidden + titlebarAppearsTransparent = true + isReleasedWhenClosed = false + + tv.frame = NSRect(x: Self.bezelThickness, + y: Self.bezelThickness, + width: screenWidth, + height: screenHeight) + let resize: NSView.AutoresizingMask = [.width, .height] + tv.autoresizingMask = resize + bezel.addSubview(tv) + contentView = bezel + } + + override var canBecomeKey: Bool { false } + override var canBecomeMain: Bool { false } + + /// Position the panel directly under `anchorFrame` (the left floating + /// panel's frame), matching its width so the TV reads as a stacked + /// component. + func anchor(below anchorFrame: NSRect) { + let h = frame.height + let panelFrame = NSRect( + x: anchorFrame.minX, + y: anchorFrame.minY - h - Self.panelInset, + width: anchorFrame.width, + height: h + ) + setFrame(panelFrame, display: true) + } +} + +/// The black bezel chrome — draws an LCD-style frame with rounded +/// corners, a recessed screen well, an inner highlight, and a small +/// power LED at the bottom-right. The actual KidLisp framebuffer view +/// is added as a subview centered in the screen well. +final class KidLispTVBezelView: NSView { + + override var isFlipped: Bool { false } // standard NSView coords for chrome + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + let b = bounds + let bezel = KidLispTVPanel.bezelThickness + + // Outer bezel — black, slightly rounded. + let outerPath = NSBezierPath(roundedRect: b, xRadius: 6, yRadius: 6) + NSColor(white: 0.08, alpha: 1.0).setFill() + outerPath.fill() + + // Subtle top-edge highlight (glossy plastic feel). + let highlightRect = NSRect(x: b.minX + 4, + y: b.maxY - 3, + width: b.width - 8, + height: 1.5) + NSColor(white: 0.28, alpha: 0.7).setFill() + NSBezierPath(roundedRect: highlightRect, xRadius: 0.75, yRadius: 0.75).fill() + + // Screen well — recessed dark rectangle. The TV view sits on top. + let wellRect = NSRect(x: b.minX + bezel, + y: b.minY + bezel, + width: b.width - 2 * bezel, + height: b.height - 2 * bezel) + NSColor.black.setFill() + NSBezierPath(rect: wellRect).fill() + + // Inset shadow line around the well — sells the recess. + ctx.saveGState() + ctx.setStrokeColor(NSColor(white: 0.0, alpha: 0.8).cgColor) + ctx.setLineWidth(1) + ctx.stroke(wellRect.insetBy(dx: 0.5, dy: 0.5)) + ctx.restoreGState() + + // Power LED — tiny green dot at bottom-right. + let ledRadius: CGFloat = 1.5 + let ledRect = NSRect(x: b.maxX - bezel / 2 - ledRadius, + y: b.minY + bezel / 2 - ledRadius, + width: ledRadius * 2, + height: ledRadius * 2) + NSColor(red: 0.3, green: 1.0, blue: 0.5, alpha: 0.95).setFill() + NSBezierPath(ovalIn: ledRect).fill() + // Faint glow ring. + NSColor(red: 0.3, green: 1.0, blue: 0.5, alpha: 0.2).setFill() + NSBezierPath(ovalIn: ledRect.insetBy(dx: -2, dy: -2)).fill() + } +} diff --git a/slab/menuband/Sources/MenuBand/KidLispTVView.swift b/slab/menuband/Sources/MenuBand/KidLispTVView.swift new file mode 100644 index 0000000000..b9d77b5094 --- /dev/null +++ b/slab/menuband/Sources/MenuBand/KidLispTVView.swift @@ -0,0 +1,154 @@ +// A tiny KidLisp "television" — runs a KidLisp source through the +// in-process Swift evaluator at 30fps and draws the framebuffer +// nearest-neighbor scaled into the view. Lives inside MenuBand's +// popover (see MenuBandPopover.swift). +// +// Drawing path: KLFramebuffer (RGBA8 byte buffer) → CGImage → +// CGContext.draw with interpolationQuality = .none for the chunky +// pixelated look. + +import AppKit +import Foundation + +final class KidLispTVView: NSView { + + private var source: String + private let resWidth: Int + private let resHeight: Int + private var timer: Timer? + /// Fired on mouseDown inside the screen well. Owner (AppDelegate) + /// uses this to pop a "$" pieces chooser. Nil = clicks are + /// swallowed by NSView's default handling. didSet invalidates + /// cursor rects so the pointing-hand cursor switches on/off + /// immediately when the callback is attached after the view is + /// already in a window. + var onScreenClick: ((NSView, NSEvent) -> Void)? { + didSet { window?.invalidateCursorRects(for: self) } + } + /// Per-frame audio amplitude source. Returns a value in (roughly) + /// 0–10 to match kidlisp.mjs's `amp` scale. Polled in `tick()` + /// before the evaluator runs the next frame so pieces that read + /// `amp` see the freshest reading. + var ampProvider: (() -> Double)? + // Persistent across frames — kidlisp.mjs's canonical model. Pieces that + // don't wipe accumulate, timing tokens remember their last fire time, + // the frame counter advances. We allocate the evaluator once and call + // `runFrame(source)` every tick on the same backing buffer. + private let fb: KLFramebuffer + private let evaluator: KLEvaluator + private var running: Bool = false + + init(source: String = "purple, ink, line, blur 5", + resWidth: Int = 192, + resHeight: Int = 120) { + self.source = source + self.resWidth = max(8, resWidth) + self.resHeight = max(8, resHeight) + self.fb = KLFramebuffer(width: self.resWidth, height: self.resHeight) + self.evaluator = KLEvaluator(fb: self.fb) + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.black.cgColor + } + + required init?(coder: NSCoder) { fatalError("init(coder:) not supported") } + + override var isFlipped: Bool { true } // (0,0) top-left, matches framebuffer + + override func mouseDown(with event: NSEvent) { + if let handler = onScreenClick { + handler(self, event) + } else { + super.mouseDown(with: event) + } + } + + override func resetCursorRects() { + // Pointing-hand on hover so the screen reads as a clickable + // surface — pairs with the chooser hook above. + if onScreenClick != nil { + addCursorRect(bounds, cursor: .pointingHand) + } + } + + func setSource(_ s: String) { + source = s + evaluator.resetBackdrops() + renderOneFrame() + needsDisplay = true + } + + static let targetFPS: Double = 60.0 + + func start() { + guard !running else { return } + running = true + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: 1.0 / Self.targetFPS, + repeats: true) { [weak self] _ in + self?.tick() + } + // Keep firing while popover scrolls/resizes. + if let t = timer { + RunLoop.current.add(t, forMode: .common) + } + } + + func stop() { + running = false + timer?.invalidate() + timer = nil + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + if window != nil { + renderOneFrame() + needsDisplay = true // paint frame 1 before the timer's first tick + start() + } else { + stop() + } + } + + private func tick() { + renderOneFrame() + needsDisplay = true + } + + private func renderOneFrame() { + if let provider = ampProvider { + evaluator.amp = provider() + } + evaluator.runFrame(source) + } + + override func draw(_ dirtyRect: NSRect) { + guard let ctx = NSGraphicsContext.current?.cgContext else { return } + if let img = makeCGImage(from: fb) { + ctx.interpolationQuality = .none + ctx.draw(img, in: bounds) + } else { + NSColor.black.setFill() + bounds.fill() + } + } + + private func makeCGImage(from fb: KLFramebuffer) -> CGImage? { + let data = Data(fb.pixels) + guard let provider = CGDataProvider(data: data as CFData) else { return nil } + let cs = CGColorSpaceCreateDeviceRGB() + let info = CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue) + return CGImage(width: fb.width, + height: fb.height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: fb.width * 4, + space: cs, + bitmapInfo: info, + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent) + } +} diff --git a/slab/menuband/Sources/MenuBand/main.swift b/slab/menuband/Sources/MenuBand/main.swift index f99b6352c8..b43b2da3d2 100644 --- a/slab/menuband/Sources/MenuBand/main.swift +++ b/slab/menuband/Sources/MenuBand/main.swift @@ -1,5 +1,12 @@ import AppKit +// Headless KidLisp renderer mode: `--kidlisp-render `. +// Short-circuits app startup so conformance harnesses can call the same +// binary without launching a status item. +if KLCLI.runIfRequested(CommandLine.arguments) { + exit(0) +} + let app = NSApplication.shared let delegate = AppDelegate() app.delegate = delegate