+added console spport for lyngio

+added console support to lyng/jlyng CLI
+added unicode escapes
+created tetris console sample
This commit is contained in:
sergeych committed 2026-03-19 01:09:32 +03:00
1 parent 1a080bc53e
commit d9d7cafec8
44 files changed
+3842 -28

No files matched your search

@@ -0,0 +1,600 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
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.obj.Obj
import net.sergeych.lyng.obj.ObjBool
import net.sergeych.lyng.obj.ObjIterable
import net.sergeych.lyng.obj.ObjIterationFinishedException
import net.sergeych.lyng.obj.ObjIterator
import net.sergeych.lyng.obj.ObjNull
import net.sergeych.lyng.obj.ObjString
import net.sergeych.lyng.obj.ObjVoid
import net.sergeych.lyng.obj.requiredArg
import net.sergeych.lyng.obj.thisAs
import net.sergeych.lyng.obj.toObj
import net.sergeych.lyng.pacman.ImportManager
import net.sergeych.lyng.raiseIllegalOperation
import net.sergeych.lyng.requireScope
import net.sergeych.lyngio.console.ConsoleEvent
import net.sergeych.lyngio.console.ConsoleEventSource
import net.sergeych.lyngio.console.LyngConsole
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
/**
* Install Lyng module `lyng.io.console` into the given scope's ImportManager.
*/
fun createConsoleModule(policy: ConsoleAccessPolicy, scope: Scope): Boolean =
createConsoleModule(policy, scope.importManager)
fun createConsole(policy: ConsoleAccessPolicy, scope: Scope): Boolean = createConsoleModule(policy, scope)
/** 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
manager.addPackage(name) { module ->
buildConsoleModule(module, policy)
}
return true
}
fun createConsole(policy: ConsoleAccessPolicy, manager: ImportManager): Boolean = createConsoleModule(policy, manager)
private suspend fun buildConsoleModule(module: ModuleScope, policy: ConsoleAccessPolicy) {
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
) {
ObjBool(console.isSupported)
}
addClassFnDoc(
name = "isTty",
doc = "Whether current stdout is attached to an interactive TTY.",
returns = type("lyng.Bool"),
moduleName = module.packageName
) {
consoleGuard {
ObjBool(console.isTty())
}
}
addClassFnDoc(
name = "ansiLevel",
doc = "Detected ANSI color capability: NONE, BASIC16, ANSI256, TRUECOLOR.",
returns = type("lyng.String"),
moduleName = module.packageName
) {
consoleGuard {
ObjString(console.ansiLevel().name)
}
}
addClassFnDoc(
name = "geometry",
doc = "Current terminal geometry or null.",
returns = type("ConsoleGeometry", nullable = true),
moduleName = module.packageName
) {
consoleGuard {
console.geometry()?.let { ObjConsoleGeometry(it.columns, it.rows) } ?: ObjNull
}
}
addClassFnDoc(
name = "details",
doc = "Get consolidated console details.",
returns = type("ConsoleDetails"),
moduleName = module.packageName
) {
consoleGuard {
val tty = console.isTty()
val ansi = console.ansiLevel()
val geometry = console.geometry()
ObjConsoleDetails(
supported = console.isSupported,
isTty = tty,
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
) {
consoleGuard {
val text = requiredArg<ObjString>(0).value
console.write(text)
ObjVoid
}
}
addClassFnDoc(
name = "flush",
doc = "Flush console output buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.flush()
ObjVoid
}
}
addClassFnDoc(
name = "home",
doc = "Move cursor to home position (1,1).",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[H")
ObjVoid
}
}
addClassFnDoc(
name = "clear",
doc = "Clear the visible screen buffer.",
moduleName = module.packageName
) {
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
) {
consoleGuard {
val row = requiredArg<net.sergeych.lyng.obj.ObjInt>(0).value
val col = requiredArg<net.sergeych.lyng.obj.ObjInt>(1).value
console.write("\u001B[${row};${col}H")
ObjVoid
}
}
addClassFnDoc(
name = "clearLine",
doc = "Clear the current line.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[2K")
ObjVoid
}
}
addClassFnDoc(
name = "enterAltScreen",
doc = "Switch to terminal alternate screen buffer.",
moduleName = module.packageName
) {
consoleGuard {
console.write("\u001B[?1049h")
ObjVoid
}
}
addClassFnDoc(
name = "leaveAltScreen",
doc = "Return from alternate screen buffer to normal screen.",
moduleName = module.packageName
) {
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
) {
consoleGuard {
val visible = requiredArg<ObjBool>(0).value
console.write(if (visible) "\u001B[?25h" else "\u001B[?25l")
ObjVoid
}
}
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
) {
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
) {
consoleGuard {
val enabled = requiredArg<ObjBool>(0).value
ObjBool(console.setRawMode(enabled))
}
}
}
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
)
}
private suspend inline fun ScopeFacade.consoleGuard(crossinline block: suspend () -> Obj): Obj {
return try {
block()
} catch (e: ConsoleAccessDeniedException) {
raiseIllegalOperation(e.reasonDetail ?: "console access denied")
} catch (e: Exception) {
raiseIllegalOperation(e.message ?: "console error")
}
}
private fun ConsoleEventSource.toConsoleEventStream(): ObjConsoleEventStream {
return ObjConsoleEventStream(this)
}
private class ObjConsoleEventStream(
private val source: ConsoleEventSource,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
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",
) {
val stream = thisAs<ObjConsoleEventStream>()
ObjConsoleEventIterator(stream.source)
}
}
}
}
private class ObjConsoleEventIterator(
private val source: ConsoleEventSource,
) : Obj() {
private var cached: Obj? = null
private var closed = false
override val objClass: net.sergeych.lyng.obj.ObjClass
get() = type
private suspend fun ensureCached(): Boolean {
if (closed) return false
if (cached != null) return true
val event = source.nextEvent()
if (event == null) {
closeSource()
return false
}
cached = event.toObjEvent()
return true
}
private suspend fun closeSource() {
if (closed) return
closed = true
source.close()
}
suspend fun hasNext(): Boolean = ensureCached()
suspend fun next(scope: Scope): Obj {
if (!ensureCached()) {
scope.raiseError(ObjIterationFinishedException(scope))
}
val out = cached ?: scope.raiseError("console iterator internal error: missing cached event")
cached = null
return out
}
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",
) {
thisAs<ObjConsoleEventIterator>().hasNext().toObj()
}
addFnDoc(
name = "next",
doc = "Return the next console event.",
returns = type("ConsoleEvent"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().next(requireScope())
}
addFnDoc(
name = "cancelIteration",
doc = "Stop reading console events and release resources.",
returns = type("lyng.Void"),
moduleName = "lyng.io.console",
) {
thisAs<ObjConsoleEventIterator>().closeSource()
ObjVoid
}
}
}
}
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)
}
private abstract class ObjConsoleEventBase(
private val eventType: String,
final override val objClass: net.sergeych.lyng.obj.ObjClass,
) : Obj() {
fun eventTypeName(): String = eventType
}
private class ObjConsoleEvent : ObjConsoleEventBase("event", 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()) }
)
}
}
}
private class ObjConsoleResizeEvent(
val columns: Int,
val rows: Int,
) : ObjConsoleEventBase("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() }
)
}
}
}
private class ObjConsoleKeyEvent(
type: String,
val key: String,
val code: String?,
val ctrl: Boolean,
val alt: Boolean,
val shift: Boolean,
val meta: Boolean,
) : ObjConsoleEventBase(type, typeObj) {
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() }
)
}
}
}
private class ObjConsoleGeometry(
val columns: Int,
val rows: Int,
) : Obj() {
override val objClass: net.sergeych.lyng.obj.ObjClass get() = type
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() }
)
}
}
}
private class ObjConsoleDetails(
val supported: Boolean,
val isTty: Boolean,
val ansiLevel: String,
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 }
)
}
}
}
@@ -0,0 +1,20 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
internal expect fun consoleFlowDebug(message: String, error: Throwable? = null)
@@ -0,0 +1,132 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
/**
* ANSI color support level detected for the active console.
*/
enum class ConsoleAnsiLevel {
NONE,
BASIC16,
ANSI256,
TRUECOLOR,
}
/**
* Console geometry in character cells.
*/
data class ConsoleGeometry(
val columns: Int,
val rows: Int,
)
/**
* Input/terminal events emitted by the console runtime.
*/
sealed interface ConsoleEvent {
data class Resize(
val columns: Int,
val rows: Int,
) : ConsoleEvent
data class KeyDown(
val key: String,
val code: String? = null,
val ctrl: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false,
val meta: Boolean = false,
) : ConsoleEvent
data class KeyUp(
val key: String,
val code: String? = null,
val ctrl: Boolean = false,
val alt: Boolean = false,
val shift: Boolean = false,
val meta: Boolean = false,
) : ConsoleEvent
}
/**
* Pull-based console event source.
*
* `nextEvent(timeoutMs)` returns:
* - next event when available,
* - `null` on timeout,
* - `null` after close.
*/
interface ConsoleEventSource {
suspend fun nextEvent(timeoutMs: Long = 0L): ConsoleEvent?
suspend fun close()
}
private object EmptyConsoleEventSource : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {
// no-op
}
}
/**
* Platform-independent console runtime surface.
*/
interface LyngConsole {
val isSupported: Boolean
suspend fun isTty(): Boolean
suspend fun geometry(): ConsoleGeometry?
suspend fun ansiLevel(): ConsoleAnsiLevel
suspend fun write(text: String)
suspend fun flush()
fun events(): ConsoleEventSource
/**
* Set terminal raw input mode. Returns true when mode was changed.
*/
suspend fun setRawMode(enabled: Boolean): Boolean
}
object UnsupportedLyngConsole : LyngConsole {
override val isSupported: Boolean = false
override suspend fun isTty(): Boolean = false
override suspend fun geometry(): ConsoleGeometry? = null
override suspend fun ansiLevel(): ConsoleAnsiLevel = ConsoleAnsiLevel.NONE
override suspend fun write(text: String) {
// no-op
}
override suspend fun flush() {
// no-op
}
override fun events(): ConsoleEventSource = EmptyConsoleEventSource
override suspend fun setRawMode(enabled: Boolean): Boolean = false
}
@@ -0,0 +1,286 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.input.RawModeScope
import com.github.ajalt.mordant.input.enterRawModeOrNull
import com.github.ajalt.mordant.rendering.AnsiLevel
import com.github.ajalt.mordant.terminal.Terminal
import kotlinx.coroutines.*
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import net.sergeych.lyng.ScriptFlowIsNoMoreCollected
import net.sergeych.mp_tools.globalLaunch
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
/**
* Mordant-backed console runtime implementation.
*/
object MordantLyngConsole : LyngConsole {
private val terminal: Terminal? by lazy {
runCatching { Terminal() }.getOrNull()
}
private val stateMutex = Mutex()
private var rawModeRequested: Boolean = false
private var rawModeScope: RawModeScope? = null
private suspend fun forceRawModeReset(t: Terminal): Boolean = stateMutex.withLock {
if (!rawModeRequested) return@withLock false
runCatching { rawModeScope?.close() }
.onFailure { consoleFlowDebug("forceRawModeReset: close failed", it) }
rawModeScope = null
rawModeRequested = false
if (!t.terminalInfo.inputInteractive) return@withLock false
val reopened = t.enterRawModeOrNull()
rawModeScope = reopened
rawModeRequested = reopened != null
reopened != null
}
override val isSupported: Boolean
get() = terminal != null
override suspend fun isTty(): Boolean {
val t = terminal ?: return false
return t.terminalInfo.outputInteractive
}
override suspend fun geometry(): ConsoleGeometry? {
val t = terminal ?: return null
val size = t.updateSize()
return ConsoleGeometry(size.width, size.height)
}
override suspend fun ansiLevel(): ConsoleAnsiLevel {
val t = terminal ?: return ConsoleAnsiLevel.NONE
return when (t.terminalInfo.ansiLevel) {
AnsiLevel.NONE -> ConsoleAnsiLevel.NONE
AnsiLevel.ANSI16 -> ConsoleAnsiLevel.BASIC16
AnsiLevel.ANSI256 -> ConsoleAnsiLevel.ANSI256
AnsiLevel.TRUECOLOR -> ConsoleAnsiLevel.TRUECOLOR
}
}
override suspend fun write(text: String) {
terminal?.rawPrint(text)
}
override suspend fun flush() {
// Mordant prints via platform streams immediately.
}
override fun events(): ConsoleEventSource {
val t = terminal ?: return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? = null
override suspend fun close() {}
}
val out = Channel<ConsoleEvent>(Channel.UNLIMITED)
val sourceState = Mutex()
var running = true
globalLaunch {
var lastWidth = t.updateSize().width
var lastHeight = t.updateSize().height
val startMark = TimeSource.Monotonic.markNow()
var lastHeartbeatMark = startMark
var loops = 0L
var readAttempts = 0L
var readFailures = 0L
var keyEvents = 0L
var resizeEvents = 0L
var rawNullLoops = 0L
var lastKeyMark = startMark
var lastRawRecoveryMark = startMark
consoleFlowDebug("events: collector started")
try {
while (currentCoroutineContext().isActive && sourceState.withLock { running }) {
loops += 1
val currentSize = runCatching { t.updateSize() }.getOrNull()
if (currentSize == null) {
delay(150)
continue
}
if (currentSize.width != lastWidth || currentSize.height != lastHeight) {
out.trySend(ConsoleEvent.Resize(currentSize.width, currentSize.height))
lastWidth = currentSize.width
lastHeight = currentSize.height
resizeEvents += 1
}
val raw = stateMutex.withLock {
if (!rawModeRequested) {
null
} else {
// Recover raw scope lazily if it was dropped due to an earlier read failure.
if (rawModeScope == null) {
rawModeScope = t.enterRawModeOrNull()
if (rawModeScope == null) {
consoleFlowDebug("events: failed to reopen raw mode scope")
} else {
consoleFlowDebug("events: raw mode scope reopened")
}
}
rawModeScope
}
}
if (raw == null || !t.terminalInfo.inputInteractive) {
rawNullLoops += 1
delay(150)
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
consoleFlowDebug(
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
)
lastHeartbeatMark = TimeSource.Monotonic.markNow()
}
continue
}
readAttempts += 1
val readResult = runCatching { raw.readEventOrNull(150.milliseconds) }
if (readResult.isFailure) {
readFailures += 1
consoleFlowDebug("events: readEventOrNull failed; resetting raw scope", readResult.exceptionOrNull())
// Raw scope became invalid; close and force reopen on next iteration.
stateMutex.withLock {
runCatching { rawModeScope?.close() }
rawModeScope = null
}
delay(50)
continue
}
val ev = readResult.getOrNull()
val resized = runCatching { t.updateSize() }.getOrNull()
if (resized != null && (resized.width != lastWidth || resized.height != lastHeight)) {
out.trySend(ConsoleEvent.Resize(resized.width, resized.height))
lastWidth = resized.width
lastHeight = resized.height
}
when (ev) {
is KeyboardEvent -> {
keyEvents += 1
lastKeyMark = TimeSource.Monotonic.markNow()
out.trySend(
ConsoleEvent.KeyDown(
key = ev.key,
code = null,
ctrl = ev.ctrl,
alt = ev.alt,
shift = ev.shift,
meta = false,
)
)
}
else -> {
// Mouse/other events are ignored in Lyng console v1.
}
}
// Some terminals silently stop delivering keyboard events while raw reads keep succeeding.
// If we had keys before and then prolonged key inactivity, proactively recycle raw scope.
if (keyEvents > 0L &&
lastKeyMark.elapsedNow() >= 4.seconds &&
lastRawRecoveryMark.elapsedNow() >= 4.seconds
) {
if (rawModeRequested) {
consoleFlowDebug("events: key inactivity detected; forcing raw reset")
val resetOk = forceRawModeReset(t)
if (resetOk) {
consoleFlowDebug("events: raw reset succeeded during inactivity recovery")
lastKeyMark = TimeSource.Monotonic.markNow()
} else {
consoleFlowDebug("events: raw reset failed during inactivity recovery")
}
lastRawRecoveryMark = TimeSource.Monotonic.markNow()
}
}
if (lastHeartbeatMark.elapsedNow() >= 2.seconds) {
consoleFlowDebug(
"events: heartbeat loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested inputInteractive=${t.terminalInfo.inputInteractive}"
)
lastHeartbeatMark = TimeSource.Monotonic.markNow()
}
}
} catch (e: CancellationException) {
consoleFlowDebug("events: collector cancelled (normal)")
// normal
} catch (e: ScriptFlowIsNoMoreCollected) {
consoleFlowDebug("events: collector stopped by flow consumer (normal)")
// normal
} catch (e: Exception) {
consoleFlowDebug("events: collector loop failed", e)
// terminate event source loop
} finally {
consoleFlowDebug(
"events: collector ended uptime=${startMark.elapsedNow().inWholeMilliseconds}ms loops=$loops reads=$readAttempts readFailures=$readFailures keys=$keyEvents resize=$resizeEvents rawNullLoops=$rawNullLoops rawRequested=$rawModeRequested"
)
out.close()
}
}
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
if (timeoutMs <= 0L) {
return out.receiveCatching().getOrNull()
}
return withTimeoutOrNull(timeoutMs.milliseconds) {
out.receiveCatching().getOrNull()
}
}
override suspend fun close() {
sourceState.withLock { running = false }
out.close()
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
val t = terminal ?: return false
return stateMutex.withLock {
if (enabled) {
if (!t.terminalInfo.inputInteractive) return@withLock false
if (rawModeRequested) return@withLock false
val scope = t.enterRawModeOrNull() ?: return@withLock false
rawModeScope = scope
rawModeRequested = true
consoleFlowDebug("setRawMode(true): enabled")
true
} else {
val hadRaw = rawModeRequested || rawModeScope != null
rawModeRequested = false
val scope = rawModeScope
rawModeScope = null
runCatching { scope?.close() }
.onFailure { consoleFlowDebug("setRawMode(false): close failed", it) }
consoleFlowDebug("setRawMode(false): disabled hadRaw=$hadRaw")
hadRaw
}
}
}
}
@@ -0,0 +1,23 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console
/**
* Get the system default console implementation.
*/
expect fun getSystemConsole(): LyngConsole
@@ -0,0 +1,55 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console.security
import net.sergeych.lyngio.fs.security.AccessContext
import net.sergeych.lyngio.fs.security.AccessDecision
import net.sergeych.lyngio.fs.security.Decision
/**
* Primitive console operations for access control decisions.
*/
sealed interface ConsoleAccessOp {
data class WriteText(val length: Int) : ConsoleAccessOp
data object ReadEvents : ConsoleAccessOp
data class SetRawMode(val enabled: Boolean) : ConsoleAccessOp
}
class ConsoleAccessDeniedException(
val op: ConsoleAccessOp,
val reasonDetail: String? = null,
) : IllegalStateException("Console access denied for $op" + (reasonDetail?.let { ": $it" } ?: ""))
/**
* Policy interface that decides whether a specific console operation is allowed.
*/
interface ConsoleAccessPolicy {
suspend fun check(op: ConsoleAccessOp, ctx: AccessContext = AccessContext()): AccessDecision
suspend fun require(op: ConsoleAccessOp, ctx: AccessContext = AccessContext()) {
val res = check(op, ctx)
if (!res.isAllowed()) throw ConsoleAccessDeniedException(op, res.reason)
}
}
object PermitAllConsoleAccessPolicy : ConsoleAccessPolicy {
override suspend fun check(op: ConsoleAccessOp, ctx: AccessContext): AccessDecision =
AccessDecision(Decision.Allow)
}
@@ -0,0 +1,68 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.console.security
import net.sergeych.lyngio.console.*
import net.sergeych.lyngio.fs.security.AccessContext
/**
* Decorator that applies a [ConsoleAccessPolicy] to a delegate [LyngConsole].
*/
class LyngConsoleSecured(
private val delegate: LyngConsole,
private val policy: ConsoleAccessPolicy,
private val ctx: AccessContext = AccessContext(),
) : LyngConsole {
override val isSupported: Boolean
get() = delegate.isSupported
override suspend fun isTty(): Boolean = delegate.isTty()
override suspend fun geometry(): ConsoleGeometry? = delegate.geometry()
override suspend fun ansiLevel(): ConsoleAnsiLevel = delegate.ansiLevel()
override suspend fun write(text: String) {
policy.require(ConsoleAccessOp.WriteText(text.length), ctx)
delegate.write(text)
}
override suspend fun flush() {
delegate.flush()
}
override fun events(): ConsoleEventSource {
val source = delegate.events()
return object : ConsoleEventSource {
override suspend fun nextEvent(timeoutMs: Long): ConsoleEvent? {
policy.require(ConsoleAccessOp.ReadEvents, ctx)
return source.nextEvent(timeoutMs)
}
override suspend fun close() {
source.close()
}
}
}
override suspend fun setRawMode(enabled: Boolean): Boolean {
policy.require(ConsoleAccessOp.SetRawMode(enabled), ctx)
return delegate.setRawMode(enabled)
}
}
@@ -0,0 +1,264 @@
/*
* Copyright 2026 Sergey S. Chernov real.sergeych@gmail.com
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
package net.sergeych.lyngio.docs
import net.sergeych.lyng.miniast.BuiltinDocRegistry
import net.sergeych.lyng.miniast.ParamDoc
import net.sergeych.lyng.miniast.type
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
}
}