From 65eeffa733e814d062a7f1b45cbced0d296b4b27 Mon Sep 17 00:00:00 2001 From: Miley Chandonnet Date: Wed, 4 Mar 2026 18:07:30 -0600 Subject: [PATCH] Add multi-target renderer protocol and implementations --- build.gradle.kts | 1 + phosphor-core/build.gradle.kts | 3 + .../phosphor/renderer/ComposeRenderer.kt | 140 ++++++++++++++++++ .../phosphor/renderer/PhosphorRenderer.kt | 11 ++ .../socket/phosphor/renderer/RenderTarget.kt | 10 ++ .../phosphor/renderer/RendererRegistry.kt | 74 +++++++++ .../phosphor/renderer/SimulationFrame.kt | 122 +++++++++++++++ .../phosphor/renderer/ComposeRendererTest.kt | 56 +++++++ .../phosphor/renderer/RendererRegistryTest.kt | 85 +++++++++++ .../phosphor/renderer/SimulationFrameTest.kt | 99 +++++++++++++ .../renderer/MultiTargetRendererExample.kt | 21 +++ .../phosphor/renderer/TerminalRenderer.kt | 134 +++++++++++++++++ .../MultiTargetRendererExampleTest.kt | 32 ++++ .../phosphor/renderer/TerminalRendererTest.kt | 83 +++++++++++ settings.gradle.kts | 1 + 15 files changed, 872 insertions(+) create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/PhosphorRenderer.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RenderTarget.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RendererRegistry.kt create mode 100644 phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/SimulationFrame.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeRendererTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/RendererRegistryTest.kt create mode 100644 phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/SimulationFrameTest.kt create mode 100644 phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExample.kt create mode 100644 phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt create mode 100644 phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExampleTest.kt create mode 100644 phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/TerminalRendererTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 7c6d251..20e44cc 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,6 @@ plugins { kotlin("multiplatform").apply(false) + kotlin("plugin.serialization").apply(false) id("org.jlleitschuh.gradle.ktlint").version("12.2.0").apply(false) id("org.jetbrains.dokka").apply(false) id("com.vanniktech.maven.publish").apply(false) diff --git a/phosphor-core/build.gradle.kts b/phosphor-core/build.gradle.kts index 7230e80..d47b508 100644 --- a/phosphor-core/build.gradle.kts +++ b/phosphor-core/build.gradle.kts @@ -7,6 +7,7 @@ import org.jetbrains.kotlin.gradle.plugin.mpp.apple.XCFramework plugins { kotlin("multiplatform") + kotlin("plugin.serialization") id("org.jlleitschuh.gradle.ktlint") id("org.jetbrains.dokka") id("com.vanniktech.maven.publish") @@ -51,12 +52,14 @@ kotlin { val commonMain by getting { dependencies { implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.6.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3") } } val commonTest by getting { dependencies { implementation(kotlin("test")) implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.10.2") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") } } } diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt new file mode 100644 index 0000000..8516732 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/ComposeRenderer.kt @@ -0,0 +1,140 @@ +package link.socket.phosphor.renderer + +import kotlin.math.roundToInt + +/** + * Generic color model for Compose-like draw surfaces. + */ +data class ComposeColor( + val red: Int, + val green: Int, + val blue: Int, + val alpha: Float = 1f, +) { + init { + require(red in 0..255) { "red must be 0..255, got $red" } + require(green in 0..255) { "green must be 0..255, got $green" } + require(blue in 0..255) { "blue must be 0..255, got $blue" } + require(alpha in 0f..1f) { "alpha must be 0..1, got $alpha" } + } + + fun lighten(factor: Float): ComposeColor { + val clamped = factor.coerceIn(0f, 1f) + return ComposeColor( + red = (red + ((255 - red) * clamped)).roundToInt(), + green = (green + ((255 - green) * clamped)).roundToInt(), + blue = (blue + ((255 - blue) * clamped)).roundToInt(), + alpha = alpha, + ) + } +} + +data class ComposeGradient( + val start: ComposeColor, + val end: ComposeColor, +) + +data class ComposeDrawCommand( + val x: Int, + val y: Int, + val char: Char, + val gradient: ComposeGradient, + val alpha: Float, + val bold: Boolean, +) + +data class ComposeRenderFrame( + val width: Int, + val height: Int, + val commands: List, +) + +/** + * Converts a simulation frame into draw commands usable by Canvas-based adapters. + */ +class ComposeRenderer( + override val preferredFps: Int = DEFAULT_TARGET_FPS, + private val skipWhitespaceCells: Boolean = true, +) : PhosphorRenderer { + override val target: RenderTarget = RenderTarget.COMPOSE + + init { + require(preferredFps > 0) { "preferredFps must be > 0, got $preferredFps" } + } + + override fun render(frame: SimulationFrame): ComposeRenderFrame { + val commands = ArrayList(frame.cells.size) + + for (row in 0 until frame.height) { + for (col in 0 until frame.width) { + val cell = frame.cellAt(row, col) + if (skipWhitespaceCells && cell.char == ' ') continue + + val start = ansi256ToColor(cell.fgColor) + val end = start.lighten(if (cell.bold) 0.25f else 0.12f) + val alpha = (cell.luminance ?: if (cell.char == ' ') 0f else 1f).coerceIn(0f, 1f) + + commands += + ComposeDrawCommand( + x = col, + y = row, + char = cell.char, + gradient = ComposeGradient(start = start, end = end), + alpha = alpha, + bold = cell.bold, + ) + } + } + + return ComposeRenderFrame( + width = frame.width, + height = frame.height, + commands = commands, + ) + } + + companion object { + const val DEFAULT_TARGET_FPS: Int = 60 + + private val ANSI_BASE_COLORS = + arrayOf( + ComposeColor(0, 0, 0), + ComposeColor(128, 0, 0), + ComposeColor(0, 128, 0), + ComposeColor(128, 128, 0), + ComposeColor(0, 0, 128), + ComposeColor(128, 0, 128), + ComposeColor(0, 128, 128), + ComposeColor(192, 192, 192), + ComposeColor(128, 128, 128), + ComposeColor(255, 0, 0), + ComposeColor(0, 255, 0), + ComposeColor(255, 255, 0), + ComposeColor(0, 0, 255), + ComposeColor(255, 0, 255), + ComposeColor(0, 255, 255), + ComposeColor(255, 255, 255), + ) + + private val ANSI_COLOR_CUBE_LEVELS = intArrayOf(0, 95, 135, 175, 215, 255) + + internal fun ansi256ToColor(index: Int): ComposeColor { + val normalized = index.coerceIn(0, 255) + + if (normalized < ANSI_BASE_COLORS.size) { + return ANSI_BASE_COLORS[normalized] + } + + if (normalized in 16..231) { + val cubeIndex = normalized - 16 + val r = ANSI_COLOR_CUBE_LEVELS[cubeIndex / 36] + val g = ANSI_COLOR_CUBE_LEVELS[(cubeIndex % 36) / 6] + val b = ANSI_COLOR_CUBE_LEVELS[cubeIndex % 6] + return ComposeColor(r, g, b) + } + + val gray = 8 + ((normalized - 232) * 10) + return ComposeColor(gray, gray, gray) + } + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/PhosphorRenderer.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/PhosphorRenderer.kt new file mode 100644 index 0000000..46b2163 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/PhosphorRenderer.kt @@ -0,0 +1,11 @@ +package link.socket.phosphor.renderer + +/** + * Platform renderer contract for a single simulation frame. + */ +interface PhosphorRenderer { + val target: RenderTarget + val preferredFps: Int + + fun render(frame: SimulationFrame): T +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RenderTarget.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RenderTarget.kt new file mode 100644 index 0000000..396602d --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RenderTarget.kt @@ -0,0 +1,10 @@ +package link.socket.phosphor.renderer + +/** + * Output surface families supported by the renderer registry. + */ +enum class RenderTarget { + TERMINAL, + COMPOSE, + WEBGL, +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RendererRegistry.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RendererRegistry.kt new file mode 100644 index 0000000..094d8b0 --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/RendererRegistry.kt @@ -0,0 +1,74 @@ +package link.socket.phosphor.renderer + +/** + * Registry for renderer instances with hot-swap support. + */ +class RendererRegistry { + private val renderers = linkedMapOf>() + private var currentTarget: RenderTarget? = null + + fun register( + renderer: PhosphorRenderer<*>, + activate: Boolean = currentTarget == null, + ): RendererRegistry { + renderers[renderer.target] = renderer + + if (activate || currentTarget == null) { + currentTarget = renderer.target + } + + return this + } + + fun unregister(target: RenderTarget): PhosphorRenderer<*>? { + val removed = renderers.remove(target) + + if (removed != null && currentTarget == target) { + currentTarget = renderers.keys.firstOrNull() + } + + return removed + } + + fun clear() { + renderers.clear() + currentTarget = null + } + + fun activate(target: RenderTarget) { + require(renderers.containsKey(target)) { + "No renderer registered for target $target" + } + currentTarget = target + } + + fun isRegistered(target: RenderTarget): Boolean = renderers.containsKey(target) + + fun activeTarget(): RenderTarget? = currentTarget + + fun activeRenderer(): PhosphorRenderer<*>? = currentTarget?.let { target -> renderers[target] } + + fun availableTargets(): Set = renderers.keys.toSet() + + @Suppress("UNCHECKED_CAST") + fun render(frame: SimulationFrame): T { + val target = currentTarget ?: error("No active renderer target is set") + val renderer = renderers[target] ?: error("No renderer registered for target $target") + return (renderer as PhosphorRenderer).render(frame) + } + + @Suppress("UNCHECKED_CAST") + fun render( + target: RenderTarget, + frame: SimulationFrame, + ): T { + val renderer = renderers[target] ?: error("No renderer registered for target $target") + return (renderer as PhosphorRenderer).render(frame) + } + + @Suppress("UNCHECKED_CAST") + fun renderAll(frame: SimulationFrame): Map = + renderers.mapValues { (_, renderer) -> + (renderer as PhosphorRenderer).render(frame) + } +} diff --git a/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/SimulationFrame.kt b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/SimulationFrame.kt new file mode 100644 index 0000000..a77543e --- /dev/null +++ b/phosphor-core/src/commonMain/kotlin/link/socket/phosphor/renderer/SimulationFrame.kt @@ -0,0 +1,122 @@ +package link.socket.phosphor.renderer + +import kotlinx.serialization.Serializable +import link.socket.phosphor.render.AsciiCell +import link.socket.phosphor.render.CellBuffer + +/** + * Serializable snapshot for one render tick. + */ +@Serializable +data class SimulationFrame( + val tick: Long, + val timestampEpochMillis: Long, + val width: Int, + val height: Int, + val cells: List, + val metadata: Map = emptyMap(), +) { + init { + require(width >= 0) { "width must be >= 0, got $width" } + require(height >= 0) { "height must be >= 0, got $height" } + require(cells.size == width * height) { + "cells size (${cells.size}) must equal width * height (${width * height})" + } + } + + fun cellAt( + row: Int, + col: Int, + ): FrameCell { + require(row in 0 until height) { "row out of bounds: $row" } + require(col in 0 until width) { "col out of bounds: $col" } + return cells[(row * width) + col] + } + + fun toCellBuffer(): CellBuffer { + val buffer = CellBuffer(width, height) + + for (row in 0 until height) { + for (col in 0 until width) { + buffer[row, col] = cellAt(row, col).toAsciiCell() + } + } + + return buffer + } + + companion object { + fun fromCellBuffer( + tick: Long, + timestampEpochMillis: Long, + buffer: CellBuffer, + metadata: Map = emptyMap(), + luminance: FloatArray? = null, + normalX: FloatArray? = null, + normalY: FloatArray? = null, + ): SimulationFrame { + val expectedSize = buffer.width * buffer.height + validateComponentSize("luminance", expectedSize, luminance) + validateComponentSize("normalX", expectedSize, normalX) + validateComponentSize("normalY", expectedSize, normalY) + + val cells = ArrayList(expectedSize) + + for (row in 0 until buffer.height) { + for (col in 0 until buffer.width) { + val index = (row * buffer.width) + col + val cell = buffer[row, col] + cells += + FrameCell( + char = cell.char, + fgColor = cell.fgColor, + bgColor = cell.bgColor, + bold = cell.bold, + luminance = luminance?.get(index), + normalX = normalX?.get(index), + normalY = normalY?.get(index), + ) + } + } + + return SimulationFrame( + tick = tick, + timestampEpochMillis = timestampEpochMillis, + width = buffer.width, + height = buffer.height, + cells = cells, + metadata = metadata, + ) + } + + private fun validateComponentSize( + componentName: String, + expectedSize: Int, + values: FloatArray?, + ) { + if (values == null) return + require(values.size == expectedSize) { + "$componentName size (${values.size}) must equal width * height ($expectedSize)" + } + } + } +} + +@Serializable +data class FrameCell( + val char: Char = ' ', + val fgColor: Int = 7, + val bgColor: Int? = null, + val bold: Boolean = false, + val luminance: Float? = null, + val normalX: Float? = null, + val normalY: Float? = null, +) { + fun toAsciiCell(): AsciiCell = + AsciiCell( + char = char, + fgColor = fgColor, + bgColor = bgColor, + bold = bold, + ) +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeRendererTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeRendererTest.kt new file mode 100644 index 0000000..24f44ea --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/ComposeRendererTest.kt @@ -0,0 +1,56 @@ +package link.socket.phosphor.renderer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ComposeRendererTest { + @Test + fun `render produces canvas commands for non-empty cells`() { + val renderer = ComposeRenderer() + val frame = + SimulationFrame( + tick = 1L, + timestampEpochMillis = 10L, + width = 2, + height = 2, + cells = + listOf( + FrameCell(char = 'A', fgColor = 196, luminance = 0.9f, bold = true), + FrameCell(char = ' ', fgColor = 196, luminance = 0f), + FrameCell(char = '.', fgColor = 34, luminance = 0.2f), + FrameCell(char = ' ', fgColor = 34, luminance = 0f), + ), + ) + + val output = renderer.render(frame) + + assertEquals(2, output.width) + assertEquals(2, output.height) + assertEquals(2, output.commands.size) + + val first = output.commands.first() + assertEquals(0, first.x) + assertEquals(0, first.y) + assertEquals('A', first.char) + assertTrue(first.alpha > 0.8f) + assertTrue(first.gradient.end.red >= first.gradient.start.red) + } + + @Test + fun `preferred fps defaults to 60 for compose targets`() { + val renderer = ComposeRenderer() + + assertEquals(60, renderer.preferredFps) + assertEquals(RenderTarget.COMPOSE, renderer.target) + } + + @Test + fun `ansi256ToColor maps color cube indexes`() { + val color = ComposeRenderer.ansi256ToColor(196) + + assertEquals(255, color.red) + assertEquals(0, color.green) + assertEquals(0, color.blue) + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/RendererRegistryTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/RendererRegistryTest.kt new file mode 100644 index 0000000..4b2a2cf --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/RendererRegistryTest.kt @@ -0,0 +1,85 @@ +package link.socket.phosphor.renderer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class RendererRegistryTest { + @Test + fun `register sets active target and supports hot swap`() { + val registry = RendererRegistry() + + registry.register(FakeRenderer(RenderTarget.TERMINAL, "terminal")) + registry.register(FakeRenderer(RenderTarget.COMPOSE, "compose"), activate = false) + + assertEquals(RenderTarget.TERMINAL, registry.activeTarget()) + + registry.activate(RenderTarget.COMPOSE) + + assertEquals(RenderTarget.COMPOSE, registry.activeTarget()) + } + + @Test + fun `render uses currently active renderer`() { + val registry = RendererRegistry() + registry.register(FakeRenderer(RenderTarget.TERMINAL, "terminal")) + registry.register(FakeRenderer(RenderTarget.COMPOSE, "compose"), activate = false) + + val frame = sampleFrame() + + val defaultOutput: String = registry.render(frame) + assertEquals("terminal:3", defaultOutput) + + registry.activate(RenderTarget.COMPOSE) + + val swappedOutput: String = registry.render(frame) + assertEquals("compose:3", swappedOutput) + } + + @Test + fun `renderAll renders to every registered target`() { + val registry = RendererRegistry() + registry.register(FakeRenderer(RenderTarget.TERMINAL, "terminal")) + registry.register(FakeRenderer(RenderTarget.COMPOSE, "compose"), activate = false) + + val outputs = registry.renderAll(sampleFrame()) + + assertEquals(2, outputs.size) + assertEquals("terminal:3", outputs[RenderTarget.TERMINAL]) + assertEquals("compose:3", outputs[RenderTarget.COMPOSE]) + } + + @Test + fun `unregister drops target and reassigns active renderer`() { + val registry = RendererRegistry() + registry.register(FakeRenderer(RenderTarget.TERMINAL, "terminal")) + registry.register(FakeRenderer(RenderTarget.COMPOSE, "compose"), activate = false) + + val removed = registry.unregister(RenderTarget.TERMINAL) + + assertNotNull(removed) + assertFalse(registry.isRegistered(RenderTarget.TERMINAL)) + assertTrue(registry.isRegistered(RenderTarget.COMPOSE)) + assertEquals(RenderTarget.COMPOSE, registry.activeTarget()) + } + + private fun sampleFrame(): SimulationFrame = + SimulationFrame( + tick = 3L, + timestampEpochMillis = 50L, + width = 1, + height = 1, + cells = listOf(FrameCell(char = 'x', fgColor = 10)), + ) + + private class FakeRenderer( + override val target: RenderTarget, + private val name: String, + ) : PhosphorRenderer { + override val preferredFps: Int = 60 + + override fun render(frame: SimulationFrame): String = "$name:${frame.tick}" + } +} diff --git a/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/SimulationFrameTest.kt b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/SimulationFrameTest.kt new file mode 100644 index 0000000..3938243 --- /dev/null +++ b/phosphor-core/src/commonTest/kotlin/link/socket/phosphor/renderer/SimulationFrameTest.kt @@ -0,0 +1,99 @@ +package link.socket.phosphor.renderer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlinx.serialization.json.Json +import link.socket.phosphor.render.AsciiCell +import link.socket.phosphor.render.CellBuffer + +class SimulationFrameTest { + @Test + fun `fromCellBuffer and toCellBuffer round-trip`() { + val buffer = CellBuffer(2, 2) + buffer[0, 0] = AsciiCell(char = 'A', fgColor = 196, bold = true) + buffer[0, 1] = AsciiCell(char = 'B', fgColor = 46, bgColor = 232) + buffer[1, 0] = AsciiCell(char = '.', fgColor = 250) + + val frame = + SimulationFrame.fromCellBuffer( + tick = 7L, + timestampEpochMillis = 123_456L, + buffer = buffer, + metadata = mapOf("deltaTime" to 0.016f), + ) + + val roundTripped = frame.toCellBuffer() + + assertEquals(2, frame.width) + assertEquals(2, frame.height) + assertEquals(4, frame.cells.size) + assertEquals(AsciiCell(char = 'A', fgColor = 196, bold = true), roundTripped[0, 0]) + assertEquals(AsciiCell(char = 'B', fgColor = 46, bgColor = 232), roundTripped[0, 1]) + assertEquals(AsciiCell(char = '.', fgColor = 250), roundTripped[1, 0]) + assertEquals(AsciiCell.EMPTY, roundTripped[1, 1]) + } + + @Test + fun `fromCellBuffer attaches optional surface components`() { + val buffer = CellBuffer(1, 2) + buffer[0, 0] = AsciiCell(char = 'x', fgColor = 10) + buffer[1, 0] = AsciiCell(char = 'y', fgColor = 11) + + val frame = + SimulationFrame.fromCellBuffer( + tick = 1L, + timestampEpochMillis = 2L, + buffer = buffer, + luminance = floatArrayOf(0.2f, 0.8f), + normalX = floatArrayOf(-0.5f, 0.5f), + normalY = floatArrayOf(1f, 1f), + ) + + assertEquals(0.2f, frame.cellAt(0, 0).luminance) + assertEquals(0.8f, frame.cellAt(1, 0).luminance) + assertEquals(-0.5f, frame.cellAt(0, 0).normalX) + assertEquals(1f, frame.cellAt(1, 0).normalY) + } + + @Test + fun `fromCellBuffer rejects mismatched component lengths`() { + val buffer = CellBuffer(2, 2) + + val error = + assertFailsWith { + SimulationFrame.fromCellBuffer( + tick = 1L, + timestampEpochMillis = 2L, + buffer = buffer, + luminance = floatArrayOf(0.1f), + ) + } + + assertNotNull(error.message) + } + + @Test + fun `SimulationFrame supports JSON serialization`() { + val frame = + SimulationFrame( + tick = 42L, + timestampEpochMillis = 99_999L, + width = 1, + height = 2, + cells = + listOf( + FrameCell(char = 'H', fgColor = 45, luminance = 0.5f), + FrameCell(char = 'i', fgColor = 46, bold = true, normalX = 0.2f, normalY = 0.8f), + ), + metadata = mapOf("fps" to 60f), + ) + + val json = Json { encodeDefaults = true } + val encoded = json.encodeToString(SimulationFrame.serializer(), frame) + val decoded = json.decodeFromString(SimulationFrame.serializer(), encoded) + + assertEquals(frame, decoded) + } +} diff --git a/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExample.kt b/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExample.kt new file mode 100644 index 0000000..ec2200d --- /dev/null +++ b/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExample.kt @@ -0,0 +1,21 @@ +package link.socket.phosphor.renderer + +/** + * Example output showing one simulation frame rendered to terminal and compose targets. + */ +data class MultiTargetRenderOutput( + val terminal: TerminalRenderFrame, + val compose: ComposeRenderFrame, +) + +object MultiTargetRendererExample { + fun renderSimultaneously( + frame: SimulationFrame, + terminalRenderer: TerminalRenderer = TerminalRenderer(), + composeRenderer: ComposeRenderer = ComposeRenderer(), + ): MultiTargetRenderOutput = + MultiTargetRenderOutput( + terminal = terminalRenderer.render(frame), + compose = composeRenderer.render(frame), + ) +} diff --git a/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt b/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt new file mode 100644 index 0000000..e52d208 --- /dev/null +++ b/phosphor-core/src/jvmMain/kotlin/link/socket/phosphor/renderer/TerminalRenderer.kt @@ -0,0 +1,134 @@ +package link.socket.phosphor.renderer + +import link.socket.phosphor.palette.AsciiLuminancePalette + +/** + * Terminal renderer that emits ANSI-encoded text lines. + */ +data class TerminalRenderFrame( + val width: Int, + val height: Int, + val lines: List, + val frameTick: Long, + val renderedAtEpochMillis: Long, + val rendered: Boolean, +) { + val text: String get() = lines.joinToString("\n") +} + +class TerminalRenderer( + private val palette: AsciiLuminancePalette = AsciiLuminancePalette.STANDARD, + override val preferredFps: Int = DEFAULT_TARGET_FPS, + private val includeAnsi: Boolean = true, + private val clockMillis: () -> Long = { System.currentTimeMillis() }, +) : PhosphorRenderer { + override val target: RenderTarget = RenderTarget.TERMINAL + + private var lastRenderAtMillis: Long? = null + private var lastRenderedFrame: TerminalRenderFrame? = null + + init { + require(preferredFps > 0) { "preferredFps must be > 0, got $preferredFps" } + } + + override fun render(frame: SimulationFrame): TerminalRenderFrame { + val now = clockMillis() + val lastFrame = lastRenderedFrame + val dimensionsChanged = + lastFrame != null && (lastFrame.width != frame.width || lastFrame.height != frame.height) + + if (!dimensionsChanged && shouldThrottle(now)) { + if (lastFrame != null) { + return lastFrame.copy( + frameTick = frame.tick, + renderedAtEpochMillis = now, + rendered = false, + ) + } + } + + val renderedFrame = buildFrame(frame = frame, rendered = true, now = now) + lastRenderAtMillis = now + lastRenderedFrame = renderedFrame + return renderedFrame + } + + fun resetThrottle() { + lastRenderAtMillis = null + lastRenderedFrame = null + } + + private fun shouldThrottle(now: Long): Boolean { + val last = lastRenderAtMillis ?: return false + val frameIntervalMillis = ((1_000f / preferredFps).toLong()).coerceAtLeast(1L) + return (now - last) < frameIntervalMillis + } + + private fun buildFrame( + frame: SimulationFrame, + rendered: Boolean, + now: Long, + ): TerminalRenderFrame { + val lines = ArrayList(frame.height) + + for (row in 0 until frame.height) { + val line = StringBuilder(frame.width * if (includeAnsi) 20 else 1) + + for (col in 0 until frame.width) { + val cell = frame.cellAt(row, col) + val char = remapChar(cell) + + if (includeAnsi) { + appendStyle(line, cell) + line.append(char) + line.append(ANSI_RESET) + } else { + line.append(char) + } + } + + lines += line.toString() + } + + return TerminalRenderFrame( + width = frame.width, + height = frame.height, + lines = lines, + frameTick = frame.tick, + renderedAtEpochMillis = now, + rendered = rendered, + ) + } + + private fun remapChar(cell: FrameCell): Char { + val luminance = cell.luminance ?: return cell.char + + return if (cell.normalX != null && cell.normalY != null) { + palette.charForSurface(luminance, cell.normalX, cell.normalY) + } else { + palette.charForLuminance(luminance) + } + } + + private fun appendStyle( + builder: StringBuilder, + cell: FrameCell, + ) { + builder.append(if (cell.bold) ANSI_BOLD else ANSI_NORMAL_WEIGHT) + builder.append(ansiForeground(cell.fgColor)) + cell.bgColor?.let { bg -> builder.append(ansiBackground(bg)) } + } + + companion object { + const val DEFAULT_TARGET_FPS: Int = 30 + + private const val ESC = "\u001B[" + private const val ANSI_BOLD = "${ESC}1m" + private const val ANSI_NORMAL_WEIGHT = "${ESC}22m" + private const val ANSI_RESET = "${ESC}0m" + + private fun ansiForeground(code: Int): String = "${ESC}38;5;${code.coerceIn(0, 255)}m" + + private fun ansiBackground(code: Int): String = "${ESC}48;5;${code.coerceIn(0, 255)}m" + } +} diff --git a/phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExampleTest.kt b/phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExampleTest.kt new file mode 100644 index 0000000..7931c4a --- /dev/null +++ b/phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/MultiTargetRendererExampleTest.kt @@ -0,0 +1,32 @@ +package link.socket.phosphor.renderer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MultiTargetRendererExampleTest { + @Test + fun `example renders same frame to terminal and compose outputs`() { + val frame = + SimulationFrame( + tick = 9L, + timestampEpochMillis = 500L, + width = 2, + height = 1, + cells = + listOf( + FrameCell(char = 'A', fgColor = 82, luminance = 0.8f), + FrameCell(char = 'B', fgColor = 196, luminance = 0.7f), + ), + ) + + val output = MultiTargetRendererExample.renderSimultaneously(frame) + + assertEquals(2, output.terminal.width) + assertEquals(1, output.terminal.height) + assertEquals(2, output.compose.width) + assertEquals(1, output.compose.height) + assertTrue(output.compose.commands.isNotEmpty()) + assertTrue(output.terminal.lines.isNotEmpty()) + } +} diff --git a/phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/TerminalRendererTest.kt b/phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/TerminalRendererTest.kt new file mode 100644 index 0000000..af9ea00 --- /dev/null +++ b/phosphor-core/src/jvmTest/kotlin/link/socket/phosphor/renderer/TerminalRendererTest.kt @@ -0,0 +1,83 @@ +package link.socket.phosphor.renderer + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import link.socket.phosphor.palette.AsciiLuminancePalette + +class TerminalRendererTest { + @Test + fun `terminal renderer outputs ansi escaped text by default`() { + val renderer = TerminalRenderer() + val frame = + SimulationFrame( + tick = 1L, + timestampEpochMillis = 100L, + width = 1, + height = 1, + cells = listOf(FrameCell(char = '#', fgColor = 196, bgColor = 232, bold = true)), + ) + + val output = renderer.render(frame) + + assertTrue(output.rendered) + assertTrue(output.text.contains("\u001B[1m")) + assertTrue(output.text.contains("\u001B[38;5;196m")) + assertTrue(output.text.contains("\u001B[48;5;232m")) + } + + @Test + fun `terminal renderer throttles to configured fps`() { + var now = 1_000L + val renderer = TerminalRenderer(preferredFps = 30, includeAnsi = false, clockMillis = { now }) + + val first = renderer.render(singleCellFrame(tick = 1L)) + assertTrue(first.rendered) + + now += 10 + val second = renderer.render(singleCellFrame(tick = 2L)) + assertFalse(second.rendered) + + now += 40 + val third = renderer.render(singleCellFrame(tick = 3L)) + assertTrue(third.rendered) + } + + @Test + fun `terminal renderer can remap glyphs from luminance using selected palette`() { + val palette = AsciiLuminancePalette.EXECUTE + val renderer = TerminalRenderer(palette = palette, includeAnsi = false) + val expected = palette.charForLuminance(1f) + + val frame = + SimulationFrame( + tick = 1L, + timestampEpochMillis = 100L, + width = 1, + height = 1, + cells = listOf(FrameCell(char = '?', fgColor = 15, luminance = 1f)), + ) + + val output = renderer.render(frame) + + assertEquals(expected.toString(), output.text) + } + + @Test + fun `terminal renderer defaults to 30 fps and terminal target`() { + val renderer = TerminalRenderer() + + assertEquals(30, renderer.preferredFps) + assertEquals(RenderTarget.TERMINAL, renderer.target) + } + + private fun singleCellFrame(tick: Long): SimulationFrame = + SimulationFrame( + tick = tick, + timestampEpochMillis = 100L, + width = 1, + height = 1, + cells = listOf(FrameCell(char = 'x', fgColor = 15)), + ) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 2ad5d67..7438cb7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,6 +13,7 @@ pluginManagement { val kotlinVersion = extra["kotlin.version"] as String kotlin("multiplatform").version(kotlinVersion) + kotlin("plugin.serialization").version(kotlinVersion) id("org.jlleitschuh.gradle.ktlint").version("12.2.0") id("org.jetbrains.dokka").version("2.1.0") id("com.vanniktech.maven.publish").version("0.30.0")