From 564c55eb10772bb8d29a871df4cf249aff47fbfc Mon Sep 17 00:00:00 2001 From: AJ Alt Date: Sun, 1 Sep 2024 19:28:26 +0000 Subject: [PATCH] Speed up raw mode readRawByte (#221) --- mordant/api/mordant.api | 4 +- .../com/github/ajalt/mordant/internal/Utf8.kt | 6 +-- .../mordant/terminal/TerminalInterface.kt | 2 + .../terminalinterface/PosixEventParser.kt | 53 ++++++++++--------- .../TerminalInterface.posix.kt | 10 +++- .../TerminalInterface.jsCommon.kt | 9 +--- .../TerminalInterface.jvm.posix.kt | 10 +--- .../TerminalInterface.native.posix.kt | 15 +++--- 8 files changed, 54 insertions(+), 55 deletions(-) diff --git a/mordant/api/mordant.api b/mordant/api/mordant.api index e2abc5f2..afb0e1c6 100644 --- a/mordant/api/mordant.api +++ b/mordant/api/mordant.api @@ -1355,7 +1355,7 @@ public final class com/github/ajalt/mordant/terminal/YesNoPrompt : com/github/aj public abstract class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfaceJvmPosix : com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix { public fun ()V - protected fun readRawByte (Lkotlin/time/TimeMark;)I + protected fun readRawByte ()Ljava/lang/Integer; } public abstract class com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix : com/github/ajalt/mordant/terminal/StandardTerminalInterface { @@ -1369,7 +1369,7 @@ public abstract class com/github/ajalt/mordant/terminal/terminalinterface/Termin public abstract fun getTermiosConstants ()Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$TermiosConstants; protected abstract fun isatty (I)Z public fun readInputEvent (Lkotlin/time/TimeMark;Lcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/InputEvent; - protected abstract fun readRawByte (Lkotlin/time/TimeMark;)I + protected abstract fun readRawByte ()Ljava/lang/Integer; public abstract fun setStdinTermios (Lcom/github/ajalt/mordant/terminal/terminalinterface/TerminalInterfacePosix$Termios;)V public fun stdinInteractive ()Z public fun stdoutInteractive ()Z diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt index 05346725..d3fd7e0c 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt @@ -1,8 +1,8 @@ package com.github.ajalt.mordant.internal /** Read bytes from a UTF-8 encoded stream, and return the next codepoint. */ -internal fun readBytesAsUtf8(readByte: () -> Int): Int { - val byte = readByte() +internal fun readBytesAsUtf8(readByte: () -> Int?): Int? { + val byte = readByte() ?: return null val byteLength: Int var codepoint: Int when { @@ -29,7 +29,7 @@ internal fun readBytesAsUtf8(readByte: () -> Int): Int { } repeat(byteLength - 1) { - val next = readByte() + val next = readByte() ?: return null if (next and 0b1100_0000 != 0b1000_0000) error("Invalid UTF-8 byte") codepoint = codepoint shl 6 or (next and 0b0011_1111) } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt index 4b1f7cc6..cab2c16a 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalInterface.kt @@ -4,6 +4,7 @@ import com.github.ajalt.mordant.input.InputEvent import com.github.ajalt.mordant.input.MouseTracking import com.github.ajalt.mordant.rendering.AnsiLevel import com.github.ajalt.mordant.rendering.Size +import kotlin.jvm.JvmInline import kotlin.time.Duration import kotlin.time.TimeMark @@ -53,6 +54,7 @@ interface TerminalInterface { * @param timeout The point in time that this call should block to while waiting for an event. * If the timeout is in the past, this method should not block. * @param mouseTracking The current mouse tracking mode. + * @return The event, or `null` if no event is available but this call should be retried */ fun readInputEvent(timeout: TimeMark, mouseTracking: MouseTracking): InputEvent? { throw NotImplementedError("Reading input events is not supported on this terminal") diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt index f4d2a539..5bcd1f2c 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/PosixEventParser.kt @@ -13,13 +13,21 @@ private const val ESC = '\u001b' internal class PosixEventParser( - private val readRawByte: (timeout: TimeMark) -> Int, + private val readRawByte: () -> Int?, ) { + private fun readRawByte(timeout: TimeMark): Int? { + do { + val byte = readRawByte() + if (byte != null) return byte + } while (timeout.hasNotPassedNow()) + return null + } + /* 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 */ - fun readInputEvent(timeout: TimeMark): InputEvent { + fun readInputEvent(timeout: TimeMark): InputEvent? { var ctrl = false var alt = false var shift = false @@ -28,12 +36,13 @@ internal class PosixEventParser( val s = StringBuilder() var ch: Char - fun read() { - ch = readRawByte(timeout).toChar() + fun read(): Char? { + ch = readRawByte(timeout)?.toChar() ?: return null s.append(ch) + return ch } - val first = codepointToString(readUtf8Int(timeout)) + val first = codepointToString(readUtf8Int(timeout) ?: return null) if (first.length > 1) { return KeyboardEvent(first) // Got a utf8 char like an emoji } else { @@ -42,11 +51,7 @@ internal class PosixEventParser( if (ch == ESC) { escaped = true - try { - read() - } catch (e: RuntimeException) { - return KeyboardEvent("Escape") - } + read() ?: return KeyboardEvent("Escape") if (ch == ESC) { return KeyboardEvent("Escape") } @@ -60,11 +65,11 @@ internal class PosixEventParser( if (ch == 'O') { // ESC O letter // ESC O modifier letter - read() + read() ?: return null if (ch in '0'..'9') { modifier = ch.code - 1 - read() + read() ?: return null } code.append(ch) @@ -75,12 +80,12 @@ internal class PosixEventParser( // ESC [ [ num char // For mouse events: // ESC [ M byte byte byte - read() + read() ?: return null if (ch == '[') { // escape codes might have a second bracket code.append(ch) - read() + read() ?: return null } else if (ch == 'M') { // mouse event return processMouseEvent(timeout) @@ -91,16 +96,16 @@ internal class PosixEventParser( // leading digits repeat(3) { if (ch in '0'..'9') { - read() + read() ?: return null } } // modifier if (ch == ';') { - read() + read() ?: return null if (ch in '0'..'9') { - read() + read() ?: return null } } @@ -342,17 +347,17 @@ internal class PosixEventParser( ) } - private fun processMouseEvent(timeout: TimeMark): InputEvent { + private fun processMouseEvent(timeout: TimeMark): InputEvent? { // Mouse event coordinates are raw values, not decimal text, and they're sometimes utf-8 // encoded to fit larger values. - val cb = readUtf8Int(timeout) - val cx = readUtf8Int(timeout) - 33 + val cb = readUtf8Int(timeout) ?: return null + val cx = (readUtf8Int(timeout) ?: return null) - 33 // XXX: I've seen the terminal not send the third byte like `ESC [ M # W`, but I can't find // that pattern documented anywhere, so maybe it's an issue with the terminal emulator not // encoding utf8 correctly? - val cy = runCatching { - readUtf8Int(TimeSource.Monotonic.markNow() + 1.milliseconds) - 33 - }.getOrElse { 0 } + val cy = readUtf8Int(TimeSource.Monotonic.markNow() + 1.milliseconds).let { + if (it == null) 0 else it - 33 + } val shift = (cb and 4) != 0 val alt = (cb and 8) != 0 val ctrl = (cb and 16) != 0 @@ -373,7 +378,7 @@ internal class PosixEventParser( } /** Read one utf-8 encoded codepoint from the input stream. */ - private fun readUtf8Int(timeout: TimeMark): Int { + private fun readUtf8Int(timeout: TimeMark): Int? { return readBytesAsUtf8 { readRawByte(timeout) } } } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt index 0cb59dd6..ad40c8eb 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.posix.kt @@ -92,7 +92,13 @@ abstract class TerminalInterfacePosix : StandardTerminalInterface() { abstract fun setStdinTermios(termios: Termios) abstract val termiosConstants: TermiosConstants protected abstract fun isatty(fd: Int): Boolean - protected abstract fun readRawByte(timeout: TimeMark): Int + + /** + * Read a byte from the terminal, or `null` if no byte is available. + * + * This will only be called while raw mode is active. + */ + protected abstract fun readRawByte(): Int? override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) @@ -127,7 +133,7 @@ abstract class TerminalInterfacePosix : StandardTerminalInterface() { } override fun readInputEvent(timeout: TimeMark, mouseTracking: MouseTracking): InputEvent? { - return PosixEventParser { t -> readRawByte(t) }.readInputEvent(timeout) + return PosixEventParser { readRawByte() }.readInputEvent(timeout) } } diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt index 36f95ac7..7ec88d0b 100644 --- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jsCommon.kt @@ -40,13 +40,8 @@ internal abstract class TerminalInterfaceNode : TerminalInterfaceJsComm abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int override fun readInputEvent(timeout: TimeMark, mouseTracking: MouseTracking): InputEvent? { - return PosixEventParser { t -> - val buf = allocBuffer(1) - do { - val c = readByteWithBuf(buf)?.let { it[0].code } - if (c != null) return@PosixEventParser c - } while (t.hasNotPassedNow()) - throw RuntimeException("Timeout reading from stdin (timeout=${timeout.elapsedNow()})") + return PosixEventParser { + readByteWithBuf(allocBuffer(1))?.let { it[0].code } }.readInputEvent(timeout) } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt index 01264fb1..521b4375 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.jvm.posix.kt @@ -1,16 +1,10 @@ package com.github.ajalt.mordant.terminal.terminalinterface -import kotlin.time.TimeMark - /** * A base class for terminal interfaces for JVM POSIX systems that uses `System.in` for input. */ abstract class TerminalInterfaceJvmPosix : TerminalInterfacePosix() { - override fun readRawByte(timeout: TimeMark): Int { - do { - val c = System.`in`.read() - if (c >= 0) return c - } while (timeout.hasNotPassedNow()) - throw RuntimeException("Timeout reading from stdin (timeout=$timeout)") + override fun readRawByte(): Int? { + return System.`in`.read().takeUnless { it <= 0 } } } diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt index 5464e624..4c0af99a 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/terminal/terminalinterface/TerminalInterface.native.posix.kt @@ -4,21 +4,18 @@ import kotlinx.cinterop.ByteVar import kotlinx.cinterop.alloc import kotlinx.cinterop.memScoped import kotlinx.cinterop.value -import kotlin.time.TimeMark internal abstract class TerminalInterfaceNativePosix : TerminalInterfacePosix() { override fun isatty(fd: Int): Boolean { return platform.posix.isatty(fd) != 0 } - override fun readRawByte(timeout: TimeMark): Int = memScoped { - do { - val c = alloc() - val read = readIntoBuffer(c) - if (read < 0) throw RuntimeException("Error reading from stdin") - if (read > 0) return c.value.toInt() - } while (timeout.hasNotPassedNow()) - throw RuntimeException("Timeout reading from stdin (timeout=$timeout)") + override fun readRawByte(): Int? = memScoped { + val c = alloc() + val read = readIntoBuffer(c) + if (read < 0) throw RuntimeException("Error reading from stdin") + if (read > 0) return c.value.toInt() + return null } // `read` has different byte widths on linux and apple