From 2a79c718baba5d906ae893ec4b780c0e8bdc6903 Mon Sep 17 00:00:00 2001 From: sergeych Date: Thu, 19 Mar 2026 22:41:06 +0300 Subject: [PATCH] 1.5.0-RC2: improved lyngio.console, some polishing --- AGENTS.md | 7 + examples/tetris_console.lyng | 238 +++++---- lyngio/build.gradle.kts | 58 +++ .../lyng/io/console/LyngConsoleModule.kt | 459 +++++++----------- .../lyngio/docs/ConsoleBuiltinDocs.kt | 246 +--------- .../sergeych/lyngio/console/JvmLyngConsole.kt | 123 ++++- .../lyng/io/console/LyngConsoleModuleTest.kt | 2 +- lyngio/stdlib/lyng/io/console.lyng | 157 ++++++ lynglib/build.gradle.kts | 2 +- lynglib/stdlib/lyng/root.lyng | 1 + 10 files changed, 637 insertions(+), 656 deletions(-) create mode 100644 lyngio/stdlib/lyng/io/console.lyng diff --git a/AGENTS.md b/AGENTS.md index d202548..7c49a10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,6 +5,13 @@ - For generics-heavy code generation, follow `docs/ai_language_reference.md` section `7.1 Generics Runtime Model and Bounds` and `7.2 Differences vs Java / Kotlin / Scala`. - Use `docs/ai_stdlib_reference.md` for default runtime/module APIs and stdlib surface. - Treat `LYNG_AI_SPEC.md` and older docs as secondary if they conflict with the two files above. +- Prefer the shortest clear loop: use `for` for straightforward iteration/ranges; use `while` only when loop state/condition is irregular or changes in ways `for` cannot express cleanly. + +## Lyng-First API Declarations +- Use `.lyng` declarations as the single source of truth for Lyng-facing API docs and types (especially module extern declarations). +- Prefer defining Lyng entities (enums/classes/type shapes) in `.lyng` files; only define them in Kotlin when there is Kotlin/platform-specific implementation detail that cannot be expressed in Lyng. +- Avoid hardcoding Lyng API documentation in Kotlin registrars when it can be declared in `.lyng`; Kotlin-side docs should be fallback/bridge only. +- For mixed pluggable modules (Lyng + Kotlin), embed module `.lyng` sources as generated Kotlin string literals, evaluate them into module scope during registration, then attach Kotlin implementations/bindings. ## Kotlin/Wasm generation guardrails - Avoid creating suspend lambdas for compiler runtime statements. Prefer explicit `object : Statement()` with `override suspend fun execute(...)`. diff --git a/examples/tetris_console.lyng b/examples/tetris_console.lyng index 43224f8..350bd11 100644 --- a/examples/tetris_console.lyng +++ b/examples/tetris_console.lyng @@ -12,11 +12,12 @@ */ import lyng.io.console +import lyng.io.fs val MIN_COLS = 56 val MIN_ROWS = 24 val PANEL_WIDTH = 24 -val BOARD_MARGIN_ROWS = 8 +val BOARD_MARGIN_ROWS = 5 val BOARD_MIN_W = 10 val BOARD_MAX_W = 16 val BOARD_MIN_H = 16 @@ -29,6 +30,7 @@ val RESIZE_WAIT_MS = 250 val ROTATION_KICKS = [0, -1, 1, -2, 2] val ANSI_ESC = "\u001b[" val ANSI_RESET = ANSI_ESC + "0m" +val ERROR_LOG_PATH = "/tmp/lyng_tetris_errors.log" val UNICODE_BLOCK = "██" val UNICODE_TOP_LEFT = "┌" val UNICODE_TOP_RIGHT = "┐" @@ -72,44 +74,37 @@ fun clearAndHome() { Console.home() } +fun logError(message: String, err: Object?): Void { + try { + val details = if (err == null) "" else ": " + err + Path(ERROR_LOG_PATH).appendUtf8(message + details + "\n") + } catch (_: Object) { + // Never let logging errors affect gameplay. + } +} + fun repeatText(s: String, n: Int): String { var out: String = "" - var i = 0 - while (i < n) { + for (i in 0.. b) a else b -} - -fun minInt(a: Int, b: Int): Int { - if (a < b) a else b -} - -fun clampInt(v: Int, lo: Int, hi: Int): Int { - minInt(hi, maxInt(lo, v)) -} +fun max(a: T, b: T): T = if (a > b) a else b fun emptyRow(width: Int): Row { val r: Row = [] - var x = 0 - while (x < width) { + for (x in 0.. 7) return false val piece: Piece = PIECES[pieceId - 1] + if (rot < 0 || rot >= piece.rotations.size) return false val cells = piece.rotations[rot] for (cell in cells) { @@ -180,21 +177,19 @@ fun clearCompletedLines(board: Board, boardW: Int, boardH: Int): Int { while (y >= 0) { val row = b[y] var full = true - var x = 0 - while (x < boardW) { + for (x in 0.. { val out: List = [] if (pieceId <= 0) { - out.add(" ") - out.add(" ") - out.add(" ") - out.add(" ") + for (i in 0..<4) { + out.add(" ") + } return out } val piece: Piece = PIECES[pieceId - 1] val cells = piece.rotations[0] - var y = 0 - while (y < 4) { + for (y in 0..<4) { var line = "" - var x = 0 - while (x < 4) { + for (x in 0..<4) { var filled = false for (cell in cells) { if (cell[0] == x && cell[1] == y) { @@ -253,10 +245,8 @@ fun nextPreviewLines(pieceId: Int, useColor: Bool): List { } } line += if (filled) blockText(pieceId, useColor) else " " - x = x + 1 } out.add(line) - y = y + 1 } out } @@ -300,44 +290,36 @@ fun render( val frameLines: List = [] - var y = 0 - while (y < boardH) { + for (y in 0.. 0) a else b line += if (id > 0) blockText(id, useColor) else emptyCellText(useColor) - x = x + 1 } line += UNICODE_VERTICAL - val p = y - if (p < panel.size) { - line += " " + panel[p] + if (y < panel.size) { + line += " " + panel[y] } frameLines.add(line) - y = y + 1 } frameLines.add(bottomBorder) - val prev = prevFrameLines - var i = 0 - while (i < frameLines.size) { + for (i in 0..= minCols && rows >= minRows) return g clearAndHome() - println("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows)) - println("Current: %sx%s"(cols, rows)) - println("Resize the console window to continue...") + val lines: List = [] + lines.add("Lyng Tetris needs at least %sx%s terminal size."(minCols, minRows)) + lines.add("Current: %sx%s"(cols, rows)) + lines.add("Resize the console window to continue.") + + val visibleLines = if (rows < lines.size) rows else lines.size + if (visibleLines > 0) { + val startRow = max(1, ((rows - visibleLines) / 2) + 1) + for (i in 0.. 0) { - s.totalLines = s.totalLines + cleared - s.score = s.score + scoreForLines(cleared, s.level) + s.totalLines += cleared + s.score += scoreForLines(cleared, s.level) } s.pieceId = s.nextId diff --git a/lyngio/build.gradle.kts b/lyngio/build.gradle.kts index e05da8a..810095e 100644 --- a/lyngio/build.gradle.kts +++ b/lyngio/build.gradle.kts @@ -112,6 +112,64 @@ kotlin { } } +abstract class GenerateLyngioConsoleDecls : DefaultTask() { + @get:InputFile + @get:PathSensitive(PathSensitivity.RELATIVE) + abstract val sourceFile: RegularFileProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + @TaskAction + fun generate() { + val targetPkg = "net.sergeych.lyngio.stdlib_included" + val pkgPath = targetPkg.replace('.', '/') + val targetDir = outputDir.get().asFile.resolve(pkgPath) + targetDir.mkdirs() + + val text = sourceFile.get().asFile.readText() + fun escapeForQuoted(s: String): String = buildString { + for (ch in s) when (ch) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> {} + '\t' -> append("\\t") + else -> append(ch) + } + } + + val out = buildString { + append("package ").append(targetPkg).append("\n\n") + append("@Suppress(\"Unused\", \"MemberVisibilityCanBePrivate\")\n") + append("internal val consoleLyng = \"") + append(escapeForQuoted(text)) + append("\"\n") + } + targetDir.resolve("console_types_lyng.generated.kt").writeText(out) + } +} + +val lyngioConsoleDeclsFile = layout.projectDirectory.file("stdlib/lyng/io/console.lyng") +val generatedLyngioDeclsDir = layout.buildDirectory.dir("generated/source/lyngioDecls/commonMain/kotlin") + +val generateLyngioConsoleDecls by tasks.registering(GenerateLyngioConsoleDecls::class) { + sourceFile.set(lyngioConsoleDeclsFile) + outputDir.set(generatedLyngioDeclsDir) +} + +kotlin.sourceSets.named("commonMain") { + kotlin.srcDir(generatedLyngioDeclsDir) +} + +kotlin.targets.configureEach { + compilations.configureEach { + compileTaskProvider.configure { + dependsOn(generateLyngioConsoleDecls) + } + } +} + android { namespace = "net.sergeych.lyngio" compileSdk = libs.versions.android.compileSdk.get().toInt() diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt index 0a2ca88..7dfde33 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyng/io/console/LyngConsoleModule.kt @@ -20,9 +20,11 @@ package net.sergeych.lyng.io.console import net.sergeych.lyng.ModuleScope import net.sergeych.lyng.Scope import net.sergeych.lyng.ScopeFacade -import net.sergeych.lyng.miniast.* +import net.sergeych.lyng.Source import net.sergeych.lyng.obj.Obj import net.sergeych.lyng.obj.ObjBool +import net.sergeych.lyng.obj.ObjEnumClass +import net.sergeych.lyng.obj.ObjEnumEntry import net.sergeych.lyng.obj.ObjIterable import net.sergeych.lyng.obj.ObjIterationFinishedException import net.sergeych.lyng.obj.ObjIterator @@ -42,6 +44,9 @@ import net.sergeych.lyngio.console.getSystemConsole import net.sergeych.lyngio.console.security.ConsoleAccessDeniedException import net.sergeych.lyngio.console.security.ConsoleAccessPolicy import net.sergeych.lyngio.console.security.LyngConsoleSecured +import net.sergeych.lyngio.stdlib_included.consoleLyng + +private const val CONSOLE_MODULE_NAME = "lyng.io.console" /** * Install Lyng module `lyng.io.console` into the given scope's ImportManager. @@ -53,10 +58,9 @@ fun createConsole(policy: ConsoleAccessPolicy, scope: Scope): Boolean = createCo /** Same as [createConsoleModule] but with explicit [ImportManager]. */ fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean { - val name = "lyng.io.console" - if (manager.packageNames.contains(name)) return false + if (manager.packageNames.contains(CONSOLE_MODULE_NAME)) return false - manager.addPackage(name) { module -> + manager.addPackage(CONSOLE_MODULE_NAME) { module -> buildConsoleModule(module, policy) } return true @@ -65,59 +69,37 @@ fun createConsoleModule(policy: ConsoleAccessPolicy, manager: ImportManager): Bo fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager) private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) { + // Load Lyng declarations for console enums/types first (module-local source of truth). + module.eval(Source(CONSOLE_MODULE_NAME, consoleLyng)) + ConsoleEnums.initialize(module) val console: LyngConsole = LyngConsoleSecured(getSystemConsole(), policy) val consoleType = object : net.sergeych.lyng.obj.ObjClass("Console") {} consoleType.apply { - addClassFnDoc( - name = "isSupported", - doc = "Whether console control API is supported on this platform.", - returns = type("lyng.Bool"), - moduleName = module.packageName - ) { + addClassFn("isSupported") { ObjBool(console.isSupported) } - addClassFnDoc( - name = "isTty", - doc = "Whether current stdout is attached to an interactive TTY.", - returns = type("lyng.Bool"), - moduleName = module.packageName - ) { + addClassFn("isTty") { consoleGuard { ObjBool(console.isTty()) } } - addClassFnDoc( - name = "ansiLevel", - doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.", - returns = type("lyng.String"), - moduleName = module.packageName - ) { + addClassFn("ansiLevel") { consoleGuard { - ObjString(console.ansiLevel().name) + ConsoleEnums.ansiLevel(console.ansiLevel().name) } } - addClassFnDoc( - name = "geometry", - doc = "Current terminal geometry or null.", - returns = type("ConsoleGeometry", nullable = true), - moduleName = module.packageName - ) { + addClassFn("geometry") { consoleGuard { console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull } } - addClassFnDoc( - name = "details", - doc = "Get consolidated console details.", - returns = type("ConsoleDetails"), - moduleName = module.packageName - ) { + addClassFn("details") { consoleGuard { val tty = console.isTty() val ansi = console.ansiLevel() @@ -125,18 +107,13 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces ObjConsoleDetails( supported = console.isSupported, isTty = tty, - ansiLevel = ansi.name, + ansiLevel = ConsoleEnums.ansiLevel(ansi.name), geometry = geometry?.let { ObjConsoleGeometry(it.columns, it.rows) }, ) } } - addClassFnDoc( - name = "write", - doc = "Write text directly to console output.", - params = listOf(ParamDoc("text", type("lyng.String"))), - moduleName = module.packageName - ) { + addClassFn("write") { consoleGuard { val text = requiredArg(0).value console.write(text) @@ -144,48 +121,28 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces } } - addClassFnDoc( - name = "flush", - doc = "Flush console output buffer.", - moduleName = module.packageName - ) { + addClassFn("flush") { consoleGuard { console.flush() ObjVoid } } - addClassFnDoc( - name = "home", - doc = "Move cursor to home position (1,1).", - moduleName = module.packageName - ) { + addClassFn("home") { consoleGuard { console.write("\u001B[H") ObjVoid } } - addClassFnDoc( - name = "clear", - doc = "Clear the visible screen buffer.", - moduleName = module.packageName - ) { + addClassFn("clear") { consoleGuard { console.write("\u001B[2J") ObjVoid } } - addClassFnDoc( - name = "moveTo", - doc = "Move cursor to 1-based row and column.", - params = listOf( - ParamDoc("row", type("lyng.Int")), - ParamDoc("column", type("lyng.Int")), - ), - moduleName = module.packageName - ) { + addClassFn("moveTo") { consoleGuard { val row = requiredArg(0).value val col = requiredArg(1).value @@ -194,45 +151,28 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces } } - addClassFnDoc( - name = "clearLine", - doc = "Clear the current line.", - moduleName = module.packageName - ) { + addClassFn("clearLine") { consoleGuard { console.write("\u001B[2K") ObjVoid } } - addClassFnDoc( - name = "enterAltScreen", - doc = "Switch to terminal alternate screen buffer.", - moduleName = module.packageName - ) { + addClassFn("enterAltScreen") { consoleGuard { console.write("\u001B[?1049h") ObjVoid } } - addClassFnDoc( - name = "leaveAltScreen", - doc = "Return from alternate screen buffer to normal screen.", - moduleName = module.packageName - ) { + addClassFn("leaveAltScreen") { consoleGuard { console.write("\u001B[?1049l") ObjVoid } } - addClassFnDoc( - name = "setCursorVisible", - doc = "Show or hide the terminal cursor.", - params = listOf(ParamDoc("visible", type("lyng.Bool"))), - moduleName = module.packageName - ) { + addClassFn("setCursorVisible") { consoleGuard { val visible = requiredArg(0).value console.write(if (visible) "\u001B[?25h" else "\u001B[?25l") @@ -240,24 +180,13 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces } } - addClassFnDoc( - name = "events", - doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.", - returns = type("ConsoleEventStream"), - moduleName = module.packageName - ) { + addClassFn("events") { consoleGuard { console.events().toConsoleEventStream() } } - addClassFnDoc( - name = "setRawMode", - doc = "Enable or disable raw keyboard mode. Returns true if mode changed.", - params = listOf(ParamDoc("enabled", type("lyng.Bool"))), - returns = type("lyng.Bool"), - moduleName = module.packageName - ) { + addClassFn("setRawMode") { consoleGuard { val enabled = requiredArg(0).value ObjBool(console.setRawMode(enabled)) @@ -265,55 +194,13 @@ private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAcces } } - module.addConstDoc( - name = "Console", - value = consoleType, - doc = "Console runtime API.", - type = type("Console"), - moduleName = module.packageName - ) - module.addConstDoc( - name = "ConsoleGeometry", - value = ObjConsoleGeometry.type, - doc = "Terminal geometry.", - type = type("lyng.Class"), - moduleName = module.packageName - ) - module.addConstDoc( - name = "ConsoleDetails", - value = ObjConsoleDetails.type, - doc = "Consolidated console capability details.", - type = type("lyng.Class"), - moduleName = module.packageName - ) - module.addConstDoc( - name = "ConsoleEvent", - value = ObjConsoleEvent.type, - doc = "Base class for console events.", - type = type("lyng.Class"), - moduleName = module.packageName - ) - module.addConstDoc( - name = "ConsoleResizeEvent", - value = ObjConsoleResizeEvent.type, - doc = "Terminal resize event.", - type = type("lyng.Class"), - moduleName = module.packageName - ) - module.addConstDoc( - name = "ConsoleKeyEvent", - value = ObjConsoleKeyEvent.typeObj, - doc = "Keyboard event.", - type = type("lyng.Class"), - moduleName = module.packageName - ) - module.addConstDoc( - name = "ConsoleEventStream", - value = ObjConsoleEventStream.type, - doc = "Endless iterable stream of console events.", - type = type("lyng.Class"), - moduleName = module.packageName - ) + module.addConst("Console", consoleType) + module.addConst("ConsoleGeometry", ObjConsoleGeometry.type) + module.addConst("ConsoleDetails", ObjConsoleDetails.type) + module.addConst("ConsoleEvent", ObjConsoleEvent.type) + module.addConst("ConsoleResizeEvent", ObjConsoleResizeEvent.type) + module.addConst("ConsoleKeyEvent", ObjConsoleKeyEvent.typeObj) + module.addConst("ConsoleEventStream", ObjConsoleEventStream.type) } private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj { @@ -338,12 +225,7 @@ private class ObjConsoleEventStream( companion object { val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventStream", ObjIterable).apply { - addFnDoc( - name = "iterator", - doc = "Create an iterator over incoming console events.", - returns = type("lyng.Iterator"), - moduleName = "lyng.io.console", - ) { + addFn("iterator") { val stream = thisAs() ObjConsoleEventIterator(stream.source) } @@ -375,7 +257,10 @@ private class ObjConsoleEventIterator( private suspend fun closeSource() { if (closed) return closed = true - source.close() + // Do not close the underlying console source from VM iterator cancellation. + // CmdFrame.cancelIterators() may call cancelIteration() while user code is still + // expected to keep processing input (e.g. recover from app-level exceptions). + // The source lifecycle is managed by the console runtime. } suspend fun hasNext(): Boolean = ensureCached() @@ -391,28 +276,13 @@ private class ObjConsoleEventIterator( companion object { val type = net.sergeych.lyng.obj.ObjClass("ConsoleEventIterator", ObjIterator).apply { - addFnDoc( - name = "hasNext", - doc = "Whether another console event is available.", - returns = type("lyng.Bool"), - moduleName = "lyng.io.console", - ) { + addFn("hasNext") { thisAs().hasNext().toObj() } - addFnDoc( - name = "next", - doc = "Return the next console event.", - returns = type("ConsoleEvent"), - moduleName = "lyng.io.console", - ) { + addFn("next") { thisAs().next(requireScope()) } - addFnDoc( - name = "cancelIteration", - doc = "Stop reading console events and release resources.", - returns = type("lyng.Void"), - moduleName = "lyng.io.console", - ) { + addFn("cancelIteration") { thisAs().closeSource() ObjVoid } @@ -422,27 +292,113 @@ private class ObjConsoleEventIterator( private fun ConsoleEvent.toObjEvent(): Obj = when (this) { is ConsoleEvent.Resize -> ObjConsoleResizeEvent(columns, rows) - is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = "keydown", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) - is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = "keyup", key = key, code = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) + is ConsoleEvent.KeyDown -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_DOWN, key = key, codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) + is ConsoleEvent.KeyUp -> ObjConsoleKeyEvent(type = ConsoleEnums.KEY_UP, key = key, codeName = code, ctrl = ctrl, alt = alt, shift = shift, meta = meta) +} + +private object ConsoleEnums { + lateinit var eventTypeClass: ObjEnumClass + private set + lateinit var keyCodeClass: ObjEnumClass + private set + lateinit var ansiLevelClass: ObjEnumClass + private set + + private lateinit var eventEntries: Map + private lateinit var keyCodeEntries: Map + private lateinit var ansiLevelEntries: Map + + val UNKNOWN: ObjEnumEntry get() = event("UNKNOWN") + val RESIZE: ObjEnumEntry get() = event("RESIZE") + val KEY_DOWN: ObjEnumEntry get() = event("KEY_DOWN") + val KEY_UP: ObjEnumEntry get() = event("KEY_UP") + val CODE_UNKNOWN: ObjEnumEntry get() = code("UNKNOWN") + val CHARACTER: ObjEnumEntry get() = code("CHARACTER") + + fun initialize(module: ModuleScope) { + eventTypeClass = resolveEnum(module, "ConsoleEventType") + keyCodeClass = resolveEnum(module, "ConsoleKeyCode") + ansiLevelClass = resolveEnum(module, "ConsoleAnsiLevel") + eventEntries = resolveEntries( + eventTypeClass, + listOf("UNKNOWN", "RESIZE", "KEY_DOWN", "KEY_UP") + ) + keyCodeEntries = resolveEntries( + keyCodeClass, + listOf( + "UNKNOWN", "CHARACTER", "ARROW_UP", "ARROW_DOWN", "ARROW_LEFT", "ARROW_RIGHT", + "HOME", "END", "INSERT", "DELETE", "PAGE_UP", "PAGE_DOWN", + "ESCAPE", "ENTER", "TAB", "BACKSPACE", "SPACE" + ) + ) + ansiLevelEntries = resolveEntries( + ansiLevelClass, + listOf("NONE", "BASIC16", "ANSI256", "TRUECOLOR") + ) + } + + private fun resolveEnum(module: ModuleScope, enumName: String): ObjEnumClass { + val local = module.get(enumName)?.value as? ObjEnumClass + if (local != null) return local + val root = module.importProvider.rootScope.get(enumName)?.value as? ObjEnumClass + return root ?: error("lyng.io.console declaration enum is missing: $enumName") + } + + private fun resolveEntries(enumClass: ObjEnumClass, names: List): Map { + return names.associateWith { name -> + (enumClass.byName[ObjString(name)] as? ObjEnumEntry) + ?: error("lyng.io.console enum entry is missing: ${enumClass.className}.$name") + } + } + + fun event(name: String): ObjEnumEntry = eventEntries[name] + ?: error("lyng.io.console enum entry is missing: ${eventTypeClass.className}.$name") + + fun code(name: String): ObjEnumEntry = keyCodeEntries[name] + ?: error("lyng.io.console enum entry is missing: ${keyCodeClass.className}.$name") + + fun ansiLevel(name: String): ObjEnumEntry = ansiLevelEntries[name] + ?: error("lyng.io.console enum entry is missing: ${ansiLevelClass.className}.$name") +} + +private val KEY_CODE_BY_KEY_NAME = mapOf( + "ArrowUp" to "ARROW_UP", + "ArrowDown" to "ARROW_DOWN", + "ArrowLeft" to "ARROW_LEFT", + "ArrowRight" to "ARROW_RIGHT", + "Home" to "HOME", + "End" to "END", + "Insert" to "INSERT", + "Delete" to "DELETE", + "PageUp" to "PAGE_UP", + "PageDown" to "PAGE_DOWN", + "Escape" to "ESCAPE", + "Enter" to "ENTER", + "Tab" to "TAB", + "Backspace" to "BACKSPACE", + " " to "SPACE", +) + +private fun codeFrom(key: String, codeName: String?): ObjEnumEntry { + val resolved = KEY_CODE_BY_KEY_NAME[codeName ?: key] + return when { + resolved != null -> ConsoleEnums.code(resolved) + key.length == 1 -> ConsoleEnums.CHARACTER + else -> ConsoleEnums.CODE_UNKNOWN + } } private abstract class ObjConsoleEventBase( - private val eventType: String, + private val type: ObjEnumEntry, final override val objClass: net.sergeych.lyng.obj.ObjClass, ) : Obj() { - fun eventTypeName(): String = eventType + fun type(): ObjEnumEntry = type } -private class ObjConsoleEvent : ObjConsoleEventBase("event", type) { +private class ObjConsoleEvent : ObjConsoleEventBase(ConsoleEnums.UNKNOWN, type) { companion object { val type = net.sergeych.lyng.obj.ObjClass("ConsoleEvent").apply { - addPropertyDoc( - name = "type", - doc = "Event type string: resize, keydown, keyup.", - type = type("lyng.String"), - moduleName = "lyng.io.console", - getter = { ObjString((this.thisObj as ObjConsoleEventBase).eventTypeName()) } - ) + addProperty(name = "type", getter = { (this.thisObj as ObjConsoleEventBase).type() }) } } } @@ -450,83 +406,40 @@ private class ObjConsoleEvent : ObjConsoleEventBase("event", type) { private class ObjConsoleResizeEvent( val columns: Int, val rows: Int, -) : ObjConsoleEventBase("resize", type) { +) : ObjConsoleEventBase(ConsoleEnums.RESIZE, type) { companion object { val type = net.sergeych.lyng.obj.ObjClass("ConsoleResizeEvent", ObjConsoleEvent.type).apply { - addPropertyDoc( - name = "columns", - doc = "Terminal width in character cells.", - type = type("lyng.Int"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() } - ) - addPropertyDoc( - name = "rows", - doc = "Terminal height in character cells.", - type = type("lyng.Int"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() } - ) + addProperty(name = "columns", getter = { (this.thisObj as ObjConsoleResizeEvent).columns.toObj() }) + addProperty(name = "rows", getter = { (this.thisObj as ObjConsoleResizeEvent).rows.toObj() }) } } } private class ObjConsoleKeyEvent( - type: String, + type: ObjEnumEntry, val key: String, - val code: String?, + val codeName: String?, val ctrl: Boolean, val alt: Boolean, val shift: Boolean, val meta: Boolean, ) : ObjConsoleEventBase(type, typeObj) { + init { + require(key.isNotEmpty()) { "ConsoleKeyEvent.key must never be empty" } + } + companion object { val typeObj = net.sergeych.lyng.obj.ObjClass("ConsoleKeyEvent", ObjConsoleEvent.type).apply { - addPropertyDoc( - name = "key", - doc = "Logical key name (e.g. ArrowLeft, a, Escape).", - type = type("lyng.String"), - moduleName = "lyng.io.console", - getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) } - ) - addPropertyDoc( - name = "code", - doc = "Optional hardware/code identifier.", - type = type("lyng.String", nullable = true), - moduleName = "lyng.io.console", - getter = { - val code = (this.thisObj as ObjConsoleKeyEvent).code - code?.let(::ObjString) ?: ObjNull - } - ) - addPropertyDoc( - name = "ctrl", - doc = "Whether Ctrl modifier is pressed.", - type = type("lyng.Bool"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() } - ) - addPropertyDoc( - name = "alt", - doc = "Whether Alt modifier is pressed.", - type = type("lyng.Bool"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() } - ) - addPropertyDoc( - name = "shift", - doc = "Whether Shift modifier is pressed.", - type = type("lyng.Bool"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() } - ) - addPropertyDoc( - name = "meta", - doc = "Whether Meta/Super modifier is pressed.", - type = type("lyng.Bool"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() } - ) + addProperty(name = "key", getter = { ObjString((this.thisObj as ObjConsoleKeyEvent).key) }) + addProperty(name = "code", getter = { codeFrom((this.thisObj as ObjConsoleKeyEvent).key, (this.thisObj as ObjConsoleKeyEvent).codeName) }) + addProperty(name = "codeName", getter = { + val code = (this.thisObj as ObjConsoleKeyEvent).codeName + code?.let(::ObjString) ?: ObjNull + }) + addProperty(name = "ctrl", getter = { (this.thisObj as ObjConsoleKeyEvent).ctrl.toObj() }) + addProperty(name = "alt", getter = { (this.thisObj as ObjConsoleKeyEvent).alt.toObj() }) + addProperty(name = "shift", getter = { (this.thisObj as ObjConsoleKeyEvent).shift.toObj() }) + addProperty(name = "meta", getter = { (this.thisObj as ObjConsoleKeyEvent).meta.toObj() }) } } } @@ -539,20 +452,8 @@ private class ObjConsoleGeometry( companion object { val type = net.sergeych.lyng.obj.ObjClass("ConsoleGeometry").apply { - addPropertyDoc( - name = "columns", - doc = "Terminal width in character cells.", - type = type("lyng.Int"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() } - ) - addPropertyDoc( - name = "rows", - doc = "Terminal height in character cells.", - type = type("lyng.Int"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() } - ) + addProperty(name = "columns", getter = { (this.thisObj as ObjConsoleGeometry).columns.toObj() }) + addProperty(name = "rows", getter = { (this.thisObj as ObjConsoleGeometry).rows.toObj() }) } } } @@ -560,41 +461,17 @@ private class ObjConsoleGeometry( private class ObjConsoleDetails( val supported: Boolean, val isTty: Boolean, - val ansiLevel: String, + val ansiLevel: ObjEnumEntry, val geometry: ObjConsoleGeometry?, ) : Obj() { override val objClass: net.sergeych.lyng.obj.ObjClass get() = type companion object { val type = net.sergeych.lyng.obj.ObjClass("ConsoleDetails").apply { - addPropertyDoc( - name = "supported", - doc = "Whether console API is supported.", - type = type("lyng.Bool"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() } - ) - addPropertyDoc( - name = "isTty", - doc = "Whether output is connected to a TTY.", - type = type("lyng.Bool"), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() } - ) - addPropertyDoc( - name = "ansiLevel", - doc = "Detected ANSI color capability level.", - type = type("lyng.String"), - moduleName = "lyng.io.console", - getter = { ObjString((this.thisObj as ObjConsoleDetails).ansiLevel) } - ) - addPropertyDoc( - name = "geometry", - doc = "Current terminal geometry or null.", - type = type("ConsoleGeometry", nullable = true), - moduleName = "lyng.io.console", - getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull } - ) + addProperty(name = "supported", getter = { (this.thisObj as ObjConsoleDetails).supported.toObj() }) + addProperty(name = "isTty", getter = { (this.thisObj as ObjConsoleDetails).isTty.toObj() }) + addProperty(name = "ansiLevel", getter = { (this.thisObj as ObjConsoleDetails).ansiLevel }) + addProperty(name = "geometry", getter = { (this.thisObj as ObjConsoleDetails).geometry ?: ObjNull }) } } } diff --git a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt index 6937061..1256f58 100644 --- a/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt +++ b/lyngio/src/commonMain/kotlin/net/sergeych/lyngio/docs/ConsoleBuiltinDocs.kt @@ -17,248 +17,12 @@ package net.sergeych.lyngio.docs -import net.sergeych.lyng.miniast.BuiltinDocRegistry -import net.sergeych.lyng.miniast.ParamDoc -import net.sergeych.lyng.miniast.type - +/** + * Console docs are declared in `lyngio/stdlib/lyng/io/console.lyng`. + * Keep this shim for compatibility with reflective loaders. + */ object ConsoleBuiltinDocs { - private var registered = false - fun ensure() { - if (registered) return - BuiltinDocRegistry.module("lyng.io.console") { - classDoc( - name = "Console", - doc = "Console runtime API." - ) { - method( - name = "isSupported", - doc = "Whether console control API is supported on this platform.", - returns = type("lyng.Bool"), - isStatic = true - ) - method( - name = "isTty", - doc = "Whether stdout is attached to an interactive TTY.", - returns = type("lyng.Bool"), - isStatic = true - ) - method( - name = "ansiLevel", - doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.", - returns = type("lyng.String"), - isStatic = true - ) - method( - name = "geometry", - doc = "Current terminal geometry or null.", - returns = type("ConsoleGeometry", nullable = true), - isStatic = true - ) - method( - name = "details", - doc = "Get consolidated console details.", - returns = type("ConsoleDetails"), - isStatic = true - ) - method( - name = "write", - doc = "Write text directly to console output.", - params = listOf(ParamDoc("text", type("lyng.String"))), - isStatic = true - ) - method( - name = "flush", - doc = "Flush console output buffer.", - isStatic = true - ) - method( - name = "home", - doc = "Move cursor to home position (1,1).", - isStatic = true - ) - method( - name = "clear", - doc = "Clear the visible screen buffer.", - isStatic = true - ) - method( - name = "moveTo", - doc = "Move cursor to 1-based row and column.", - params = listOf( - ParamDoc("row", type("lyng.Int")), - ParamDoc("column", type("lyng.Int")), - ), - isStatic = true - ) - method( - name = "clearLine", - doc = "Clear the current line.", - isStatic = true - ) - method( - name = "enterAltScreen", - doc = "Switch to terminal alternate screen buffer.", - isStatic = true - ) - method( - name = "leaveAltScreen", - doc = "Return from alternate screen buffer to normal screen.", - isStatic = true - ) - method( - name = "setCursorVisible", - doc = "Show or hide the terminal cursor.", - params = listOf(ParamDoc("visible", type("lyng.Bool"))), - isStatic = true - ) - method( - name = "events", - doc = "Endless iterable console event source (resize, keydown, keyup). Use in a loop, often inside launch.", - returns = type("ConsoleEventStream"), - isStatic = true - ) - method( - name = "setRawMode", - doc = "Enable or disable raw keyboard mode. Returns true if mode changed.", - params = listOf(ParamDoc("enabled", type("lyng.Bool"))), - returns = type("lyng.Bool"), - isStatic = true - ) - } - classDoc( - name = "ConsoleEventStream", - doc = "Endless iterable stream of console events." - ) { - method( - name = "iterator", - doc = "Create an iterator over incoming console events.", - returns = type("lyng.Iterator") - ) - } - classDoc( - name = "ConsoleGeometry", - doc = "Terminal geometry." - ) { - field( - name = "columns", - doc = "Terminal width in character cells.", - type = type("lyng.Int") - ) - field( - name = "rows", - doc = "Terminal height in character cells.", - type = type("lyng.Int") - ) - } - classDoc( - name = "ConsoleDetails", - doc = "Consolidated console capability details." - ) { - field( - name = "supported", - doc = "Whether console API is supported.", - type = type("lyng.Bool") - ) - field( - name = "isTty", - doc = "Whether output is attached to a TTY.", - type = type("lyng.Bool") - ) - field( - name = "ansiLevel", - doc = "Detected ANSI color capability.", - type = type("lyng.String") - ) - field( - name = "geometry", - doc = "Current geometry or null.", - type = type("ConsoleGeometry", nullable = true) - ) - } - classDoc( - name = "ConsoleEvent", - doc = "Base class for console events." - ) { - field( - name = "type", - doc = "Event kind string.", - type = type("lyng.String") - ) - } - classDoc( - name = "ConsoleResizeEvent", - doc = "Resize event." - ) { - field( - name = "type", - doc = "Event kind string: resize.", - type = type("lyng.String") - ) - field( - name = "columns", - doc = "Terminal width in character cells.", - type = type("lyng.Int") - ) - field( - name = "rows", - doc = "Terminal height in character cells.", - type = type("lyng.Int") - ) - } - classDoc( - name = "ConsoleKeyEvent", - doc = "Keyboard event." - ) { - field( - name = "type", - doc = "Event kind string: keydown or keyup.", - type = type("lyng.String") - ) - field( - name = "key", - doc = "Logical key name.", - type = type("lyng.String") - ) - field( - name = "code", - doc = "Optional hardware code.", - type = type("lyng.String", nullable = true) - ) - field( - name = "ctrl", - doc = "Ctrl modifier state.", - type = type("lyng.Bool") - ) - field( - name = "alt", - doc = "Alt modifier state.", - type = type("lyng.Bool") - ) - field( - name = "shift", - doc = "Shift modifier state.", - type = type("lyng.Bool") - ) - field( - name = "meta", - doc = "Meta modifier state.", - type = type("lyng.Bool") - ) - } - - valDoc( - name = "Console", - doc = "Console runtime API.", - type = type("Console") - ) - valDoc(name = "ConsoleGeometry", doc = "Terminal geometry class.", type = type("lyng.Class")) - valDoc(name = "ConsoleDetails", doc = "Console details class.", type = type("lyng.Class")) - valDoc(name = "ConsoleEvent", doc = "Base console event class.", type = type("lyng.Class")) - valDoc(name = "ConsoleResizeEvent", doc = "Resize event class.", type = type("lyng.Class")) - valDoc(name = "ConsoleKeyEvent", doc = "Keyboard event class.", type = type("lyng.Class")) - valDoc(name = "ConsoleEventStream", doc = "Iterable console event stream class.", type = type("lyng.Class")) - } - registered = true + // No Kotlin-side doc registration: console.lyng is the source of truth. } } diff --git a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt index 7495581..827ec49 100644 --- a/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt +++ b/lyngio/src/jvmMain/kotlin/net/sergeych/lyngio/console/JvmLyngConsole.kt @@ -26,6 +26,7 @@ import org.jline.terminal.Terminal import org.jline.terminal.TerminalBuilder import org.jline.utils.NonBlockingReader import java.io.EOFException +import java.io.InterruptedIOException import java.util.* import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -41,7 +42,7 @@ import kotlin.time.TimeSource * to avoid dual-terminal contention. */ object JvmLyngConsole : LyngConsole { - private const val DEBUG_REVISION = "jline-r26-force-rebuild-on-noop-recovery-2026-03-19" + private const val DEBUG_REVISION = "jline-r27-no-close-on-vm-iterator-cancel-2026-03-19" private val codeSourceLocation: String by lazy { runCatching { JvmLyngConsole::class.java.protectionDomain?.codeSource?.location?.toString() @@ -50,6 +51,44 @@ object JvmLyngConsole : LyngConsole { private val terminalRef = AtomicReference(null) private val terminalInitLock = Any() + private val shutdownHook = Thread( + { + restoreTerminalStateOnShutdown() + }, + "lyng-console-shutdown" + ).apply { isDaemon = true } + + init { + runCatching { Runtime.getRuntime().addShutdownHook(shutdownHook) } + .onFailure { consoleFlowDebug("jline-events: shutdown hook install failed", it) } + } + + private fun restoreTerminalStateOnShutdown() { + val term = terminalRef.get() ?: return + runCatching { + term.writer().print("\u001B[?25h") + term.writer().print("\u001B[?1049l") + term.writer().flush() + }.onFailure { + consoleFlowDebug("jline-events: shutdown visual restore failed", it) + } + val saved = if (runCatching { stateMutex.tryLock() }.getOrNull() == true) { + try { + rawModeRequested = false + val s = rawSavedAttributes + rawSavedAttributes = null + s + } finally { + stateMutex.unlock() + } + } else { + null + } + if (saved != null) { + runCatching { term.setAttributes(saved) } + .onFailure { consoleFlowDebug("jline-events: shutdown raw attrs restore failed", it) } + } + } private fun currentTerminal(): Terminal? { val existing = terminalRef.get() @@ -270,13 +309,13 @@ object JvmLyngConsole : LyngConsole { .onFailure { consoleFlowDebug("jline-events: reader shutdown failed during recovery", it) } reader = activeTerm.reader() - if (reader.hashCode() == prevReader.hashCode()) { - consoleFlowDebug("jline-events: reader recovery no-op oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()} -> forcing terminal rebuild") + if (reader === prevReader) { + consoleFlowDebug("jline-events: reader recovery no-op oldReader=${System.identityHashCode(prevReader)} newReader=${System.identityHashCode(reader)} -> forcing terminal rebuild") if (!tryRebuildTerminal()) { consoleFlowDebug("jline-events: forced terminal rebuild did not produce a new reader") } } else { - consoleFlowDebug("jline-events: reader recovered oldReader=${prevReader.hashCode()} newReader=${reader.hashCode()}") + consoleFlowDebug("jline-events: reader recovered oldReader=${System.identityHashCode(prevReader)} newReader=${System.identityHashCode(reader)}") } readerRecoveries.incrementAndGet() @@ -305,18 +344,56 @@ object JvmLyngConsole : LyngConsole { } else { keySendFailures.incrementAndGet() } - } catch (_: InterruptedException) { - break + } catch (e: InterruptedException) { + // Keep input alive if this is a transient interrupt while still running. + if (!running.get() || !keyLoopRunning.get()) break + recoveryRequested.set(true) + consoleFlowDebug("jline-events: key-reader interrupted; scheduling reader recovery", e) + Thread.interrupted() + continue + } catch (e: InterruptedIOException) { + // Common during reader shutdown/rebind. Recover silently and keep input flowing. + if (!running.get() || !keyLoopRunning.get()) break + recoveryRequested.set(true) + consoleFlowDebug("jline-events: read interrupted; scheduling reader recovery", e) + try { + Thread.sleep(10) + } catch (ie: InterruptedException) { + if (!running.get() || !keyLoopRunning.get()) break + recoveryRequested.set(true) + consoleFlowDebug("jline-events: interrupted during recovery backoff; continuing", ie) + Thread.interrupted() + } + } catch (e: EOFException) { + // EOF from reader should trigger rebind/rebuild rather than ending input stream. + if (!running.get() || !keyLoopRunning.get()) break + recoveryRequested.set(true) + consoleFlowDebug("jline-events: reader EOF; scheduling reader recovery", e) + try { + Thread.sleep(20) + } catch (ie: InterruptedException) { + if (!running.get() || !keyLoopRunning.get()) break + recoveryRequested.set(true) + consoleFlowDebug("jline-events: interrupted during EOF backoff; continuing", ie) + Thread.interrupted() + } } catch (e: Throwable) { readFailures.incrementAndGet() + recoveryRequested.set(true) consoleFlowDebug("jline-events: blocking read failed", e) try { Thread.sleep(50) - } catch (_: InterruptedException) { - break + } catch (ie: InterruptedException) { + if (!running.get() || !keyLoopRunning.get()) break + recoveryRequested.set(true) + consoleFlowDebug("jline-events: interrupted during error backoff; continuing", ie) + Thread.interrupted() } } } + consoleFlowDebug( + "jline-events: key-reader thread stopped running=${running.get()} keyLoopRunning=${keyLoopRunning.get()} loops=${keyLoopCount.get()} keys=${keyEvents.get()} readFailures=${readFailures.get()}" + ) } heartbeatThread = thread(start = true, isDaemon = true, name = "lyng-jline-heartbeat") { @@ -334,11 +411,17 @@ object JvmLyngConsole : LyngConsole { val readBlockedMs = if (readStartNs > 0L && readEndNs < readStartNs) { (System.nanoTime() - readStartNs) / 1_000_000L } else 0L - if (requested && keyCodesRead.get() > 0L && idleMs >= 1400L) { + val streamIdle = requested && keyCodesRead.get() > 0L && idleMs >= 1400L + val readStalled = requested && readBlockedMs >= 1600L + if (streamIdle || readStalled) { val sinceRecoveryMs = (System.nanoTime() - lastRecoveryNs.get()) / 1_000_000L if (sinceRecoveryMs >= 1200L) { recoveryRequested.set(true) - consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery") + if (readStalled) { + consoleFlowDebug("jline-events: key read blocked ${readBlockedMs}ms; scheduling reader recovery") + } else { + consoleFlowDebug("jline-events: key stream idle ${idleMs}ms; scheduling reader recovery") + } } } consoleFlowDebug( @@ -366,6 +449,7 @@ object JvmLyngConsole : LyngConsole { } override suspend fun close() { + consoleFlowDebug("jline-events: collector close requested", Throwable("collector close caller")) cleanup() consoleFlowDebug( "jline-events: collector ended keys=${keyEvents.get()} readFailures=${readFailures.get()}" @@ -485,12 +569,15 @@ object JvmLyngConsole : LyngConsole { ctrl: Boolean = false, alt: Boolean = false, shift: Boolean = false, - ): ConsoleEvent.KeyDown = ConsoleEvent.KeyDown( - key = value, - code = null, - ctrl = ctrl, - alt = alt, - shift = shift, - meta = false - ) + ): ConsoleEvent.KeyDown { + require(value.isNotEmpty()) { "ConsoleEvent.KeyDown.key must never be empty" } + return ConsoleEvent.KeyDown( + key = value, + code = null, + ctrl = ctrl, + alt = alt, + shift = shift, + meta = false + ) + } } diff --git a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt index 6f3833e..de15122 100644 --- a/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt +++ b/lyngio/src/jvmTest/kotlin/net/sergeych/lyng/io/console/LyngConsoleModuleTest.kt @@ -56,7 +56,7 @@ class LyngConsoleModuleTest { val d = Console.details() assert(d.supported is Bool) assert(d.isTty is Bool) - assert(d.ansiLevel is String) + assert(d.ansiLevel is ConsoleAnsiLevel) val g = Console.geometry() if (g != null) { diff --git a/lyngio/stdlib/lyng/io/console.lyng b/lyngio/stdlib/lyng/io/console.lyng new file mode 100644 index 0000000..d70a694 --- /dev/null +++ b/lyngio/stdlib/lyng/io/console.lyng @@ -0,0 +1,157 @@ +package lyng.io.console + +/* Console event kinds used by `ConsoleEvent.type`. */ +enum ConsoleEventType { + UNKNOWN, + RESIZE, + KEY_DOWN, + KEY_UP +} + +/* Normalized key codes used by `ConsoleKeyEvent.code`. */ +enum ConsoleKeyCode { + UNKNOWN, + CHARACTER, + ARROW_UP, + ARROW_DOWN, + ARROW_LEFT, + ARROW_RIGHT, + HOME, + END, + INSERT, + DELETE, + PAGE_UP, + PAGE_DOWN, + ESCAPE, + ENTER, + TAB, + BACKSPACE, + SPACE +} + +/* Detected ANSI terminal capability level. */ +enum ConsoleAnsiLevel { + NONE, + BASIC16, + ANSI256, + TRUECOLOR +} + +/* Base class for console events. */ +extern class ConsoleEvent { + /* Event kind for stable matching/switching. */ + val type: ConsoleEventType +} + +/* Terminal resize event. */ +extern class ConsoleResizeEvent : ConsoleEvent { + /* Current terminal width in character cells. */ + val columns: Int + /* Current terminal height in character cells. */ + val rows: Int +} + +/* Keyboard event. */ +extern class ConsoleKeyEvent : ConsoleEvent { + /* + Logical key name normalized for app-level handling, for example: + "a", "A", "ArrowLeft", "Escape", "Enter". + */ + val key: String + /* Normalized key code enum for robust matching independent of backend specifics. */ + val code: ConsoleKeyCode + /* + Optional backend-specific raw identifier (if available). + Not guaranteed to be present or stable across platforms. + */ + val codeName: String? + /* True when Ctrl was pressed during the key event. */ + val ctrl: Bool + /* True when Alt/Option was pressed during the key event. */ + val alt: Bool + /* True when Shift was pressed during the key event. */ + val shift: Bool + /* True when Meta/Super/Command was pressed during the key event. */ + val meta: Bool +} + +/* Pull iterator over console events. */ +extern class ConsoleEventIterator : Iterator { + /* Whether another event is currently available from the stream. */ + override fun hasNext(): Bool + /* Returns next event or throws iteration-finished when exhausted/cancelled. */ + override fun next(): ConsoleEvent + /* Stops this iterator. The underlying console service remains managed by runtime. */ + override fun cancelIteration(): void +} + +/* Endless iterable console event stream. */ +extern class ConsoleEventStream : Iterable { + /* Creates a fresh event iterator bound to the current console input stream. */ + override fun iterator(): ConsoleEventIterator +} + +/* Terminal geometry in character cells. */ +extern class ConsoleGeometry { + val columns: Int + val rows: Int +} + +/* Snapshot of console support/capabilities. */ +extern class ConsoleDetails { + /* True when current runtime has console control implementation. */ + val supported: Bool + /* True when output/input are attached to an interactive terminal. */ + val isTty: Bool + /* Detected terminal color capability level. */ + val ansiLevel: ConsoleAnsiLevel + /* Current terminal size if available, otherwise null. */ + val geometry: ConsoleGeometry? +} + +/* Console API singleton object. */ +extern object Console { + /* Returns true when console control API is implemented in this runtime. */ + fun isSupported(): Bool + /* Returns true when process is attached to interactive TTY. */ + fun isTty(): Bool + /* Returns detected color capability level. */ + fun ansiLevel(): ConsoleAnsiLevel + /* Returns current terminal geometry, or null when unavailable. */ + fun geometry(): ConsoleGeometry? + /* Returns combined capability snapshot in one call. */ + fun details(): ConsoleDetails + + /* Writes raw text to console output buffer (no implicit newline). */ + fun write(text: String): void + /* Flushes pending console output. Call after batched writes. */ + fun flush(): void + + /* Moves cursor to home position (row 1, column 1). */ + fun home(): void + /* Clears visible screen buffer. Cursor position is backend-dependent after clear. */ + fun clear(): void + /* Moves cursor to 1-based row/column. Values outside viewport are backend-defined. */ + fun moveTo(row: Int, column: Int): void + /* Clears current line content. Cursor stays on the same line. */ + fun clearLine(): void + + /* Switches terminal into alternate screen buffer (useful for TUIs). */ + fun enterAltScreen(): void + /* Returns from alternate screen buffer to the normal terminal screen. */ + fun leaveAltScreen(): void + /* Shows or hides the cursor. Prefer restoring visibility in finally blocks. */ + fun setCursorVisible(visible: Bool): void + + /* + Returns endless event stream (resize + key events). + Typical usage is consuming in a launched loop. + */ + fun events(): ConsoleEventStream + + /* + Enables/disables raw keyboard mode. + Returns true when state was actually changed. + */ + fun setRawMode(enabled: Bool): Bool +} diff --git a/lynglib/build.gradle.kts b/lynglib/build.gradle.kts index 456a8ff..762461a 100644 --- a/lynglib/build.gradle.kts +++ b/lynglib/build.gradle.kts @@ -21,7 +21,7 @@ import org.jetbrains.kotlin.gradle.ExperimentalWasmDsl import org.jetbrains.kotlin.gradle.dsl.JvmTarget group = "net.sergeych" -version = "1.5.0-RC" +version = "1.5.0-RC2" // Removed legacy buildscript classpath declarations; plugins are applied via the plugins DSL below diff --git a/lynglib/stdlib/lyng/root.lyng b/lynglib/stdlib/lyng/root.lyng index c58f85d..b36c97e 100644 --- a/lynglib/stdlib/lyng/root.lyng +++ b/lynglib/stdlib/lyng/root.lyng @@ -81,6 +81,7 @@ extern fun abs(x: Object): Real extern fun ln(x: Object): Real extern fun pow(x: Object, y: Object): Real extern fun sqrt(x: Object): Real +extern fun clamp(value: T, range: Range): T class SeededRandom { extern fun nextInt(): Int