Skip to content

Commit

Permalink
Add raw mode support to JS and wasm
Browse files Browse the repository at this point in the history
  • Loading branch information
ajalt committed Aug 20, 2024
1 parent f4c9daa commit 18ed685
Show file tree
Hide file tree
Showing 17 changed files with 204 additions and 141 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
- Added new optional methods to `TerminalInterface` to control raw mode: `getTerminalSize`, `readInputEvent`, `enterRawMode`, and `shouldAutoUpdateSize`.
- Added new terminal implementation that uses the [Foreign Function and Memory (FFM) API](https://openjdk.java.net/jeps/419) added in JDK 22.
- Split the library up into modules, so you can produce smaller JVM artifacts by only using the parts you need.
- Added support for raw mode and input events to JS and wasmJS targets when running on node.js.

### Changed
- **Breaking Change** Moved `Terminal.info.width` and `height` to `Terminal.size.width` and `height`.
Expand Down
17 changes: 17 additions & 0 deletions buildSrc/src/main/kotlin/mordant-js-sample-conventions.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl

plugins {
kotlin("multiplatform")
}

kotlin {
js {
nodejs()
binaries.executable()
}
@OptIn(ExperimentalWasmDsl::class)
wasmJs {
nodejs()
binaries.executable()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
plugins {
id("mordant-jvm-sample-conventions")
id("mordant-native-sample-conventions")
id("mordant-js-sample-conventions")
}

kotlin {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,6 @@ kotlin {

// https://kotlinlang.org/docs/multiplatform-hierarchy.html#see-the-full-hierarchy-template
sourceSets {
val nonJsMain by creating { dependsOn(commonMain.get()) }
for (target in listOf(jvmMain, nativeMain)) {
target.get().dependsOn(nonJsMain)
}
val posixMain by creating { dependsOn(nativeMain.get()) }
linuxMain.get().dependsOn(posixMain)
appleMain.get().dependsOn(posixMain)
Expand Down
8 changes: 5 additions & 3 deletions docs/input.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,9 +162,11 @@ app and operating system. Some things to keep in mind:
- On Linux and macOS, the Escape key isn't reported as a key press; instead, it begins a "VTI escape
sequence" that the terminal uses to report key presses. For example if you press `Escape`, then `[`,
then `d`, the terminal will report that as the left arrow key being pressed. It's up to you whether
you consider this a feature or a limitation.
- Raw mode isn't supported on JS or wasmJS targets. You can use Node.js's `readline` module to read
input in a similar way, or in the browser you can use the `keydown` and `mousedown` events.
you consider this a feature or a limitation.
- Raw mode is supported on JS or wasmJS targets on node.js only. The `timeout` parameter is ignored
when reading events and the call will block until a key is read. You can also use Node.js's
`readline` module to read input with callbacks instead of blocking, or in the browser you can use
the `keydown` and `mousedown` events.

## Interactive List Selection

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,137 +3,23 @@ package com.github.ajalt.mordant.terminal.terminalinterface
import com.github.ajalt.mordant.input.InputEvent
import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.input.MouseEvent
import com.github.ajalt.mordant.input.MouseTracking
import com.github.ajalt.mordant.internal.CSI
import com.github.ajalt.mordant.internal.readBytesAsUtf8
import com.github.ajalt.mordant.terminal.StandardTerminalInterface
import kotlin.time.ComparableTimeMark
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.TimeSource

// TODO: docs
@Suppress("unused", "SpellCheckingInspection")
abstract class TerminalInterfacePosix : StandardTerminalInterface() {
protected companion object {
const val STDIN_FILENO = 0
const val STDOUT_FILENO = 1
const val STDERR_FILENO = 2

private const val ESC = '\u001b'

val MacosTermiosConstants = TermiosConstants(
VTIME = 17,
VMIN = 16,
INPCK = 0x00000010u,
ISTRIP = 0x00000020u,
INLCR = 0x00000040u,
IGNCR = 0x00000080u,
ICRNL = 0x00000100u,
IXON = 0x00000200u,
OPOST = 0x00000001u,
CS8 = 0x00000300u,
ISIG = 0x00000080u,
ICANON = 0x00000100u,
ECHO = 0x00000008u,
IEXTEN = 0x00000400u,
)
private const val ESC = '\u001b'

val LinuxTermiosConstants = TermiosConstants(
VTIME = 5,
VMIN = 6,
INPCK = 0x0000010u,
ISTRIP = 0x0000020u,
INLCR = 0x0000040u,
IGNCR = 0x0000080u,
ICRNL = 0x0000100u,
IXON = 0x0000400u,
OPOST = 0x0000001u,
CS8 = 0x0000030u,
ISIG = 0x0000001u,
ICANON = 0x0000002u,
ECHO = 0x0000008u,
IEXTEN = 0x0008000u,
)
}

@Suppress("ArrayInDataClass")
data class Termios(
val iflag: UInt,
val oflag: UInt,
val cflag: UInt,
val lflag: UInt,
val cc: ByteArray,
)

/**
* Constants for termios flags and control characters.
*
* The values for these are platform-specific.
* https://www.man7.org/linux/man-pages/man3/termios.3.html
*/
@Suppress("PropertyName")
data class TermiosConstants(
val VTIME: Int,
val VMIN: Int,
val INPCK: UInt,
val ISTRIP: UInt,
val INLCR: UInt,
val IGNCR: UInt,
val ICRNL: UInt,
val IXON: UInt,
val OPOST: UInt,
val CS8: UInt,
val ISIG: UInt,
val ICANON: UInt,
val ECHO: UInt,
val IEXTEN: UInt,
)


abstract fun getStdinTermios(): Termios
abstract fun setStdinTermios(termios: Termios)
abstract val termiosConstants: TermiosConstants
protected abstract fun isatty(fd: Int): Boolean
protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char

override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO)
override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO)

// https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking
override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
val orig = getStdinTermios()
val c = termiosConstants
val new = Termios(
iflag = orig.iflag and (c.ICRNL or c.IGNCR or c.INPCK or c.ISTRIP or c.IXON).inv(),
// we leave OPOST on so we don't change \r\n handling
oflag = orig.oflag,
cflag = orig.cflag or c.CS8,
lflag = orig.lflag and (c.ECHO or c.ICANON or c.IEXTEN or c.ISIG).inv(),
cc = orig.cc.copyOf().also {
it[c.VMIN] = 0 // min wait time on read
it[c.VTIME] = 1 // max wait time on read, in 10ths of a second
},
)
setStdinTermios(new)
when (mouseTracking) {
MouseTracking.Off -> {}
MouseTracking.Normal -> print("${CSI}?1005h${CSI}?1000h")
MouseTracking.Button -> print("${CSI}?1005h${CSI}?1002h")
MouseTracking.Any -> print("${CSI}?1005h${CSI}?1003h")
}
return AutoCloseable {
if (mouseTracking != MouseTracking.Off) print("${CSI}?1000l")
setStdinTermios(orig)
}
}

internal class PosixEventParser(
private val readRawByte: (t0: ComparableTimeMark, timeout: Duration) -> Char
) {
/*
Some patterns seen in terminal key escape codes, derived from combos seen
at https://github.com/nodejs/node/blob/main/lib/internal/readline/utils.js
*/
override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
fun readInputEvent(timeout: Duration): InputEvent {
val t0 = TimeSource.Monotonic.markNow()
var ctrl = false
var alt = false
Expand Down Expand Up @@ -486,4 +372,3 @@ abstract class TerminalInterfacePosix : StandardTerminalInterface() {
return readBytesAsUtf8 { readRawByte(t0, timeout).code }
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
package com.github.ajalt.mordant.terminal.terminalinterface

import com.github.ajalt.mordant.input.InputEvent
import com.github.ajalt.mordant.input.MouseTracking
import com.github.ajalt.mordant.internal.CSI
import com.github.ajalt.mordant.terminal.StandardTerminalInterface
import kotlin.time.ComparableTimeMark
import kotlin.time.Duration

// TODO: docs
@Suppress("unused", "SpellCheckingInspection")
abstract class TerminalInterfacePosix : StandardTerminalInterface() {
protected companion object {
const val STDIN_FILENO = 0
const val STDOUT_FILENO = 1
const val STDERR_FILENO = 2

val MacosTermiosConstants = TermiosConstants(
VTIME = 17,
VMIN = 16,
INPCK = 0x00000010u,
ISTRIP = 0x00000020u,
INLCR = 0x00000040u,
IGNCR = 0x00000080u,
ICRNL = 0x00000100u,
IXON = 0x00000200u,
OPOST = 0x00000001u,
CS8 = 0x00000300u,
ISIG = 0x00000080u,
ICANON = 0x00000100u,
ECHO = 0x00000008u,
IEXTEN = 0x00000400u,
)

val LinuxTermiosConstants = TermiosConstants(
VTIME = 5,
VMIN = 6,
INPCK = 0x0000010u,
ISTRIP = 0x0000020u,
INLCR = 0x0000040u,
IGNCR = 0x0000080u,
ICRNL = 0x0000100u,
IXON = 0x0000400u,
OPOST = 0x0000001u,
CS8 = 0x0000030u,
ISIG = 0x0000001u,
ICANON = 0x0000002u,
ECHO = 0x0000008u,
IEXTEN = 0x0008000u,
)
}

@Suppress("ArrayInDataClass")
data class Termios(
val iflag: UInt,
val oflag: UInt,
val cflag: UInt,
val lflag: UInt,
val cc: ByteArray,
)

/**
* Constants for termios flags and control characters.
*
* The values for these are platform-specific.
* https://www.man7.org/linux/man-pages/man3/termios.3.html
*/
@Suppress("PropertyName")
data class TermiosConstants(
val VTIME: Int,
val VMIN: Int,
val INPCK: UInt,
val ISTRIP: UInt,
val INLCR: UInt,
val IGNCR: UInt,
val ICRNL: UInt,
val IXON: UInt,
val OPOST: UInt,
val CS8: UInt,
val ISIG: UInt,
val ICANON: UInt,
val ECHO: UInt,
val IEXTEN: UInt,
)


abstract fun getStdinTermios(): Termios
abstract fun setStdinTermios(termios: Termios)
abstract val termiosConstants: TermiosConstants
protected abstract fun isatty(fd: Int): Boolean
protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char

override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO)
override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO)

// https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html
// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking
override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
val orig = getStdinTermios()
val c = termiosConstants
val new = Termios(
iflag = orig.iflag and (c.ICRNL or c.IGNCR or c.INPCK or c.ISTRIP or c.IXON).inv(),
// we leave OPOST on so we don't change \r\n handling
oflag = orig.oflag,
cflag = orig.cflag or c.CS8,
lflag = orig.lflag and (c.ECHO or c.ICANON or c.IEXTEN or c.ISIG).inv(),
cc = orig.cc.copyOf().also {
it[c.VMIN] = 0 // min wait time on read
it[c.VTIME] = 1 // max wait time on read, in 10ths of a second
},
)
setStdinTermios(new)
when (mouseTracking) {
MouseTracking.Off -> {}
MouseTracking.Normal -> print("${CSI}?1005h${CSI}?1000h")
MouseTracking.Button -> print("${CSI}?1005h${CSI}?1002h")
MouseTracking.Any -> print("${CSI}?1005h${CSI}?1003h")
}
return AutoCloseable {
if (mouseTracking != MouseTracking.Off) print("${CSI}?1000l")
setStdinTermios(orig)
}
}

override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
return PosixEventParser { t0, t -> readRawByte(t0, t) }.readInputEvent(timeout)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,6 @@ internal abstract class TerminalInterfaceJsCommon: StandardTerminalInterface() {
abstract fun exitProcess(status: Int)
abstract fun readFileIfExists(filename: String): String?

// The public interface never is in nonJsMain, so these will never be called
override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
throw UnsupportedOperationException("Reading keyboard is not supported on this platform")
}
override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable {
throw UnsupportedOperationException("Raw mode is not supported on this platform")
}
}

internal abstract class TerminalInterfaceNode<BufferT> : TerminalInterfaceJsCommon() {
Expand All @@ -33,11 +26,7 @@ internal abstract class TerminalInterfaceNode<BufferT> : TerminalInterfaceJsComm
buildString {
val buf = allocBuffer(1)
do {
val len = readSync(
fd = 0, buffer = buf, offset = 0, len = 1
)
if (len == 0) break
val char = "$buf" // don't call toString here due to KT-55817
val char = readByteWithBuf(buf) ?: break
append(char)
} while (char != "\n" && char != "${0.toChar()}")
}
Expand All @@ -47,7 +36,23 @@ internal abstract class TerminalInterfaceNode<BufferT> : TerminalInterfaceJsComm
}

abstract fun allocBuffer(size: Int): BufferT
abstract fun bufferToString(buffer: BufferT): String
abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int

override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? {
return PosixEventParser { _, _ ->
val buf = allocBuffer(1)
readByteWithBuf(buf)?.let { it[0] }
?: throw RuntimeException("Failed reading from stdin")
}.readInputEvent(timeout)
}

private fun readByteWithBuf(buf: BufferT): String? {
val len = readSync(fd = 0, buffer = buf, offset = 0, len = 1)
if (len == 0) return null
// don't call kotlin's toString here due to KT-55817
return bufferToString(buf)
}
}

internal object TerminalInterfaceBrowser : TerminalInterfaceJsCommon() {
Expand All @@ -65,6 +70,7 @@ internal object TerminalInterfaceBrowser : TerminalInterfaceJsCommon() {
}

override fun readFileIfExists(filename: String): String? = null

}

// There are no shutdown hooks on browsers, so we don't need to do anything here
Expand Down
Loading

0 comments on commit 18ed685

Please sign in to comment.