Skip to content

Commit

Permalink
Speed up raw mode readRawByte (#221)
Browse files Browse the repository at this point in the history
  • Loading branch information
ajalt authored Sep 1, 2024
1 parent 06d0e35 commit 564c55e
Show file tree
Hide file tree
Showing 8 changed files with 54 additions and 55 deletions.
4 changes: 2 additions & 2 deletions mordant/api/mordant.api
Original file line number Diff line number Diff line change
Expand Up @@ -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 <init> ()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 {
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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")
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,8 @@ internal abstract class TerminalInterfaceNode<BufferT> : 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)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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 }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ByteVar>()
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<ByteVar>()
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
Expand Down

0 comments on commit 564c55e

Please sign in to comment.