From 772a23ee9437be8b5a32372ba62dfb3fa61996c1 Mon Sep 17 00:00:00 2001 From: AJ Date: Wed, 29 May 2024 11:11:43 -0700 Subject: [PATCH 01/45] Add raw mode to windows JNA --- .../internal/WindowsScanCodeToKeyEvent.kt | 150 ++++++++++ .../WindowsVirtualKeyCodeToKeyEvent.kt | 119 ++++++++ .../mordant/internal/jna/JnaWin32MppImpls.kt | 268 +++++++++++++++++- 3 files changed, 527 insertions(+), 10 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt new file mode 100644 index 000000000..e016fee8a --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt @@ -0,0 +1,150 @@ +package com.example + +// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values#code_values_on_windows +object WindowsScanCodeToKeyEvent { + private val map = mapOf( + (0x0001).toShort() to "Escape", + (0x0002).toShort() to "Digit1", + (0x0003).toShort() to "Digit2", + (0x0004).toShort() to "Digit3", + (0x0005).toShort() to "Digit4", + (0x0006).toShort() to "Digit5", + (0x0007).toShort() to "Digit6", + (0x0008).toShort() to "Digit7", + (0x0009).toShort() to "Digit8", + (0x000A).toShort() to "Digit9", + (0x000B).toShort() to "Digit0", + (0x000C).toShort() to "Minus", + (0x000D).toShort() to "Equal", + (0x000E).toShort() to "Backspace", + (0x000F).toShort() to "Tab", + (0x0010).toShort() to "KeyQ", + (0x0011).toShort() to "KeyW", + (0x0012).toShort() to "KeyE", + (0x0013).toShort() to "KeyR", + (0x0014).toShort() to "KeyT", + (0x0015).toShort() to "KeyY", + (0x0016).toShort() to "KeyU", + (0x0017).toShort() to "KeyI", + (0x0018).toShort() to "KeyO", + (0x0019).toShort() to "KeyP", + (0x001A).toShort() to "BracketLeft", + (0x001B).toShort() to "BracketRight", + (0x001C).toShort() to "Enter", + (0x001D).toShort() to "ControlLeft", + (0x001E).toShort() to "KeyA", + (0x001F).toShort() to "KeyS", + (0x0020).toShort() to "KeyD", + (0x0021).toShort() to "KeyF", + (0x0022).toShort() to "KeyG", + (0x0023).toShort() to "KeyH", + (0x0024).toShort() to "KeyJ", + (0x0025).toShort() to "KeyK", + (0x0026).toShort() to "KeyL", + (0x0027).toShort() to "Semicolon", + (0x0028).toShort() to "Quote", + (0x0029).toShort() to "Backquote", + (0x002A).toShort() to "ShiftLeft", + (0x002B).toShort() to "Backslash", + (0x002C).toShort() to "KeyZ", + (0x002D).toShort() to "KeyX", + (0x002E).toShort() to "KeyC", + (0x002F).toShort() to "KeyV", + (0x0030).toShort() to "KeyB", + (0x0031).toShort() to "KeyN", + (0x0032).toShort() to "KeyM", + (0x0033).toShort() to "Comma", + (0x0034).toShort() to "Period", + (0x0035).toShort() to "Slash", + (0x0036).toShort() to "ShiftRight", + (0x0037).toShort() to "NumpadMultiply", + (0x0038).toShort() to "AltLeft", + (0x0039).toShort() to "Space", + (0x003A).toShort() to "CapsLock", + (0x003B).toShort() to "F1", + (0x003C).toShort() to "F2", + (0x003D).toShort() to "F3", + (0x003E).toShort() to "F4", + (0x003F).toShort() to "F5", + (0x0040).toShort() to "F6", + (0x0041).toShort() to "F7", + (0x0042).toShort() to "F8", + (0x0043).toShort() to "F9", + (0x0044).toShort() to "F10", + (0x0045).toShort() to "Pause", + (0x0046).toShort() to "ScrollLock", + (0x0047).toShort() to "Numpad7", + (0x0048).toShort() to "Numpad8", + (0x0049).toShort() to "Numpad9", + (0x004A).toShort() to "NumpadSubtract", + (0x004B).toShort() to "Numpad4", + (0x004C).toShort() to "Numpad5", + (0x004D).toShort() to "Numpad6", + (0x004E).toShort() to "NumpadAdd", + (0x004F).toShort() to "Numpad1", + (0x0050).toShort() to "Numpad2", + (0x0051).toShort() to "Numpad3", + (0x0052).toShort() to "Numpad0", + (0x0053).toShort() to "NumpadDecimal", + (0x0056).toShort() to "IntlBackslash", + (0x0057).toShort() to "F11", + (0x0058).toShort() to "F12", + (0x0059).toShort() to "NumpadEqual", + (0x0064).toShort() to "F13", + (0x0065).toShort() to "F14", + (0x0066).toShort() to "F15", + (0x0067).toShort() to "F16", + (0x0068).toShort() to "F17", + (0x0069).toShort() to "F18", + (0x006A).toShort() to "F19", + (0x006B).toShort() to "F20", + (0x006C).toShort() to "F21", + (0x006D).toShort() to "F22", + (0x006E).toShort() to "F23", + (0x0070).toShort() to "KanaMode", + (0x0073).toShort() to "IntlRo", + (0x0076).toShort() to "F24", + (0x0079).toShort() to "Convert", + (0x007B).toShort() to "NonConvert", + (0x007D).toShort() to "IntlYen", + (0x007E).toShort() to "NumpadComma", + (0xE010).toShort() to "MediaTrackPrevious", + (0xE019).toShort() to "MediaTrackNext", + (0xE01C).toShort() to "NumpadEnter", + (0xE01D).toShort() to "ControlRight", + (0xE020).toShort() to "AudioVolumeMute", + (0xE021).toShort() to "LaunchApp2", + (0xE022).toShort() to "MediaPlayPause", + (0xE024).toShort() to "MediaStop", + (0xE032).toShort() to "BrowserHome", + (0xE035).toShort() to "NumpadDivide", + (0xE037).toShort() to "PrintScreen", + (0xE038).toShort() to "AltRight", + (0xE045).toShort() to "NumLock", + (0xE047).toShort() to "Home", + (0xE048).toShort() to "ArrowUp", + (0xE049).toShort() to "PageUp", + (0xE04B).toShort() to "ArrowLeft", + (0xE04D).toShort() to "ArrowRight", + (0xE04F).toShort() to "End", + (0xE050).toShort() to "ArrowDown", + (0xE051).toShort() to "PageDown", + (0xE052).toShort() to "Insert", + (0xE053).toShort() to "Delete", + (0xE05D).toShort() to "ContextMenu", + (0xE05E).toShort() to "Power", + (0xE065).toShort() to "BrowserSearch", + (0xE066).toShort() to "BrowserFavorites", + (0xE067).toShort() to "BrowserRefresh", + (0xE068).toShort() to "BrowserStop", + (0xE069).toShort() to "BrowserForward", + (0xE06A).toShort() to "BrowserBack", + (0xE06B).toShort() to "LaunchApp1", + (0xE06C).toShort() to "LaunchMail", + (0xE06D).toShort() to "MediaSelect", + ) + + fun getName(scanCode: Short): String { + return map[scanCode] ?: "Unidentified" + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt new file mode 100644 index 000000000..a10cdebe9 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt @@ -0,0 +1,119 @@ +package com.example + +// https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes +// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values +object WindowsVirtualKeyCodeToKeyEvent { + private val map = mapOf( + (0x12).toShort() to "Alt", // VK_MENU + (0x14).toShort() to "CapsLock", // VK_CAPITAL + (0x11).toShort() to "Control", // VK_CONTROL + (0x5B).toShort() to "Meta", // VK_LWIN + (0x90).toShort() to "NumLock", // VK_NUMLOCK + (0x91).toShort() to "ScrollLock", // VK_SCROLL + (0x10).toShort() to "Shift", // VK_SHIFT + (0x0D).toShort() to "Enter", // VK_RETURN + (0x09).toShort() to "Tab", // VK_TAB + (0x20).toShort() to " ", // VK_SPACE + (0x28).toShort() to "ArrowDown", // VK_DOWN + (0x25).toShort() to "ArrowLeft", // VK_LEFT + (0x27).toShort() to "ArrowRight", // VK_RIGHT + (0x26).toShort() to "ArrowUp", // VK_UP + (0x23).toShort() to "End", // VK_END + (0x24).toShort() to "Home", // VK_HOME + (0x22).toShort() to "PageDown", // VK_NEXT + (0x21).toShort() to "PageUp", // VK_PRIOR + (0x08).toShort() to "Backspace", // VK_BACK + (0x0C).toShort() to "Clear", // VK_CLEAR + (0xF7).toShort() to "CrSel", // VK_CRSEL + (0x2E).toShort() to "Delete", // VK_DELETE + (0xF9).toShort() to "EraseEof", // VK_EREOF + (0xF8).toShort() to "ExSel", // VK_EXSEL + (0x2D).toShort() to "Insert", // VK_INSERT + (0x1E).toShort() to "Accept", // VK_ACCEPT + (0xF0).toShort() to "Attn", // VK_OEM_ATTN + (0x5D).toShort() to "ContextMenu", // VK_APPS + (0x1B).toShort() to "Escape", // VK_ESCAPE + (0x2B).toShort() to "Execute", // VK_EXECUTE + (0xF1).toShort() to "Finish", // VK_OEM_FINISH + (0x2F).toShort() to "Help", // VK_HELP + (0x13).toShort() to "Pause", // VK_PAUSE + (0xFA).toShort() to "Play", // VK_PLAY + (0x29).toShort() to "Select", // VK_SELECT + (0x2C).toShort() to "PrintScreen", // VK_SNAPSHOT + (0x5F).toShort() to "Standby", // VK_SLEEP + (0xF0).toShort() to "Alphanumeric", // VK_OEM_ATTN + (0x1C).toShort() to "Convert", // VK_CONVERT + (0x18).toShort() to "FinalMode", // VK_FINAL + (0x1F).toShort() to "ModeChange", // VK_MODECHANGE + (0x1D).toShort() to "NonConvert", // VK_NONCONVERT + (0xE5).toShort() to "Process", // VK_PROCESSKEY + (0x15).toShort() to "HangulMode", // VK_HANGUL + (0x19).toShort() to "HanjaMode", // VK_HANJA + (0x17).toShort() to "JunjaMode", // VK_JUNJA + (0xF3).toShort() to "Hankaku", // VK_OEM_AUTO + (0xF2).toShort() to "Hiragana", // VK_OEM_COPY + (0x15).toShort() to "KanaMode", // VK_KANA + (0xF1).toShort() to "Katakana", // VK_OEM_FINISH + (0xF5).toShort() to "Romaji", // VK_OEM_BACKTAB + (0xF4).toShort() to "Zenkaku", // VK_OEM_ENLW + (0x70).toShort() to "F1", // VK_F1 + (0x71).toShort() to "F2", // VK_F2 + (0x72).toShort() to "F3", // VK_F3 + (0x73).toShort() to "F4", // VK_F4 + (0x74).toShort() to "F5", // VK_F5 + (0x75).toShort() to "F6", // VK_F6 + (0x76).toShort() to "F7", // VK_F7 + (0x77).toShort() to "F8", // VK_F8 + (0x78).toShort() to "F9", // VK_F9 + (0x79).toShort() to "F10", // VK_F10 + (0x7A).toShort() to "F11", // VK_F11 + (0x7B).toShort() to "F12", // VK_F12 + (0x7C).toShort() to "F13", // VK_F13 + (0x7D).toShort() to "F14", // VK_F14 + (0x7E).toShort() to "F15", // VK_F15 + (0x7F).toShort() to "F16", // VK_F16 + (0x80).toShort() to "F17", // VK_F17 + (0x81).toShort() to "F18", // VK_F18 + (0x82).toShort() to "F19", // VK_F19 + (0x83).toShort() to "F20", // VK_F20 + (0xB3).toShort() to "MediaPlayPause", // VK_MEDIA_PLAY_PAUSE + (0xB2).toShort() to "MediaStop", // VK_MEDIA_STOP + (0xB0).toShort() to "MediaTrackNext", // VK_MEDIA_NEXT_TRACK + (0xB1).toShort() to "MediaTrackPrevious", // VK_MEDIA_PREV_TRACK + (0xAE).toShort() to "AudioVolumeDown", // VK_VOLUME_DOWN + (0xAD).toShort() to "AudioVolumeMute", // VK_VOLUME_MUTE + (0xAF).toShort() to "AudioVolumeUp", // VK_VOLUME_UP + (0xFB).toShort() to "ZoomToggle", // VK_ZOOM + (0xB4).toShort() to "LaunchMail", // VK_LAUNCH_MAIL + (0xB5).toShort() to "LaunchMediaPlayer", // VK_LAUNCH_MEDIA_SELECT + (0xB6).toShort() to "LaunchApplication1", // VK_LAUNCH_APP1 + (0xB7).toShort() to "LaunchApplication2", // VK_LAUNCH_APP2 + (0xA6).toShort() to "BrowserBack", // VK_BROWSER_BACK + (0xAB).toShort() to "BrowserFavorites", // VK_BROWSER_FAVORITES + (0xA7).toShort() to "BrowserForward", // VK_BROWSER_FORWARD + (0xAC).toShort() to "BrowserHome", // VK_BROWSER_HOME + (0xA8).toShort() to "BrowserRefresh", // VK_BROWSER_REFRESH + (0xAA).toShort() to "BrowserSearch", // VK_BROWSER_SEARCH + (0xA9).toShort() to "BrowserStop", // VK_BROWSER_STOP + (0x6E).toShort() to "Decimal", // VK_DECIMAL + (0x6A).toShort() to "Multiply", // VK_MULTIPLY + (0x6B).toShort() to "Add", // VK_ADD + (0x6F).toShort() to "Divide", // VK_DIVIDE + (0x6D).toShort() to "Subtract", // VK_SUBTRACT + (0x6C).toShort() to "Separator", // VK_SEPARATOR + (0x60).toShort() to "0", // VK_NUMPAD0 + (0x61).toShort() to "1",// VK_NUMPAD1 + (0x62).toShort() to "2",// VK_NUMPAD2 + (0x63).toShort() to "3",// VK_NUMPAD3 + (0x64).toShort() to "4",// VK_NUMPAD4 + (0x65).toShort() to "5",// VK_NUMPAD5 + (0x66).toShort() to "6",// VK_NUMPAD6 + (0x67).toShort() to "7",// VK_NUMPAD7 + (0x68).toShort() to "8",// VK_NUMPAD8 + (0x69).toShort() to "9",// VK_NUMPAD9 + ) + + fun getName(keyCode: Short): String { + return map[keyCode] ?: "Unidentified" + } +} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt index ede22c5c9..161aaec5d 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt @@ -1,25 +1,29 @@ package com.github.ajalt.mordant.internal.jna +import com.example.WindowsScanCodeToKeyEvent +import com.example.WindowsVirtualKeyCodeToKeyEvent import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.PointerType -import com.sun.jna.Structure +import com.sun.jna.* import com.sun.jna.ptr.IntByReference import com.sun.jna.win32.W32APIOptions +import kotlin.time.Duration +import kotlin.time.TimeSource // Interface definitions from // https://github.com/java-native-access/jna/blob/master/contrib/platform/src/com/sun/jna/platform/win32/Kernel32.java // copied here so that we don't need the entire platform dependency @Delete @Suppress("FunctionName", "PropertyName", "ClassName", "unused") -private interface WinKernel32Lib : Library { +interface WinKernel32Lib : Library { companion object { const val STD_INPUT_HANDLE = -10 const val STD_OUTPUT_HANDLE = -11 const val STD_ERROR_HANDLE = -12 + + // https://learn.microsoft.com/en-us/windows/console/setconsolemode + const val ENABLE_PROCESSED_INPUT = 0x0001 } class HANDLE : PointerType() @@ -72,31 +76,215 @@ private interface WinKernel32Lib : Library { var dwMaximumWindowSize: COORD? = null } + class UnionChar : Union { + constructor() + + constructor(c: Char) { + setType(Char::class.javaPrimitiveType) + UnicodeChar = c + } + + constructor(c: Byte) { + setType(Byte::class.javaPrimitiveType) + AsciiChar = c + } + + fun set(c: Char) { + setType(Char::class.javaPrimitiveType) + UnicodeChar = c + } + + fun set(c: Byte) { + setType(Byte::class.javaPrimitiveType) + AsciiChar = c + } + + @JvmField + var UnicodeChar: Char = 0.toChar() + + @JvmField + var AsciiChar: Byte = 0 + } + + + @Structure.FieldOrder( + "bKeyDown", + "wRepeatCount", + "wVirtualKeyCode", + "wVirtualScanCode", + "uChar", + "dwControlKeyState" + ) + class KEY_EVENT_RECORD : Structure() { + @JvmField + var bKeyDown: Boolean = false + + @JvmField + var wRepeatCount: Short = 0 + + @JvmField + var wVirtualKeyCode: Short = 0 + + @JvmField + var wVirtualScanCode: Short = 0 + + @JvmField + var uChar: UnionChar? = null + + @JvmField + var dwControlKeyState: Int = 0 + } + + @Structure.FieldOrder( + "dwMousePosition", "dwButtonState", "dwControlKeyState", "dwEventFlags" + ) + class MOUSE_EVENT_RECORD : Structure() { + @JvmField + var dwMousePosition: COORD? = null + + @JvmField + var dwButtonState: Int = 0 + + @JvmField + var dwControlKeyState: Int = 0 + + @JvmField + var dwEventFlags: Int = 0 + } + + @Structure.FieldOrder("dwSize") + class WINDOW_BUFFER_SIZE_RECORD : Structure() { + @JvmField + var dwSize: COORD? = null + } + + @Structure.FieldOrder("dwCommandId") + class MENU_EVENT_RECORD : Structure() { + @JvmField + var dwCommandId: Int = 0 + } + + @Structure.FieldOrder("bSetFocus") + class FOCUS_EVENT_RECORD : Structure() { + @JvmField + var bSetFocus: Boolean = false + } + + // https://learn.microsoft.com/en-us/windows/console/input-record-str + @Structure.FieldOrder("EventType", "Event") + class INPUT_RECORD : Structure() { + companion object { + const val KEY_EVENT: Short = 0x0001 + const val MOUSE_EVENT: Short = 0x0002 + const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004 + const val MENU_EVENT: Short = 0x0008 + const val FOCUS_EVENT: Short = 0x0010 + } + + @JvmField + var EventType: Short = 0 + + @JvmField + var Event: EventUnion? = null + + class EventUnion : Union() { + @JvmField + var KeyEvent: KEY_EVENT_RECORD? = null + + @JvmField + var MouseEvent: MOUSE_EVENT_RECORD? = null + + @JvmField + var WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD? = null + + @JvmField + var MenuEvent: MENU_EVENT_RECORD? = null + + @JvmField + var FocusEvent: FOCUS_EVENT_RECORD? = null + } + + override fun read() { + readField("EventType") + when (EventType) { + KEY_EVENT -> Event!!.setType(KEY_EVENT_RECORD::class.java) + MOUSE_EVENT -> Event!!.setType(MOUSE_EVENT_RECORD::class.java) + WINDOW_BUFFER_SIZE_EVENT -> Event!!.setType(WINDOW_BUFFER_SIZE_RECORD::class.java) + MENU_EVENT -> Event!!.setType(MENU_EVENT_RECORD::class.java) + FOCUS_EVENT -> Event!!.setType(MENU_EVENT_RECORD::class.java) + } + super.read() + } + } + + fun GetStdHandle(nStdHandle: Int): HANDLE - fun GetConsoleMode(hConsoleHandle: HANDLE, lpMode: IntByReference): Boolean + + @Throws(LastErrorException::class) + fun GetConsoleMode(hConsoleHandle: HANDLE, lpMode: IntByReference) + fun GetConsoleScreenBufferInfo( hConsoleOutput: HANDLE, lpConsoleScreenBufferInfo: CONSOLE_SCREEN_BUFFER_INFO, ): Boolean + + @Throws(LastErrorException::class) + fun SetConsoleMode(hConsoleHandle: HANDLE, dwMode: Int) + + // https://learn.microsoft.com/en-us/windows/win32/api/synchapi/nf-synchapi-waitforsingleobject + fun WaitForSingleObject(hHandle: Pointer, dwMilliseconds: Int): Int + + @Throws(LastErrorException::class) + fun ReadConsoleInput( + hConsoleOutput: HANDLE, + lpBuffer: Array, + nLength: Int, + lpNumberOfEventsRead: IntByReference, + ) } + @Delete internal class JnaWin32MppImpls : MppImpls { + private companion object { + // https://learn.microsoft.com/en-us/windows/console/key-event-record-str + const val RIGHT_ALT_PRESSED: Int = 0x0001 + const val LEFT_ALT_PRESSED: Int = 0x0002 + const val RIGHT_CTRL_PRESSED: Int = 0x0004 + const val LEFT_CTRL_PRESSED: Int = 0x0008 + const val SHIFT_PRESSED: Int = 0x0010 + } + private val kernel = - Native.load("kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS); + Native.load("kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS) private val stdoutHandle = kernel.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE) private val stdinHandle = kernel.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE) private val stderrHandle = kernel.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE) override fun stdoutInteractive(): Boolean { - return kernel.GetConsoleMode(stdoutHandle, IntByReference()) + return try { + kernel.GetConsoleMode(stdoutHandle, IntByReference()) + true + } catch (e: LastErrorException) { + false + } } override fun stdinInteractive(): Boolean { - return kernel.GetConsoleMode(stdinHandle, IntByReference()) + return try { + kernel.GetConsoleMode(stdinHandle, IntByReference()) + true + } catch (e: LastErrorException) { + false + } } override fun stderrInteractive(): Boolean { - return kernel.GetConsoleMode(stderrHandle, IntByReference()) + return try { + kernel.GetConsoleMode(stderrHandle, IntByReference()) + true + } catch (e: LastErrorException) { + false + } } override fun getTerminalSize(): Size? { @@ -106,4 +294,64 @@ internal class JnaWin32MppImpls : MppImpls { } return csbi.srWindow?.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } + + fun enterRawMode(): Int { + val originalMode = IntByReference() + kernel.GetConsoleMode(stdinHandle, originalMode) + + // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add + // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. + kernel.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT) + return originalMode.value + } + + fun exitRawMode(originalMode: Int) { + kernel.SetConsoleMode(stdinHandle, originalMode) + } + + private fun readRawKeyEvent(timeout: Duration): WinKernel32Lib.KEY_EVENT_RECORD? { + val dwMilliseconds = timeout.inWholeMilliseconds.coerceIn(0, Int.MAX_VALUE.toLong()).toInt() + val waitResult = kernel.WaitForSingleObject(stdinHandle.pointer, dwMilliseconds) + if (waitResult != 0) { + return null + } + val inputEvents = arrayOfNulls(1) + val eventsRead = IntByReference() + kernel.ReadConsoleInput(stdinHandle, inputEvents, inputEvents.size, eventsRead) + if (eventsRead.value == 0) { + return null + } + return inputEvents[0]!!.Event!!.KeyEvent!! + } + + fun readKeyEvent(timeout: Duration): KeyEvent? { + val t0 = TimeSource.Monotonic.markNow() + while (t0.elapsedNow() < timeout) { + val event = readRawKeyEvent(timeout - t0.elapsedNow()) + // ignore key up events + if (event != null && event.bKeyDown) { + val unicodeChar = event.uChar!!.UnicodeChar + return KeyEvent( + key = if (unicodeChar.code != 0) unicodeChar.toString() + else WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode), + code = WindowsScanCodeToKeyEvent.getName(event.wVirtualScanCode), + ctrl = event.dwControlKeyState and (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) != 0, + alt = event.dwControlKeyState and (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) != 0, + shift = event.dwControlKeyState and SHIFT_PRESSED != 0, + meta = false, // meta key isn't delivered as an event on windows + ) + } + } + return null + } } + +// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent +data class KeyEvent( + val key: String, + val code: String, + val ctrl: Boolean, + val alt: Boolean, + val shift: Boolean, + val meta: Boolean, +) From d04c77b2e1b090b1cc6a1aee6ccf73c6d467e72a Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 31 May 2024 13:33:03 -0700 Subject: [PATCH 02/45] Add raw mode to linux JNA --- .../ajalt/mordant/input/KeyboardEvent.kt | 12 + .../mordant/internal/jna/JnaLinuxMppImpls.kt | 618 ++++++++++++++++++ .../internal/jna/JnaLinuxMppImplsLinux.kt | 62 -- .../mordant/internal/jna/JnaWin32MppImpls.kt | 16 +- 4 files changed, 634 insertions(+), 74 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt create mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt delete mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt new file mode 100644 index 000000000..61491c21e --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt @@ -0,0 +1,12 @@ +package com.github.ajalt.mordant.input + +// TODO: docs +// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent +data class KeyboardEvent( + val key: String, + val code: String, // maybe get rid of this since it's not available on posix? + val ctrl: Boolean, + val alt: Boolean, // `Option ⌥` key on mac + val shift: Boolean, + // maybe add a `data` field for escape sequences? +) diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt new file mode 100644 index 000000000..771679c75 --- /dev/null +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt @@ -0,0 +1,618 @@ +package com.github.ajalt.mordant.internal.jna + +import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.internal.MppImpls +import com.github.ajalt.mordant.internal.Size +import com.oracle.svm.core.annotate.Delete +import com.sun.jna.* +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration +import kotlin.time.TimeSource + +private const val VINTR: Int = 0 +private const val VQUIT: Int = 1 +private const val VERASE: Int = 2 +private const val VKILL: Int = 3 +private const val VEOF: Int = 4 +private const val VTIME: Int = 5 +private const val VMIN: Int = 6 +private const val VSWTC: Int = 7 +private const val VSTART: Int = 8 +private const val VSTOP: Int = 9 +private const val VSUSP: Int = 10 +private const val VEOL: Int = 11 +private const val VREPRINT: Int = 12 +private const val VDISCARD: Int = 13 +private const val VWERASE: Int = 14 +private const val VLNEXT: Int = 15 +private const val VEOL2: Int = 16 + +private const val IGNBRK: Int = 0x0000001 +private const val BRKINT: Int = 0x0000002 +private const val IGNPAR: Int = 0x0000004 +private const val PARMRK: Int = 0x0000008 +private const val INPCK: Int = 0x0000010 +private const val ISTRIP: Int = 0x0000020 +private const val INLCR: Int = 0x0000040 +private const val IGNCR: Int = 0x0000080 +private const val ICRNL: Int = 0x0000100 +private const val IUCLC: Int = 0x0000200 +private const val IXON: Int = 0x0000400 +private const val IXANY: Int = 0x0000800 +private const val IXOFF: Int = 0x0001000 +private const val IMAXBEL: Int = 0x0002000 +private const val IUTF8: Int = 0x0004000 + +private const val OPOST: Int = 0x0000001 +private const val OLCUC: Int = 0x0000002 +private const val ONLCR: Int = 0x0000004 +private const val OCRNL: Int = 0x0000008 +private const val ONOCR: Int = 0x0000010 +private const val ONLRET: Int = 0x0000020 +private const val OFILL: Int = 0x0000040 +private const val OFDEL: Int = 0x0000080 +private const val NLDLY: Int = 0x0000100 +private const val NL0: Int = 0x0000000 +private const val NL1: Int = 0x0000100 +private const val CRDLY: Int = 0x0000600 +private const val CR0: Int = 0x0000000 +private const val CR1: Int = 0x0000200 +private const val CR2: Int = 0x0000400 +private const val CR3: Int = 0x0000600 +private const val TABDLY: Int = 0x0001800 +private const val TAB0: Int = 0x0000000 +private const val TAB1: Int = 0x0000800 +private const val TAB2: Int = 0x0001000 +private const val TAB3: Int = 0x0001800 +private const val XTABS: Int = 0x0001800 +private const val BSDLY: Int = 0x0002000 +private const val BS0: Int = 0x0000000 +private const val BS1: Int = 0x0002000 +private const val VTDLY: Int = 0x0004000 +private const val VT0: Int = 0x0000000 +private const val VT1: Int = 0x0004000 +private const val FFDLY: Int = 0x0008000 +private const val FF0: Int = 0x0000000 +private const val FF1: Int = 0x0008000 + +private const val CBAUD: Int = 0x000100f +private const val B0: Int = 0x0000000 +private const val B50: Int = 0x0000001 +private const val B75: Int = 0x0000002 +private const val B110: Int = 0x0000003 +private const val B134: Int = 0x0000004 +private const val B150: Int = 0x0000005 +private const val B200: Int = 0x0000006 +private const val B300: Int = 0x0000007 +private const val B600: Int = 0x0000008 +private const val B1200: Int = 0x0000009 +private const val B1800: Int = 0x000000a +private const val B2400: Int = 0x000000b +private const val B4800: Int = 0x000000c +private const val B9600: Int = 0x000000d +private const val B19200: Int = 0x000000e +private const val B38400: Int = 0x000000f +private const val EXTA: Int = B19200 +private const val EXTB: Int = B38400 +private const val CSIZE: Int = 0x0000030 +private const val CS5: Int = 0x0000000 +private const val CS6: Int = 0x0000010 +private const val CS7: Int = 0x0000020 +private const val CS8: Int = 0x0000030 +private const val CSTOPB: Int = 0x0000040 +private const val CREAD: Int = 0x0000080 +private const val PARENB: Int = 0x0000100 +private const val PARODD: Int = 0x0000200 +private const val HUPCL: Int = 0x0000400 +private const val CLOCAL: Int = 0x0000800 + +private const val ISIG: Int = 0x0000001 +private const val ICANON: Int = 0x0000002 +private const val XCASE: Int = 0x0000004 +private const val ECHO: Int = 0x0000008 +private const val ECHOE: Int = 0x0000010 +private const val ECHOK: Int = 0x0000020 +private const val ECHONL: Int = 0x0000040 +private const val NOFLSH: Int = 0x0000080 +private const val TOSTOP: Int = 0x0000100 +private const val ECHOCTL: Int = 0x0000200 +private const val ECHOPRT: Int = 0x0000400 +private const val ECHOKE: Int = 0x0000800 +private const val FLUSHO: Int = 0x0001000 +private const val PENDIN: Int = 0x0002000 +private const val IEXTEN: Int = 0x0008000 +private const val EXTPROC: Int = 0x0010000 + +private const val TCSANOW: Int = 0x0 +private const val TCSADRAIN: Int = 0x1 +private const val TCSAFLUSH: Int = 0x2 + +private const val ESC = '\u001b' + +@Delete +@Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") +private interface PosixLibC : Library { + + @Suppress("unused") + @Structure.FieldOrder("ws_row", "ws_col", "ws_xpixel", "ws_ypixel") + class winsize : Structure() { + @JvmField + var ws_row: Short = 0 + + @JvmField + var ws_col: Short = 0 + + @JvmField + var ws_xpixel: Short = 0 + + @JvmField + var ws_ypixel: Short = 0 + } + + @Structure.FieldOrder( + "c_iflag", "c_oflag", "c_cflag", "c_lflag", "c_line", "c_cc", "c_ispeed", "c_ospeed" + ) + class termios : Structure() { + @JvmField + var c_iflag: Int = 0 + + @JvmField + var c_oflag: Int = 0 + + @JvmField + var c_cflag: Int = 0 + + @JvmField + var c_lflag: Int = 0 + + @JvmField + var c_line: Byte = 0 + + @JvmField + var c_cc: ByteArray = ByteArray(32) + + @JvmField + var c_ispeed: Int = 0 + + @JvmField + var c_ospeed: Int = 0 + } + + + fun isatty(fd: Int): Int + fun ioctl(fd: Int, cmd: Int, data: winsize?): Int + + @Throws(LastErrorException::class) + fun tcgetattr(fd: Int, termios: termios) + + @Throws(LastErrorException::class) + fun tcsetattr(fd: Int, cmd: Int, termios: termios) +} + +@Delete +internal class JnaLinuxMppImpls : MppImpls { + @Suppress("SpellCheckingInspection") + private companion object { + private const val STDIN_FILENO = 0 + private const val STDOUT_FILENO = 1 + private const val STDERR_FILENO = 2 + + private const val TIOCGWINSZ = 0x00005413 + } + + private val libC: PosixLibC = Native.load(Platform.C_LIBRARY_NAME, PosixLibC::class.java) + override fun stdoutInteractive(): Boolean = libC.isatty(STDOUT_FILENO) == 1 + override fun stdinInteractive(): Boolean = libC.isatty(STDIN_FILENO) == 1 + override fun stderrInteractive(): Boolean = libC.isatty(STDERR_FILENO) == 1 + + override fun getTerminalSize(): Size? { + val size = PosixLibC.winsize() + return if (libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) { + null + } else { + Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) + } + } + + // https://www.man7.org/linux/man-pages/man3/termios.3.html + // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html + fun enterRawMode(): AutoCloseable { + val originalTermios = PosixLibC.termios() + val termios = PosixLibC.termios() + libC.tcgetattr(STDIN_FILENO, originalTermios) + libC.tcgetattr(STDIN_FILENO, termios) + // we leave OPOST on so we don't change \r\n handling + termios.c_iflag = termios.c_iflag and (ICRNL or INPCK or ISTRIP or IXON).inv() + termios.c_cflag = termios.c_cflag or CS8 + termios.c_lflag = termios.c_lflag and (ECHO or ICANON or IEXTEN or ISIG).inv() + termios.c_cc[VMIN] = 0 // min wait time on read + termios.c_cc[VTIME] = 1 // max wait time on read, in 10ths of a second + libC.tcsetattr(STDIN_FILENO, TCSADRAIN, termios) + return AutoCloseable { libC.tcsetattr(STDIN_FILENO, TCSADRAIN, originalTermios) } + } + + private fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { + while (t0.elapsedNow() < timeout) { + val c = System.`in`.read().takeIf { it >= 0 }?.toChar() + if (c != null) return c + } + 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 + + ESC letter + ESC [ letter + ESC [ modifier letter + ESC [ 1 ; modifier letter + ESC [ num char + ESC [ num ; modifier char + ESC O letter + ESC O modifier letter + ESC O 1 ; modifier letter + ESC N letter + ESC [ [ num ; modifier char + ESC [ [ 1 ; modifier letter + ESC ESC [ num char + ESC ESC O letter + + - char is usually ~ but $ and ^ also happen with rxvt + - modifier is 1 + + (shift * 1) + + (left_alt * 2) + + (ctrl * 4) + + (right_alt * 8) + - two leading ESCs apparently mean the same as one leading ESC + */ + fun readKeyEvent(timeout: Duration = Duration.INFINITE): KeyboardEvent? { + val t0 = TimeSource.Monotonic.markNow() + var ctrl = false + var alt = false + var shift = false + var escaped = false + var name: String? = null + val s = StringBuilder() + var ch: Char = ' ' + + fun readTimeout(): Boolean { + ch = readRawByte(t0, timeout) ?: return true + s.append(ch) + return false + } + + if (readTimeout()) return null + + if (ch == ESC) { + escaped = true + if (readTimeout()) return KeyboardEvent( + key = "Escape", + code = "Escape", + ctrl = false, + alt = false, + shift = false + ) + if (ch == ESC) readTimeout() + } + + if (escaped && (ch == 'O' || ch == '[')) { + // ANSI escape sequence + val code = StringBuilder(ch.toString()) + var modifier = 0 + + if (ch == 'O') { + // ESC O letter + // ESC O modifier letter + if (readTimeout()) return null + + if (ch in '0'..'9') { + modifier = ch.code - 1 + if (readTimeout()) return null + } + + code.append(ch) + } else if (ch == '[') { + // ESC [ letter + // ESC [ modifier letter + // ESC [ [ modifier letter + // ESC [ [ num char + if (readTimeout()) return null + + if (ch == '[') { + // escape codes might have a second bracket + code.append(ch) + if (readTimeout()) return null + } + + /* + * Here and later we try to buffer just enough data to get + * a complete ascii sequence. + * + * We have basically two classes of ascii characters to process: + * + * + * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } + * + * This particular example is featuring Ctrl+F12 in xterm. + * + * - `;5` part is optional, e.g. it could be `\x1b[24~` + * - first part can contain one or two digits + * - there is also special case when there can be 3 digits + * but without modifier. They are the case of paste bracket mode + * + * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ + * + * + * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } + * + * This particular example is featuring Ctrl+Home in xterm. + * + * - `1;5` part is optional, e.g. it could be `\x1b[H` + * - `1;` part is optional, e.g. it could be `\x1b[5H` + * + * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ + * + */ + val cmdStart = s.length - 1 + + // leading digits + repeat(3) { + if (ch in '0'..'9') { + if (readTimeout()) return null + } + } + + // modifier + if (ch == ';') { + if (readTimeout()) return null + + if (ch in '0'..'9') { + if (readTimeout()) return null + } + } + + /* + * We buffered enough data, now trying to extract code + * and modifier from it + */ + val cmd = s.substring(cmdStart) + var match = Regex("""(\d\d?)(?:;(\d))?([~^$])|(\d{3}~)""").matchEntire(cmd) + if (match != null) { + if (match.groupValues[4].isNotEmpty()) { + code.append(match.groupValues[4]) + } else { + code.append(match.groupValues[1] + match.groupValues[3]) + modifier = (match.groupValues[2].toIntOrNull() ?: 1) - 1 + } + } else { + match = Regex("""((\d;)?(\d))?([A-Za-z])""").matchEntire(cmd) + if (match != null) { + code.append(match.groupValues[4]) + modifier = (match.groupValues[3].toIntOrNull() ?: 1) - 1 + } else { + code.append(cmd) + } + } + } + + // Parse the key modifier + ctrl = (modifier and 4) != 0 + alt = (modifier and 10) != 0 + shift = (modifier and 1) != 0 + + when (code.toString()) { + "[P" -> name = "F1" + "[Q" -> name = "F2" + "[R" -> name = "F3" + "[S" -> name = "F4" + "OP" -> name = "F1" + "OQ" -> name = "F2" + "OR" -> name = "F3" + "OS" -> name = "F4" + "[11~" -> name = "F1" + "[12~" -> name = "F2" + "[13~" -> name = "F3" + "[14~" -> name = "F4" + "[200~" -> name = "PasteStart" + "[201~" -> name = "PasteEnd" + "[[A" -> name = "F1" + "[[B" -> name = "F2" + "[[C" -> name = "F3" + "[[D" -> name = "F4" + "[[E" -> name = "F5" + "[15~" -> name = "F5" + "[17~" -> name = "F6" + "[18~" -> name = "F7" + "[19~" -> name = "F8" + "[20~" -> name = "F9" + "[21~" -> name = "F10" + "[23~" -> name = "F11" + "[24~" -> name = "F12" + "[A" -> name = "ArrowUp" + "[B" -> name = "ArrowDown" + "[C" -> name = "ArrowRight" + "[D" -> name = "ArrowLeft" + "[E" -> name = "Clear" + "[F" -> name = "End" + "[H" -> name = "Home" + "OA" -> name = "ArrowUp" + "OB" -> name = "ArrowDown" + "OC" -> name = "ArrowRight" + "OD" -> name = "ArrowLeft" + "OE" -> name = "Clear" + "OF" -> name = "End" + "OH" -> name = "Home" + "[1~" -> name = "Home" + "[2~" -> name = "Insert" + "[3~" -> name = "Delete" + "[4~" -> name = "End" + "[5~" -> name = "PageUp" + "[6~" -> name = "PageDown" + "[[5~" -> name = "PageUp" + "[[6~" -> name = "PageDown" + "[7~" -> name = "Home" + "[8~" -> name = "End" + "[a" -> { + name = "ArrowUp" + shift = true + } + + "[b" -> { + name = "ArrowDown" + shift = true + } + + "[c" -> { + name = "ArrowRight" + shift = true + } + + "[d" -> { + name = "ArrowLeft" + shift = true + } + + "[e" -> { + name = "Clear" + shift = true + } + + "[2$" -> { + name = "Insert" + shift = true + } + + "[3$" -> { + name = "Delete" + shift = true + } + + "[5$" -> { + name = "PageUp" + shift = true + } + + "[6$" -> { + name = "PageDown" + shift = true + } + + "[7$" -> { + name = "Home" + shift = true + } + + "[8$" -> { + name = "End" + shift = true + } + + "Oa" -> { + name = "ArrowUp" + ctrl = true + } + + "Ob" -> { + name = "ArrowDown" + ctrl = true + } + + "Oc" -> { + name = "ArrowRight" + ctrl = true + } + + "Od" -> { + name = "ArrowLeft" + ctrl = true + } + + "Oe" -> { + name = "Clear" + ctrl = true + } + + "[2^" -> { + name = "Insert" + ctrl = true + } + + "[3^" -> { + name = "Delete" + ctrl = true + } + + "[5^" -> { + name = "PageUp" + ctrl = true + } + + "[6^" -> { + name = "PageDown" + ctrl = true + } + + "[7^" -> { + name = "Home" + ctrl = true + } + + "[8^" -> { + name = "End" + ctrl = true + } + + "[Z" -> { + name = "Tab" + shift = true + } + + else -> name = "Unidentified" + } + } else if (ch == '\r') { + name = "Enter" + alt = escaped + } else if (ch == '\n') { + name = "Enter" + alt = escaped + } else if (ch == '\t') { + name = "Tab" + alt = escaped + } else if (ch == '\b' || ch == '\u007f') { + // backspace or ctrl+h + name = "Backspace" + alt = escaped + } else if (ch == ESC) { + // escape key + name = "Escape" + alt = escaped + } else if (ch == ' ') { + name = " " + alt = escaped + } else if (!escaped && ch <= '\u001a') { + // ctrl+letter + name = (ch.code + 'a'.code - 1).toChar().toString() + ctrl = true + } else if (ch.isLetter() || ch.isDigit()) { + // Letter, number, shift+letter + name = ch.toString() + shift = ch in 'A'..'Z' + alt = escaped + } else if (escaped) { + // Escape sequence timeout + TODO() +// name = ch.length ? undefined : "escape" +// alt = true + } + + return KeyboardEvent( + key = name ?: ch.toString(), + code = name ?: ch.toString(), + ctrl = ctrl, + alt = alt, + shift = shift, + ) + } +} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt deleted file mode 100644 index d9e29bd0d..000000000 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImplsLinux.kt +++ /dev/null @@ -1,62 +0,0 @@ -package com.github.ajalt.mordant.internal.jna - -import com.github.ajalt.mordant.internal.MppImpls -import com.github.ajalt.mordant.internal.Size -import com.oracle.svm.core.annotate.Delete -import com.sun.jna.Library -import com.sun.jna.Native -import com.sun.jna.Platform -import com.sun.jna.Structure - -@Delete -@Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") -private interface PosixLibC : Library { - - @Suppress("unused") - class winsize : Structure() { - @JvmField - var ws_row: Short = 0 - - @JvmField - var ws_col: Short = 0 - - @JvmField - var ws_xpixel: Short = 0 - - @JvmField - var ws_ypixel: Short = 0 - - override fun getFieldOrder(): List { - return mutableListOf("ws_row", "ws_col", "ws_xpixel", "ws_ypixel") - } - } - - fun isatty(fd: Int): Int - fun ioctl(fd: Int, cmd: Int, data: winsize?): Int -} - -@Delete -internal class JnaLinuxMppImpls : MppImpls { - @Suppress("SpellCheckingInspection") - private companion object { - const val STDIN_FILENO = 0 - const val STDOUT_FILENO = 1 - const val STDERR_FILENO = 2 - - const val TIOCGWINSZ = 0x00005413 - } - - private val libC: PosixLibC = Native.load(Platform.C_LIBRARY_NAME, PosixLibC::class.java) - override fun stdoutInteractive(): Boolean = libC.isatty(STDOUT_FILENO) == 1 - override fun stdinInteractive(): Boolean = libC.isatty(STDIN_FILENO) == 1 - override fun stderrInteractive(): Boolean = libC.isatty(STDERR_FILENO) == 1 - - override fun getTerminalSize(): Size? { - val size = PosixLibC.winsize() - return if (libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) { - null - } else { - Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) - } - } -} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt index 161aaec5d..1f7741197 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt @@ -2,6 +2,7 @@ package com.github.ajalt.mordant.internal.jna import com.example.WindowsScanCodeToKeyEvent import com.example.WindowsVirtualKeyCodeToKeyEvent +import com.github.ajalt.mordant.input.KeyboardEvent import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete @@ -301,6 +302,7 @@ internal class JnaWin32MppImpls : MppImpls { // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. + // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c kernel.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT) return originalMode.value } @@ -324,21 +326,20 @@ internal class JnaWin32MppImpls : MppImpls { return inputEvents[0]!!.Event!!.KeyEvent!! } - fun readKeyEvent(timeout: Duration): KeyEvent? { + fun readKeyEvent(timeout: Duration): KeyboardEvent? { val t0 = TimeSource.Monotonic.markNow() while (t0.elapsedNow() < timeout) { val event = readRawKeyEvent(timeout - t0.elapsedNow()) // ignore key up events if (event != null && event.bKeyDown) { val unicodeChar = event.uChar!!.UnicodeChar - return KeyEvent( + return KeyboardEvent( key = if (unicodeChar.code != 0) unicodeChar.toString() else WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode), code = WindowsScanCodeToKeyEvent.getName(event.wVirtualScanCode), ctrl = event.dwControlKeyState and (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) != 0, alt = event.dwControlKeyState and (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) != 0, shift = event.dwControlKeyState and SHIFT_PRESSED != 0, - meta = false, // meta key isn't delivered as an event on windows ) } } @@ -346,12 +347,3 @@ internal class JnaWin32MppImpls : MppImpls { } } -// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent -data class KeyEvent( - val key: String, - val code: String, - val ctrl: Boolean, - val alt: Boolean, - val shift: Boolean, - val meta: Boolean, -) From 71ce14fb34ac8b5f594c95f538171889565bed42 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 1 Jun 2024 13:54:30 -0700 Subject: [PATCH 03/45] Add native impls of input functions --- .../ajalt/mordant/input/KeyboardEvent.kt | 1 - .../ajalt/mordant/input/KeyboardInput.kt | 17 + .../input/internal/KeyboardInputLinux.kt | 387 ++++++++++++++++++ .../input/internal/KeyboardInputWindows.kt | 48 +++ .../WindowsVirtualKeyCodeToKeyEvent.kt | 159 +++++++ .../ajalt/mordant/internal/MppInternal.kt | 6 + .../internal/WindowsScanCodeToKeyEvent.kt | 150 ------- .../WindowsVirtualKeyCodeToKeyEvent.kt | 119 ------ .../github/ajalt/mordant/internal/MppImpls.kt | 7 + .../ajalt/mordant/internal/MppInternal.jvm.kt | 5 + .../mordant/internal/jna/JnaLinuxMppImpls.kt | 381 +---------------- .../mordant/internal/jna/JnaWin32MppImpls.kt | 42 +- .../mordant/internal/MppInternal.mingw.kt | 39 ++ .../mordant/internal/MppInternal.posix.kt | 52 +++ 14 files changed, 737 insertions(+), 676 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt delete mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt delete mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt create mode 100644 mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt index 61491c21e..1cf849e99 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt @@ -4,7 +4,6 @@ package com.github.ajalt.mordant.input // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent data class KeyboardEvent( val key: String, - val code: String, // maybe get rid of this since it's not available on posix? val ctrl: Boolean, val alt: Boolean, // `Option ⌥` key on mac val shift: Boolean, diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt new file mode 100644 index 000000000..4feb653fa --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt @@ -0,0 +1,17 @@ +package com.github.ajalt.mordant.input + +import com.github.ajalt.mordant.internal.enterRawModeMpp +import com.github.ajalt.mordant.internal.readKeyMpp +import com.github.ajalt.mordant.terminal.Terminal +import kotlin.time.Duration + +// TODO docs +fun Terminal.readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { + if (!info.inputInteractive) return null + return readKeyMpp(timeout) +} + +fun Terminal.enterRawMode(): AutoCloseable { + if (!info.inputInteractive) return AutoCloseable { } + return enterRawModeMpp() +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt new file mode 100644 index 000000000..468b8d0ff --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt @@ -0,0 +1,387 @@ +package com.github.ajalt.mordant.input.internal + +import com.github.ajalt.mordant.input.KeyboardEvent +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration +import kotlin.time.TimeSource + +object KeyboardInputLinux { + private const val ESC = '\u001b' + + /* + 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 + + ESC letter + ESC [ letter + ESC [ modifier letter + ESC [ 1 ; modifier letter + ESC [ num char + ESC [ num ; modifier char + ESC O letter + ESC O modifier letter + ESC O 1 ; modifier letter + ESC N letter + ESC [ [ num ; modifier char + ESC [ [ 1 ; modifier letter + ESC ESC [ num char + ESC ESC O letter + + - char is usually ~ but $ and ^ also happen with rxvt + - modifier is 1 + + (shift * 1) + + (left_alt * 2) + + (ctrl * 4) + + (right_alt * 8) + - two leading ESCs apparently mean the same as one leading ESC + */ + fun readKeyEvent( + timeout: Duration, + readRawByte: (t0: ComparableTimeMark, timeout: Duration) -> Char?, + ): KeyboardEvent? { + val t0 = TimeSource.Monotonic.markNow() + var ctrl = false + var alt = false + var shift = false + var escaped = false + var name: String? = null + val s = StringBuilder() + var ch: Char = ' ' + + fun readTimeout(): Boolean { + ch = readRawByte(t0, timeout) ?: return true + s.append(ch) + return false + } + + if (readTimeout()) return null + + if (ch == ESC) { + escaped = true + if (readTimeout()) return KeyboardEvent( + key = "Escape", + ctrl = false, + alt = false, + shift = false + ) + if (ch == ESC) readTimeout() + } + + if (escaped && (ch == 'O' || ch == '[')) { + // ANSI escape sequence + val code = StringBuilder(ch.toString()) + var modifier = 0 + + if (ch == 'O') { + // ESC O letter + // ESC O modifier letter + if (readTimeout()) return null + + if (ch in '0'..'9') { + modifier = ch.code - 1 + if (readTimeout()) return null + } + + code.append(ch) + } else if (ch == '[') { + // ESC [ letter + // ESC [ modifier letter + // ESC [ [ modifier letter + // ESC [ [ num char + if (readTimeout()) return null + + if (ch == '[') { + // escape codes might have a second bracket + code.append(ch) + if (readTimeout()) return null + } + + /* + * Here and later we try to buffer just enough data to get + * a complete ascii sequence. + * + * We have basically two classes of ascii characters to process: + * + * + * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } + * + * This particular example is featuring Ctrl+F12 in xterm. + * + * - `;5` part is optional, e.g. it could be `\x1b[24~` + * - first part can contain one or two digits + * - there is also special case when there can be 3 digits + * but without modifier. They are the case of paste bracket mode + * + * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ + * + * + * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } + * + * This particular example is featuring Ctrl+Home in xterm. + * + * - `1;5` part is optional, e.g. it could be `\x1b[H` + * - `1;` part is optional, e.g. it could be `\x1b[5H` + * + * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ + * + */ + val cmdStart = s.length - 1 + + // leading digits + repeat(3) { + if (ch in '0'..'9') { + if (readTimeout()) return null + } + } + + // modifier + if (ch == ';') { + if (readTimeout()) return null + + if (ch in '0'..'9') { + if (readTimeout()) return null + } + } + + /* + * We buffered enough data, now trying to extract code + * and modifier from it + */ + val cmd = s.substring(cmdStart) + var match = Regex("""(\d\d?)(?:;(\d))?([~^$])|(\d{3}~)""").matchEntire(cmd) + if (match != null) { + if (match.groupValues[4].isNotEmpty()) { + code.append(match.groupValues[4]) + } else { + code.append(match.groupValues[1] + match.groupValues[3]) + modifier = (match.groupValues[2].toIntOrNull() ?: 1) - 1 + } + } else { + match = Regex("""((\d;)?(\d))?([A-Za-z])""").matchEntire(cmd) + if (match != null) { + code.append(match.groupValues[4]) + modifier = (match.groupValues[3].toIntOrNull() ?: 1) - 1 + } else { + code.append(cmd) + } + } + } + + // Parse the key modifier + ctrl = (modifier and 4) != 0 + alt = (modifier and 10) != 0 + shift = (modifier and 1) != 0 + + when (code.toString()) { + "[P" -> name = "F1" + "[Q" -> name = "F2" + "[R" -> name = "F3" + "[S" -> name = "F4" + "OP" -> name = "F1" + "OQ" -> name = "F2" + "OR" -> name = "F3" + "OS" -> name = "F4" + "[11~" -> name = "F1" + "[12~" -> name = "F2" + "[13~" -> name = "F3" + "[14~" -> name = "F4" + "[200~" -> name = "PasteStart" + "[201~" -> name = "PasteEnd" + "[[A" -> name = "F1" + "[[B" -> name = "F2" + "[[C" -> name = "F3" + "[[D" -> name = "F4" + "[[E" -> name = "F5" + "[15~" -> name = "F5" + "[17~" -> name = "F6" + "[18~" -> name = "F7" + "[19~" -> name = "F8" + "[20~" -> name = "F9" + "[21~" -> name = "F10" + "[23~" -> name = "F11" + "[24~" -> name = "F12" + "[A" -> name = "ArrowUp" + "[B" -> name = "ArrowDown" + "[C" -> name = "ArrowRight" + "[D" -> name = "ArrowLeft" + "[E" -> name = "Clear" + "[F" -> name = "End" + "[H" -> name = "Home" + "OA" -> name = "ArrowUp" + "OB" -> name = "ArrowDown" + "OC" -> name = "ArrowRight" + "OD" -> name = "ArrowLeft" + "OE" -> name = "Clear" + "OF" -> name = "End" + "OH" -> name = "Home" + "[1~" -> name = "Home" + "[2~" -> name = "Insert" + "[3~" -> name = "Delete" + "[4~" -> name = "End" + "[5~" -> name = "PageUp" + "[6~" -> name = "PageDown" + "[[5~" -> name = "PageUp" + "[[6~" -> name = "PageDown" + "[7~" -> name = "Home" + "[8~" -> name = "End" + "[a" -> { + name = "ArrowUp" + shift = true + } + + "[b" -> { + name = "ArrowDown" + shift = true + } + + "[c" -> { + name = "ArrowRight" + shift = true + } + + "[d" -> { + name = "ArrowLeft" + shift = true + } + + "[e" -> { + name = "Clear" + shift = true + } + + "[2$" -> { + name = "Insert" + shift = true + } + + "[3$" -> { + name = "Delete" + shift = true + } + + "[5$" -> { + name = "PageUp" + shift = true + } + + "[6$" -> { + name = "PageDown" + shift = true + } + + "[7$" -> { + name = "Home" + shift = true + } + + "[8$" -> { + name = "End" + shift = true + } + + "Oa" -> { + name = "ArrowUp" + ctrl = true + } + + "Ob" -> { + name = "ArrowDown" + ctrl = true + } + + "Oc" -> { + name = "ArrowRight" + ctrl = true + } + + "Od" -> { + name = "ArrowLeft" + ctrl = true + } + + "Oe" -> { + name = "Clear" + ctrl = true + } + + "[2^" -> { + name = "Insert" + ctrl = true + } + + "[3^" -> { + name = "Delete" + ctrl = true + } + + "[5^" -> { + name = "PageUp" + ctrl = true + } + + "[6^" -> { + name = "PageDown" + ctrl = true + } + + "[7^" -> { + name = "Home" + ctrl = true + } + + "[8^" -> { + name = "End" + ctrl = true + } + + "[Z" -> { + name = "Tab" + shift = true + } + + else -> name = "Unidentified" + } + } else if (ch == '\r') { + name = "Enter" + alt = escaped + } else if (ch == '\n') { + name = "Enter" + alt = escaped + } else if (ch == '\t') { + name = "Tab" + alt = escaped + } else if (ch == '\b' || ch == '\u007f') { + // backspace or ctrl+h + name = "Backspace" + alt = escaped + } else if (ch == ESC) { + // escape key + name = "Escape" + alt = escaped + } else if (ch == ' ') { + name = " " + alt = escaped + } else if (!escaped && ch <= '\u001a') { + // ctrl+letter + name = (ch.code + 'a'.code - 1).toChar().toString() + ctrl = true + } else if (ch.isLetter() || ch.isDigit()) { + // Letter, number, shift+letter + name = ch.toString() + shift = ch in 'A'..'Z' + alt = escaped + } else if (escaped) { + // Escape sequence timeout + TODO("Escape sequence timeout") +// name = ch.length ? undefined : "escape" +// alt = true + } + + return KeyboardEvent( + key = name ?: ch.toString(), + ctrl = ctrl, + alt = alt, + shift = shift, + ) + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt new file mode 100644 index 000000000..cfee9d787 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt @@ -0,0 +1,48 @@ +package com.github.ajalt.mordant.input.internal + +import com.github.ajalt.mordant.input.KeyboardEvent +import kotlin.time.Duration +import kotlin.time.TimeSource + +internal object KeyboardInputWindows { + // https://learn.microsoft.com/en-us/windows/console/key-event-record-str + private const val RIGHT_ALT_PRESSED: UInt = 0x0001u + private const val LEFT_ALT_PRESSED: UInt = 0x0002u + private const val RIGHT_CTRL_PRESSED: UInt = 0x0004u + private const val LEFT_CTRL_PRESSED: UInt = 0x0008u + private const val SHIFT_PRESSED: UInt = 0x0010u + private val CTRL_PRESSED_MASK = (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) + private val ALT_PRESSED_MASK = (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) + + data class KeyEventRecord( + val bKeyDown: Boolean, + val wVirtualKeyCode: UShort, + val uChar: Char, + val dwControlKeyState: UInt, + ) + + fun readKeyEvent( + timeout: Duration, + readRawKeyEvent: (dwMilliseconds: Int) -> KeyEventRecord?, + ): KeyboardEvent? { + val t0 = TimeSource.Monotonic.markNow() + while (t0.elapsedNow() < timeout) { + val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds + .coerceIn(0, Int.MAX_VALUE.toLong()).toInt() + val event = readRawKeyEvent(dwMilliseconds) + // ignore key up events + if (event != null && event.bKeyDown) { + return KeyboardEvent( + key = when { + event.uChar.code != 0 -> event.uChar.toString() + else -> WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) + }, + ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, + alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, + shift = event.dwControlKeyState and SHIFT_PRESSED != 0u, + ) + } + } + return null + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt new file mode 100644 index 000000000..761eb2e1a --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt @@ -0,0 +1,159 @@ +package com.github.ajalt.mordant.input.internal + +// https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes +// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values +internal object WindowsVirtualKeyCodeToKeyEvent { + private val map: Map = mapOf( + (0x12).toUShort() to "Alt", // VK_MENU + (0x14).toUShort() to "CapsLock", // VK_CAPITAL + (0x11).toUShort() to "Control", // VK_CONTROL + (0x5B).toUShort() to "Meta", // VK_LWIN + (0x90).toUShort() to "NumLock", // VK_NUMLOCK + (0x91).toUShort() to "ScrollLock", // VK_SCROLL + (0x10).toUShort() to "Shift", // VK_SHIFT + (0x0D).toUShort() to "Enter", // VK_RETURN + (0x09).toUShort() to "Tab", // VK_TAB + (0x20).toUShort() to " ", // VK_SPACE + (0x28).toUShort() to "ArrowDown", // VK_DOWN + (0x25).toUShort() to "ArrowLeft", // VK_LEFT + (0x27).toUShort() to "ArrowRight", // VK_RIGHT + (0x26).toUShort() to "ArrowUp", // VK_UP + (0x23).toUShort() to "End", // VK_END + (0x24).toUShort() to "Home", // VK_HOME + (0x22).toUShort() to "PageDown", // VK_NEXT + (0x21).toUShort() to "PageUp", // VK_PRIOR + (0x08).toUShort() to "Backspace", // VK_BACK + (0x0C).toUShort() to "Clear", // VK_CLEAR + (0xF7).toUShort() to "CrSel", // VK_CRSEL + (0x2E).toUShort() to "Delete", // VK_DELETE + (0xF9).toUShort() to "EraseEof", // VK_EREOF + (0xF8).toUShort() to "ExSel", // VK_EXSEL + (0x2D).toUShort() to "Insert", // VK_INSERT + (0x1E).toUShort() to "Accept", // VK_ACCEPT + (0xF0).toUShort() to "Attn", // VK_OEM_ATTN + (0x5D).toUShort() to "ContextMenu", // VK_APPS + (0x1B).toUShort() to "Escape", // VK_ESCAPE + (0x2B).toUShort() to "Execute", // VK_EXECUTE + (0xF1).toUShort() to "Finish", // VK_OEM_FINISH + (0x2F).toUShort() to "Help", // VK_HELP + (0x13).toUShort() to "Pause", // VK_PAUSE + (0xFA).toUShort() to "Play", // VK_PLAY + (0x29).toUShort() to "Select", // VK_SELECT + (0x2C).toUShort() to "PrintScreen", // VK_SNAPSHOT + (0x5F).toUShort() to "Standby", // VK_SLEEP + (0xF0).toUShort() to "Alphanumeric", // VK_OEM_ATTN + (0x1C).toUShort() to "Convert", // VK_CONVERT + (0x18).toUShort() to "FinalMode", // VK_FINAL + (0x1F).toUShort() to "ModeChange", // VK_MODECHANGE + (0x1D).toUShort() to "NonConvert", // VK_NONCONVERT + (0xE5).toUShort() to "Process", // VK_PROCESSKEY + (0x15).toUShort() to "HangulMode", // VK_HANGUL + (0x19).toUShort() to "HanjaMode", // VK_HANJA + (0x17).toUShort() to "JunjaMode", // VK_JUNJA + (0xF3).toUShort() to "Hankaku", // VK_OEM_AUTO + (0xF2).toUShort() to "Hiragana", // VK_OEM_COPY + (0x15).toUShort() to "KanaMode", // VK_KANA + (0xF1).toUShort() to "Katakana", // VK_OEM_FINISH + (0xF5).toUShort() to "Romaji", // VK_OEM_BACKTAB + (0xF4).toUShort() to "Zenkaku", // VK_OEM_ENLW + (0x70).toUShort() to "F1", // VK_F1 + (0x71).toUShort() to "F2", // VK_F2 + (0x72).toUShort() to "F3", // VK_F3 + (0x73).toUShort() to "F4", // VK_F4 + (0x74).toUShort() to "F5", // VK_F5 + (0x75).toUShort() to "F6", // VK_F6 + (0x76).toUShort() to "F7", // VK_F7 + (0x77).toUShort() to "F8", // VK_F8 + (0x78).toUShort() to "F9", // VK_F9 + (0x79).toUShort() to "F10", // VK_F10 + (0x7A).toUShort() to "F11", // VK_F11 + (0x7B).toUShort() to "F12", // VK_F12 + (0x7C).toUShort() to "F13", // VK_F13 + (0x7D).toUShort() to "F14", // VK_F14 + (0x7E).toUShort() to "F15", // VK_F15 + (0x7F).toUShort() to "F16", // VK_F16 + (0x80).toUShort() to "F17", // VK_F17 + (0x81).toUShort() to "F18", // VK_F18 + (0x82).toUShort() to "F19", // VK_F19 + (0x83).toUShort() to "F20", // VK_F20 + (0x84).toUShort() to "F21", // VK_F21 + (0x85).toUShort() to "F22", // VK_F22 + (0x86).toUShort() to "F23", // VK_F23 + (0x87).toUShort() to "F24", // VK_F24 + (0xB3).toUShort() to "MediaPlayPause", // VK_MEDIA_PLAY_PAUSE + (0xB2).toUShort() to "MediaStop", // VK_MEDIA_STOP + (0xB0).toUShort() to "MediaTrackNext", // VK_MEDIA_NEXT_TRACK + (0xB1).toUShort() to "MediaTrackPrevious", // VK_MEDIA_PREV_TRACK + (0xAE).toUShort() to "AudioVolumeDown", // VK_VOLUME_DOWN + (0xAD).toUShort() to "AudioVolumeMute", // VK_VOLUME_MUTE + (0xAF).toUShort() to "AudioVolumeUp", // VK_VOLUME_UP + (0xFB).toUShort() to "ZoomToggle", // VK_ZOOM + (0xB4).toUShort() to "LaunchMail", // VK_LAUNCH_MAIL + (0xB5).toUShort() to "LaunchMediaPlayer", // VK_LAUNCH_MEDIA_SELECT + (0xB6).toUShort() to "LaunchApplication1", // VK_LAUNCH_APP1 + (0xB7).toUShort() to "LaunchApplication2", // VK_LAUNCH_APP2 + (0xA6).toUShort() to "BrowserBack", // VK_BROWSER_BACK + (0xAB).toUShort() to "BrowserFavorites", // VK_BROWSER_FAVORITES + (0xA7).toUShort() to "BrowserForward", // VK_BROWSER_FORWARD + (0xAC).toUShort() to "BrowserHome", // VK_BROWSER_HOME + (0xA8).toUShort() to "BrowserRefresh", // VK_BROWSER_REFRESH + (0xAA).toUShort() to "BrowserSearch", // VK_BROWSER_SEARCH + (0xA9).toUShort() to "BrowserStop", // VK_BROWSER_STOP + (0x6E).toUShort() to "Decimal", // VK_DECIMAL + (0x6A).toUShort() to "Multiply", // VK_MULTIPLY + (0x6B).toUShort() to "Add", // VK_ADD + (0x6F).toUShort() to "Divide", // VK_DIVIDE + (0x6D).toUShort() to "Subtract", // VK_SUBTRACT + (0x6C).toUShort() to "Separator", // VK_SEPARATOR + (0x30).toUShort() to "0", + (0x31).toUShort() to "1", + (0x32).toUShort() to "2", + (0x33).toUShort() to "3", + (0x34).toUShort() to "4", + (0x35).toUShort() to "5", + (0x36).toUShort() to "6", + (0x37).toUShort() to "7", + (0x38).toUShort() to "8", + (0x39).toUShort() to "9", + (0x60).toUShort() to "0", // VK_NUMPAD0 + (0x61).toUShort() to "1",// VK_NUMPAD1 + (0x62).toUShort() to "2",// VK_NUMPAD2 + (0x63).toUShort() to "3",// VK_NUMPAD3 + (0x64).toUShort() to "4",// VK_NUMPAD4 + (0x65).toUShort() to "5",// VK_NUMPAD5 + (0x66).toUShort() to "6",// VK_NUMPAD6 + (0x67).toUShort() to "7",// VK_NUMPAD7 + (0x68).toUShort() to "8",// VK_NUMPAD8 + (0x69).toUShort() to "9",// VK_NUMPAD9 + (0x41).toUShort() to "a", + (0x42).toUShort() to "b", + (0x43).toUShort() to "c", + (0x44).toUShort() to "d", + (0x45).toUShort() to "e", + (0x46).toUShort() to "f", + (0x47).toUShort() to "g", + (0x48).toUShort() to "h", + (0x49).toUShort() to "i", + (0x4A).toUShort() to "j", + (0x4B).toUShort() to "k", + (0x4C).toUShort() to "l", + (0x4D).toUShort() to "m", + (0x4E).toUShort() to "n", + (0x4F).toUShort() to "o", + (0x50).toUShort() to "p", + (0x51).toUShort() to "q", + (0x52).toUShort() to "r", + (0x53).toUShort() to "s", + (0x54).toUShort() to "t", + (0x55).toUShort() to "u", + (0x56).toUShort() to "v", + (0x57).toUShort() to "w", + (0x58).toUShort() to "x", + (0x59).toUShort() to "y", + (0x5A).toUShort() to "z", + ) + + fun getName(keyCode: UShort): String { + return map[keyCode] ?: "Unidentified" + } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt index 5d35d2aa6..17b74b90b 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt @@ -1,6 +1,8 @@ package com.github.ajalt.mordant.internal +import com.github.ajalt.mordant.input.KeyboardEvent import com.github.ajalt.mordant.terminal.* +import kotlin.time.Duration internal interface MppAtomicInt { fun getAndIncrement(): Int @@ -62,3 +64,7 @@ internal expect fun exitProcessMpp(status: Int) internal expect fun readFileIfExists(filename: String): String? internal expect fun hasFileSystem(): Boolean + +internal expect fun readKeyMpp(timeout: Duration): KeyboardEvent? + +internal expect fun enterRawModeMpp(): AutoCloseable diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt deleted file mode 100644 index e016fee8a..000000000 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsScanCodeToKeyEvent.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.example - -// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values#code_values_on_windows -object WindowsScanCodeToKeyEvent { - private val map = mapOf( - (0x0001).toShort() to "Escape", - (0x0002).toShort() to "Digit1", - (0x0003).toShort() to "Digit2", - (0x0004).toShort() to "Digit3", - (0x0005).toShort() to "Digit4", - (0x0006).toShort() to "Digit5", - (0x0007).toShort() to "Digit6", - (0x0008).toShort() to "Digit7", - (0x0009).toShort() to "Digit8", - (0x000A).toShort() to "Digit9", - (0x000B).toShort() to "Digit0", - (0x000C).toShort() to "Minus", - (0x000D).toShort() to "Equal", - (0x000E).toShort() to "Backspace", - (0x000F).toShort() to "Tab", - (0x0010).toShort() to "KeyQ", - (0x0011).toShort() to "KeyW", - (0x0012).toShort() to "KeyE", - (0x0013).toShort() to "KeyR", - (0x0014).toShort() to "KeyT", - (0x0015).toShort() to "KeyY", - (0x0016).toShort() to "KeyU", - (0x0017).toShort() to "KeyI", - (0x0018).toShort() to "KeyO", - (0x0019).toShort() to "KeyP", - (0x001A).toShort() to "BracketLeft", - (0x001B).toShort() to "BracketRight", - (0x001C).toShort() to "Enter", - (0x001D).toShort() to "ControlLeft", - (0x001E).toShort() to "KeyA", - (0x001F).toShort() to "KeyS", - (0x0020).toShort() to "KeyD", - (0x0021).toShort() to "KeyF", - (0x0022).toShort() to "KeyG", - (0x0023).toShort() to "KeyH", - (0x0024).toShort() to "KeyJ", - (0x0025).toShort() to "KeyK", - (0x0026).toShort() to "KeyL", - (0x0027).toShort() to "Semicolon", - (0x0028).toShort() to "Quote", - (0x0029).toShort() to "Backquote", - (0x002A).toShort() to "ShiftLeft", - (0x002B).toShort() to "Backslash", - (0x002C).toShort() to "KeyZ", - (0x002D).toShort() to "KeyX", - (0x002E).toShort() to "KeyC", - (0x002F).toShort() to "KeyV", - (0x0030).toShort() to "KeyB", - (0x0031).toShort() to "KeyN", - (0x0032).toShort() to "KeyM", - (0x0033).toShort() to "Comma", - (0x0034).toShort() to "Period", - (0x0035).toShort() to "Slash", - (0x0036).toShort() to "ShiftRight", - (0x0037).toShort() to "NumpadMultiply", - (0x0038).toShort() to "AltLeft", - (0x0039).toShort() to "Space", - (0x003A).toShort() to "CapsLock", - (0x003B).toShort() to "F1", - (0x003C).toShort() to "F2", - (0x003D).toShort() to "F3", - (0x003E).toShort() to "F4", - (0x003F).toShort() to "F5", - (0x0040).toShort() to "F6", - (0x0041).toShort() to "F7", - (0x0042).toShort() to "F8", - (0x0043).toShort() to "F9", - (0x0044).toShort() to "F10", - (0x0045).toShort() to "Pause", - (0x0046).toShort() to "ScrollLock", - (0x0047).toShort() to "Numpad7", - (0x0048).toShort() to "Numpad8", - (0x0049).toShort() to "Numpad9", - (0x004A).toShort() to "NumpadSubtract", - (0x004B).toShort() to "Numpad4", - (0x004C).toShort() to "Numpad5", - (0x004D).toShort() to "Numpad6", - (0x004E).toShort() to "NumpadAdd", - (0x004F).toShort() to "Numpad1", - (0x0050).toShort() to "Numpad2", - (0x0051).toShort() to "Numpad3", - (0x0052).toShort() to "Numpad0", - (0x0053).toShort() to "NumpadDecimal", - (0x0056).toShort() to "IntlBackslash", - (0x0057).toShort() to "F11", - (0x0058).toShort() to "F12", - (0x0059).toShort() to "NumpadEqual", - (0x0064).toShort() to "F13", - (0x0065).toShort() to "F14", - (0x0066).toShort() to "F15", - (0x0067).toShort() to "F16", - (0x0068).toShort() to "F17", - (0x0069).toShort() to "F18", - (0x006A).toShort() to "F19", - (0x006B).toShort() to "F20", - (0x006C).toShort() to "F21", - (0x006D).toShort() to "F22", - (0x006E).toShort() to "F23", - (0x0070).toShort() to "KanaMode", - (0x0073).toShort() to "IntlRo", - (0x0076).toShort() to "F24", - (0x0079).toShort() to "Convert", - (0x007B).toShort() to "NonConvert", - (0x007D).toShort() to "IntlYen", - (0x007E).toShort() to "NumpadComma", - (0xE010).toShort() to "MediaTrackPrevious", - (0xE019).toShort() to "MediaTrackNext", - (0xE01C).toShort() to "NumpadEnter", - (0xE01D).toShort() to "ControlRight", - (0xE020).toShort() to "AudioVolumeMute", - (0xE021).toShort() to "LaunchApp2", - (0xE022).toShort() to "MediaPlayPause", - (0xE024).toShort() to "MediaStop", - (0xE032).toShort() to "BrowserHome", - (0xE035).toShort() to "NumpadDivide", - (0xE037).toShort() to "PrintScreen", - (0xE038).toShort() to "AltRight", - (0xE045).toShort() to "NumLock", - (0xE047).toShort() to "Home", - (0xE048).toShort() to "ArrowUp", - (0xE049).toShort() to "PageUp", - (0xE04B).toShort() to "ArrowLeft", - (0xE04D).toShort() to "ArrowRight", - (0xE04F).toShort() to "End", - (0xE050).toShort() to "ArrowDown", - (0xE051).toShort() to "PageDown", - (0xE052).toShort() to "Insert", - (0xE053).toShort() to "Delete", - (0xE05D).toShort() to "ContextMenu", - (0xE05E).toShort() to "Power", - (0xE065).toShort() to "BrowserSearch", - (0xE066).toShort() to "BrowserFavorites", - (0xE067).toShort() to "BrowserRefresh", - (0xE068).toShort() to "BrowserStop", - (0xE069).toShort() to "BrowserForward", - (0xE06A).toShort() to "BrowserBack", - (0xE06B).toShort() to "LaunchApp1", - (0xE06C).toShort() to "LaunchMail", - (0xE06D).toShort() to "MediaSelect", - ) - - fun getName(scanCode: Short): String { - return map[scanCode] ?: "Unidentified" - } -} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt deleted file mode 100644 index a10cdebe9..000000000 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/WindowsVirtualKeyCodeToKeyEvent.kt +++ /dev/null @@ -1,119 +0,0 @@ -package com.example - -// https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes -// https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values -object WindowsVirtualKeyCodeToKeyEvent { - private val map = mapOf( - (0x12).toShort() to "Alt", // VK_MENU - (0x14).toShort() to "CapsLock", // VK_CAPITAL - (0x11).toShort() to "Control", // VK_CONTROL - (0x5B).toShort() to "Meta", // VK_LWIN - (0x90).toShort() to "NumLock", // VK_NUMLOCK - (0x91).toShort() to "ScrollLock", // VK_SCROLL - (0x10).toShort() to "Shift", // VK_SHIFT - (0x0D).toShort() to "Enter", // VK_RETURN - (0x09).toShort() to "Tab", // VK_TAB - (0x20).toShort() to " ", // VK_SPACE - (0x28).toShort() to "ArrowDown", // VK_DOWN - (0x25).toShort() to "ArrowLeft", // VK_LEFT - (0x27).toShort() to "ArrowRight", // VK_RIGHT - (0x26).toShort() to "ArrowUp", // VK_UP - (0x23).toShort() to "End", // VK_END - (0x24).toShort() to "Home", // VK_HOME - (0x22).toShort() to "PageDown", // VK_NEXT - (0x21).toShort() to "PageUp", // VK_PRIOR - (0x08).toShort() to "Backspace", // VK_BACK - (0x0C).toShort() to "Clear", // VK_CLEAR - (0xF7).toShort() to "CrSel", // VK_CRSEL - (0x2E).toShort() to "Delete", // VK_DELETE - (0xF9).toShort() to "EraseEof", // VK_EREOF - (0xF8).toShort() to "ExSel", // VK_EXSEL - (0x2D).toShort() to "Insert", // VK_INSERT - (0x1E).toShort() to "Accept", // VK_ACCEPT - (0xF0).toShort() to "Attn", // VK_OEM_ATTN - (0x5D).toShort() to "ContextMenu", // VK_APPS - (0x1B).toShort() to "Escape", // VK_ESCAPE - (0x2B).toShort() to "Execute", // VK_EXECUTE - (0xF1).toShort() to "Finish", // VK_OEM_FINISH - (0x2F).toShort() to "Help", // VK_HELP - (0x13).toShort() to "Pause", // VK_PAUSE - (0xFA).toShort() to "Play", // VK_PLAY - (0x29).toShort() to "Select", // VK_SELECT - (0x2C).toShort() to "PrintScreen", // VK_SNAPSHOT - (0x5F).toShort() to "Standby", // VK_SLEEP - (0xF0).toShort() to "Alphanumeric", // VK_OEM_ATTN - (0x1C).toShort() to "Convert", // VK_CONVERT - (0x18).toShort() to "FinalMode", // VK_FINAL - (0x1F).toShort() to "ModeChange", // VK_MODECHANGE - (0x1D).toShort() to "NonConvert", // VK_NONCONVERT - (0xE5).toShort() to "Process", // VK_PROCESSKEY - (0x15).toShort() to "HangulMode", // VK_HANGUL - (0x19).toShort() to "HanjaMode", // VK_HANJA - (0x17).toShort() to "JunjaMode", // VK_JUNJA - (0xF3).toShort() to "Hankaku", // VK_OEM_AUTO - (0xF2).toShort() to "Hiragana", // VK_OEM_COPY - (0x15).toShort() to "KanaMode", // VK_KANA - (0xF1).toShort() to "Katakana", // VK_OEM_FINISH - (0xF5).toShort() to "Romaji", // VK_OEM_BACKTAB - (0xF4).toShort() to "Zenkaku", // VK_OEM_ENLW - (0x70).toShort() to "F1", // VK_F1 - (0x71).toShort() to "F2", // VK_F2 - (0x72).toShort() to "F3", // VK_F3 - (0x73).toShort() to "F4", // VK_F4 - (0x74).toShort() to "F5", // VK_F5 - (0x75).toShort() to "F6", // VK_F6 - (0x76).toShort() to "F7", // VK_F7 - (0x77).toShort() to "F8", // VK_F8 - (0x78).toShort() to "F9", // VK_F9 - (0x79).toShort() to "F10", // VK_F10 - (0x7A).toShort() to "F11", // VK_F11 - (0x7B).toShort() to "F12", // VK_F12 - (0x7C).toShort() to "F13", // VK_F13 - (0x7D).toShort() to "F14", // VK_F14 - (0x7E).toShort() to "F15", // VK_F15 - (0x7F).toShort() to "F16", // VK_F16 - (0x80).toShort() to "F17", // VK_F17 - (0x81).toShort() to "F18", // VK_F18 - (0x82).toShort() to "F19", // VK_F19 - (0x83).toShort() to "F20", // VK_F20 - (0xB3).toShort() to "MediaPlayPause", // VK_MEDIA_PLAY_PAUSE - (0xB2).toShort() to "MediaStop", // VK_MEDIA_STOP - (0xB0).toShort() to "MediaTrackNext", // VK_MEDIA_NEXT_TRACK - (0xB1).toShort() to "MediaTrackPrevious", // VK_MEDIA_PREV_TRACK - (0xAE).toShort() to "AudioVolumeDown", // VK_VOLUME_DOWN - (0xAD).toShort() to "AudioVolumeMute", // VK_VOLUME_MUTE - (0xAF).toShort() to "AudioVolumeUp", // VK_VOLUME_UP - (0xFB).toShort() to "ZoomToggle", // VK_ZOOM - (0xB4).toShort() to "LaunchMail", // VK_LAUNCH_MAIL - (0xB5).toShort() to "LaunchMediaPlayer", // VK_LAUNCH_MEDIA_SELECT - (0xB6).toShort() to "LaunchApplication1", // VK_LAUNCH_APP1 - (0xB7).toShort() to "LaunchApplication2", // VK_LAUNCH_APP2 - (0xA6).toShort() to "BrowserBack", // VK_BROWSER_BACK - (0xAB).toShort() to "BrowserFavorites", // VK_BROWSER_FAVORITES - (0xA7).toShort() to "BrowserForward", // VK_BROWSER_FORWARD - (0xAC).toShort() to "BrowserHome", // VK_BROWSER_HOME - (0xA8).toShort() to "BrowserRefresh", // VK_BROWSER_REFRESH - (0xAA).toShort() to "BrowserSearch", // VK_BROWSER_SEARCH - (0xA9).toShort() to "BrowserStop", // VK_BROWSER_STOP - (0x6E).toShort() to "Decimal", // VK_DECIMAL - (0x6A).toShort() to "Multiply", // VK_MULTIPLY - (0x6B).toShort() to "Add", // VK_ADD - (0x6F).toShort() to "Divide", // VK_DIVIDE - (0x6D).toShort() to "Subtract", // VK_SUBTRACT - (0x6C).toShort() to "Separator", // VK_SEPARATOR - (0x60).toShort() to "0", // VK_NUMPAD0 - (0x61).toShort() to "1",// VK_NUMPAD1 - (0x62).toShort() to "2",// VK_NUMPAD2 - (0x63).toShort() to "3",// VK_NUMPAD3 - (0x64).toShort() to "4",// VK_NUMPAD4 - (0x65).toShort() to "5",// VK_NUMPAD5 - (0x66).toShort() to "6",// VK_NUMPAD6 - (0x67).toShort() to "7",// VK_NUMPAD7 - (0x68).toShort() to "8",// VK_NUMPAD8 - (0x69).toShort() to "9",// VK_NUMPAD9 - ) - - fun getName(keyCode: Short): String { - return map[keyCode] ?: "Unidentified" - } -} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt index cdfe8f506..14ff341a7 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt @@ -1,5 +1,8 @@ package com.github.ajalt.mordant.internal +import com.github.ajalt.mordant.input.KeyboardEvent +import kotlin.time.Duration + internal interface MppImpls { fun stdoutInteractive(): Boolean @@ -7,6 +10,8 @@ internal interface MppImpls { fun stderrInteractive(): Boolean fun getTerminalSize(): Size? fun fastIsTty(): Boolean = true + fun readKey(timeout: Duration): KeyboardEvent? + fun enterRawMode(): AutoCloseable } @@ -17,4 +22,6 @@ internal class FallbackMppImpls : MppImpls { override fun stderrInteractive(): Boolean = System.console() != null override fun getTerminalSize(): Size? = null override fun fastIsTty(): Boolean = false + override fun readKey(timeout: Duration): KeyboardEvent? = null + override fun enterRawMode(): AutoCloseable = AutoCloseable { } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt index 7f7d2bce6..871ef3764 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt @@ -1,5 +1,6 @@ package com.github.ajalt.mordant.internal +import com.github.ajalt.mordant.input.KeyboardEvent import com.github.ajalt.mordant.internal.jna.JnaLinuxMppImpls import com.github.ajalt.mordant.internal.jna.JnaMacosMppImpls import com.github.ajalt.mordant.internal.jna.JnaWin32MppImpls @@ -11,6 +12,7 @@ import java.lang.management.ManagementFactory import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.system.exitProcess +import kotlin.time.Duration private class JvmAtomicRef(value: T) : MppAtomicRef { private val ref = AtomicReference(value) @@ -141,6 +143,8 @@ private val impls: MppImpls = System.getProperty("os.name").let { os -> internal actual fun stdoutInteractive(): Boolean = impls.stdoutInteractive() internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive() internal actual fun getTerminalSize(): Size? = impls.getTerminalSize() +internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? = impls.readKey(timeout) +internal actual fun enterRawModeMpp(): AutoCloseable = impls.enterRawMode() internal actual val FAST_ISATTY: Boolean = true internal actual val CR_IMPLIES_LF: Boolean = false internal actual fun hasFileSystem(): Boolean = true @@ -154,3 +158,4 @@ internal actual fun readFileIfExists(filename: String): String? { if (!file.isFile) return null return file.bufferedReader().use { it.readText() } } + diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt index 771679c75..12eaf2483 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt @@ -1,6 +1,7 @@ package com.github.ajalt.mordant.internal.jna import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.internal.KeyboardInputLinux import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete @@ -127,7 +128,6 @@ private const val TCSANOW: Int = 0x0 private const val TCSADRAIN: Int = 0x1 private const val TCSAFLUSH: Int = 0x2 -private const val ESC = '\u001b' @Delete @Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") @@ -216,7 +216,7 @@ internal class JnaLinuxMppImpls : MppImpls { // https://www.man7.org/linux/man-pages/man3/termios.3.html // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html - fun enterRawMode(): AutoCloseable { + override fun enterRawMode(): AutoCloseable { val originalTermios = PosixLibC.termios() val termios = PosixLibC.termios() libC.tcgetattr(STDIN_FILENO, originalTermios) @@ -239,380 +239,7 @@ internal class JnaLinuxMppImpls : MppImpls { 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 - - ESC letter - ESC [ letter - ESC [ modifier letter - ESC [ 1 ; modifier letter - ESC [ num char - ESC [ num ; modifier char - ESC O letter - ESC O modifier letter - ESC O 1 ; modifier letter - ESC N letter - ESC [ [ num ; modifier char - ESC [ [ 1 ; modifier letter - ESC ESC [ num char - ESC ESC O letter - - - char is usually ~ but $ and ^ also happen with rxvt - - modifier is 1 + - (shift * 1) + - (left_alt * 2) + - (ctrl * 4) + - (right_alt * 8) - - two leading ESCs apparently mean the same as one leading ESC - */ - fun readKeyEvent(timeout: Duration = Duration.INFINITE): KeyboardEvent? { - val t0 = TimeSource.Monotonic.markNow() - var ctrl = false - var alt = false - var shift = false - var escaped = false - var name: String? = null - val s = StringBuilder() - var ch: Char = ' ' - - fun readTimeout(): Boolean { - ch = readRawByte(t0, timeout) ?: return true - s.append(ch) - return false - } - - if (readTimeout()) return null - - if (ch == ESC) { - escaped = true - if (readTimeout()) return KeyboardEvent( - key = "Escape", - code = "Escape", - ctrl = false, - alt = false, - shift = false - ) - if (ch == ESC) readTimeout() - } - - if (escaped && (ch == 'O' || ch == '[')) { - // ANSI escape sequence - val code = StringBuilder(ch.toString()) - var modifier = 0 - - if (ch == 'O') { - // ESC O letter - // ESC O modifier letter - if (readTimeout()) return null - - if (ch in '0'..'9') { - modifier = ch.code - 1 - if (readTimeout()) return null - } - - code.append(ch) - } else if (ch == '[') { - // ESC [ letter - // ESC [ modifier letter - // ESC [ [ modifier letter - // ESC [ [ num char - if (readTimeout()) return null - - if (ch == '[') { - // escape codes might have a second bracket - code.append(ch) - if (readTimeout()) return null - } - - /* - * Here and later we try to buffer just enough data to get - * a complete ascii sequence. - * - * We have basically two classes of ascii characters to process: - * - * - * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } - * - * This particular example is featuring Ctrl+F12 in xterm. - * - * - `;5` part is optional, e.g. it could be `\x1b[24~` - * - first part can contain one or two digits - * - there is also special case when there can be 3 digits - * but without modifier. They are the case of paste bracket mode - * - * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ - * - * - * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } - * - * This particular example is featuring Ctrl+Home in xterm. - * - * - `1;5` part is optional, e.g. it could be `\x1b[H` - * - `1;` part is optional, e.g. it could be `\x1b[5H` - * - * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ - * - */ - val cmdStart = s.length - 1 - - // leading digits - repeat(3) { - if (ch in '0'..'9') { - if (readTimeout()) return null - } - } - - // modifier - if (ch == ';') { - if (readTimeout()) return null - - if (ch in '0'..'9') { - if (readTimeout()) return null - } - } - - /* - * We buffered enough data, now trying to extract code - * and modifier from it - */ - val cmd = s.substring(cmdStart) - var match = Regex("""(\d\d?)(?:;(\d))?([~^$])|(\d{3}~)""").matchEntire(cmd) - if (match != null) { - if (match.groupValues[4].isNotEmpty()) { - code.append(match.groupValues[4]) - } else { - code.append(match.groupValues[1] + match.groupValues[3]) - modifier = (match.groupValues[2].toIntOrNull() ?: 1) - 1 - } - } else { - match = Regex("""((\d;)?(\d))?([A-Za-z])""").matchEntire(cmd) - if (match != null) { - code.append(match.groupValues[4]) - modifier = (match.groupValues[3].toIntOrNull() ?: 1) - 1 - } else { - code.append(cmd) - } - } - } - - // Parse the key modifier - ctrl = (modifier and 4) != 0 - alt = (modifier and 10) != 0 - shift = (modifier and 1) != 0 - - when (code.toString()) { - "[P" -> name = "F1" - "[Q" -> name = "F2" - "[R" -> name = "F3" - "[S" -> name = "F4" - "OP" -> name = "F1" - "OQ" -> name = "F2" - "OR" -> name = "F3" - "OS" -> name = "F4" - "[11~" -> name = "F1" - "[12~" -> name = "F2" - "[13~" -> name = "F3" - "[14~" -> name = "F4" - "[200~" -> name = "PasteStart" - "[201~" -> name = "PasteEnd" - "[[A" -> name = "F1" - "[[B" -> name = "F2" - "[[C" -> name = "F3" - "[[D" -> name = "F4" - "[[E" -> name = "F5" - "[15~" -> name = "F5" - "[17~" -> name = "F6" - "[18~" -> name = "F7" - "[19~" -> name = "F8" - "[20~" -> name = "F9" - "[21~" -> name = "F10" - "[23~" -> name = "F11" - "[24~" -> name = "F12" - "[A" -> name = "ArrowUp" - "[B" -> name = "ArrowDown" - "[C" -> name = "ArrowRight" - "[D" -> name = "ArrowLeft" - "[E" -> name = "Clear" - "[F" -> name = "End" - "[H" -> name = "Home" - "OA" -> name = "ArrowUp" - "OB" -> name = "ArrowDown" - "OC" -> name = "ArrowRight" - "OD" -> name = "ArrowLeft" - "OE" -> name = "Clear" - "OF" -> name = "End" - "OH" -> name = "Home" - "[1~" -> name = "Home" - "[2~" -> name = "Insert" - "[3~" -> name = "Delete" - "[4~" -> name = "End" - "[5~" -> name = "PageUp" - "[6~" -> name = "PageDown" - "[[5~" -> name = "PageUp" - "[[6~" -> name = "PageDown" - "[7~" -> name = "Home" - "[8~" -> name = "End" - "[a" -> { - name = "ArrowUp" - shift = true - } - - "[b" -> { - name = "ArrowDown" - shift = true - } - - "[c" -> { - name = "ArrowRight" - shift = true - } - - "[d" -> { - name = "ArrowLeft" - shift = true - } - - "[e" -> { - name = "Clear" - shift = true - } - - "[2$" -> { - name = "Insert" - shift = true - } - - "[3$" -> { - name = "Delete" - shift = true - } - - "[5$" -> { - name = "PageUp" - shift = true - } - - "[6$" -> { - name = "PageDown" - shift = true - } - - "[7$" -> { - name = "Home" - shift = true - } - - "[8$" -> { - name = "End" - shift = true - } - - "Oa" -> { - name = "ArrowUp" - ctrl = true - } - - "Ob" -> { - name = "ArrowDown" - ctrl = true - } - - "Oc" -> { - name = "ArrowRight" - ctrl = true - } - - "Od" -> { - name = "ArrowLeft" - ctrl = true - } - - "Oe" -> { - name = "Clear" - ctrl = true - } - - "[2^" -> { - name = "Insert" - ctrl = true - } - - "[3^" -> { - name = "Delete" - ctrl = true - } - - "[5^" -> { - name = "PageUp" - ctrl = true - } - - "[6^" -> { - name = "PageDown" - ctrl = true - } - - "[7^" -> { - name = "Home" - ctrl = true - } - - "[8^" -> { - name = "End" - ctrl = true - } - - "[Z" -> { - name = "Tab" - shift = true - } - - else -> name = "Unidentified" - } - } else if (ch == '\r') { - name = "Enter" - alt = escaped - } else if (ch == '\n') { - name = "Enter" - alt = escaped - } else if (ch == '\t') { - name = "Tab" - alt = escaped - } else if (ch == '\b' || ch == '\u007f') { - // backspace or ctrl+h - name = "Backspace" - alt = escaped - } else if (ch == ESC) { - // escape key - name = "Escape" - alt = escaped - } else if (ch == ' ') { - name = " " - alt = escaped - } else if (!escaped && ch <= '\u001a') { - // ctrl+letter - name = (ch.code + 'a'.code - 1).toChar().toString() - ctrl = true - } else if (ch.isLetter() || ch.isDigit()) { - // Letter, number, shift+letter - name = ch.toString() - shift = ch in 'A'..'Z' - alt = escaped - } else if (escaped) { - // Escape sequence timeout - TODO() -// name = ch.length ? undefined : "escape" -// alt = true - } - - return KeyboardEvent( - key = name ?: ch.toString(), - code = name ?: ch.toString(), - ctrl = ctrl, - alt = alt, - shift = shift, - ) + override fun readKey(timeout: Duration): KeyboardEvent? { + return KeyboardInputLinux.readKeyEvent(timeout, ::readRawByte) } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt index 1f7741197..ee79fea53 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt @@ -1,8 +1,7 @@ package com.github.ajalt.mordant.internal.jna -import com.example.WindowsScanCodeToKeyEvent -import com.example.WindowsVirtualKeyCodeToKeyEvent import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.internal.KeyboardInputWindows import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete @@ -10,7 +9,6 @@ import com.sun.jna.* import com.sun.jna.ptr.IntByReference import com.sun.jna.win32.W32APIOptions import kotlin.time.Duration -import kotlin.time.TimeSource // Interface definitions from // https://github.com/java-native-access/jna/blob/master/contrib/platform/src/com/sun/jna/platform/win32/Kernel32.java @@ -296,7 +294,7 @@ internal class JnaWin32MppImpls : MppImpls { return csbi.srWindow?.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } - fun enterRawMode(): Int { + override fun enterRawMode(): AutoCloseable { val originalMode = IntByReference() kernel.GetConsoleMode(stdinHandle, originalMode) @@ -304,15 +302,11 @@ internal class JnaWin32MppImpls : MppImpls { // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c kernel.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT) - return originalMode.value - } - fun exitRawMode(originalMode: Int) { - kernel.SetConsoleMode(stdinHandle, originalMode) + return AutoCloseable { kernel.SetConsoleMode(stdinHandle, originalMode.value) } } - private fun readRawKeyEvent(timeout: Duration): WinKernel32Lib.KEY_EVENT_RECORD? { - val dwMilliseconds = timeout.inWholeMilliseconds.coerceIn(0, Int.MAX_VALUE.toLong()).toInt() + private fun readRawKeyEvent(dwMilliseconds: Int): KeyboardInputWindows.KeyEventRecord? { val waitResult = kernel.WaitForSingleObject(stdinHandle.pointer, dwMilliseconds) if (waitResult != 0) { return null @@ -323,27 +317,17 @@ internal class JnaWin32MppImpls : MppImpls { if (eventsRead.value == 0) { return null } - return inputEvents[0]!!.Event!!.KeyEvent!! + val keyEvent = inputEvents[0]!!.Event!!.KeyEvent!! + return KeyboardInputWindows.KeyEventRecord( + bKeyDown = keyEvent.bKeyDown, + wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), + uChar = keyEvent.uChar!!.UnicodeChar, + dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), + ) } - fun readKeyEvent(timeout: Duration): KeyboardEvent? { - val t0 = TimeSource.Monotonic.markNow() - while (t0.elapsedNow() < timeout) { - val event = readRawKeyEvent(timeout - t0.elapsedNow()) - // ignore key up events - if (event != null && event.bKeyDown) { - val unicodeChar = event.uChar!!.UnicodeChar - return KeyboardEvent( - key = if (unicodeChar.code != 0) unicodeChar.toString() - else WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode), - code = WindowsScanCodeToKeyEvent.getName(event.wVirtualScanCode), - ctrl = event.dwControlKeyState and (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) != 0, - alt = event.dwControlKeyState and (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) != 0, - shift = event.dwControlKeyState and SHIFT_PRESSED != 0, - ) - } - } - return null + override fun readKey(timeout: Duration): KeyboardEvent? { + return KeyboardInputWindows.readKeyEvent(timeout, ::readRawKeyEvent) } } diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt index e0237bfcf..85507409f 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt @@ -2,8 +2,11 @@ package com.github.ajalt.mordant.internal +import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.internal.KeyboardInputWindows import kotlinx.cinterop.* import platform.windows.* +import kotlin.time.Duration // https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo @@ -41,3 +44,39 @@ internal actual fun ttySetEcho(echo: Boolean) = memScoped { } internal actual fun hasFileSystem(): Boolean = true + +internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? { + return KeyboardInputWindows.readKeyEvent(timeout, ::readRawKeyEvent) +} + +private fun readRawKeyEvent(dwMilliseconds: Int): KeyboardInputWindows.KeyEventRecord? = memScoped { + val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) + val waitResult = WaitForSingleObject(stdinHandle, dwMilliseconds.toUInt()) + if (waitResult != 0u) return null + val inputEvents = allocArray(1) + val eventsRead = alloc() + ReadConsoleInput!!(stdinHandle, inputEvents, 1u, eventsRead.ptr) + if (eventsRead.value == 0u) { + return null + } + val keyEvent = inputEvents[0].Event.KeyEvent + return KeyboardInputWindows.KeyEventRecord( + bKeyDown = keyEvent.bKeyDown != 0, + wVirtualKeyCode = keyEvent.wVirtualKeyCode, + uChar = keyEvent.uChar.UnicodeChar.toInt().toChar(), + dwControlKeyState = keyEvent.dwControlKeyState, + ) +} + +internal actual fun enterRawModeMpp(): AutoCloseable = memScoped{ + val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) + val originalMode = alloc() + GetConsoleMode(stdinHandle, originalMode.ptr) + + // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add + // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. + // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c + SetConsoleMode(stdinHandle, ENABLE_PROCESSED_INPUT.toUInt()) + + return AutoCloseable { SetConsoleMode(stdinHandle, originalMode.value) } +} diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt new file mode 100644 index 000000000..05fbdf016 --- /dev/null +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt @@ -0,0 +1,52 @@ +@file:OptIn(ExperimentalForeignApi::class) + +package com.github.ajalt.mordant.internal + +import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.internal.KeyboardInputLinux +import kotlinx.cinterop.* +import platform.posix.* +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +private fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? = memScoped { + while (t0.elapsedNow() < timeout) { + val c = alloc() + val read = read(STDIN_FILENO, c.ptr, 1u) + if (read < 0) return null + if (read > 0) return c.value.toInt().toChar() + } + return null +} + +internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? { + return KeyboardInputLinux.readKeyEvent(timeout, ::readRawByte) +} + +internal actual fun enterRawModeMpp(): AutoCloseable = memScoped { + val termios = alloc() + tcgetattr(STDIN_FILENO, termios.ptr) + + // This is clunky, but K/N doesn't seem to have a way to copy a struct, and calling tcgetattr + // twice here doesn't work because the kernel seems to mutate the returned values later. + val originalTermios = cValue { + repeat(NCCS) { c_cc[it] = termios.c_cc[it] } + c_cflag = termios.c_cflag + c_iflag = termios.c_iflag + c_ispeed = termios.c_ispeed + c_lflag = termios.c_lflag + c_line = termios.c_line + c_oflag = termios.c_oflag + c_ospeed = termios.c_ospeed + } + + // we leave OPOST on so we don't change \r\n handling + termios.c_iflag = termios.c_iflag and (ICRNL or INPCK or ISTRIP or IXON).inv().toUInt() + termios.c_cflag = termios.c_cflag or CS8.toUInt() + termios.c_lflag = termios.c_lflag and (ECHO or ICANON or IEXTEN or ISIG).inv().toUInt() + termios.c_cc[VMIN] = 0u // min wait time on read + termios.c_cc[VTIME] = 1u // max wait time on read, in 10ths of a second + tcsetattr(STDIN_FILENO, TCSADRAIN, termios.ptr) + return AutoCloseable { tcsetattr(STDIN_FILENO, TCSADRAIN, originalTermios.ptr) } +} From 276e6b0fee878652904f5b43ef708ed2f40196c8 Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 2 Jun 2024 10:09:04 -0700 Subject: [PATCH 04/45] Add raw mode to mac JNA and native --- .../input/internal/PosixRawModeHandler.kt | 161 ++++++++++++++++++ .../mordant/internal/jna/JnaLinuxMppImpls.kt | 40 +++-- .../mordant/internal/jna/JnaMacosMppImpls.kt | 66 ++++++- .../mordant/internal/MppInternal.posix.kt | 51 +++--- 4 files changed, 280 insertions(+), 38 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt new file mode 100644 index 000000000..176f0439b --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt @@ -0,0 +1,161 @@ +@file:Suppress("SpellCheckingInspection") + +package com.github.ajalt.mordant.input.internal + +internal abstract class PosixRawModeHandler { + protected class Termios( + val iflag: UInt, + val oflag: UInt, + val cflag: UInt, + val lflag: UInt, + val cline: Byte, + val cc: ByteArray, + val ispeed: UInt, + val ospeed: UInt, + ) + + protected abstract fun getStdinTermios(): Termios + protected abstract fun setStdinTermios(termios: Termios) + + // https://www.man7.org/linux/man-pages/man3/termios.3.html + // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html + fun enterRawMode(): AutoCloseable { + val orig = getStdinTermios() + val new = Termios( + iflag = orig.iflag and (ICRNL or IGNCR or INPCK or ISTRIP or IXON).inv(), + // we leave OPOST on so we don't change \r\n handling + oflag = orig.oflag, + cflag = orig.cflag or CS8, + lflag = orig.lflag and (ECHO or ICANON or IEXTEN or ISIG).inv(), + cline = orig.cline, + cc = orig.cc.copyOf().also { + it[VMIN] = 0 // min wait time on read + it[VTIME] = 1 // max wait time on read, in 10ths of a second + }, + ispeed = orig.ispeed, + ospeed = orig.ospeed, + ) + setStdinTermios(new) + return AutoCloseable { setStdinTermios(orig) } + } +} + + +private const val VINTR: Int = 0 +private const val VQUIT: Int = 1 +private const val VERASE: Int = 2 +private const val VKILL: Int = 3 +private const val VEOF: Int = 4 +private const val VTIME: Int = 5 +private const val VMIN: Int = 6 +private const val VSWTC: Int = 7 +private const val VSTART: Int = 8 +private const val VSTOP: Int = 9 +private const val VSUSP: Int = 10 +private const val VEOL: Int = 11 +private const val VREPRINT: Int = 12 +private const val VDISCARD: Int = 13 +private const val VWERASE: Int = 14 +private const val VLNEXT: Int = 15 +private const val VEOL2: Int = 16 + +private const val IGNBRK: UInt = 0x0000001u +private const val BRKINT: UInt = 0x0000002u +private const val IGNPAR: UInt = 0x0000004u +private const val PARMRK: UInt = 0x0000008u +private const val INPCK: UInt = 0x0000010u +private const val ISTRIP: UInt = 0x0000020u +private const val INLCR: UInt = 0x0000040u +private const val IGNCR: UInt = 0x0000080u +private const val ICRNL: UInt = 0x0000100u +private const val IUCLC: UInt = 0x0000200u +private const val IXON: UInt = 0x0000400u +private const val IXANY: UInt = 0x0000800u +private const val IXOFF: UInt = 0x0001000u +private const val IMAXBEL: UInt = 0x0002000u +private const val IUTF8: UInt = 0x0004000u + +private const val OPOST: UInt = 0x0000001u +private const val OLCUC: UInt = 0x0000002u +private const val ONLCR: UInt = 0x0000004u +private const val OCRNL: UInt = 0x0000008u +private const val ONOCR: UInt = 0x0000010u +private const val ONLRET: UInt = 0x0000020u +private const val OFILL: UInt = 0x0000040u +private const val OFDEL: UInt = 0x0000080u +private const val NLDLY: UInt = 0x0000100u +private const val NL0: UInt = 0x0000000u +private const val NL1: UInt = 0x0000100u +private const val CRDLY: UInt = 0x0000600u +private const val CR0: UInt = 0x0000000u +private const val CR1: UInt = 0x0000200u +private const val CR2: UInt = 0x0000400u +private const val CR3: UInt = 0x0000600u +private const val TABDLY: UInt = 0x0001800u +private const val TAB0: UInt = 0x0000000u +private const val TAB1: UInt = 0x0000800u +private const val TAB2: UInt = 0x0001000u +private const val TAB3: UInt = 0x0001800u +private const val XTABS: UInt = 0x0001800u +private const val BSDLY: UInt = 0x0002000u +private const val BS0: UInt = 0x0000000u +private const val BS1: UInt = 0x0002000u +private const val VTDLY: UInt = 0x0004000u +private const val VT0: UInt = 0x0000000u +private const val VT1: UInt = 0x0004000u +private const val FFDLY: UInt = 0x0008000u +private const val FF0: UInt = 0x0000000u +private const val FF1: UInt = 0x0008000u + +private const val CBAUD: UInt = 0x000100fu +private const val B0: UInt = 0x0000000u +private const val B50: UInt = 0x0000001u +private const val B75: UInt = 0x0000002u +private const val B110: UInt = 0x0000003u +private const val B134: UInt = 0x0000004u +private const val B150: UInt = 0x0000005u +private const val B200: UInt = 0x0000006u +private const val B300: UInt = 0x0000007u +private const val B600: UInt = 0x0000008u +private const val B1200: UInt = 0x0000009u +private const val B1800: UInt = 0x000000au +private const val B2400: UInt = 0x000000bu +private const val B4800: UInt = 0x000000cu +private const val B9600: UInt = 0x000000du +private const val B19200: UInt = 0x000000eu +private const val B38400: UInt = 0x000000fu +private const val EXTA: UInt = B19200 +private const val EXTB: UInt = B38400 +private const val CSIZE: UInt = 0x0000030u +private const val CS5: UInt = 0x0000000u +private const val CS6: UInt = 0x0000010u +private const val CS7: UInt = 0x0000020u +private const val CS8: UInt = 0x0000030u +private const val CSTOPB: UInt = 0x0000040u +private const val CREAD: UInt = 0x0000080u +private const val PARENB: UInt = 0x0000100u +private const val PARODD: UInt = 0x0000200u +private const val HUPCL: UInt = 0x0000400u +private const val CLOCAL: UInt = 0x0000800u + +private const val ISIG: UInt = 0x0000001u +private const val ICANON: UInt = 0x0000002u +private const val XCASE: UInt = 0x0000004u +private const val ECHO: UInt = 0x0000008u +private const val ECHOE: UInt = 0x0000010u +private const val ECHOK: UInt = 0x0000020u +private const val ECHONL: UInt = 0x0000040u +private const val NOFLSH: UInt = 0x0000080u +private const val TOSTOP: UInt = 0x0000100u +private const val ECHOCTL: UInt = 0x0000200u +private const val ECHOPRT: UInt = 0x0000400u +private const val ECHOKE: UInt = 0x0000800u +private const val FLUSHO: UInt = 0x0001000u +private const val PENDIN: UInt = 0x0002000u +private const val IEXTEN: UInt = 0x0008000u +private const val EXTPROC: UInt = 0x0010000u + +private const val TCSANOW: UInt = 0x0u +private const val TCSADRAIN: UInt = 0x1u +private const val TCSAFLUSH: UInt = 0x2u + diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt index 12eaf2483..3d880a025 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt @@ -2,6 +2,7 @@ package com.github.ajalt.mordant.internal.jna import com.github.ajalt.mordant.input.KeyboardEvent import com.github.ajalt.mordant.input.internal.KeyboardInputLinux +import com.github.ajalt.mordant.input.internal.PosixRawModeHandler import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete @@ -190,7 +191,7 @@ private interface PosixLibC : Library { } @Delete -internal class JnaLinuxMppImpls : MppImpls { +internal class JnaLinuxMppImpls : MppImpls, PosixRawModeHandler() { @Suppress("SpellCheckingInspection") private companion object { private const val STDIN_FILENO = 0 @@ -214,21 +215,32 @@ internal class JnaLinuxMppImpls : MppImpls { } } - // https://www.man7.org/linux/man-pages/man3/termios.3.html - // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html - override fun enterRawMode(): AutoCloseable { - val originalTermios = PosixLibC.termios() + override fun getStdinTermios(): Termios { val termios = PosixLibC.termios() - libC.tcgetattr(STDIN_FILENO, originalTermios) libC.tcgetattr(STDIN_FILENO, termios) - // we leave OPOST on so we don't change \r\n handling - termios.c_iflag = termios.c_iflag and (ICRNL or INPCK or ISTRIP or IXON).inv() - termios.c_cflag = termios.c_cflag or CS8 - termios.c_lflag = termios.c_lflag and (ECHO or ICANON or IEXTEN or ISIG).inv() - termios.c_cc[VMIN] = 0 // min wait time on read - termios.c_cc[VTIME] = 1 // max wait time on read, in 10ths of a second - libC.tcsetattr(STDIN_FILENO, TCSADRAIN, termios) - return AutoCloseable { libC.tcsetattr(STDIN_FILENO, TCSADRAIN, originalTermios) } + return Termios( + iflag = termios.c_iflag.toUInt(), + oflag = termios.c_oflag.toUInt(), + cflag = termios.c_cflag.toUInt(), + lflag = termios.c_lflag.toUInt(), + cline = termios.c_line, + cc = termios.c_cc.copyOf(), + ispeed = termios.c_ispeed.toUInt(), + ospeed = termios.c_ospeed.toUInt(), + ) + } + + override fun setStdinTermios(termios: Termios) { + val nativeTermios = PosixLibC.termios() + nativeTermios.c_iflag = termios.iflag.toInt() + nativeTermios.c_oflag = termios.oflag.toInt() + nativeTermios.c_cflag = termios.cflag.toInt() + nativeTermios.c_lflag = termios.lflag.toInt() + nativeTermios.c_line = termios.cline + termios.cc.copyInto(nativeTermios.c_cc) + nativeTermios.c_ispeed = termios.ispeed.toInt() + nativeTermios.c_ospeed = termios.ospeed.toInt() + libC.tcsetattr(STDIN_FILENO, TCSADRAIN , nativeTermios) } private fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt index a9d2666a0..8923dab7b 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt @@ -1,5 +1,6 @@ package com.github.ajalt.mordant.internal.jna +import com.github.ajalt.mordant.input.internal.PosixRawModeHandler import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete @@ -7,6 +8,8 @@ import com.sun.jna.* import java.io.IOException import java.util.concurrent.TimeUnit +private const val TCSANOW: Int = 0x0 + @Delete @Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") private interface MacosLibC : Library { @@ -30,12 +33,47 @@ private interface MacosLibC : Library { } } + @Structure.FieldOrder( + "c_iflag", "c_oflag", "c_cflag", "c_lflag", "c_cc", "c_ispeed", "c_ospeed" + ) + class termios : Structure() { + @JvmField + var c_iflag: NativeLong = NativeLong() + + @JvmField + var c_oflag: NativeLong = NativeLong() + + @JvmField + var c_cflag: NativeLong = NativeLong() + + @JvmField + var c_lflag: NativeLong = NativeLong() + + @JvmField + var c_line: Byte = 0 + + @JvmField + var c_cc: ByteArray = ByteArray(20) + + @JvmField + var c_ispeed: NativeLong = NativeLong() + + @JvmField + var c_ospeed: NativeLong = NativeLong() + } + fun isatty(fd: Int): Int fun ioctl(fd: Int, cmd: NativeLong?, data: winsize?): Int + + @Throws(LastErrorException::class) + fun tcgetattr(fd: Int, termios: termios) + + @Throws(LastErrorException::class) + fun tcsetattr(fd: Int, cmd: Int, termios: termios) } @Delete -internal class JnaMacosMppImpls : MppImpls { +internal class JnaMacosMppImpls : MppImpls, PosixRawModeHandler() { @Suppress("SpellCheckingInspection") private companion object { const val STDIN_FILENO = 0 @@ -65,7 +103,33 @@ internal class JnaMacosMppImpls : MppImpls { return getSttySize(100) } + override fun getStdinTermios(): Termios { + val termios = MacosLibC.termios() + libC.tcgetattr(STDIN_FILENO, termios) + return Termios( + iflag = termios.c_iflag.toInt().toUInt(), + oflag = termios.c_oflag.toInt().toUInt(), + cflag = termios.c_cflag.toInt().toUInt(), + lflag = termios.c_lflag.toInt().toUInt(), + cline = termios.c_line, + cc = termios.c_cc.copyOf(), + ispeed = termios.c_ispeed.toInt().toUInt(), + ospeed = termios.c_ospeed.toInt().toUInt(), + ) + } + override fun setStdinTermios(termios: Termios) { + val nativeTermios = MacosLibC.termios() + nativeTermios.c_iflag = NativeLong(termios.iflag.toLong()) + nativeTermios.c_oflag = NativeLong(termios.oflag.toLong()) + nativeTermios.c_cflag = NativeLong(termios.cflag.toLong()) + nativeTermios.c_lflag = NativeLong(termios.lflag.toLong()) + nativeTermios.c_line = termios.cline + termios.cc.copyInto(nativeTermios.c_cc) + nativeTermios.c_ispeed = NativeLong(termios.ispeed.toLong()) + nativeTermios.c_ospeed = NativeLong(termios.ospeed.toLong()) + libC.tcsetattr(STDIN_FILENO, TCSANOW, nativeTermios) + } } @Suppress("SameParameterValue") diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt index 05fbdf016..83f33b2d7 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt @@ -4,11 +4,11 @@ package com.github.ajalt.mordant.internal import com.github.ajalt.mordant.input.KeyboardEvent import com.github.ajalt.mordant.input.internal.KeyboardInputLinux +import com.github.ajalt.mordant.input.internal.PosixRawModeHandler import kotlinx.cinterop.* import platform.posix.* import kotlin.time.ComparableTimeMark import kotlin.time.Duration -import kotlin.time.Duration.Companion.seconds private fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? = memScoped { while (t0.elapsedNow() < timeout) { @@ -24,29 +24,34 @@ internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? { return KeyboardInputLinux.readKeyEvent(timeout, ::readRawByte) } -internal actual fun enterRawModeMpp(): AutoCloseable = memScoped { - val termios = alloc() - tcgetattr(STDIN_FILENO, termios.ptr) +internal actual fun enterRawModeMpp(): AutoCloseable = LinuxNativeRawModeHandler.enterRawMode() - // This is clunky, but K/N doesn't seem to have a way to copy a struct, and calling tcgetattr - // twice here doesn't work because the kernel seems to mutate the returned values later. - val originalTermios = cValue { - repeat(NCCS) { c_cc[it] = termios.c_cc[it] } - c_cflag = termios.c_cflag - c_iflag = termios.c_iflag - c_ispeed = termios.c_ispeed - c_lflag = termios.c_lflag - c_line = termios.c_line - c_oflag = termios.c_oflag - c_ospeed = termios.c_ospeed +private object LinuxNativeRawModeHandler : PosixRawModeHandler() { + override fun getStdinTermios(): Termios = memScoped { + val termios = alloc() + tcgetattr(STDIN_FILENO, termios.ptr) + return Termios( + iflag = termios.c_iflag, + oflag = termios.c_oflag, + cflag = termios.c_cflag, + lflag = termios.c_lflag, + cline = termios.c_line.toByte(), + cc = ByteArray(NCCS) { termios.c_cc[it].toByte() }, + ispeed = termios.c_ispeed, + ospeed = termios.c_ospeed, + ) } - // we leave OPOST on so we don't change \r\n handling - termios.c_iflag = termios.c_iflag and (ICRNL or INPCK or ISTRIP or IXON).inv().toUInt() - termios.c_cflag = termios.c_cflag or CS8.toUInt() - termios.c_lflag = termios.c_lflag and (ECHO or ICANON or IEXTEN or ISIG).inv().toUInt() - termios.c_cc[VMIN] = 0u // min wait time on read - termios.c_cc[VTIME] = 1u // max wait time on read, in 10ths of a second - tcsetattr(STDIN_FILENO, TCSADRAIN, termios.ptr) - return AutoCloseable { tcsetattr(STDIN_FILENO, TCSADRAIN, originalTermios.ptr) } + override fun setStdinTermios(termios: Termios): Unit = memScoped { + val nativeTermios = alloc() + nativeTermios.c_iflag = termios.iflag + nativeTermios.c_oflag = termios.oflag + nativeTermios.c_cflag = termios.cflag + nativeTermios.c_lflag = termios.lflag + nativeTermios.c_line = termios.cline.toUByte() + repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].toUByte() } + nativeTermios.c_ispeed = termios.ispeed + nativeTermios.c_ospeed = termios.ospeed + tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) + } } From d2de034b52a9e0cad76f9b08a43d7daa8b4babee Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 2 Jun 2024 11:46:42 -0700 Subject: [PATCH 05/45] Move common mpp code to SyscallHandler interface --- mordant/build.gradle.kts | 4 + .../mordant/internal/MppInternal.apple.kt | 22 -- .../ajalt/mordant/animation/Animation.kt | 3 +- .../ajalt/mordant/input/KeyboardInput.kt | 9 +- .../input/internal/PosixRawModeHandler.kt | 161 ----------- .../ajalt/mordant/internal/MppInternal.kt | 16 +- .../internal/syscalls/SyscallHandler.kt | 24 ++ .../syscalls/SyscallHandler.posix.kt} | 178 +++++++++++- .../syscalls/SyscallHandler.windows.kt} | 32 ++- .../github/ajalt/mordant/terminal/Terminal.kt | 2 +- .../mordant/terminal/TerminalDetection.kt | 13 +- .../mordant/internal/MppInternal.jsCommon.kt | 4 - .../github/ajalt/mordant/internal/MppImpls.kt | 27 -- .../ajalt/mordant/internal/MppInternal.jvm.kt | 45 ++- .../mordant/internal/jna/JnaLinuxMppImpls.kt | 257 ------------------ .../nativeimage/NativeImagePosixMppImpls.kt | 35 ++- .../nativeimage/NativeImageWin32MppImpls.kt | 59 ++-- .../syscalls/SyscallHandler.jna.linux.kt | 110 ++++++++ .../SyscallHandler.jna.macos.kt} | 30 +- .../syscalls/SyscallHandler.jna.posix.kt | 14 + .../SyscallHandler.jna.windows.kt} | 28 +- .../mordant/internal/MppInternal.linux.kt | 21 -- .../mordant/internal/MppInternal.mingw.kt | 60 +--- .../syscalls/SyscallHandler.native.windows.kt | 68 +++++ .../mordant/internal/MppInternal.native.kt | 9 +- .../mordant/internal/MppInternal.posix.kt | 57 +--- .../syscalls/SyscallHanlder.native.posix.kt | 61 +++++ .../com/github/ajalt/mordant/internal/tty.kt | 2 - 28 files changed, 572 insertions(+), 779 deletions(-) delete mode 100644 mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt delete mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt rename mordant/src/commonMain/kotlin/com/github/ajalt/mordant/{input/internal/KeyboardInputLinux.kt => internal/syscalls/SyscallHandler.posix.kt} (61%) rename mordant/src/commonMain/kotlin/com/github/ajalt/mordant/{input/internal/KeyboardInputWindows.kt => internal/syscalls/SyscallHandler.windows.kt} (56%) delete mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt delete mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt create mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt rename mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/{jna/JnaMacosMppImpls.kt => syscalls/SyscallHandler.jna.macos.kt} (82%) create mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt rename mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/{jna/JnaWin32MppImpls.kt => syscalls/SyscallHandler.jna.windows.kt} (90%) create mode 100644 mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt create mode 100644 mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt diff --git a/mordant/build.gradle.kts b/mordant/build.gradle.kts index 67a2bc8d5..3cdccc825 100644 --- a/mordant/build.gradle.kts +++ b/mordant/build.gradle.kts @@ -5,6 +5,10 @@ plugins { kotlin { sourceSets { + all { + languageSettings.optIn("kotlinx.cinterop.ExperimentalForeignApi") + languageSettings.optIn("kotlin.experimental.ExperimentalNativeApi") + } commonMain.dependencies { api(libs.colormath) implementation(libs.markdown) diff --git a/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt b/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt deleted file mode 100644 index e20c134f2..000000000 --- a/mordant/src/appleMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.apple.kt +++ /dev/null @@ -1,22 +0,0 @@ -@file:OptIn(ExperimentalForeignApi::class) - -package com.github.ajalt.mordant.internal - -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import platform.posix.STDIN_FILENO -import platform.posix.TIOCGWINSZ -import platform.posix.ioctl -import platform.posix.winsize - -internal actual fun getTerminalSize(): Size? { - return memScoped { - val size = alloc() - if (ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) { - null - } else { - Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) - } - } -} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt index 6a7cc0e5b..cad513850 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt @@ -1,7 +1,6 @@ package com.github.ajalt.mordant.animation import com.github.ajalt.mordant.internal.* -import com.github.ajalt.mordant.internal.FAST_ISATTY import com.github.ajalt.mordant.internal.MppAtomicRef import com.github.ajalt.mordant.internal.Size import com.github.ajalt.mordant.internal.update @@ -142,7 +141,7 @@ abstract class Animation( * place. */ fun update(data: T) { - if (FAST_ISATTY) terminal.info.updateTerminalSize() + if (SYSCALL_HANDLER.fastIsTty()) terminal.info.updateTerminalSize() val rendered = renderData(data).render(terminal) val height = rendered.height diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt index 4feb653fa..96c8d7b28 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt @@ -1,17 +1,16 @@ package com.github.ajalt.mordant.input -import com.github.ajalt.mordant.internal.enterRawModeMpp -import com.github.ajalt.mordant.internal.readKeyMpp +import com.github.ajalt.mordant.internal.SYSCALL_HANDLER import com.github.ajalt.mordant.terminal.Terminal import kotlin.time.Duration -// TODO docs +// TODO docs, tests fun Terminal.readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { if (!info.inputInteractive) return null - return readKeyMpp(timeout) + return SYSCALL_HANDLER.readKeyEvent(timeout) } fun Terminal.enterRawMode(): AutoCloseable { if (!info.inputInteractive) return AutoCloseable { } - return enterRawModeMpp() + return SYSCALL_HANDLER.enterRawMode() } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt deleted file mode 100644 index 176f0439b..000000000 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/PosixRawModeHandler.kt +++ /dev/null @@ -1,161 +0,0 @@ -@file:Suppress("SpellCheckingInspection") - -package com.github.ajalt.mordant.input.internal - -internal abstract class PosixRawModeHandler { - protected class Termios( - val iflag: UInt, - val oflag: UInt, - val cflag: UInt, - val lflag: UInt, - val cline: Byte, - val cc: ByteArray, - val ispeed: UInt, - val ospeed: UInt, - ) - - protected abstract fun getStdinTermios(): Termios - protected abstract fun setStdinTermios(termios: Termios) - - // https://www.man7.org/linux/man-pages/man3/termios.3.html - // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html - fun enterRawMode(): AutoCloseable { - val orig = getStdinTermios() - val new = Termios( - iflag = orig.iflag and (ICRNL or IGNCR or INPCK or ISTRIP or IXON).inv(), - // we leave OPOST on so we don't change \r\n handling - oflag = orig.oflag, - cflag = orig.cflag or CS8, - lflag = orig.lflag and (ECHO or ICANON or IEXTEN or ISIG).inv(), - cline = orig.cline, - cc = orig.cc.copyOf().also { - it[VMIN] = 0 // min wait time on read - it[VTIME] = 1 // max wait time on read, in 10ths of a second - }, - ispeed = orig.ispeed, - ospeed = orig.ospeed, - ) - setStdinTermios(new) - return AutoCloseable { setStdinTermios(orig) } - } -} - - -private const val VINTR: Int = 0 -private const val VQUIT: Int = 1 -private const val VERASE: Int = 2 -private const val VKILL: Int = 3 -private const val VEOF: Int = 4 -private const val VTIME: Int = 5 -private const val VMIN: Int = 6 -private const val VSWTC: Int = 7 -private const val VSTART: Int = 8 -private const val VSTOP: Int = 9 -private const val VSUSP: Int = 10 -private const val VEOL: Int = 11 -private const val VREPRINT: Int = 12 -private const val VDISCARD: Int = 13 -private const val VWERASE: Int = 14 -private const val VLNEXT: Int = 15 -private const val VEOL2: Int = 16 - -private const val IGNBRK: UInt = 0x0000001u -private const val BRKINT: UInt = 0x0000002u -private const val IGNPAR: UInt = 0x0000004u -private const val PARMRK: UInt = 0x0000008u -private const val INPCK: UInt = 0x0000010u -private const val ISTRIP: UInt = 0x0000020u -private const val INLCR: UInt = 0x0000040u -private const val IGNCR: UInt = 0x0000080u -private const val ICRNL: UInt = 0x0000100u -private const val IUCLC: UInt = 0x0000200u -private const val IXON: UInt = 0x0000400u -private const val IXANY: UInt = 0x0000800u -private const val IXOFF: UInt = 0x0001000u -private const val IMAXBEL: UInt = 0x0002000u -private const val IUTF8: UInt = 0x0004000u - -private const val OPOST: UInt = 0x0000001u -private const val OLCUC: UInt = 0x0000002u -private const val ONLCR: UInt = 0x0000004u -private const val OCRNL: UInt = 0x0000008u -private const val ONOCR: UInt = 0x0000010u -private const val ONLRET: UInt = 0x0000020u -private const val OFILL: UInt = 0x0000040u -private const val OFDEL: UInt = 0x0000080u -private const val NLDLY: UInt = 0x0000100u -private const val NL0: UInt = 0x0000000u -private const val NL1: UInt = 0x0000100u -private const val CRDLY: UInt = 0x0000600u -private const val CR0: UInt = 0x0000000u -private const val CR1: UInt = 0x0000200u -private const val CR2: UInt = 0x0000400u -private const val CR3: UInt = 0x0000600u -private const val TABDLY: UInt = 0x0001800u -private const val TAB0: UInt = 0x0000000u -private const val TAB1: UInt = 0x0000800u -private const val TAB2: UInt = 0x0001000u -private const val TAB3: UInt = 0x0001800u -private const val XTABS: UInt = 0x0001800u -private const val BSDLY: UInt = 0x0002000u -private const val BS0: UInt = 0x0000000u -private const val BS1: UInt = 0x0002000u -private const val VTDLY: UInt = 0x0004000u -private const val VT0: UInt = 0x0000000u -private const val VT1: UInt = 0x0004000u -private const val FFDLY: UInt = 0x0008000u -private const val FF0: UInt = 0x0000000u -private const val FF1: UInt = 0x0008000u - -private const val CBAUD: UInt = 0x000100fu -private const val B0: UInt = 0x0000000u -private const val B50: UInt = 0x0000001u -private const val B75: UInt = 0x0000002u -private const val B110: UInt = 0x0000003u -private const val B134: UInt = 0x0000004u -private const val B150: UInt = 0x0000005u -private const val B200: UInt = 0x0000006u -private const val B300: UInt = 0x0000007u -private const val B600: UInt = 0x0000008u -private const val B1200: UInt = 0x0000009u -private const val B1800: UInt = 0x000000au -private const val B2400: UInt = 0x000000bu -private const val B4800: UInt = 0x000000cu -private const val B9600: UInt = 0x000000du -private const val B19200: UInt = 0x000000eu -private const val B38400: UInt = 0x000000fu -private const val EXTA: UInt = B19200 -private const val EXTB: UInt = B38400 -private const val CSIZE: UInt = 0x0000030u -private const val CS5: UInt = 0x0000000u -private const val CS6: UInt = 0x0000010u -private const val CS7: UInt = 0x0000020u -private const val CS8: UInt = 0x0000030u -private const val CSTOPB: UInt = 0x0000040u -private const val CREAD: UInt = 0x0000080u -private const val PARENB: UInt = 0x0000100u -private const val PARODD: UInt = 0x0000200u -private const val HUPCL: UInt = 0x0000400u -private const val CLOCAL: UInt = 0x0000800u - -private const val ISIG: UInt = 0x0000001u -private const val ICANON: UInt = 0x0000002u -private const val XCASE: UInt = 0x0000004u -private const val ECHO: UInt = 0x0000008u -private const val ECHOE: UInt = 0x0000010u -private const val ECHOK: UInt = 0x0000020u -private const val ECHONL: UInt = 0x0000040u -private const val NOFLSH: UInt = 0x0000080u -private const val TOSTOP: UInt = 0x0000100u -private const val ECHOCTL: UInt = 0x0000200u -private const val ECHOPRT: UInt = 0x0000400u -private const val ECHOKE: UInt = 0x0000800u -private const val FLUSHO: UInt = 0x0001000u -private const val PENDIN: UInt = 0x0002000u -private const val IEXTEN: UInt = 0x0008000u -private const val EXTPROC: UInt = 0x0010000u - -private const val TCSANOW: UInt = 0x0u -private const val TCSADRAIN: UInt = 0x1u -private const val TCSAFLUSH: UInt = 0x2u - diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt index 17b74b90b..0401b1f15 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt @@ -1,8 +1,7 @@ package com.github.ajalt.mordant.internal -import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.internal.syscalls.SyscallHandler import com.github.ajalt.mordant.terminal.* -import kotlin.time.Duration internal interface MppAtomicInt { fun getAndIncrement(): Int @@ -32,15 +31,8 @@ internal expect fun MppAtomicInt(initial: Int): MppAtomicInt internal expect fun getEnv(key: String): String? -/** Return a pair of [width, height], or null if it can't be detected */ -internal expect fun getTerminalSize(): Size? - internal expect fun runningInIdeaJavaAgent(): Boolean -internal expect fun stdoutInteractive(): Boolean - -internal expect fun stdinInteractive(): Boolean - internal expect fun codepointSequence(string: String): Sequence internal expect fun makePrintingTerminalCursor(terminal: Terminal): TerminalCursor @@ -55,8 +47,6 @@ internal expect fun sendInterceptedPrintRequest( interceptors: List, ) -internal expect val FAST_ISATTY: Boolean - internal expect val CR_IMPLIES_LF: Boolean internal expect fun exitProcessMpp(status: Int) @@ -65,6 +55,6 @@ internal expect fun readFileIfExists(filename: String): String? internal expect fun hasFileSystem(): Boolean -internal expect fun readKeyMpp(timeout: Duration): KeyboardEvent? +internal expect fun getSyscallHandler() : SyscallHandler -internal expect fun enterRawModeMpp(): AutoCloseable +internal val SYSCALL_HANDLER: SyscallHandler = getSyscallHandler() diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt new file mode 100644 index 000000000..9bb05c898 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt @@ -0,0 +1,24 @@ +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.internal.Size +import kotlin.time.Duration + +internal interface SyscallHandler { + fun stdoutInteractive(): Boolean + fun stdinInteractive(): Boolean + fun stderrInteractive(): Boolean + fun getTerminalSize(): Size? + fun fastIsTty(): Boolean = true + fun readKeyEvent(timeout: Duration): KeyboardEvent? + fun enterRawMode(): AutoCloseable +} + +internal object DumbSyscallHandler : SyscallHandler { + override fun stdoutInteractive(): Boolean = false + override fun stdinInteractive(): Boolean = false + override fun stderrInteractive(): Boolean = false + override fun getTerminalSize(): Size? = null + override fun readKeyEvent(timeout: Duration): KeyboardEvent? = null + override fun enterRawMode(): AutoCloseable = AutoCloseable { } +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt similarity index 61% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt rename to mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 468b8d0ff..dbd01671a 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputLinux.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -1,12 +1,178 @@ -package com.github.ajalt.mordant.input.internal +package com.github.ajalt.mordant.internal.syscalls import com.github.ajalt.mordant.input.KeyboardEvent import kotlin.time.ComparableTimeMark import kotlin.time.Duration import kotlin.time.TimeSource -object KeyboardInputLinux { - private const val ESC = '\u001b' +internal abstract class SyscallHandlerPosix : SyscallHandler { + protected companion object { + const val STDIN_FILENO = 0 + const val STDOUT_FILENO = 1 + const val STDERR_FILENO = 2 + + private const val ESC = '\u001b' + + private const val VINTR: Int = 0 + private const val VQUIT: Int = 1 + private const val VERASE: Int = 2 + private const val VKILL: Int = 3 + private const val VEOF: Int = 4 + private const val VTIME: Int = 5 + private const val VMIN: Int = 6 + private const val VSWTC: Int = 7 + private const val VSTART: Int = 8 + private const val VSTOP: Int = 9 + private const val VSUSP: Int = 10 + private const val VEOL: Int = 11 + private const val VREPRINT: Int = 12 + private const val VDISCARD: Int = 13 + private const val VWERASE: Int = 14 + private const val VLNEXT: Int = 15 + private const val VEOL2: Int = 16 + + private const val IGNBRK: UInt = 0x0000001u + private const val BRKINT: UInt = 0x0000002u + private const val IGNPAR: UInt = 0x0000004u + private const val PARMRK: UInt = 0x0000008u + private const val INPCK: UInt = 0x0000010u + private const val ISTRIP: UInt = 0x0000020u + private const val INLCR: UInt = 0x0000040u + private const val IGNCR: UInt = 0x0000080u + private const val ICRNL: UInt = 0x0000100u + private const val IUCLC: UInt = 0x0000200u + private const val IXON: UInt = 0x0000400u + private const val IXANY: UInt = 0x0000800u + private const val IXOFF: UInt = 0x0001000u + private const val IMAXBEL: UInt = 0x0002000u + private const val IUTF8: UInt = 0x0004000u + + private const val OPOST: UInt = 0x0000001u + private const val OLCUC: UInt = 0x0000002u + private const val ONLCR: UInt = 0x0000004u + private const val OCRNL: UInt = 0x0000008u + private const val ONOCR: UInt = 0x0000010u + private const val ONLRET: UInt = 0x0000020u + private const val OFILL: UInt = 0x0000040u + private const val OFDEL: UInt = 0x0000080u + private const val NLDLY: UInt = 0x0000100u + private const val NL0: UInt = 0x0000000u + private const val NL1: UInt = 0x0000100u + private const val CRDLY: UInt = 0x0000600u + private const val CR0: UInt = 0x0000000u + private const val CR1: UInt = 0x0000200u + private const val CR2: UInt = 0x0000400u + private const val CR3: UInt = 0x0000600u + private const val TABDLY: UInt = 0x0001800u + private const val TAB0: UInt = 0x0000000u + private const val TAB1: UInt = 0x0000800u + private const val TAB2: UInt = 0x0001000u + private const val TAB3: UInt = 0x0001800u + private const val XTABS: UInt = 0x0001800u + private const val BSDLY: UInt = 0x0002000u + private const val BS0: UInt = 0x0000000u + private const val BS1: UInt = 0x0002000u + private const val VTDLY: UInt = 0x0004000u + private const val VT0: UInt = 0x0000000u + private const val VT1: UInt = 0x0004000u + private const val FFDLY: UInt = 0x0008000u + private const val FF0: UInt = 0x0000000u + private const val FF1: UInt = 0x0008000u + + private const val CBAUD: UInt = 0x000100fu + private const val B0: UInt = 0x0000000u + private const val B50: UInt = 0x0000001u + private const val B75: UInt = 0x0000002u + private const val B110: UInt = 0x0000003u + private const val B134: UInt = 0x0000004u + private const val B150: UInt = 0x0000005u + private const val B200: UInt = 0x0000006u + private const val B300: UInt = 0x0000007u + private const val B600: UInt = 0x0000008u + private const val B1200: UInt = 0x0000009u + private const val B1800: UInt = 0x000000au + private const val B2400: UInt = 0x000000bu + private const val B4800: UInt = 0x000000cu + private const val B9600: UInt = 0x000000du + private const val B19200: UInt = 0x000000eu + private const val B38400: UInt = 0x000000fu + private const val EXTA: UInt = B19200 + private const val EXTB: UInt = B38400 + private const val CSIZE: UInt = 0x0000030u + private const val CS5: UInt = 0x0000000u + private const val CS6: UInt = 0x0000010u + private const val CS7: UInt = 0x0000020u + private const val CS8: UInt = 0x0000030u + private const val CSTOPB: UInt = 0x0000040u + private const val CREAD: UInt = 0x0000080u + private const val PARENB: UInt = 0x0000100u + private const val PARODD: UInt = 0x0000200u + private const val HUPCL: UInt = 0x0000400u + private const val CLOCAL: UInt = 0x0000800u + + private const val ISIG: UInt = 0x0000001u + private const val ICANON: UInt = 0x0000002u + private const val XCASE: UInt = 0x0000004u + private const val ECHO: UInt = 0x0000008u + private const val ECHOE: UInt = 0x0000010u + private const val ECHOK: UInt = 0x0000020u + private const val ECHONL: UInt = 0x0000040u + private const val NOFLSH: UInt = 0x0000080u + private const val TOSTOP: UInt = 0x0000100u + private const val ECHOCTL: UInt = 0x0000200u + private const val ECHOPRT: UInt = 0x0000400u + private const val ECHOKE: UInt = 0x0000800u + private const val FLUSHO: UInt = 0x0001000u + private const val PENDIN: UInt = 0x0002000u + private const val IEXTEN: UInt = 0x0008000u + private const val EXTPROC: UInt = 0x0010000u + + private const val TCSANOW: UInt = 0x0u + private const val TCSADRAIN: UInt = 0x1u + private const val TCSAFLUSH: UInt = 0x2u + } + + protected class Termios( + val iflag: UInt, + val oflag: UInt, + val cflag: UInt, + val lflag: UInt, + val cline: Byte, + val cc: ByteArray, + val ispeed: UInt, + val ospeed: UInt, + ) + + protected abstract fun getStdinTermios(): Termios + protected abstract fun setStdinTermios(termios: Termios) + protected abstract fun isatty(fd: Int): Int + protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? + + override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0 + override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) != 0 + override fun stderrInteractive(): Boolean = isatty(STDERR_FILENO) != 0 + + // https://www.man7.org/linux/man-pages/man3/termios.3.html + // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html + override fun enterRawMode(): AutoCloseable { + val orig = getStdinTermios() + val new = Termios( + iflag = orig.iflag and (ICRNL or IGNCR or INPCK or ISTRIP or IXON).inv(), + // we leave OPOST on so we don't change \r\n handling + oflag = orig.oflag, + cflag = orig.cflag or CS8, + lflag = orig.lflag and (ECHO or ICANON or IEXTEN or ISIG).inv(), + cline = orig.cline, + cc = orig.cc.copyOf().also { + it[VMIN] = 0 // min wait time on read + it[VTIME] = 1 // max wait time on read, in 10ths of a second + }, + ispeed = orig.ispeed, + ospeed = orig.ospeed, + ) + setStdinTermios(new) + return AutoCloseable { setStdinTermios(orig) } + } /* Some patterns seen in terminal key escape codes, derived from combos seen @@ -35,10 +201,7 @@ object KeyboardInputLinux { (right_alt * 8) - two leading ESCs apparently mean the same as one leading ESC */ - fun readKeyEvent( - timeout: Duration, - readRawByte: (t0: ComparableTimeMark, timeout: Duration) -> Char?, - ): KeyboardEvent? { + override fun readKeyEvent(timeout: Duration): KeyboardEvent? { val t0 = TimeSource.Monotonic.markNow() var ctrl = false var alt = false @@ -385,3 +548,4 @@ object KeyboardInputLinux { ) } } + diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt similarity index 56% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt rename to mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index cfee9d787..ebb164664 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/KeyboardInputWindows.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -1,30 +1,32 @@ -package com.github.ajalt.mordant.input.internal +package com.github.ajalt.mordant.internal.syscalls import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.internal.WindowsVirtualKeyCodeToKeyEvent import kotlin.time.Duration import kotlin.time.TimeSource -internal object KeyboardInputWindows { - // https://learn.microsoft.com/en-us/windows/console/key-event-record-str - private const val RIGHT_ALT_PRESSED: UInt = 0x0001u - private const val LEFT_ALT_PRESSED: UInt = 0x0002u - private const val RIGHT_CTRL_PRESSED: UInt = 0x0004u - private const val LEFT_CTRL_PRESSED: UInt = 0x0008u - private const val SHIFT_PRESSED: UInt = 0x0010u - private val CTRL_PRESSED_MASK = (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) - private val ALT_PRESSED_MASK = (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) +internal abstract class SyscallHandlerWindows: SyscallHandler { + private companion object { + // https://learn.microsoft.com/en-us/windows/console/key-event-record-str + const val RIGHT_ALT_PRESSED: UInt = 0x0001u + const val LEFT_ALT_PRESSED: UInt = 0x0002u + const val RIGHT_CTRL_PRESSED: UInt = 0x0004u + const val LEFT_CTRL_PRESSED: UInt = 0x0008u + const val SHIFT_PRESSED: UInt = 0x0010u + val CTRL_PRESSED_MASK = (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) + val ALT_PRESSED_MASK = (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) + } - data class KeyEventRecord( + protected data class KeyEventRecord( val bKeyDown: Boolean, val wVirtualKeyCode: UShort, val uChar: Char, val dwControlKeyState: UInt, ) - fun readKeyEvent( - timeout: Duration, - readRawKeyEvent: (dwMilliseconds: Int) -> KeyEventRecord?, - ): KeyboardEvent? { + protected abstract fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? + + override fun readKeyEvent(timeout: Duration): KeyboardEvent? { val t0 = TimeSource.Monotonic.markNow() while (t0.elapsedNow() < timeout) { val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt index a916163e2..22fc01484 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/Terminal.kt @@ -374,7 +374,7 @@ class Terminal private constructor( } private fun sendPrintRequest(request: PrintRequest) { - if (FAST_ISATTY) info.updateTerminalSize() + if (SYSCALL_HANDLER.fastIsTty()) info.updateTerminalSize() sendInterceptedPrintRequest(request, terminalInterface, interceptors.value) } } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt index 5f41e8604..72e1e7890 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalDetection.kt @@ -1,6 +1,9 @@ package com.github.ajalt.mordant.terminal -import com.github.ajalt.mordant.internal.* +import com.github.ajalt.mordant.internal.SYSCALL_HANDLER +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.getEnv +import com.github.ajalt.mordant.internal.runningInIdeaJavaAgent import com.github.ajalt.mordant.rendering.AnsiLevel import com.github.ajalt.mordant.rendering.AnsiLevel.* @@ -14,8 +17,8 @@ internal object TerminalDetection { ): TerminalInfo { // intellij console is interactive, even though isatty returns false val ij = isIntellijRunActionConsole() - val inputInteractive = interactive ?: (ij || stdinInteractive()) - val outputInteractive = interactive ?: (ij || stdoutInteractive()) + val inputInteractive = interactive ?: (ij || SYSCALL_HANDLER.stdinInteractive()) + val outputInteractive = interactive ?: (ij || SYSCALL_HANDLER.stdoutInteractive()) val level = ansiLevel ?: ansiLevel(outputInteractive) val ansiHyperLinks = hyperlinks ?: (outputInteractive && level != NONE && ansiHyperLinks()) val (w, h) = detectInitialSize() @@ -31,10 +34,10 @@ internal object TerminalDetection { } /** Returns the size, or `null` if the size can't be detected */ - fun detectSize(): Size? = getTerminalSize() + fun detectSize(): Size? = SYSCALL_HANDLER.getTerminalSize() private fun detectInitialSize(): Size { - return getTerminalSize() ?: Size( + return detectSize() ?: Size( width = (getEnv("COLUMNS")?.toIntOrNull() ?: 79), height = (getEnv("LINES")?.toIntOrNull() ?: 24) ) diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt index a146e0a56..fc9e2c42a 100644 --- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt @@ -103,10 +103,7 @@ private val impls: JsMppImpls = makeNodeMppImpls() ?: BrowserMppImpls internal actual fun runningInIdeaJavaAgent(): Boolean = false -internal actual fun getTerminalSize(): Size? = impls.getTerminalSize() internal actual fun getEnv(key: String): String? = impls.readEnvvar(key) -internal actual fun stdoutInteractive(): Boolean = impls.stdoutInteractive() -internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive() internal actual fun printStderr(message: String, newline: Boolean) { impls.printStderr(message, newline) } @@ -137,5 +134,4 @@ internal actual fun sendInterceptedPrintRequest( ) } -internal actual val FAST_ISATTY: Boolean = true internal actual fun hasFileSystem(): Boolean = impls !is BrowserMppImpls diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt deleted file mode 100644 index 14ff341a7..000000000 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppImpls.kt +++ /dev/null @@ -1,27 +0,0 @@ -package com.github.ajalt.mordant.internal - -import com.github.ajalt.mordant.input.KeyboardEvent -import kotlin.time.Duration - - -internal interface MppImpls { - fun stdoutInteractive(): Boolean - fun stdinInteractive(): Boolean - fun stderrInteractive(): Boolean - fun getTerminalSize(): Size? - fun fastIsTty(): Boolean = true - fun readKey(timeout: Duration): KeyboardEvent? - fun enterRawMode(): AutoCloseable -} - - -/** A non-JNA implementation for unimplemented OSes like FreeBSD */ -internal class FallbackMppImpls : MppImpls { - override fun stdoutInteractive(): Boolean = System.console() != null - override fun stdinInteractive(): Boolean = System.console() != null - override fun stderrInteractive(): Boolean = System.console() != null - override fun getTerminalSize(): Size? = null - override fun fastIsTty(): Boolean = false - override fun readKey(timeout: Duration): KeyboardEvent? = null - override fun enterRawMode(): AutoCloseable = AutoCloseable { } -} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt index 871ef3764..4373230e2 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt @@ -1,18 +1,12 @@ package com.github.ajalt.mordant.internal -import com.github.ajalt.mordant.input.KeyboardEvent -import com.github.ajalt.mordant.internal.jna.JnaLinuxMppImpls -import com.github.ajalt.mordant.internal.jna.JnaMacosMppImpls -import com.github.ajalt.mordant.internal.jna.JnaWin32MppImpls -import com.github.ajalt.mordant.internal.nativeimage.NativeImagePosixMppImpls -import com.github.ajalt.mordant.internal.nativeimage.NativeImageWin32MppImpls +import com.github.ajalt.mordant.internal.syscalls.* import com.github.ajalt.mordant.terminal.* import java.io.File import java.lang.management.ManagementFactory import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import kotlin.system.exitProcess -import kotlin.time.Duration private class JvmAtomicRef(value: T) : MppAtomicRef { private val ref = AtomicReference(value) @@ -122,30 +116,26 @@ internal actual fun sendInterceptedPrintRequest( }) } -private val impls: MppImpls = System.getProperty("os.name").let { os -> - try { - // Inlined version of ImageInfo.inImageCode() - val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") - val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" - when { - isNativeImage && os.startsWith("Windows") -> NativeImageWin32MppImpls() - isNativeImage && (os == "Linux" || os == "Mac OS X") -> NativeImagePosixMppImpls() - os.startsWith("Windows") -> JnaWin32MppImpls() - os == "Linux" -> JnaLinuxMppImpls() - os == "Mac OS X" -> JnaMacosMppImpls() - else -> FallbackMppImpls() +internal actual fun getSyscallHandler(): SyscallHandler { + return System.getProperty("os.name").let { os -> + try { + // Inlined version of ImageInfo.inImageCode() + val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") + val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" + when { + isNativeImage && os.startsWith("Windows") -> TODO("NativeImageWin32MppImpls()") + isNativeImage && (os == "Linux" || os == "Mac OS X") -> TODO("NativeImagePosixMppImpls()") + os.startsWith("Windows") -> SyscallHandlerJnaWindows + os == "Linux" -> SyscallHandlerJnaLinux + os == "Mac OS X" -> SyscallHandlerJnaMacos + else -> DumbSyscallHandler + } + } catch (e: UnsatisfiedLinkError) { + DumbSyscallHandler } - } catch (e: UnsatisfiedLinkError) { - FallbackMppImpls() } } -internal actual fun stdoutInteractive(): Boolean = impls.stdoutInteractive() -internal actual fun stdinInteractive(): Boolean = impls.stdinInteractive() -internal actual fun getTerminalSize(): Size? = impls.getTerminalSize() -internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? = impls.readKey(timeout) -internal actual fun enterRawModeMpp(): AutoCloseable = impls.enterRawMode() -internal actual val FAST_ISATTY: Boolean = true internal actual val CR_IMPLIES_LF: Boolean = false internal actual fun hasFileSystem(): Boolean = true @@ -158,4 +148,3 @@ internal actual fun readFileIfExists(filename: String): String? { if (!file.isFile) return null return file.bufferedReader().use { it.readText() } } - diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt deleted file mode 100644 index 3d880a025..000000000 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaLinuxMppImpls.kt +++ /dev/null @@ -1,257 +0,0 @@ -package com.github.ajalt.mordant.internal.jna - -import com.github.ajalt.mordant.input.KeyboardEvent -import com.github.ajalt.mordant.input.internal.KeyboardInputLinux -import com.github.ajalt.mordant.input.internal.PosixRawModeHandler -import com.github.ajalt.mordant.internal.MppImpls -import com.github.ajalt.mordant.internal.Size -import com.oracle.svm.core.annotate.Delete -import com.sun.jna.* -import kotlin.time.ComparableTimeMark -import kotlin.time.Duration -import kotlin.time.TimeSource - -private const val VINTR: Int = 0 -private const val VQUIT: Int = 1 -private const val VERASE: Int = 2 -private const val VKILL: Int = 3 -private const val VEOF: Int = 4 -private const val VTIME: Int = 5 -private const val VMIN: Int = 6 -private const val VSWTC: Int = 7 -private const val VSTART: Int = 8 -private const val VSTOP: Int = 9 -private const val VSUSP: Int = 10 -private const val VEOL: Int = 11 -private const val VREPRINT: Int = 12 -private const val VDISCARD: Int = 13 -private const val VWERASE: Int = 14 -private const val VLNEXT: Int = 15 -private const val VEOL2: Int = 16 - -private const val IGNBRK: Int = 0x0000001 -private const val BRKINT: Int = 0x0000002 -private const val IGNPAR: Int = 0x0000004 -private const val PARMRK: Int = 0x0000008 -private const val INPCK: Int = 0x0000010 -private const val ISTRIP: Int = 0x0000020 -private const val INLCR: Int = 0x0000040 -private const val IGNCR: Int = 0x0000080 -private const val ICRNL: Int = 0x0000100 -private const val IUCLC: Int = 0x0000200 -private const val IXON: Int = 0x0000400 -private const val IXANY: Int = 0x0000800 -private const val IXOFF: Int = 0x0001000 -private const val IMAXBEL: Int = 0x0002000 -private const val IUTF8: Int = 0x0004000 - -private const val OPOST: Int = 0x0000001 -private const val OLCUC: Int = 0x0000002 -private const val ONLCR: Int = 0x0000004 -private const val OCRNL: Int = 0x0000008 -private const val ONOCR: Int = 0x0000010 -private const val ONLRET: Int = 0x0000020 -private const val OFILL: Int = 0x0000040 -private const val OFDEL: Int = 0x0000080 -private const val NLDLY: Int = 0x0000100 -private const val NL0: Int = 0x0000000 -private const val NL1: Int = 0x0000100 -private const val CRDLY: Int = 0x0000600 -private const val CR0: Int = 0x0000000 -private const val CR1: Int = 0x0000200 -private const val CR2: Int = 0x0000400 -private const val CR3: Int = 0x0000600 -private const val TABDLY: Int = 0x0001800 -private const val TAB0: Int = 0x0000000 -private const val TAB1: Int = 0x0000800 -private const val TAB2: Int = 0x0001000 -private const val TAB3: Int = 0x0001800 -private const val XTABS: Int = 0x0001800 -private const val BSDLY: Int = 0x0002000 -private const val BS0: Int = 0x0000000 -private const val BS1: Int = 0x0002000 -private const val VTDLY: Int = 0x0004000 -private const val VT0: Int = 0x0000000 -private const val VT1: Int = 0x0004000 -private const val FFDLY: Int = 0x0008000 -private const val FF0: Int = 0x0000000 -private const val FF1: Int = 0x0008000 - -private const val CBAUD: Int = 0x000100f -private const val B0: Int = 0x0000000 -private const val B50: Int = 0x0000001 -private const val B75: Int = 0x0000002 -private const val B110: Int = 0x0000003 -private const val B134: Int = 0x0000004 -private const val B150: Int = 0x0000005 -private const val B200: Int = 0x0000006 -private const val B300: Int = 0x0000007 -private const val B600: Int = 0x0000008 -private const val B1200: Int = 0x0000009 -private const val B1800: Int = 0x000000a -private const val B2400: Int = 0x000000b -private const val B4800: Int = 0x000000c -private const val B9600: Int = 0x000000d -private const val B19200: Int = 0x000000e -private const val B38400: Int = 0x000000f -private const val EXTA: Int = B19200 -private const val EXTB: Int = B38400 -private const val CSIZE: Int = 0x0000030 -private const val CS5: Int = 0x0000000 -private const val CS6: Int = 0x0000010 -private const val CS7: Int = 0x0000020 -private const val CS8: Int = 0x0000030 -private const val CSTOPB: Int = 0x0000040 -private const val CREAD: Int = 0x0000080 -private const val PARENB: Int = 0x0000100 -private const val PARODD: Int = 0x0000200 -private const val HUPCL: Int = 0x0000400 -private const val CLOCAL: Int = 0x0000800 - -private const val ISIG: Int = 0x0000001 -private const val ICANON: Int = 0x0000002 -private const val XCASE: Int = 0x0000004 -private const val ECHO: Int = 0x0000008 -private const val ECHOE: Int = 0x0000010 -private const val ECHOK: Int = 0x0000020 -private const val ECHONL: Int = 0x0000040 -private const val NOFLSH: Int = 0x0000080 -private const val TOSTOP: Int = 0x0000100 -private const val ECHOCTL: Int = 0x0000200 -private const val ECHOPRT: Int = 0x0000400 -private const val ECHOKE: Int = 0x0000800 -private const val FLUSHO: Int = 0x0001000 -private const val PENDIN: Int = 0x0002000 -private const val IEXTEN: Int = 0x0008000 -private const val EXTPROC: Int = 0x0010000 - -private const val TCSANOW: Int = 0x0 -private const val TCSADRAIN: Int = 0x1 -private const val TCSAFLUSH: Int = 0x2 - - -@Delete -@Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") -private interface PosixLibC : Library { - - @Suppress("unused") - @Structure.FieldOrder("ws_row", "ws_col", "ws_xpixel", "ws_ypixel") - class winsize : Structure() { - @JvmField - var ws_row: Short = 0 - - @JvmField - var ws_col: Short = 0 - - @JvmField - var ws_xpixel: Short = 0 - - @JvmField - var ws_ypixel: Short = 0 - } - - @Structure.FieldOrder( - "c_iflag", "c_oflag", "c_cflag", "c_lflag", "c_line", "c_cc", "c_ispeed", "c_ospeed" - ) - class termios : Structure() { - @JvmField - var c_iflag: Int = 0 - - @JvmField - var c_oflag: Int = 0 - - @JvmField - var c_cflag: Int = 0 - - @JvmField - var c_lflag: Int = 0 - - @JvmField - var c_line: Byte = 0 - - @JvmField - var c_cc: ByteArray = ByteArray(32) - - @JvmField - var c_ispeed: Int = 0 - - @JvmField - var c_ospeed: Int = 0 - } - - - fun isatty(fd: Int): Int - fun ioctl(fd: Int, cmd: Int, data: winsize?): Int - - @Throws(LastErrorException::class) - fun tcgetattr(fd: Int, termios: termios) - - @Throws(LastErrorException::class) - fun tcsetattr(fd: Int, cmd: Int, termios: termios) -} - -@Delete -internal class JnaLinuxMppImpls : MppImpls, PosixRawModeHandler() { - @Suppress("SpellCheckingInspection") - private companion object { - private const val STDIN_FILENO = 0 - private const val STDOUT_FILENO = 1 - private const val STDERR_FILENO = 2 - - private const val TIOCGWINSZ = 0x00005413 - } - - private val libC: PosixLibC = Native.load(Platform.C_LIBRARY_NAME, PosixLibC::class.java) - override fun stdoutInteractive(): Boolean = libC.isatty(STDOUT_FILENO) == 1 - override fun stdinInteractive(): Boolean = libC.isatty(STDIN_FILENO) == 1 - override fun stderrInteractive(): Boolean = libC.isatty(STDERR_FILENO) == 1 - - override fun getTerminalSize(): Size? { - val size = PosixLibC.winsize() - return if (libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) { - null - } else { - Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) - } - } - - override fun getStdinTermios(): Termios { - val termios = PosixLibC.termios() - libC.tcgetattr(STDIN_FILENO, termios) - return Termios( - iflag = termios.c_iflag.toUInt(), - oflag = termios.c_oflag.toUInt(), - cflag = termios.c_cflag.toUInt(), - lflag = termios.c_lflag.toUInt(), - cline = termios.c_line, - cc = termios.c_cc.copyOf(), - ispeed = termios.c_ispeed.toUInt(), - ospeed = termios.c_ospeed.toUInt(), - ) - } - - override fun setStdinTermios(termios: Termios) { - val nativeTermios = PosixLibC.termios() - nativeTermios.c_iflag = termios.iflag.toInt() - nativeTermios.c_oflag = termios.oflag.toInt() - nativeTermios.c_cflag = termios.cflag.toInt() - nativeTermios.c_lflag = termios.lflag.toInt() - nativeTermios.c_line = termios.cline - termios.cc.copyInto(nativeTermios.c_cc) - nativeTermios.c_ispeed = termios.ispeed.toInt() - nativeTermios.c_ospeed = termios.ospeed.toInt() - libC.tcsetattr(STDIN_FILENO, TCSADRAIN , nativeTermios) - } - - private fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { - while (t0.elapsedNow() < timeout) { - val c = System.`in`.read().takeIf { it >= 0 }?.toChar() - if (c != null) return c - } - return null - } - - override fun readKey(timeout: Duration): KeyboardEvent? { - return KeyboardInputLinux.readKeyEvent(timeout, ::readRawByte) - } -} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt index 60c39f80d..f9fddef3b 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.internal.nativeimage -import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import org.graalvm.nativeimage.Platform import org.graalvm.nativeimage.Platforms @@ -50,20 +49,20 @@ private object PosixLibC { external fun ioctl(fd: Int, cmd: Int, winSize: winsize?): Int } - -@Platforms(Platform.LINUX::class, Platform.MACOS::class) -internal class NativeImagePosixMppImpls : MppImpls { - - override fun stdoutInteractive() = PosixLibC.isatty(PosixLibC.STDOUT_FILENO()) - override fun stdinInteractive() = PosixLibC.isatty(PosixLibC.STDIN_FILENO()) - override fun stderrInteractive() = PosixLibC.isatty(PosixLibC.STDERR_FILENO()) - - override fun getTerminalSize(): Size? { - val size = StackValue.get(PosixLibC.winsize::class.java) - return if (PosixLibC.ioctl(0, PosixLibC.TIOCGWINSZ(), size) < 0) { - null - } else { - Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) - } - } -} +// +//@Platforms(Platform.LINUX::class, Platform.MACOS::class) +//internal class NativeImagePosixMppImpls : MppImpls { +// +// override fun stdoutInteractive() = PosixLibC.isatty(PosixLibC.STDOUT_FILENO()) +// override fun stdinInteractive() = PosixLibC.isatty(PosixLibC.STDIN_FILENO()) +// override fun stderrInteractive() = PosixLibC.isatty(PosixLibC.STDERR_FILENO()) +// +// override fun getTerminalSize(): Size? { +// val size = StackValue.get(PosixLibC.winsize::class.java) +// return if (PosixLibC.ioctl(0, PosixLibC.TIOCGWINSZ(), size) < 0) { +// null +// } else { +// Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) +// } +// } +//} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt index 33ace7b5a..3b46d96fa 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.internal.nativeimage -import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import org.graalvm.nativeimage.Platform import org.graalvm.nativeimage.Platforms @@ -62,32 +61,32 @@ private object WinKernel32Lib { } -@Platforms(Platform.WINDOWS::class) -internal class NativeImageWin32MppImpls : MppImpls { - - override fun stdoutInteractive(): Boolean { - val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) - return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) - } - - override fun stderrInteractive(): Boolean { - val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE()) - return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) - } - - override fun stdinInteractive(): Boolean { - val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) - return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) - } - - override fun getTerminalSize(): Size? { - val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java) - val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) - return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) { - null - } else { - Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1) - } - } - -} +//@Platforms(Platform.WINDOWS::class) +//internal class NativeImageWin32MppImpls : MppImpls { +// +// override fun stdoutInteractive(): Boolean { +// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) +// return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) +// } +// +// override fun stderrInteractive(): Boolean { +// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE()) +// return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) +// } +// +// override fun stdinInteractive(): Boolean { +// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) +// return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) +// } +// +// override fun getTerminalSize(): Size? { +// val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java) +// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) +// return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) { +// null +// } else { +// Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1) +// } +// } +// +//} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt new file mode 100644 index 000000000..5858f7852 --- /dev/null +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt @@ -0,0 +1,110 @@ +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.internal.Size +import com.oracle.svm.core.annotate.Delete +import com.sun.jna.* + +@Delete +@Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") +private interface PosixLibC : Library { + + @Suppress("unused") + @Structure.FieldOrder("ws_row", "ws_col", "ws_xpixel", "ws_ypixel") + class winsize : Structure() { + @JvmField + var ws_row: Short = 0 + + @JvmField + var ws_col: Short = 0 + + @JvmField + var ws_xpixel: Short = 0 + + @JvmField + var ws_ypixel: Short = 0 + } + + @Structure.FieldOrder( + "c_iflag", "c_oflag", "c_cflag", "c_lflag", "c_line", "c_cc", "c_ispeed", "c_ospeed" + ) + class termios : Structure() { + @JvmField + var c_iflag: Int = 0 + + @JvmField + var c_oflag: Int = 0 + + @JvmField + var c_cflag: Int = 0 + + @JvmField + var c_lflag: Int = 0 + + @JvmField + var c_line: Byte = 0 + + @JvmField + var c_cc: ByteArray = ByteArray(32) + + @JvmField + var c_ispeed: Int = 0 + + @JvmField + var c_ospeed: Int = 0 + } + + + fun isatty(fd: Int): Int + fun ioctl(fd: Int, cmd: Int, data: winsize?): Int + + @Throws(LastErrorException::class) + fun tcgetattr(fd: Int, termios: termios) + + @Throws(LastErrorException::class) + fun tcsetattr(fd: Int, cmd: Int, termios: termios) +} + +@Delete +internal object SyscallHandlerJnaLinux : SyscallHandlerJnaPosix() { + private const val TIOCGWINSZ = 0x00005413 + private const val TCSADRAIN: Int = 0x1 + private val libC: PosixLibC = Native.load(Platform.C_LIBRARY_NAME, PosixLibC::class.java) + override fun isatty(fd: Int): Int = libC.isatty(fd) + + override fun getTerminalSize(): Size? { + val size = PosixLibC.winsize() + return if (libC.ioctl(STDIN_FILENO, TIOCGWINSZ, size) < 0) { + null + } else { + Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) + } + } + + override fun getStdinTermios(): Termios { + val termios = PosixLibC.termios() + libC.tcgetattr(STDIN_FILENO, termios) + return Termios( + iflag = termios.c_iflag.toUInt(), + oflag = termios.c_oflag.toUInt(), + cflag = termios.c_cflag.toUInt(), + lflag = termios.c_lflag.toUInt(), + cline = termios.c_line, + cc = termios.c_cc.copyOf(), + ispeed = termios.c_ispeed.toUInt(), + ospeed = termios.c_ospeed.toUInt(), + ) + } + + override fun setStdinTermios(termios: Termios) { + val nativeTermios = PosixLibC.termios() + nativeTermios.c_iflag = termios.iflag.toInt() + nativeTermios.c_oflag = termios.oflag.toInt() + nativeTermios.c_cflag = termios.cflag.toInt() + nativeTermios.c_lflag = termios.lflag.toInt() + nativeTermios.c_line = termios.cline + termios.cc.copyInto(nativeTermios.c_cc) + nativeTermios.c_ispeed = termios.ispeed.toInt() + nativeTermios.c_ospeed = termios.ospeed.toInt() + libC.tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios) + } +} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.macos.kt similarity index 82% rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.macos.kt index 8923dab7b..49a303153 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaMacosMppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.macos.kt @@ -1,15 +1,11 @@ -package com.github.ajalt.mordant.internal.jna +package com.github.ajalt.mordant.internal.syscalls -import com.github.ajalt.mordant.input.internal.PosixRawModeHandler -import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete import com.sun.jna.* import java.io.IOException import java.util.concurrent.TimeUnit -private const val TCSANOW: Int = 0x0 - @Delete @Suppress("ClassName", "PropertyName", "MemberVisibilityCanBePrivate", "SpellCheckingInspection") private interface MacosLibC : Library { @@ -73,27 +69,19 @@ private interface MacosLibC : Library { } @Delete -internal class JnaMacosMppImpls : MppImpls, PosixRawModeHandler() { - @Suppress("SpellCheckingInspection") - private companion object { - const val STDIN_FILENO = 0 - const val STDOUT_FILENO = 1 - const val STDERR_FILENO = 2 - - val TIOCGWINSZ = when { - Platform.isMIPS() || Platform.isPPC() || Platform.isSPARC() -> 0x40087468L - else -> 0x00005413L - } +@Suppress("SpellCheckingInspection") +internal object SyscallHandlerJnaMacos : SyscallHandlerJnaPosix() { + private const val TCSANOW: Int = 0x0 + private val TIOCGWINSZ = when { + Platform.isMIPS() || Platform.isPPC() || Platform.isSPARC() -> 0x40087468L + else -> 0x00005413L } private val libC: MacosLibC = Native.load(Platform.C_LIBRARY_NAME, MacosLibC::class.java) - override fun stdoutInteractive(): Boolean = libC.isatty(STDOUT_FILENO) == 1 - override fun stdinInteractive(): Boolean = libC.isatty(STDIN_FILENO) == 1 - override fun stderrInteractive(): Boolean = libC.isatty(STDERR_FILENO) == 1 - + override fun isatty(fd: Int): Int = libC.isatty(fd) override fun fastIsTty(): Boolean = false override fun getTerminalSize(): Size? { - // TODO: this seems to fail on macosArm64, use stty on mac for now + // TODO: JNA has a bug that causes this to fail on macosArm64, use stty on mac for now // val size = MacosLibC.winsize() // return if (libC.ioctl(STDIN_FILENO, NativeLong(TIOCGWINSZ), size) < 0) { // null diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt new file mode 100644 index 000000000..438901be2 --- /dev/null +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt @@ -0,0 +1,14 @@ +package com.github.ajalt.mordant.internal.syscalls + +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration + +internal abstract class SyscallHandlerJnaPosix : SyscallHandlerPosix() { + override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { + while (t0.elapsedNow() < timeout) { + val c = System.`in`.read().takeIf { it >= 0 }?.toChar() + if (c != null) return c + } + return null + } +} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.windows.kt similarity index 90% rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.windows.kt index ee79fea53..caee67241 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/jna/JnaWin32MppImpls.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.windows.kt @@ -1,8 +1,6 @@ -package com.github.ajalt.mordant.internal.jna +package com.github.ajalt.mordant.internal.syscalls import com.github.ajalt.mordant.input.KeyboardEvent -import com.github.ajalt.mordant.input.internal.KeyboardInputWindows -import com.github.ajalt.mordant.internal.MppImpls import com.github.ajalt.mordant.internal.Size import com.oracle.svm.core.annotate.Delete import com.sun.jna.* @@ -244,18 +242,10 @@ interface WinKernel32Lib : Library { @Delete -internal class JnaWin32MppImpls : MppImpls { - private companion object { - // https://learn.microsoft.com/en-us/windows/console/key-event-record-str - const val RIGHT_ALT_PRESSED: Int = 0x0001 - const val LEFT_ALT_PRESSED: Int = 0x0002 - const val RIGHT_CTRL_PRESSED: Int = 0x0004 - const val LEFT_CTRL_PRESSED: Int = 0x0008 - const val SHIFT_PRESSED: Int = 0x0010 - } - - private val kernel = - Native.load("kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS) +internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { + private val kernel = Native.load( + "kernel32", WinKernel32Lib::class.java, W32APIOptions.DEFAULT_OPTIONS + ) private val stdoutHandle = kernel.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE) private val stdinHandle = kernel.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE) private val stderrHandle = kernel.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE) @@ -306,7 +296,7 @@ internal class JnaWin32MppImpls : MppImpls { return AutoCloseable { kernel.SetConsoleMode(stdinHandle, originalMode.value) } } - private fun readRawKeyEvent(dwMilliseconds: Int): KeyboardInputWindows.KeyEventRecord? { + override fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? { val waitResult = kernel.WaitForSingleObject(stdinHandle.pointer, dwMilliseconds) if (waitResult != 0) { return null @@ -318,16 +308,12 @@ internal class JnaWin32MppImpls : MppImpls { return null } val keyEvent = inputEvents[0]!!.Event!!.KeyEvent!! - return KeyboardInputWindows.KeyEventRecord( + return KeyEventRecord( bKeyDown = keyEvent.bKeyDown, wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), uChar = keyEvent.uChar!!.UnicodeChar, dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), ) } - - override fun readKey(timeout: Duration): KeyboardEvent? { - return KeyboardInputWindows.readKeyEvent(timeout, ::readRawKeyEvent) - } } diff --git a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt index c062747bf..50ea57066 100644 --- a/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt +++ b/mordant/src/linuxMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.linux.kt @@ -1,24 +1,3 @@ -@file:OptIn(ExperimentalForeignApi::class) - package com.github.ajalt.mordant.internal -import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.alloc -import kotlinx.cinterop.memScoped -import platform.posix.STDIN_FILENO -import platform.posix.TIOCGWINSZ -import platform.posix.ioctl -import platform.posix.winsize - -internal actual fun getTerminalSize(): Size? { - return memScoped { - val size = alloc() - if (ioctl(STDIN_FILENO, TIOCGWINSZ.toULong(), size) < 0) { - null - } else { - Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) - } - } -} - internal actual fun hasFileSystem(): Boolean = true diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt index 85507409f..9a42daafd 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt @@ -1,29 +1,10 @@ -@file:OptIn(ExperimentalForeignApi::class) - package com.github.ajalt.mordant.internal -import com.github.ajalt.mordant.input.KeyboardEvent -import com.github.ajalt.mordant.input.internal.KeyboardInputWindows +import com.github.ajalt.mordant.internal.syscalls.SyscallHandler +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativeWindows import kotlinx.cinterop.* import platform.windows.* -import kotlin.time.Duration - -// https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo -internal actual fun getTerminalSize(): Size? = memScoped { - val csbi = alloc() - val stdoutHandle = GetStdHandle(STD_OUTPUT_HANDLE) - if (stdoutHandle == INVALID_HANDLE_VALUE) { - return@memScoped null - } - - if (GetConsoleScreenBufferInfo(stdoutHandle, csbi.ptr) == 0) { - return@memScoped null - } - csbi.srWindow.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } -} - -// https://docs.microsoft.com/en-us/windows/console/setconsolemode // https://docs.microsoft.com/en-us/windows/console/getconsolemode internal actual fun ttySetEcho(echo: Boolean) = memScoped { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) @@ -44,39 +25,4 @@ internal actual fun ttySetEcho(echo: Boolean) = memScoped { } internal actual fun hasFileSystem(): Boolean = true - -internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? { - return KeyboardInputWindows.readKeyEvent(timeout, ::readRawKeyEvent) -} - -private fun readRawKeyEvent(dwMilliseconds: Int): KeyboardInputWindows.KeyEventRecord? = memScoped { - val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - val waitResult = WaitForSingleObject(stdinHandle, dwMilliseconds.toUInt()) - if (waitResult != 0u) return null - val inputEvents = allocArray(1) - val eventsRead = alloc() - ReadConsoleInput!!(stdinHandle, inputEvents, 1u, eventsRead.ptr) - if (eventsRead.value == 0u) { - return null - } - val keyEvent = inputEvents[0].Event.KeyEvent - return KeyboardInputWindows.KeyEventRecord( - bKeyDown = keyEvent.bKeyDown != 0, - wVirtualKeyCode = keyEvent.wVirtualKeyCode, - uChar = keyEvent.uChar.UnicodeChar.toInt().toChar(), - dwControlKeyState = keyEvent.dwControlKeyState, - ) -} - -internal actual fun enterRawModeMpp(): AutoCloseable = memScoped{ - val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - val originalMode = alloc() - GetConsoleMode(stdinHandle, originalMode.ptr) - - // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add - // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. - // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c - SetConsoleMode(stdinHandle, ENABLE_PROCESSED_INPUT.toUInt()) - - return AutoCloseable { SetConsoleMode(stdinHandle, originalMode.value) } -} +internal actual fun getSyscallHandler(): SyscallHandler = SyscallHandlerNativeWindows diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt new file mode 100644 index 000000000..f722a98fc --- /dev/null +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -0,0 +1,68 @@ + +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.internal.Size +import kotlinx.cinterop.* +import platform.windows.* + +internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { + // https://docs.microsoft.com/en-us/windows/console/getconsolemode + override fun stdoutInteractive(): Boolean = memScoped { + GetConsoleMode(GetStdHandle(STD_OUTPUT_HANDLE), alloc().ptr) != 0 + } + + override fun stdinInteractive(): Boolean = memScoped { + GetConsoleMode(GetStdHandle(STD_INPUT_HANDLE), alloc().ptr) != 0 + } + + override fun stderrInteractive(): Boolean = memScoped { + GetConsoleMode(GetStdHandle(STD_ERROR_HANDLE), alloc().ptr) != 0 + } + + // https://docs.microsoft.com/en-us/windows/console/getconsolescreenbufferinfo + override fun getTerminalSize(): Size? = memScoped { + val csbi = alloc() + val stdoutHandle = GetStdHandle(STD_OUTPUT_HANDLE) + if (stdoutHandle == INVALID_HANDLE_VALUE) { + return@memScoped null + } + + if (GetConsoleScreenBufferInfo(stdoutHandle, csbi.ptr) == 0) { + return@memScoped null + } + csbi.srWindow.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } + } + + override fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? = memScoped { + val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) + val waitResult = WaitForSingleObject(stdinHandle, dwMilliseconds.toUInt()) + if (waitResult != 0u) return null + val inputEvents = allocArray(1) + val eventsRead = alloc() + ReadConsoleInput!!(stdinHandle, inputEvents, 1u, eventsRead.ptr) + if (eventsRead.value == 0u) { + return null + } + val keyEvent = inputEvents[0].Event.KeyEvent + return KeyEventRecord( + bKeyDown = keyEvent.bKeyDown != 0, + wVirtualKeyCode = keyEvent.wVirtualKeyCode, + uChar = keyEvent.uChar.UnicodeChar.toInt().toChar(), + dwControlKeyState = keyEvent.dwControlKeyState, + ) + } + + override fun enterRawMode(): AutoCloseable = memScoped { + val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) + val originalMode = alloc() + GetConsoleMode(stdinHandle, originalMode.ptr) + + // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add + // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. + // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c + SetConsoleMode(stdinHandle, ENABLE_PROCESSED_INPUT.toUInt()) + + return AutoCloseable { SetConsoleMode(stdinHandle, originalMode.value) } + } + +} diff --git a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt index ac016a431..5199f1e1a 100644 --- a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt +++ b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalForeignApi::class, ExperimentalNativeApi::class) - package com.github.ajalt.mordant.internal import com.github.ajalt.mordant.terminal.* @@ -7,7 +5,6 @@ import kotlinx.cinterop.* import platform.posix.* import kotlin.concurrent.AtomicInt import kotlin.concurrent.AtomicReference -import kotlin.experimental.ExperimentalNativeApi import kotlin.system.exitProcess private class NativeAtomicRef(value: T) : MppAtomicRef { @@ -46,10 +43,6 @@ internal actual fun runningInIdeaJavaAgent(): Boolean = false internal actual fun getEnv(key: String): String? = getenv(key)?.toKStringFromUtf8() -internal actual fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0 - -internal actual fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) != 0 - internal actual fun codepointSequence(string: String): Sequence = sequence { var i = 0 while (i < string.length) { @@ -69,6 +62,7 @@ internal actual fun printStderr(message: String, newline: Boolean) { fflush(stderr) } +// TODO: use the syscall handler for this? internal expect fun ttySetEcho(echo: Boolean) internal actual fun readLineOrNullMpp(hideInput: Boolean): String? { @@ -175,5 +169,4 @@ internal actual fun readFileIfExists(filename: String): String? { } internal actual fun exitProcessMpp(status: Int): Unit = exitProcess(status) -internal actual val FAST_ISATTY: Boolean = true internal actual val CR_IMPLIES_LF: Boolean = false diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt index 83f33b2d7..0694c605c 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.posix.kt @@ -1,57 +1,6 @@ -@file:OptIn(ExperimentalForeignApi::class) - package com.github.ajalt.mordant.internal -import com.github.ajalt.mordant.input.KeyboardEvent -import com.github.ajalt.mordant.input.internal.KeyboardInputLinux -import com.github.ajalt.mordant.input.internal.PosixRawModeHandler -import kotlinx.cinterop.* -import platform.posix.* -import kotlin.time.ComparableTimeMark -import kotlin.time.Duration - -private fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? = memScoped { - while (t0.elapsedNow() < timeout) { - val c = alloc() - val read = read(STDIN_FILENO, c.ptr, 1u) - if (read < 0) return null - if (read > 0) return c.value.toInt().toChar() - } - return null -} - -internal actual fun readKeyMpp(timeout: Duration): KeyboardEvent? { - return KeyboardInputLinux.readKeyEvent(timeout, ::readRawByte) -} - -internal actual fun enterRawModeMpp(): AutoCloseable = LinuxNativeRawModeHandler.enterRawMode() - -private object LinuxNativeRawModeHandler : PosixRawModeHandler() { - override fun getStdinTermios(): Termios = memScoped { - val termios = alloc() - tcgetattr(STDIN_FILENO, termios.ptr) - return Termios( - iflag = termios.c_iflag, - oflag = termios.c_oflag, - cflag = termios.c_cflag, - lflag = termios.c_lflag, - cline = termios.c_line.toByte(), - cc = ByteArray(NCCS) { termios.c_cc[it].toByte() }, - ispeed = termios.c_ispeed, - ospeed = termios.c_ospeed, - ) - } +import com.github.ajalt.mordant.internal.syscalls.SyscallHandler +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativePosix - override fun setStdinTermios(termios: Termios): Unit = memScoped { - val nativeTermios = alloc() - nativeTermios.c_iflag = termios.iflag - nativeTermios.c_oflag = termios.oflag - nativeTermios.c_cflag = termios.cflag - nativeTermios.c_lflag = termios.lflag - nativeTermios.c_line = termios.cline.toUByte() - repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].toUByte() } - nativeTermios.c_ispeed = termios.ispeed - nativeTermios.c_ospeed = termios.ospeed - tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) - } -} +internal actual fun getSyscallHandler(): SyscallHandler = SyscallHandlerNativePosix diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt new file mode 100644 index 000000000..ad701e422 --- /dev/null +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -0,0 +1,61 @@ + +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.internal.Size +import kotlinx.cinterop.* +import platform.posix.* +import kotlin.time.ComparableTimeMark +import kotlin.time.Duration + +internal object SyscallHandlerNativePosix: SyscallHandlerPosix() { + override fun isatty(fd: Int): Int { + return platform.posix.isatty(fd) + } + + override fun getTerminalSize(): Size? = memScoped { + val size = alloc() + if (ioctl(platform.posix.STDIN_FILENO, TIOCGWINSZ.toULong(), size) < 0) { + null + } else { + Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) + } + } + + override fun getStdinTermios(): Termios = memScoped { + val termios = alloc() + tcgetattr(STDIN_FILENO, termios.ptr) + return Termios( + iflag = termios.c_iflag, + oflag = termios.c_oflag, + cflag = termios.c_cflag, + lflag = termios.c_lflag, + cline = termios.c_line.toByte(), + cc = ByteArray(NCCS) { termios.c_cc[it].toByte() }, + ispeed = termios.c_ispeed, + ospeed = termios.c_ospeed, + ) + } + + override fun setStdinTermios(termios: Termios): Unit = memScoped { + val nativeTermios = alloc() + nativeTermios.c_iflag = termios.iflag + nativeTermios.c_oflag = termios.oflag + nativeTermios.c_cflag = termios.cflag + nativeTermios.c_lflag = termios.lflag + nativeTermios.c_line = termios.cline.toUByte() + repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].toUByte() } + nativeTermios.c_ispeed = termios.ispeed + nativeTermios.c_ospeed = termios.ospeed + tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) + } + + override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? = memScoped { + while (t0.elapsedNow() < timeout) { + val c = alloc() + val read = read(platform.posix.STDIN_FILENO, c.ptr, 1u) + if (read < 0) return null + if (read > 0) return c.value.toInt().toChar() + } + return null + } +} diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt index 9a3c59d04..25e36e6c9 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt @@ -1,5 +1,3 @@ -@file:OptIn(ExperimentalForeignApi::class) - package com.github.ajalt.mordant.internal import kotlinx.cinterop.* From e2960396cd0635a85336ba2a3c8df88e24e51bbb Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 3 Jun 2024 10:27:19 -0700 Subject: [PATCH 06/45] Add graal syscall handlers --- .../internal/syscalls/SyscallHandler.posix.kt | 8 +- .../syscalls/SyscallHandler.windows.kt | 1 - .../WindowsVirtualKeyCodeToKeyEvent.kt | 2 +- .../ajalt/mordant/internal/MppInternal.jvm.kt | 12 +- .../nativeimage/NativeImagePosixMppImpls.kt | 68 ------ .../nativeimage/NativeImageWin32MppImpls.kt | 92 -------- ...a.posix.kt => SyscallHandler.jvm.posix.kt} | 2 +- .../{ => jna}/SyscallHandler.jna.linux.kt | 7 +- .../{ => jna}/SyscallHandler.jna.macos.kt | 7 +- .../{ => jna}/SyscallHandler.jna.windows.kt | 7 +- .../SyscallHandler.nativeimage.posix.kt | 128 +++++++++++ .../SyscallHandler.nativeimage.windows.kt | 198 ++++++++++++++++++ .../syscalls/SyscallHanlder.native.posix.kt | 4 +- 13 files changed, 354 insertions(+), 182 deletions(-) rename mordant/src/commonMain/kotlin/com/github/ajalt/mordant/{input/internal => internal/syscalls}/WindowsVirtualKeyCodeToKeyEvent.kt (99%) delete mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt delete mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt rename mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/{SyscallHandler.jna.posix.kt => SyscallHandler.jvm.posix.kt} (86%) rename mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/{ => jna}/SyscallHandler.jna.linux.kt (92%) rename mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/{ => jna}/SyscallHandler.jna.macos.kt (94%) rename mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/{ => jna}/SyscallHandler.jna.windows.kt (98%) create mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt create mode 100644 mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index dbd01671a..fe5728d9d 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -145,12 +145,12 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { protected abstract fun getStdinTermios(): Termios protected abstract fun setStdinTermios(termios: Termios) - protected abstract fun isatty(fd: Int): Int + protected abstract fun isatty(fd: Int): Boolean protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? - override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) != 0 - override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) != 0 - override fun stderrInteractive(): Boolean = isatty(STDERR_FILENO) != 0 + override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) + override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) + override fun stderrInteractive(): Boolean = isatty(STDERR_FILENO) // https://www.man7.org/linux/man-pages/man3/termios.3.html // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index ebb164664..50f5dd8a6 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -1,7 +1,6 @@ package com.github.ajalt.mordant.internal.syscalls import com.github.ajalt.mordant.input.KeyboardEvent -import com.github.ajalt.mordant.input.internal.WindowsVirtualKeyCodeToKeyEvent import kotlin.time.Duration import kotlin.time.TimeSource diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt similarity index 99% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt rename to mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt index 761eb2e1a..87d0a10e9 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/internal/WindowsVirtualKeyCodeToKeyEvent.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt @@ -1,4 +1,4 @@ -package com.github.ajalt.mordant.input.internal +package com.github.ajalt.mordant.internal.syscalls // https://learn.microsoft.com/en-us/windows/win32/inputdev/virtual-key-codes // https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt index 4373230e2..203cb4b84 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt @@ -1,6 +1,12 @@ package com.github.ajalt.mordant.internal -import com.github.ajalt.mordant.internal.syscalls.* +import com.github.ajalt.mordant.internal.syscalls.DumbSyscallHandler +import com.github.ajalt.mordant.internal.syscalls.SyscallHandler +import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaLinux +import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaMacos +import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaWindows +import com.github.ajalt.mordant.internal.syscalls.nativeimage.NativeImageWin32MppImpls +import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImagePosix import com.github.ajalt.mordant.terminal.* import java.io.File import java.lang.management.ManagementFactory @@ -123,8 +129,8 @@ internal actual fun getSyscallHandler(): SyscallHandler { val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" when { - isNativeImage && os.startsWith("Windows") -> TODO("NativeImageWin32MppImpls()") - isNativeImage && (os == "Linux" || os == "Mac OS X") -> TODO("NativeImagePosixMppImpls()") + isNativeImage && os.startsWith("Windows") -> NativeImageWin32MppImpls + isNativeImage && (os == "Linux" || os == "Mac OS X") -> SyscallHandlerNativeImagePosix os.startsWith("Windows") -> SyscallHandlerJnaWindows os == "Linux" -> SyscallHandlerJnaLinux os == "Mac OS X" -> SyscallHandlerJnaMacos diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt deleted file mode 100644 index f9fddef3b..000000000 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImagePosixMppImpls.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.github.ajalt.mordant.internal.nativeimage - -import com.github.ajalt.mordant.internal.Size -import org.graalvm.nativeimage.Platform -import org.graalvm.nativeimage.Platforms -import org.graalvm.nativeimage.StackValue -import org.graalvm.nativeimage.c.CContext -import org.graalvm.nativeimage.c.constant.CConstant -import org.graalvm.nativeimage.c.function.CFunction -import org.graalvm.nativeimage.c.struct.CField -import org.graalvm.nativeimage.c.struct.CStruct -import org.graalvm.word.PointerBase - -@CContext(PosixLibC.Directives::class) -@Platforms(Platform.LINUX::class, Platform.MACOS::class) -@Suppress("ClassName", "PropertyName", "SpellCheckingInspection", "FunctionName") -private object PosixLibC { - - class Directives : CContext.Directives { - override fun getHeaderFiles() = listOf("", "") - } - - @CConstant("STDIN_FILENO") - external fun STDIN_FILENO(): Int - - @CConstant("STDOUT_FILENO") - external fun STDOUT_FILENO(): Int - - @CConstant("STDERR_FILENO") - external fun STDERR_FILENO(): Int - - @CConstant("TIOCGWINSZ") - external fun TIOCGWINSZ(): Int - - @CStruct("winsize", addStructKeyword = true) - interface winsize : PointerBase { - - @get:CField("ws_row") - val ws_row: Short - - @get:CField("ws_col") - val ws_col: Short - } - - @CFunction("isatty") - external fun isatty(fd: Int): Boolean - - @CFunction("ioctl") - external fun ioctl(fd: Int, cmd: Int, winSize: winsize?): Int - -} -// -//@Platforms(Platform.LINUX::class, Platform.MACOS::class) -//internal class NativeImagePosixMppImpls : MppImpls { -// -// override fun stdoutInteractive() = PosixLibC.isatty(PosixLibC.STDOUT_FILENO()) -// override fun stdinInteractive() = PosixLibC.isatty(PosixLibC.STDIN_FILENO()) -// override fun stderrInteractive() = PosixLibC.isatty(PosixLibC.STDERR_FILENO()) -// -// override fun getTerminalSize(): Size? { -// val size = StackValue.get(PosixLibC.winsize::class.java) -// return if (PosixLibC.ioctl(0, PosixLibC.TIOCGWINSZ(), size) < 0) { -// null -// } else { -// Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) -// } -// } -//} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt deleted file mode 100644 index 3b46d96fa..000000000 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/nativeimage/NativeImageWin32MppImpls.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.github.ajalt.mordant.internal.nativeimage - -import com.github.ajalt.mordant.internal.Size -import org.graalvm.nativeimage.Platform -import org.graalvm.nativeimage.Platforms -import org.graalvm.nativeimage.StackValue -import org.graalvm.nativeimage.c.CContext -import org.graalvm.nativeimage.c.constant.CConstant -import org.graalvm.nativeimage.c.function.CFunction -import org.graalvm.nativeimage.c.struct.CField -import org.graalvm.nativeimage.c.struct.CStruct -import org.graalvm.nativeimage.c.type.CIntPointer -import org.graalvm.word.PointerBase - -@Platforms(Platform.WINDOWS::class) -@CContext(WinKernel32Lib.Directives::class) -@Suppress("FunctionName", "PropertyName", "ClassName") -private object WinKernel32Lib { - - class Directives : CContext.Directives { - override fun getHeaderFiles() = listOf("") - } - - @CConstant("STD_INPUT_HANDLE") - external fun STD_INPUT_HANDLE(): Int - - @CConstant("STD_OUTPUT_HANDLE") - external fun STD_OUTPUT_HANDLE(): Int - - @CConstant("STD_ERROR_HANDLE") - external fun STD_ERROR_HANDLE(): Int - - @CStruct("CONSOLE_SCREEN_BUFFER_INFO") - interface CONSOLE_SCREEN_BUFFER_INFO : PointerBase { - - @get:CField("srWindow.Left") - val Left: Short - - @get:CField("srWindow.Top") - val Top: Short - - @get:CField("srWindow.Right") - val Right: Short - - @get:CField("srWindow.Bottom") - val Bottom: Short - - } - - @CFunction("GetStdHandle") - external fun GetStdHandle(nStdHandle: Int): PointerBase? - - @CFunction("GetConsoleMode") - external fun GetConsoleMode(hConsoleHandle: PointerBase?, lpMode: CIntPointer?): Boolean - - @CFunction("GetConsoleScreenBufferInfo") - external fun GetConsoleScreenBufferInfo( - hConsoleOutput: PointerBase?, - lpConsoleScreenBufferInfo: Long, - ): Boolean - -} - -//@Platforms(Platform.WINDOWS::class) -//internal class NativeImageWin32MppImpls : MppImpls { -// -// override fun stdoutInteractive(): Boolean { -// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) -// return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) -// } -// -// override fun stderrInteractive(): Boolean { -// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE()) -// return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) -// } -// -// override fun stdinInteractive(): Boolean { -// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) -// return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) -// } -// -// override fun getTerminalSize(): Size? { -// val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java) -// val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) -// return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) { -// null -// } else { -// Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1) -// } -// } -// -//} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt similarity index 86% rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt index 438901be2..19cea4af2 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt @@ -3,7 +3,7 @@ package com.github.ajalt.mordant.internal.syscalls import kotlin.time.ComparableTimeMark import kotlin.time.Duration -internal abstract class SyscallHandlerJnaPosix : SyscallHandlerPosix() { +internal abstract class SyscallHandlerJvmPosix : SyscallHandlerPosix() { override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { while (t0.elapsedNow() < timeout) { val c = System.`in`.read().takeIf { it >= 0 }?.toChar() diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt similarity index 92% rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt index 5858f7852..f531f66d2 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.linux.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt @@ -1,6 +1,7 @@ -package com.github.ajalt.mordant.internal.syscalls +package com.github.ajalt.mordant.internal.syscalls.jna import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix import com.oracle.svm.core.annotate.Delete import com.sun.jna.* @@ -65,11 +66,11 @@ private interface PosixLibC : Library { } @Delete -internal object SyscallHandlerJnaLinux : SyscallHandlerJnaPosix() { +internal object SyscallHandlerJnaLinux : SyscallHandlerJvmPosix() { private const val TIOCGWINSZ = 0x00005413 private const val TCSADRAIN: Int = 0x1 private val libC: PosixLibC = Native.load(Platform.C_LIBRARY_NAME, PosixLibC::class.java) - override fun isatty(fd: Int): Int = libC.isatty(fd) + override fun isatty(fd: Int): Boolean = libC.isatty(fd) != 0 override fun getTerminalSize(): Size? { val size = PosixLibC.winsize() diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.macos.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt similarity index 94% rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.macos.kt rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt index 49a303153..85a93fa82 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.macos.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt @@ -1,6 +1,7 @@ -package com.github.ajalt.mordant.internal.syscalls +package com.github.ajalt.mordant.internal.syscalls.jna import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix import com.oracle.svm.core.annotate.Delete import com.sun.jna.* import java.io.IOException @@ -70,7 +71,7 @@ private interface MacosLibC : Library { @Delete @Suppress("SpellCheckingInspection") -internal object SyscallHandlerJnaMacos : SyscallHandlerJnaPosix() { +internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() { private const val TCSANOW: Int = 0x0 private val TIOCGWINSZ = when { Platform.isMIPS() || Platform.isPPC() || Platform.isSPARC() -> 0x40087468L @@ -78,7 +79,7 @@ internal object SyscallHandlerJnaMacos : SyscallHandlerJnaPosix() { } private val libC: MacosLibC = Native.load(Platform.C_LIBRARY_NAME, MacosLibC::class.java) - override fun isatty(fd: Int): Int = libC.isatty(fd) + override fun isatty(fd: Int): Boolean = libC.isatty(fd) != 0 override fun fastIsTty(): Boolean = false override fun getTerminalSize(): Size? { // TODO: JNA has a bug that causes this to fail on macosArm64, use stty on mac for now diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt similarity index 98% rename from mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.windows.kt rename to mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt index caee67241..bd61fb064 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jna.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt @@ -1,19 +1,18 @@ -package com.github.ajalt.mordant.internal.syscalls +package com.github.ajalt.mordant.internal.syscalls.jna -import com.github.ajalt.mordant.input.KeyboardEvent import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWindows import com.oracle.svm.core.annotate.Delete import com.sun.jna.* import com.sun.jna.ptr.IntByReference import com.sun.jna.win32.W32APIOptions -import kotlin.time.Duration // Interface definitions from // https://github.com/java-native-access/jna/blob/master/contrib/platform/src/com/sun/jna/platform/win32/Kernel32.java // copied here so that we don't need the entire platform dependency @Delete @Suppress("FunctionName", "PropertyName", "ClassName", "unused") -interface WinKernel32Lib : Library { +private interface WinKernel32Lib : Library { companion object { const val STD_INPUT_HANDLE = -10 const val STD_OUTPUT_HANDLE = -11 diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt new file mode 100644 index 000000000..81e45b9c5 --- /dev/null +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt @@ -0,0 +1,128 @@ +package com.github.ajalt.mordant.internal.syscalls.nativeimage + +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJvmPosix +import org.graalvm.nativeimage.Platform +import org.graalvm.nativeimage.Platforms +import org.graalvm.nativeimage.StackValue +import org.graalvm.nativeimage.c.CContext +import org.graalvm.nativeimage.c.constant.CConstant +import org.graalvm.nativeimage.c.function.CFunction +import org.graalvm.nativeimage.c.struct.CField +import org.graalvm.nativeimage.c.struct.CStruct +import org.graalvm.word.PointerBase + +@CContext(PosixLibC.Directives::class) +@Platforms(Platform.LINUX::class, Platform.MACOS::class) +@Suppress("ClassName", "PropertyName", "SpellCheckingInspection", "FunctionName") +private object PosixLibC { + + class Directives : CContext.Directives { + override fun getHeaderFiles() = listOf("", "") + } + + @CConstant("TIOCGWINSZ") + external fun TIOCGWINSZ(): Int + + @CConstant("TCSADRAIN") + external fun TCSADRAIN(): Int + + @CStruct("winsize", addStructKeyword = true) + interface winsize : PointerBase { + + @get:CField("ws_row") + val ws_row: Short + + @get:CField("ws_col") + val ws_col: Short + } + + @CStruct("termios", addStructKeyword = true) + interface termios : PointerBase { + @get:CField("c_iflag") + @set:CField("c_iflag") + var c_iflag: Int + + @get:CField("c_oflag") + @set:CField("c_oflag") + var c_oflag: Int + + @get:CField("c_cflag") + @set:CField("c_cflag") + var c_cflag: Int + + @get:CField("c_lflag") + @set:CField("c_lflag") + var c_lflag: Int + + @get:CField("c_line") + @set:CField("c_line") + var c_line: Byte + + @get:CField("c_cc") + @set:CField("c_cc") + var c_cc: ByteArray + + @get:CField("c_ispeed") + @set:CField("c_ispeed") + var c_ispeed: Int + + @get:CField("c_ospeed") + @set:CField("c_ospeed") + var c_ospeed: Int + } + + @CFunction("isatty") + external fun isatty(fd: Int): Boolean + + @CFunction("ioctl") + external fun ioctl(fd: Int, cmd: Int, winSize: winsize?): Int + + @CFunction("tcgetattr") + external fun tcgetattr(fd: Int, termios: termios) + + @CFunction("tcsetattr") + external fun tcsetattr(fd: Int, cmd: Int, termios: termios) +} + +@Platforms(Platform.LINUX::class, Platform.MACOS::class) +internal object SyscallHandlerNativeImagePosix : SyscallHandlerJvmPosix() { + override fun isatty(fd: Int): Boolean = PosixLibC.isatty(fd) + + override fun getTerminalSize(): Size? { + val size = StackValue.get(PosixLibC.winsize::class.java) + return if (PosixLibC.ioctl(0, PosixLibC.TIOCGWINSZ(), size) < 0) { + null + } else { + Size(width = size.ws_col.toInt(), height = size.ws_row.toInt()) + } + } + + override fun getStdinTermios(): Termios { + val termios = StackValue.get(PosixLibC.termios::class.java) + PosixLibC.tcgetattr(STDIN_FILENO, termios) + return Termios( + iflag = termios.c_iflag.toUInt(), + oflag = termios.c_oflag.toUInt(), + cflag = termios.c_cflag.toUInt(), + lflag = termios.c_lflag.toUInt(), + cline = termios.c_line, + cc = termios.c_cc.copyOf(), + ispeed = termios.c_ispeed.toUInt(), + ospeed = termios.c_ospeed.toUInt(), + ) + } + + override fun setStdinTermios(termios: Termios) { + val nativeTermios = StackValue.get(PosixLibC.termios::class.java) + nativeTermios.c_iflag = termios.iflag.toInt() + nativeTermios.c_oflag = termios.oflag.toInt() + nativeTermios.c_cflag = termios.cflag.toInt() + nativeTermios.c_lflag = termios.lflag.toInt() + nativeTermios.c_line = termios.cline + termios.cc.copyInto(nativeTermios.c_cc) + nativeTermios.c_ispeed = termios.ispeed.toInt() + nativeTermios.c_ospeed = termios.ospeed.toInt() + PosixLibC.tcsetattr(STDIN_FILENO, PosixLibC.TCSADRAIN(), nativeTermios) + } +} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt new file mode 100644 index 000000000..c203b018f --- /dev/null +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -0,0 +1,198 @@ +package com.github.ajalt.mordant.internal.syscalls.nativeimage + +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWindows +import org.graalvm.nativeimage.Platform +import org.graalvm.nativeimage.Platforms +import org.graalvm.nativeimage.StackValue +import org.graalvm.nativeimage.c.CContext +import org.graalvm.nativeimage.c.constant.CConstant +import org.graalvm.nativeimage.c.function.CFunction +import org.graalvm.nativeimage.c.struct.CField +import org.graalvm.nativeimage.c.struct.CFieldAddress +import org.graalvm.nativeimage.c.struct.CStruct +import org.graalvm.nativeimage.c.type.CIntPointer +import org.graalvm.word.PointerBase + +@Platforms(Platform.WINDOWS::class) +@CContext(WinKernel32Lib.Directives::class) +@Suppress("FunctionName", "PropertyName", "ClassName") +private object WinKernel32Lib { + + class Directives : CContext.Directives { + override fun getHeaderFiles() = listOf("") + } + + @CConstant("STD_INPUT_HANDLE") + external fun STD_INPUT_HANDLE(): Int + + @CConstant("STD_OUTPUT_HANDLE") + external fun STD_OUTPUT_HANDLE(): Int + + @CConstant("STD_ERROR_HANDLE") + external fun STD_ERROR_HANDLE(): Int + + @CConstant("ENABLE_PROCESSED_INPUT") + external fun ENABLE_PROCESSED_INPUT(): Int + + @CStruct("CONSOLE_SCREEN_BUFFER_INFO") + interface CONSOLE_SCREEN_BUFFER_INFO : PointerBase { + + @get:CField("srWindow.Left") + val Left: Short + + @get:CField("srWindow.Top") + val Top: Short + + @get:CField("srWindow.Right") + val Right: Short + + @get:CField("srWindow.Bottom") + val Bottom: Short + + } + + @CStruct("uChar") + interface UnionChar : PointerBase { + @get:CFieldAddress("UnicodeChar") + val UnicodeChar: Char + + @get:CFieldAddress("AsciiChar") + val AsciiChar: Byte + } + + @CStruct("KEY_EVENT_RECORD") + interface KEY_EVENT_RECORD : PointerBase{ + @get:CField("bKeyDown") + val bKeyDown: Boolean + + @get:CField("wRepeatCount") + val wRepeatCount: Short + + @get:CField("wVirtualKeyCode") + val wVirtualKeyCode: Short + + @get:CField("wVirtualScanCode") + val wVirtualScanCode: Short + + @get:CField("uChar") + val uChar: UnionChar? + + @get:CField("dwControlKeyState") + val dwControlKeyState: Int + } + + @CStruct("Event") + interface EventUnion : PointerBase { + @get:CFieldAddress("KeyEvent") + val KeyEvent: KEY_EVENT_RECORD + // ... other fields omitted until we need them + } + + @CStruct("INPUT_RECORD") + interface INPUT_RECORD : PointerBase { + companion object { + const val KEY_EVENT: Short = 0x0001 + const val MOUSE_EVENT: Short = 0x0002 + const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004 + const val MENU_EVENT: Short = 0x0008 + const val FOCUS_EVENT: Short = 0x0010 + } + + @get:CField("EventType") + val EventType: Short + + @get:CField("Event") + val Event: EventUnion + } + + + @CFunction("GetStdHandle") + external fun GetStdHandle(nStdHandle: Int): PointerBase? + + @CFunction("GetConsoleMode") + external fun GetConsoleMode(hConsoleHandle: PointerBase?, lpMode: CIntPointer?): Boolean + + @CFunction("SetConsoleMode") + external fun SetConsoleMode(hConsoleHandle: PointerBase?, dwMode: Int) + + @CFunction("GetConsoleScreenBufferInfo") + external fun GetConsoleScreenBufferInfo( + hConsoleOutput: PointerBase?, + lpConsoleScreenBufferInfo: Long, + ): Boolean + + @CFunction("WaitForSingleObject") + external fun WaitForSingleObject(hHandle: PointerBase?, dwMilliseconds: Int): Int + + @CFunction("ReadConsoleInput") + external fun ReadConsoleInput( + hConsoleOutput: PointerBase?, + lpBuffer: INPUT_RECORD, + nLength: Int, + lpNumberOfEventsRead: CIntPointer?, + ) +} + +@Platforms(Platform.WINDOWS::class) +internal object NativeImageWin32MppImpls : SyscallHandlerWindows() { + + override fun stdoutInteractive(): Boolean { + val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) + return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) + } + + override fun stderrInteractive(): Boolean { + val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE()) + return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) + } + + override fun stdinInteractive(): Boolean { + val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) + return WinKernel32Lib.GetConsoleMode(handle, StackValue.get(CIntPointer::class.java)) + } + + override fun getTerminalSize(): Size? { + val csbi = StackValue.get(WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO::class.java) + val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) + return if (!WinKernel32Lib.GetConsoleScreenBufferInfo(handle, csbi.rawValue())) { + null + } else { + Size(width = csbi.Right - csbi.Left + 1, height = csbi.Bottom - csbi.Top + 1) + } + } + + override fun enterRawMode(): AutoCloseable { + val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) + val originalMode = StackValue.get(CIntPointer::class.java) + WinKernel32Lib.GetConsoleMode(stdinHandle, originalMode) + + // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add + // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. + // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c + WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT()) + + return AutoCloseable { WinKernel32Lib.SetConsoleMode(stdinHandle, originalMode.read()) } + } + + override fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? { + val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) + val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds) + if (waitResult != 0) { + return null + } + val inputEvents = StackValue.get(WinKernel32Lib.INPUT_RECORD::class.java) + val eventsRead = StackValue.get(CIntPointer::class.java) + WinKernel32Lib.ReadConsoleInput(stdinHandle, inputEvents, 1, eventsRead) + if (eventsRead.read() == 0) { + return null + } + val keyEvent = inputEvents.Event.KeyEvent + return KeyEventRecord( + bKeyDown = keyEvent.bKeyDown, + wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), + uChar = keyEvent.uChar!!.UnicodeChar, + dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), + ) + } +} diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt index ad701e422..bcfebd561 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -8,8 +8,8 @@ import kotlin.time.ComparableTimeMark import kotlin.time.Duration internal object SyscallHandlerNativePosix: SyscallHandlerPosix() { - override fun isatty(fd: Int): Int { - return platform.posix.isatty(fd) + override fun isatty(fd: Int): Boolean { + return platform.posix.isatty(fd) != 0 } override fun getTerminalSize(): Size? = memScoped { From a3368f812a5f8f1af95d0540296f53ea06168970 Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 3 Jun 2024 10:45:05 -0700 Subject: [PATCH 07/45] Move input package to nonJsMain source set --- .../src/main/kotlin/mordant-native-conventions.gradle.kts | 5 +++++ .../github/ajalt/mordant/internal/MppInternal.jsCommon.kt | 4 ---- .../com/github/ajalt/mordant/internal/MppInternal.js.kt | 4 +--- .../kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt | 0 .../ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt | 0 .../mordant/internal/syscalls/SyscallHandler.windows.kt | 0 .../internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt | 0 .../com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt | 3 --- 8 files changed, 6 insertions(+), 10 deletions(-) rename mordant/src/{commonMain => nonJsMain}/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt (100%) rename mordant/src/{commonMain => nonJsMain}/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt (100%) rename mordant/src/{commonMain => nonJsMain}/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt (100%) rename mordant/src/{commonMain => nonJsMain}/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt (100%) diff --git a/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts b/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts index fe64ec004..65cfe38a1 100644 --- a/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/mordant-native-conventions.gradle.kts @@ -24,7 +24,12 @@ kotlin { applyDefaultHierarchyTemplate() + // 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) diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt index fc9e2c42a..f03429eaf 100644 --- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt @@ -51,7 +51,6 @@ internal interface JsMppImpls { fun readLineOrNull(): String? fun makeTerminalCursor(terminal: Terminal): TerminalCursor fun exitProcess(status: Int) - fun cwd(): String fun readFileIfExists(filename: String): String? } @@ -63,9 +62,6 @@ private object BrowserMppImpls : JsMppImpls { override fun getTerminalSize(): Size? = null override fun printStderr(message: String, newline: Boolean) = browserPrintln(message) override fun exitProcess(status: Int) {} - override fun cwd(): String { - return "??" - } // readlnOrNull will just throw an exception on browsers override fun readLineOrNull(): String? = readlnOrNull() diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt index 12aabacc4..a39fe69fb 100644 --- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt +++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt @@ -22,9 +22,7 @@ private class NodeMppImpls(private val fs: dynamic) : BaseNodeMppImpls( override fun exitProcess(status: Int) { process.exit(status) } - override fun cwd(): String { - return process.cwd() as String - } + override fun getTerminalSize(): Size? { // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY // is false diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt similarity index 100% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt similarity index 100% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt similarity index 100% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt similarity index 100% rename from mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt index 6fb2dc435..0585333ef 100644 --- a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt +++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt @@ -62,9 +62,6 @@ private class NodeMppImpls(private val fs: FsModule) : BaseNodeMppImpls() val jsSize = process.stdout.getWindowSize() return Size(width = jsSize[0]!!.toInt(), height = jsSize[1]!!.toInt()) } - override fun cwd(): String { - return process.cwd() - } override fun printStderr(message: String, newline: Boolean) { process.stderr.write(if (newline) message + "\n" else message) From e486c27b6f2365fe3b50074c6d5387a59db0b1b0 Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 3 Jun 2024 11:08:32 -0700 Subject: [PATCH 08/45] Add JS and Wasm syscall handlers --- .../mordant/internal/MppInternal.jsCommon.kt | 63 ++-------- .../syscalls/SyscallHandler.jsCommon.kt | 70 ++++++++++++ .../ajalt/mordant/internal/MppInternal.js.kt | 62 +--------- .../internal/syscalls/SyscallHandler.js.kt | 66 +++++++++++ .../mordant/internal/MppInternal.wasmJs.kt | 108 +----------------- .../internal/syscalls/SyscallHandler.wasm.kt | 107 +++++++++++++++++ 6 files changed, 260 insertions(+), 216 deletions(-) create mode 100644 mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt create mode 100644 mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt create mode 100644 mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt index f03429eaf..5727b779e 100644 --- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jsCommon.kt @@ -1,11 +1,14 @@ package com.github.ajalt.mordant.internal +import com.github.ajalt.mordant.internal.syscalls.SyscallHandler +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerBrowser +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsCommon import com.github.ajalt.mordant.terminal.* // Since `js()` and `external` work differently in wasm and js, we need to define the functions that // use them twice -internal expect fun makeNodeMppImpls(): JsMppImpls? +internal expect fun makeNodeSyscallHandler(): SyscallHandlerJsCommon? internal expect fun browserPrintln(message: String) private class JsAtomicRef(override var value: T) : MppAtomicRef { @@ -41,61 +44,11 @@ internal actual fun MppAtomicInt(initial: Int): MppAtomicInt = JsAtomicInt(initi internal actual fun MppAtomicRef(value: T): MppAtomicRef = JsAtomicRef(value) -internal interface JsMppImpls { - fun readEnvvar(key: String): String? - fun stdoutInteractive(): Boolean - fun stdinInteractive(): Boolean - fun stderrInteractive(): Boolean - fun getTerminalSize(): Size? - fun printStderr(message: String, newline: Boolean) - fun readLineOrNull(): String? - fun makeTerminalCursor(terminal: Terminal): TerminalCursor - fun exitProcess(status: Int) - fun readFileIfExists(filename: String): String? +internal actual fun getSyscallHandler(): SyscallHandler { + return makeNodeSyscallHandler() ?: SyscallHandlerBrowser } -private object BrowserMppImpls : JsMppImpls { - override fun readEnvvar(key: String): String? = null - override fun stdoutInteractive(): Boolean = false - override fun stdinInteractive(): Boolean = false - override fun stderrInteractive(): Boolean = false - override fun getTerminalSize(): Size? = null - override fun printStderr(message: String, newline: Boolean) = browserPrintln(message) - override fun exitProcess(status: Int) {} - - // readlnOrNull will just throw an exception on browsers - override fun readLineOrNull(): String? = readlnOrNull() - override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { - return BrowserTerminalCursor(terminal) - } - - override fun readFileIfExists(filename: String): String? = null -} - -internal abstract class BaseNodeMppImpls : JsMppImpls { - final override fun readLineOrNull(): String? { - return try { - 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 - append(char) - } while (char != "\n" && char != "${0.toChar()}") - } - } catch (e: Exception) { - null - } - } - - abstract fun allocBuffer(size: Int): BufferT - abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int -} - -private val impls: JsMppImpls = makeNodeMppImpls() ?: BrowserMppImpls +private val impls get() = SYSCALL_HANDLER as SyscallHandlerJsCommon internal actual fun runningInIdeaJavaAgent(): Boolean = false @@ -130,4 +83,4 @@ internal actual fun sendInterceptedPrintRequest( ) } -internal actual fun hasFileSystem(): Boolean = impls !is BrowserMppImpls +internal actual fun hasFileSystem(): Boolean = impls !is SyscallHandlerBrowser diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt new file mode 100644 index 000000000..039bbd128 --- /dev/null +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt @@ -0,0 +1,70 @@ +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.internal.browserPrintln +import com.github.ajalt.mordant.terminal.PrintTerminalCursor +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.terminal.TerminalCursor +import kotlin.time.Duration + +internal interface SyscallHandlerJsCommon: SyscallHandler { + fun readEnvvar(key: String): String? + fun printStderr(message: String, newline: Boolean) + fun readLineOrNull(): String? + fun makeTerminalCursor(terminal: Terminal): TerminalCursor + fun exitProcess(status: Int) + fun readFileIfExists(filename: String): String? + + // The public interface never is in nonJsMain, so these will never be called + override fun readKeyEvent(timeout: Duration): KeyboardEvent? { + throw UnsupportedOperationException("Reading keyboard is not supported on this platform") + } + override fun enterRawMode(): AutoCloseable { + throw UnsupportedOperationException("Raw mode is not supported on this platform") + } +} + +internal abstract class SyscallHandlerNode : SyscallHandlerJsCommon { + final override fun readLineOrNull(): String? { + return try { + 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 + append(char) + } while (char != "\n" && char != "${0.toChar()}") + } + } catch (e: Exception) { + null + } + } + + abstract fun allocBuffer(size: Int): BufferT + abstract fun readSync(fd: Int, buffer: BufferT, offset: Int, len: Int): Int +} + +internal object SyscallHandlerBrowser : SyscallHandlerJsCommon { + override fun readEnvvar(key: String): String? = null + override fun stdoutInteractive(): Boolean = false + override fun stdinInteractive(): Boolean = false + override fun stderrInteractive(): Boolean = false + override fun getTerminalSize(): Size? = null + override fun printStderr(message: String, newline: Boolean) = browserPrintln(message) + override fun exitProcess(status: Int) {} + + // readlnOrNull will just throw an exception on browsers + override fun readLineOrNull(): String? = readlnOrNull() + override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { + return BrowserTerminalCursor(terminal) + } + + override fun readFileIfExists(filename: String): String? = null +} + +// There are no shutdown hooks on browsers, so we don't need to do anything here +private class BrowserTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt index a39fe69fb..1f1877631 100644 --- a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt +++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.js.kt @@ -1,5 +1,8 @@ package com.github.ajalt.mordant.internal +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsCommon +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsNode +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNode import com.github.ajalt.mordant.terminal.PrintTerminalCursor import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.terminal.TerminalCursor @@ -14,47 +17,9 @@ internal actual fun browserPrintln(message: String) { console.error(message) } -private class NodeMppImpls(private val fs: dynamic) : BaseNodeMppImpls() { - override fun readEnvvar(key: String): String? = process.env[key] as? String - override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean - override fun stdinInteractive(): Boolean = js("Boolean(process.stdin.isTTY)") as Boolean - override fun stderrInteractive(): Boolean = js("Boolean(process.stderr.isTTY)") as Boolean - override fun exitProcess(status: Int) { - process.exit(status) - } - - override fun getTerminalSize(): Size? { - // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY - // is false - if (process.stdout.getWindowSize == undefined) return null - val s = process.stdout.getWindowSize() - return Size(width = s[0] as Int, height = s[1] as Int) - } - - override fun printStderr(message: String, newline: Boolean) { - process.stderr.write(if (newline) message + "\n" else message) - } - - override fun allocBuffer(size: Int): dynamic { - return Buffer.alloc(size) - } - - override fun readSync(fd: Int, buffer: dynamic, offset: Int, len: Int): Int { - return fs.readSync(fd, buffer, offset, len, null) as Int - } - - override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { - return NodeTerminalCursor(terminal) - } - - override fun readFileIfExists(filename: String): String? { - return fs.readFileSync(filename, "utf-8") as? String - } -} - -internal actual fun makeNodeMppImpls(): JsMppImpls? { +internal actual fun makeNodeSyscallHandler(): SyscallHandlerJsCommon? { return try { - NodeMppImpls(nodeRequire("fs")) + SyscallHandlerJsNode(nodeRequire("fs")) } catch (e: Exception) { null } @@ -67,21 +32,4 @@ internal actual fun codepointSequence(string: String): Sequence { } } -private class NodeTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) { - private var shutdownHook: (() -> Unit)? = null - - override fun show() { - shutdownHook?.let { process.removeListener("exit", it) } - super.show() - } - - override fun hide(showOnExit: Boolean) { - if (showOnExit && shutdownHook == null) { - shutdownHook = { show() } - process.on("exit", shutdownHook) - } - super.hide(showOnExit) - } -} - internal actual val CR_IMPLIES_LF: Boolean = false diff --git a/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt new file mode 100644 index 000000000..7b0d7881d --- /dev/null +++ b/mordant/src/jsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.js.kt @@ -0,0 +1,66 @@ +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.terminal.PrintTerminalCursor +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.terminal.TerminalCursor + + +private external val process: dynamic +private external val Buffer: dynamic + +internal class SyscallHandlerJsNode(private val fs: dynamic): SyscallHandlerNode() { + override fun readEnvvar(key: String): String? = process.env[key] as? String + override fun stdoutInteractive(): Boolean = js("Boolean(process.stdout.isTTY)") as Boolean + override fun stdinInteractive(): Boolean = js("Boolean(process.stdin.isTTY)") as Boolean + override fun stderrInteractive(): Boolean = js("Boolean(process.stderr.isTTY)") as Boolean + override fun exitProcess(status: Int) { + process.exit(status) + } + + override fun getTerminalSize(): Size? { + // For some undocumented reason, getWindowSize is undefined sometimes, presumably when isTTY + // is false + if (process.stdout.getWindowSize == undefined) return null + val s = process.stdout.getWindowSize() + return Size(width = s[0] as Int, height = s[1] as Int) + } + + override fun printStderr(message: String, newline: Boolean) { + process.stderr.write(if (newline) message + "\n" else message) + } + + override fun allocBuffer(size: Int): dynamic { + return Buffer.alloc(size) + } + + override fun readSync(fd: Int, buffer: dynamic, offset: Int, len: Int): Int { + return fs.readSync(fd, buffer, offset, len, null) as Int + } + + override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { + return NodeTerminalCursor(terminal) + } + + override fun readFileIfExists(filename: String): String? { + return fs.readFileSync(filename, "utf-8") as? String + } +} + +private class NodeTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) { + private var shutdownHook: (() -> Unit)? = null + + override fun show() { + shutdownHook?.let { process.removeListener("exit", it) } + super.show() + } + + override fun hide(showOnExit: Boolean) { + if (showOnExit && shutdownHook == null) { + shutdownHook = { show() } + process.on("exit", shutdownHook) + } + super.hide(showOnExit) + } +} + diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt index 0585333ef..61aecb754 100644 --- a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt +++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt @@ -1,112 +1,14 @@ package com.github.ajalt.mordant.internal -import com.github.ajalt.mordant.terminal.PrintTerminalCursor -import com.github.ajalt.mordant.terminal.Terminal -import com.github.ajalt.mordant.terminal.TerminalCursor - -private external interface Stream { - val isTTY: Boolean - fun write(s: String) - fun getWindowSize(): JsArray -} - -@Suppress("ClassName") -private external object process { - val stdout: Stream - val stdin: Stream - val stderr: Stream - - fun on(event: String, listener: () -> Unit) - fun removeListener(event: String, listener: () -> Unit) - fun exit(status: Int) - fun cwd() : String -} - -private external interface FsModule { - fun readSync(fd: Int, buffer: JsAny, offset: Int, len: Int, position: JsAny?): Int -} - -private external object Buffer { - fun alloc(size: Int): JsAny -} - -@Suppress("RedundantNullableReturnType") // invalid diagnostic due to KTIJ-28239 -private fun nodeReadEnvvar(@Suppress("UNUSED_PARAMETER") key: String): String? = - js("process.env[key]") - -private fun nodeWidowSizeIsDefined(): Boolean = - js("process.stdout.getWindowSize != undefined") - -// Have to use js() instead of extern since kotlin can't catch exceptions from external wasm -// functions -@Suppress("RedundantNullableReturnType", "UNUSED_PARAMETER") -private fun nodeReadFileSync(filename: String): String? = - js( - """{ - try { - return require('fs').readFileSync(filename).toString() - } catch (e) { - return null - } - }""" - ) - -private class NodeMppImpls(private val fs: FsModule) : BaseNodeMppImpls() { - override fun readEnvvar(key: String): String? = nodeReadEnvvar(key) - override fun stdoutInteractive(): Boolean = process.stdout.isTTY - override fun stdinInteractive(): Boolean = process.stdin.isTTY - override fun stderrInteractive(): Boolean = process.stderr.isTTY - override fun exitProcess(status: Int): Unit = process.exit(status) - override fun getTerminalSize(): Size? { - if (!nodeWidowSizeIsDefined()) return null - val jsSize = process.stdout.getWindowSize() - return Size(width = jsSize[0]!!.toInt(), height = jsSize[1]!!.toInt()) - } - - override fun printStderr(message: String, newline: Boolean) { - process.stderr.write(if (newline) message + "\n" else message) - } - - override fun allocBuffer(size: Int): JsAny = Buffer.alloc(size) - - override fun readSync(fd: Int, buffer: JsAny, offset: Int, len: Int): Int { - return fs.readSync(fd, buffer, offset, len, null) - } - - override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { - return NodeTerminalCursor(terminal) - } - - override fun readFileIfExists(filename: String): String? { - return nodeReadFileSync(filename) - } -} +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerJsCommon +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWasm internal actual fun browserPrintln(message: String): Unit = js("console.error(message)") -internal actual fun makeNodeMppImpls(): JsMppImpls? { - return if (runningOnNode()) NodeMppImpls(importNodeFsModule()) else null -} - -private class NodeTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) { - private var shutdownHook: (() -> Unit)? = null - - override fun show() { - shutdownHook?.let { process.removeListener("exit", it) } - super.show() - } - - override fun hide(showOnExit: Boolean) { - if (showOnExit && shutdownHook == null) { - val function = { show() } - shutdownHook = function - process.on("exit", function) - } - super.hide(showOnExit) - } +internal actual fun makeNodeSyscallHandler(): SyscallHandlerJsCommon? { + return if (runningOnNode()) SyscallHandlerWasm() else null } - private external interface CodePointString { fun codePointAt(index: Int): Int } @@ -131,8 +33,6 @@ internal actual fun codepointSequence(string: String): Sequence { private fun runningOnNode(): Boolean = js("Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'") -private fun importNodeFsModule(): FsModule = - js("""require("fs")""") // For some reason, \r seems to be treated as \r\n on wasm internal actual val CR_IMPLIES_LF: Boolean = true diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt new file mode 100644 index 000000000..2ebb985ee --- /dev/null +++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.wasm.kt @@ -0,0 +1,107 @@ +package com.github.ajalt.mordant.internal.syscalls + +import com.github.ajalt.mordant.internal.Size +import com.github.ajalt.mordant.terminal.PrintTerminalCursor +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.terminal.TerminalCursor + + +private external interface Stream { + val isTTY: Boolean + fun write(s: String) + fun getWindowSize(): JsArray +} + +@Suppress("ClassName") +private external object process { + val stdout: Stream + val stdin: Stream + val stderr: Stream + + fun on(event: String, listener: () -> Unit) + fun removeListener(event: String, listener: () -> Unit) + fun exit(status: Int) +} + +private external interface FsModule { + fun readSync(fd: Int, buffer: JsAny, offset: Int, len: Int, position: JsAny?): Int +} + +private external object Buffer { + fun alloc(size: Int): JsAny +} + +@Suppress("RedundantNullableReturnType") // invalid diagnostic due to KTIJ-28239 +private fun nodeReadEnvvar(@Suppress("UNUSED_PARAMETER") key: String): String? = + js("process.env[key]") + +private fun nodeWidowSizeIsDefined(): Boolean = + js("process.stdout.getWindowSize != undefined") + +// Have to use js() instead of extern since kotlin can't catch exceptions from external wasm +// functions +@Suppress("RedundantNullableReturnType", "UNUSED_PARAMETER") +private fun nodeReadFileSync(filename: String): String? = + js( + """{ + try { + return require('fs').readFileSync(filename).toString() + } catch (e) { + return null + } + }""" + ) + + +internal class SyscallHandlerWasm : SyscallHandlerNode() { + private val fs: FsModule = importNodeFsModule() + + override fun readEnvvar(key: String): String? = nodeReadEnvvar(key) + override fun stdoutInteractive(): Boolean = process.stdout.isTTY + override fun stdinInteractive(): Boolean = process.stdin.isTTY + override fun stderrInteractive(): Boolean = process.stderr.isTTY + override fun exitProcess(status: Int): Unit = process.exit(status) + override fun getTerminalSize(): Size? { + if (!nodeWidowSizeIsDefined()) return null + val jsSize = process.stdout.getWindowSize() + return Size(width = jsSize[0]!!.toInt(), height = jsSize[1]!!.toInt()) + } + + override fun printStderr(message: String, newline: Boolean) { + process.stderr.write(if (newline) message + "\n" else message) + } + + override fun allocBuffer(size: Int): JsAny = Buffer.alloc(size) + + override fun readSync(fd: Int, buffer: JsAny, offset: Int, len: Int): Int { + return fs.readSync(fd, buffer, offset, len, null) + } + + override fun makeTerminalCursor(terminal: Terminal): TerminalCursor { + return NodeTerminalCursor(terminal) + } + + override fun readFileIfExists(filename: String): String? { + return nodeReadFileSync(filename) + } +} + +private class NodeTerminalCursor(terminal: Terminal) : PrintTerminalCursor(terminal) { + private var shutdownHook: (() -> Unit)? = null + + override fun show() { + shutdownHook?.let { process.removeListener("exit", it) } + super.show() + } + + override fun hide(showOnExit: Boolean) { + if (showOnExit && shutdownHook == null) { + val function = { show() } + shutdownHook = function + process.on("exit", function) + } + super.hide(showOnExit) + } +} + +private fun importNodeFsModule(): FsModule = js("""require("fs")""") From 7b65f5a3e495cf27ca0a4a5c162dd144e22cc38d Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 4 Jun 2024 10:51:38 -0700 Subject: [PATCH 09/45] Add interactive list widgets --- .../ajalt/mordant/widgets/DefinitionList.kt | 2 +- .../ajalt/mordant/widgets/SelectList.kt | 70 +++++++++++ .../ajalt/mordant/widgets/SelectListTest.kt | 112 ++++++++++++++++++ .../ajalt/mordant/internal/MppInternal.jvm.kt | 4 +- .../jna/SyscallHandler.jna.windows.kt | 8 +- .../SyscallHandler.nativeimage.windows.kt | 2 +- .../syscalls/SyscallHandler.native.windows.kt | 8 +- .../mordant/input/InteractiveSelectList.kt | 47 ++++++++ .../ajalt/mordant/input/KeyboardInput.kt | 16 ++- .../syscalls/SyscallHandler.windows.kt | 1 + .../mordant/internal/MppInternal.wasmJs.kt | 2 +- runsample | 15 +++ runsample.bat | 24 ++++ samples/select/README.md | 3 + samples/select/build.gradle.kts | 3 + .../com/github/ajalt/mordant/samples/main.kt | 22 ++++ settings.gradle.kts | 1 + 17 files changed, 321 insertions(+), 19 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt create mode 100644 mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt create mode 100644 mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt create mode 100755 runsample create mode 100755 runsample.bat create mode 100644 samples/select/README.md create mode 100644 samples/select/build.gradle.kts create mode 100644 samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/DefinitionList.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/DefinitionList.kt index 3b35ea8e9..94c940bb4 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/DefinitionList.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/DefinitionList.kt @@ -177,7 +177,7 @@ class DefinitionListEntryBuilder { } /** - * Build a [DefinitionList] widget. + * Build a definition list widget. * * Call [entry][DefinitionListBuilder.entry] to add entries */ diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt new file mode 100644 index 000000000..488ad126c --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt @@ -0,0 +1,70 @@ +package com.github.ajalt.mordant.widgets + +import com.github.ajalt.mordant.rendering.* +import com.github.ajalt.mordant.table.Borders +import com.github.ajalt.mordant.table.table +import com.github.ajalt.mordant.table.verticalLayout +import com.github.ajalt.mordant.terminal.Terminal + +/** + * A list widget with selectable items. + */ +class SelectList( + private val entries: List, + private val title: Widget? = null, + private val cursorIndex: Int = -1, // -1 for no cursor + private val cursorMarker: String = "❯", // style for color + private val selectedMarker: String = "✓", // may be empty + private val unselectedMarker: String = "•", // may be empty + private val captionBottom: Widget? = null, + private val selectedStyle: TextStyle = TextStyle(), // TODO: theme? + private val unselectedTitleStyle: TextStyle = TextStyle(), // TODO: theme? + private val unselectedMarkerStyle: TextStyle = TextStyle(), // TODO: theme? + ) : Widget { + class Entry( + val title: String, + val description: Widget? = null, + val selected: Boolean = false, + ) { + constructor(title: String, description: String?, selected: Boolean = false) + : this(title, description?.let(::Text), selected) + } + + private val layout = table { + title?.let(::captionTop) + captionBottom?.let(::captionBottom) + cellBorders = Borders.LEFT_RIGHT + tableBorders = Borders.NONE + borderType = BorderType.BLANK + padding = Padding(0) + val cursorBlank = " ".repeat(Span.word(cursorMarker.replace(" ", ".")).cellWidth) + val styledSelectedMarker = selectedStyle(selectedMarker) + val styledUnselectedMarker = unselectedMarkerStyle(unselectedMarker) + body { + for ((i, entry) in entries.withIndex()) { + row { + if (cursorIndex != null) { + cell(if (i == cursorIndex) cursorMarker else cursorBlank) + } + if (selectedMarker.isNotEmpty()) { + cell(if (entry.selected) styledSelectedMarker else styledUnselectedMarker) + } + val title = when { + entry.selected -> selectedStyle(entry.title) + else -> unselectedTitleStyle(entry.title) + } + cell(when { + entry.description != null -> verticalLayout { + cells(title, entry.description) + } + + else -> Text(title) + }) + } + } + } + } + + override fun measure(t: Terminal, width: Int): WidthRange = layout.measure(t, width) + override fun render(t: Terminal, width: Int): Lines = layout.render(t, width) +} diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt new file mode 100644 index 000000000..b6aaa0048 --- /dev/null +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt @@ -0,0 +1,112 @@ +package com.github.ajalt.mordant.widgets + +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.rendering.TextColors.* +import com.github.ajalt.mordant.rendering.TextStyle +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.test.RenderingTest +import com.github.ajalt.mordant.widgets.SelectList.Entry +import kotlin.js.JsName +import kotlin.test.Test + +class SelectListTest : RenderingTest() { + @Test + @JsName("no_optional_elements") + fun `no optional elements`() = doTest( + """ + ░foo + ░bar + ░baz + """, + Entry("foo"), + Entry("bar"), + Entry("baz"), + selectedMarker = "", + cursorMarker = "", + ) + + @Test + @JsName("no_selected_marker") + fun `no selected marker`() = doTest( + """ + ░title + ░ foo + ░ bar + ░❯ baz + """, + Entry("foo"), + Entry("bar"), + Entry("baz"), + title = Text("title"), + cursorIndex = 2, + selectedMarker = "", + ) + + @Test + @JsName("multi_selected_marker") + fun `multi selected marker`() = doTest( + """ + ░title + ░❯ • foo + ░ ✓ bar + ░ • baz + ░ ✓ qux + ░caption + """, + Entry("foo"), + Entry("bar", selected = true), + Entry("baz"), + Entry("qux", selected = true), + title = Text("title"), + cursorIndex = 0, + captionBottom = Text("caption"), + ) + + @Test + @JsName("styles_with_descriptions") + fun `styles with descriptions`() = doTest( + """ + ░ ${blue("•")} ${red("foo")} + ░ desc1 + ░ line2 + ░ line3 + ░❯ ${green("✓")} ${green("bar")} + ░ desc2 + ░ ${blue("•")} ${red("baz")} + """, + Entry("foo", description = "desc1\n line2\n line3"), + Entry("bar", selected = true, description = "desc2"), + Entry("baz"), + cursorIndex = 1, + selectedStyle = green, + unselectedTitleStyle = red, + unselectedMarkerStyle = blue, + ) + + private fun doTest( + expected: String, + vararg entries: Entry, + title: Widget? = null, + cursorIndex: Int = -1, + cursorMarker: String = "❯", + selectedMarker: String = "✓", + unselectedMarker: String = "•", + captionBottom: Widget? = null, + selectedStyle: TextStyle = TextStyle(), + unselectedTitleStyle: TextStyle = TextStyle(), + unselectedMarkerStyle: TextStyle = TextStyle(), + ) = checkRender( + SelectList( + entries = entries.toList(), + title = title, + cursorIndex = cursorIndex, + cursorMarker = cursorMarker, + selectedMarker = selectedMarker, + unselectedMarker = unselectedMarker, + captionBottom = captionBottom, + selectedStyle = selectedStyle, + unselectedTitleStyle = unselectedTitleStyle, + unselectedMarkerStyle = unselectedMarkerStyle, + ), expected + ) +} diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt index 203cb4b84..d20a944b9 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt @@ -5,7 +5,7 @@ import com.github.ajalt.mordant.internal.syscalls.SyscallHandler import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaLinux import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaMacos import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaWindows -import com.github.ajalt.mordant.internal.syscalls.nativeimage.NativeImageWin32MppImpls +import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImageWindows import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImagePosix import com.github.ajalt.mordant.terminal.* import java.io.File @@ -129,7 +129,7 @@ internal actual fun getSyscallHandler(): SyscallHandler { val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" when { - isNativeImage && os.startsWith("Windows") -> NativeImageWin32MppImpls + isNativeImage && os.startsWith("Windows") -> SyscallHandlerNativeImageWindows isNativeImage && (os == "Linux" || os == "Mac OS X") -> SyscallHandlerNativeImagePosix os.startsWith("Windows") -> SyscallHandlerJnaWindows os == "Linux" -> SyscallHandlerJnaLinux diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt index bd61fb064..8615a1583 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt @@ -287,10 +287,10 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { val originalMode = IntByReference() kernel.GetConsoleMode(stdinHandle, originalMode) - // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add - // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. - // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c - kernel.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT) + // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add + // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those + // events. + kernel.SetConsoleMode(stdinHandle, 0) return AutoCloseable { kernel.SetConsoleMode(stdinHandle, originalMode.value) } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index c203b018f..a94581626 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -135,7 +135,7 @@ private object WinKernel32Lib { } @Platforms(Platform.WINDOWS::class) -internal object NativeImageWin32MppImpls : SyscallHandlerWindows() { +internal object SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { override fun stdoutInteractive(): Boolean { val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index f722a98fc..983f01ad6 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -57,10 +57,10 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { val originalMode = alloc() GetConsoleMode(stdinHandle, originalMode.ptr) - // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add - // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. - // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c - SetConsoleMode(stdinHandle, ENABLE_PROCESSED_INPUT.toUInt()) + // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add + // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those + // events. + SetConsoleMode(stdinHandle, 0u) return AutoCloseable { SetConsoleMode(stdinHandle, originalMode.value) } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt new file mode 100644 index 000000000..872f8b68f --- /dev/null +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -0,0 +1,47 @@ +package com.github.ajalt.mordant.input + +import com.github.ajalt.mordant.animation.animation +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.widgets.SelectList +import com.github.ajalt.mordant.widgets.Text + +fun Terminal.interactiveSelectList( + items: List, + title: String = "", + selectedMarker: String = "✓", + cursorMarker: String = "❯ ", + multiSelectMarker: String = "• ", + includeInstructions: Boolean = true,// TODO includeInstructions +): String? { + // TODO: descriptions + val entries = items.map { SelectList.Entry(it) } + enterRawMode()?.use { scope -> + val a = animation { i -> + SelectList( + entries, + title = if (title.isEmpty()) null else Text(title), // TODO style + cursorIndex = i, + selectedMarker = selectedMarker, + cursorMarker = cursorMarker, + unselectedMarker = multiSelectMarker + ) + } + try { + var cursor = 0 + while (true) { + a.update(cursor) + val key = scope.readKey() + when { + key == null -> return null + key.key == "c" && key.ctrl -> return null + key.key == "ArrowUp" -> cursor = (cursor - 1).coerceAtLeast(0) + key.key == "ArrowDown" -> cursor = (cursor + 1).coerceAtMost(entries.lastIndex) + key.key == "Enter" -> return items[cursor] + } + } + } finally { + a.stop() + } + } + return null +} diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt index 96c8d7b28..b973f6f34 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt @@ -4,13 +4,17 @@ import com.github.ajalt.mordant.internal.SYSCALL_HANDLER import com.github.ajalt.mordant.terminal.Terminal import kotlin.time.Duration -// TODO docs, tests -fun Terminal.readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { +// TODO: docs, tests +fun Terminal.enterRawMode(): RawModeScope? { if (!info.inputInteractive) return null - return SYSCALL_HANDLER.readKeyEvent(timeout) + return RawModeScope(SYSCALL_HANDLER.enterRawMode()) } -fun Terminal.enterRawMode(): AutoCloseable { - if (!info.inputInteractive) return AutoCloseable { } - return SYSCALL_HANDLER.enterRawMode() +class RawModeScope internal constructor( + closeable: AutoCloseable, +) : AutoCloseable by closeable { + // TODO: docs, tests + fun readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { + return SYSCALL_HANDLER.readKeyEvent(timeout) + } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index 50f5dd8a6..5f3b00dac 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -35,6 +35,7 @@ internal abstract class SyscallHandlerWindows: SyscallHandler { if (event != null && event.bKeyDown) { return KeyboardEvent( key = when { + event.uChar == '\r' -> "Enter" event.uChar.code != 0 -> event.uChar.toString() else -> WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) }, diff --git a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt index 61aecb754..9fe5e9d45 100644 --- a/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt +++ b/mordant/src/wasmJsMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.wasmJs.kt @@ -29,7 +29,7 @@ internal actual fun codepointSequence(string: String): Sequence { return generateSequence { it.next().value?.codePointAt(0) } } -// See jsMain/MppImpl.kt for the details of node detection +// See jsMain for the details of node detection private fun runningOnNode(): Boolean = js("Object.prototype.toString.call(typeof process !== 'undefined' ? process : 0) === '[object process]'") diff --git a/runsample b/runsample new file mode 100755 index 000000000..bdc31708a --- /dev/null +++ b/runsample @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Run one of the samples. +# The first argument must be the name of the sample task (e.g. echo). +# Any remaining arguments are forwarded to the sample's argv. + +task=$1 +shift 1 + +if [ -z "${task}" ] || [ ! -d "samples/${task}" ] +then + echo "Unknown sample: '${task}'" + exit 1 +fi + +./gradlew --quiet ":samples:${task}:installDist" && "./samples/${task}/build/install/${task}/bin/${task}" "$@" diff --git a/runsample.bat b/runsample.bat new file mode 100755 index 000000000..e5178b142 --- /dev/null +++ b/runsample.bat @@ -0,0 +1,24 @@ +@if "%DEBUG%"=="" @echo off +:: Run one of the samples. +:: The first argument must be the name of the sample task (e.g. echo). +:: Any remaining arguments are forwarded to the sample's argv. + +if "%OS%"=="Windows_NT" setlocal EnableDelayedExpansion + +set TASK=%~1 + +set SAMPLE=false +if defined TASK if not "!TASK: =!"=="" if exist "samples\%TASK%\*" set SAMPLE=true + +if "%SAMPLE%"=="false" ( + echo Unknown sample: '%TASK%' + exit /b 1 +) + +set ARGS=%* +set ARGS=!ARGS:*%1=! +if "!ARGS:~0,1!"==" " set ARGS=!ARGS:~1! + +call gradlew --quiet ":samples:%TASK%:installDist" && call "samples\%TASK%\build\install\%TASK%\bin\%TASK%" %ARGS% + +if "%OS%"=="Windows_NT" endlocal diff --git a/samples/select/README.md b/samples/select/README.md new file mode 100644 index 000000000..fd37826c2 --- /dev/null +++ b/samples/select/README.md @@ -0,0 +1,3 @@ +# Select List Sample + +This sample shows how to use the interactive select list widgets. \ No newline at end of file diff --git a/samples/select/build.gradle.kts b/samples/select/build.gradle.kts new file mode 100644 index 000000000..b4788949b --- /dev/null +++ b/samples/select/build.gradle.kts @@ -0,0 +1,3 @@ +plugins { + id("mordant-mpp-sample-conventions") +} diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt new file mode 100644 index 000000000..b594b42f2 --- /dev/null +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -0,0 +1,22 @@ +package com.github.ajalt.mordant.samples + +import com.github.ajalt.mordant.input.interactiveSelectList +import com.github.ajalt.mordant.rendering.BorderType.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR +import com.github.ajalt.mordant.rendering.TextAlign.LEFT +import com.github.ajalt.mordant.rendering.TextAlign.RIGHT +import com.github.ajalt.mordant.rendering.TextColors.* +import com.github.ajalt.mordant.rendering.TextStyle +import com.github.ajalt.mordant.rendering.TextStyles.dim +import com.github.ajalt.mordant.table.Borders.* +import com.github.ajalt.mordant.table.table +import com.github.ajalt.mordant.terminal.Terminal + + +fun main() { + val terminal = Terminal() + val result = terminal.interactiveSelectList( + listOf("United States", "Canada", "Mexico"), + title = "Select a country", + ) + terminal.info("Selected: $result") +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 7c7b9ab31..f77ebd691 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -7,6 +7,7 @@ include( "samples:hexviewer", "samples:markdown", "samples:progress", + "samples:select", "samples:table", "samples:tour", "test:graalvm", From 370066c07901d0e1d927fa22441051476163bc32 Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 4 Jun 2024 11:21:18 -0700 Subject: [PATCH 10/45] Add multi select list --- .../ajalt/mordant/widgets/SelectList.kt | 2 +- .../mordant/input/InteractiveSelectList.kt | 117 +++++++++++++++--- .../com/github/ajalt/mordant/samples/main.kt | 40 ++++-- 3 files changed, 131 insertions(+), 28 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt index 488ad126c..a148c3f60 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt @@ -21,7 +21,7 @@ class SelectList( private val unselectedTitleStyle: TextStyle = TextStyle(), // TODO: theme? private val unselectedMarkerStyle: TextStyle = TextStyle(), // TODO: theme? ) : Widget { - class Entry( + data class Entry(// TODO: docs val title: String, val description: Widget? = null, val selected: Boolean = false, diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 872f8b68f..adf71ef46 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -1,47 +1,134 @@ package com.github.ajalt.mordant.input import com.github.ajalt.mordant.animation.animation +import com.github.ajalt.mordant.rendering.TextStyle +import com.github.ajalt.mordant.rendering.TextStyles.bold +import com.github.ajalt.mordant.rendering.TextStyles.dim +import com.github.ajalt.mordant.rendering.Widget import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.widgets.SelectList import com.github.ajalt.mordant.widgets.Text -fun Terminal.interactiveSelectList( - items: List, - title: String = "", - selectedMarker: String = "✓", - cursorMarker: String = "❯ ", - multiSelectMarker: String = "• ", - includeInstructions: Boolean = true,// TODO includeInstructions -): String? { +private fun Terminal.animateSelectList( + singleSelect: Boolean, + limit: Int, + entries: List, + title: Widget?, + cursorIndex: Int, + cursorMarker: String, + selectedMarker: String, + unselectedMarker: String, + captionBottom: Widget?, + selectedStyle: TextStyle, + unselectedTitleStyle: TextStyle, + unselectedMarkerStyle: TextStyle, + clearOnExit: Boolean = true, +): List? { // TODO: descriptions - val entries = items.map { SelectList.Entry(it) } + val items = entries.toMutableList() enterRawMode()?.use { scope -> val a = animation { i -> SelectList( - entries, - title = if (title.isEmpty()) null else Text(title), // TODO style + items, + title = title, cursorIndex = i, selectedMarker = selectedMarker, cursorMarker = cursorMarker, - unselectedMarker = multiSelectMarker + unselectedMarker = unselectedMarker, + selectedStyle = selectedStyle, + unselectedTitleStyle = unselectedTitleStyle, + unselectedMarkerStyle = unselectedMarkerStyle, + captionBottom = captionBottom, ) } try { - var cursor = 0 + var cursor = cursorIndex while (true) { a.update(cursor) val key = scope.readKey() + val entry = items[cursor] when { key == null -> return null key.key == "c" && key.ctrl -> return null key.key == "ArrowUp" -> cursor = (cursor - 1).coerceAtLeast(0) key.key == "ArrowDown" -> cursor = (cursor + 1).coerceAtMost(entries.lastIndex) - key.key == "Enter" -> return items[cursor] + !singleSelect && key.key == "x" -> { + if (entry.selected || items.count { it.selected } < limit) { + items[cursor] = entry.copy(selected = !entry.selected) + } + } + + key.key == "Enter" -> { + if (singleSelect) return listOf(entry) + return items + } } } } finally { - a.stop() + if (clearOnExit) a.clear() else a.stop() } } return null } + +fun Terminal.interactiveSelectList( + entries: List, + title: String = "", + cursorMarker: String = "❯", + startingCursorIndex: Int = 0, + includeInstructions: Boolean = true, + onlyShowActiveDescription: Boolean = false, // TODO + clearOnExit: Boolean = true, +): String? { + return animateSelectList( + singleSelect = true, + limit = 1, + entries = entries.map { SelectList.Entry(it) }, + title = Text(title), + cursorIndex = startingCursorIndex, + cursorMarker = cursorMarker, + selectedMarker = "", + unselectedMarker = "", + captionBottom = if (includeInstructions) { + // TODO: theme + Text(dim("${bold("↑")} up • ${bold("↓")} down • ${bold("enter")} select")) + } else null, + selectedStyle = TextStyle(), + unselectedTitleStyle = TextStyle(), + unselectedMarkerStyle = TextStyle(), + clearOnExit = clearOnExit, + )?.first()?.title +} + +fun Terminal.interactiveMultiSelectList( + entries: List, + title: String = "", + limit: Int = Int.MAX_VALUE, + cursorMarker: String = "❯", + selectedMarker: String = "✓", // may be empty + unselectedMarker: String = "•", // may be empty + startingCursorIndex: Int = 0, + includeInstructions: Boolean = true, + onlyShowActiveDescription: Boolean = false, // TODO + clearOnExit: Boolean = true, +): List? { + return animateSelectList( + singleSelect = false, + limit = limit, + entries = entries, + title = Text(title), + cursorIndex = startingCursorIndex, + cursorMarker = cursorMarker, + selectedMarker = selectedMarker, + unselectedMarker = unselectedMarker, + captionBottom = if (includeInstructions) { + // TODO: theme + Text(dim("${("x")} toggle • ${("↑")} up • ${("↓")} down • ${("enter")} confirm")) + } else null, + selectedStyle = TextStyle(), + unselectedTitleStyle = TextStyle(), + unselectedMarkerStyle = TextStyle(), + clearOnExit = clearOnExit, + )?.mapNotNull { if (it.selected) it.title else null } +} + diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index b594b42f2..3dd4fdb68 100644 --- a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -1,22 +1,38 @@ package com.github.ajalt.mordant.samples +import com.github.ajalt.mordant.input.interactiveMultiSelectList import com.github.ajalt.mordant.input.interactiveSelectList -import com.github.ajalt.mordant.rendering.BorderType.Companion.SQUARE_DOUBLE_SECTION_SEPARATOR -import com.github.ajalt.mordant.rendering.TextAlign.LEFT -import com.github.ajalt.mordant.rendering.TextAlign.RIGHT -import com.github.ajalt.mordant.rendering.TextColors.* -import com.github.ajalt.mordant.rendering.TextStyle -import com.github.ajalt.mordant.rendering.TextStyles.dim -import com.github.ajalt.mordant.table.Borders.* -import com.github.ajalt.mordant.table.table import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.widgets.SelectList +import com.github.ajalt.mordant.widgets.SelectList.Entry fun main() { val terminal = Terminal() - val result = terminal.interactiveSelectList( - listOf("United States", "Canada", "Mexico"), - title = "Select a country", + val size = terminal.interactiveSelectList( + listOf("Small", "Medium", "Large"), + title = "Select a Pizza Size", ) - terminal.info("Selected: $result") + if (size == null) { + terminal.danger("Aborted pizza order") + return + } + val toppings = terminal.interactiveMultiSelectList( + listOf( + Entry("Pepperoni"), + Entry("Sausage"), + Entry("Mushrooms"), + Entry("Olives"), + Entry("Pineapple"), + Entry("Anchovies"), + ), + title = "Select Toppings", + limit = 3, + ) + if (toppings == null) { + terminal.danger("Aborted pizza order") + return + } + val toppingString = if (toppings.isEmpty()) "no toppings" else toppings.joinToString() + terminal.success("You ordered a $size pizza with $toppingString") } From 1813aeeddf3cd3cd9363105abf4899f838f6e476 Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 4 Jun 2024 12:06:19 -0700 Subject: [PATCH 11/45] Prefer virtual key code to char codes on windows --- .../com/github/ajalt/mordant/input/KeyboardEvent.kt | 10 +++++++--- .../ajalt/mordant/input/InteractiveSelectList.kt | 10 +++++----- .../internal/syscalls/SyscallHandler.windows.kt | 7 ++++--- .../syscalls/WindowsVirtualKeyCodeToKeyEvent.kt | 4 +--- 4 files changed, 17 insertions(+), 14 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt index 1cf849e99..8656f5ae2 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt @@ -4,8 +4,12 @@ package com.github.ajalt.mordant.input // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent data class KeyboardEvent( val key: String, - val ctrl: Boolean, - val alt: Boolean, // `Option ⌥` key on mac - val shift: Boolean, + val ctrl: Boolean = false, + val alt: Boolean = false, // `Option ⌥` key on mac + val shift: Boolean = false, // maybe add a `data` field for escape sequences? ) + +fun KeyboardEvent.isCtrlC(): Boolean { + return key == "c" && ctrl && !alt && !shift +} diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index adf71ef46..e7d15a258 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -49,16 +49,16 @@ private fun Terminal.animateSelectList( val entry = items[cursor] when { key == null -> return null - key.key == "c" && key.ctrl -> return null - key.key == "ArrowUp" -> cursor = (cursor - 1).coerceAtLeast(0) - key.key == "ArrowDown" -> cursor = (cursor + 1).coerceAtMost(entries.lastIndex) - !singleSelect && key.key == "x" -> { + key.isCtrlC() -> return null + key == KeyboardEvent("ArrowUp") -> cursor = (cursor - 1).coerceAtLeast(0) + key == KeyboardEvent("ArrowDown") -> cursor = (cursor + 1).coerceAtMost(entries.lastIndex) + !singleSelect && key == KeyboardEvent("x") -> { if (entry.selected || items.count { it.selected } < limit) { items[cursor] = entry.copy(selected = !entry.selected) } } - key.key == "Enter" -> { + key == KeyboardEvent("Enter") -> { if (singleSelect) return listOf(entry) return items } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index 5f3b00dac..476001df1 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -4,7 +4,7 @@ import com.github.ajalt.mordant.input.KeyboardEvent import kotlin.time.Duration import kotlin.time.TimeSource -internal abstract class SyscallHandlerWindows: SyscallHandler { +internal abstract class SyscallHandlerWindows : SyscallHandler { private companion object { // https://learn.microsoft.com/en-us/windows/console/key-event-record-str const val RIGHT_ALT_PRESSED: UInt = 0x0001u @@ -33,11 +33,12 @@ internal abstract class SyscallHandlerWindows: SyscallHandler { val event = readRawKeyEvent(dwMilliseconds) // ignore key up events if (event != null && event.bKeyDown) { + val virtualName = WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) return KeyboardEvent( key = when { - event.uChar == '\r' -> "Enter" + virtualName != null -> virtualName event.uChar.code != 0 -> event.uChar.toString() - else -> WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) + else -> "Unidentified" }, ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt index 87d0a10e9..c76a314ed 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/WindowsVirtualKeyCodeToKeyEvent.kt @@ -153,7 +153,5 @@ internal object WindowsVirtualKeyCodeToKeyEvent { (0x5A).toUShort() to "z", ) - fun getName(keyCode: UShort): String { - return map[keyCode] ?: "Unidentified" - } + fun getName(keyCode: UShort): String? = map[keyCode] } From b93dc444ddca4e63bb032a070840e7a146afacea Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 4 Jun 2024 14:05:13 -0700 Subject: [PATCH 12/45] Add theme styles to select list --- .../ajalt/mordant/internal/MppInternal.kt | 1 + .../github/ajalt/mordant/rendering/Theme.kt | 23 +++- .../ajalt/mordant/widgets/SelectList.kt | 127 ++++++++++++------ .../mordant/input/InteractiveSelectList.kt | 48 ++++--- .../com/github/ajalt/mordant/samples/main.kt | 8 +- 5 files changed, 134 insertions(+), 73 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt index 0401b1f15..569ed1a7e 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.kt @@ -11,6 +11,7 @@ internal interface MppAtomicInt { internal interface MppAtomicRef { val value: T + /** @return true if the value was set */ fun compareAndSet(expected: T, newValue: T): Boolean fun getAndSet(newValue: T): T } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt index 8bd8309f0..cead9b3e0 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt @@ -58,6 +58,13 @@ sealed class Theme( "markdown.h4" to TextStyle(DEFAULT_HEADER, underline = true), "markdown.h5" to TextStyle(DEFAULT_HEADER, italic = true), "markdown.h6" to TextStyle(DEFAULT_HEADER, dim = true), + + "select.title" to TextStyle(DEFAULT_HEADER, bold = true), + "select.cursor" to TextStyle(DEFAULT_HIGHLIGHT), + "select.selected" to TextStyle(DEFAULT_GREEN), + "select.unselected-title" to DEFAULT_STYLE, + "select.unselected-marker" to TextStyle(dim = true), + "select.instructions" to TextStyle(dim = true) ), mapOf( "list.number.separator" to ".", @@ -76,6 +83,10 @@ sealed class Theme( "markdown.h5.rule" to " ", "markdown.h6.rule" to " ", "markdown.blockquote.bar" to "▎", + + "select.cursor" to "❯", + "select.selected" to "✓", + "select.unselected" to "•", ), mapOf( "progressbar.pulse" to true, @@ -112,6 +123,10 @@ sealed class Theme( strings["markdown.h2.rule"] = "-" strings["markdown.blockquote.bar"] = "|" + strings["select.cursor"] = ">" + strings["select.selected"] = "x" + strings["select.unselected"] = " " + flags["markdown.table.ascii"] = true } } @@ -123,7 +138,9 @@ sealed class Theme( val muted: TextStyle get() = style("muted") /** Return a style if defined, or [default] otherwise */ - fun style(style: String, default: TextStyle = DEFAULT_STYLE): TextStyle = styles.getOrElse(style) { default } + fun style(style: String, default: TextStyle = DEFAULT_STYLE): TextStyle { + return styles.getOrElse(style) { default } + } /** Return a style if defined, or `null` otherwise */ fun styleOrNull(style: String): TextStyle? = styles[style] @@ -141,7 +158,9 @@ sealed class Theme( fun stringOrNull(string: String): String? = strings[string] /** Return a dimension if defined, or [default] otherwise */ - fun dimension(dimension: String, default: Int = 0): Int = dimensions.getOrElse(dimension) { default } + fun dimension(dimension: String, default: Int = 0): Int { + return dimensions.getOrElse(dimension) { default } + } /** Return a dimension if defined, or `null` otherwise */ fun dimensionOrNull(dimension: String): Int? = dimensions[dimension] diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt index a148c3f60..ea0eadf41 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt @@ -1,5 +1,9 @@ package com.github.ajalt.mordant.widgets +import com.github.ajalt.mordant.internal.DEFAULT_STYLE +import com.github.ajalt.mordant.internal.MppAtomicRef +import com.github.ajalt.mordant.internal.ThemeString +import com.github.ajalt.mordant.internal.ThemeStyle import com.github.ajalt.mordant.rendering.* import com.github.ajalt.mordant.table.Borders import com.github.ajalt.mordant.table.table @@ -9,19 +13,50 @@ import com.github.ajalt.mordant.terminal.Terminal /** * A list widget with selectable items. */ -class SelectList( +class SelectList private constructor( private val entries: List, - private val title: Widget? = null, - private val cursorIndex: Int = -1, // -1 for no cursor - private val cursorMarker: String = "❯", // style for color - private val selectedMarker: String = "✓", // may be empty - private val unselectedMarker: String = "•", // may be empty - private val captionBottom: Widget? = null, - private val selectedStyle: TextStyle = TextStyle(), // TODO: theme? - private val unselectedTitleStyle: TextStyle = TextStyle(), // TODO: theme? - private val unselectedMarkerStyle: TextStyle = TextStyle(), // TODO: theme? - ) : Widget { - data class Entry(// TODO: docs + private val title: Widget?, + private val cursorIndex: Int?, + private val styleOnHover: Boolean , + private val cursorMarker: ThemeString, + private val selectedMarker: ThemeString, + private val unselectedMarker: ThemeString, + private val captionBottom: Widget?, + private val cursorStyle: ThemeStyle, + private val selectedStyle: ThemeStyle, + private val unselectedTitleStyle: ThemeStyle, + private val unselectedMarkerStyle: ThemeStyle, +) : Widget { + constructor( + entries: List, + title: Widget? = null, + cursorIndex: Int = -1, // -1 for no cursor + styleOnHover: Boolean = false, + cursorMarker: String? = null, + selectedMarker: String? = null, + unselectedMarker: String? = null, + captionBottom: Widget? = null, + selectedStyle: TextStyle? = null, + cursorStyle: TextStyle? = null, + unselectedTitleStyle: TextStyle? = null, + unselectedMarkerStyle: TextStyle? = null, + ) : this( + entries = entries, + title = title, + cursorIndex = cursorIndex, + styleOnHover = styleOnHover, + cursorMarker = ThemeString.of("select.cursor", cursorMarker, "❯"), + selectedMarker = ThemeString.of("select.selected", selectedMarker, "✓"), + unselectedMarker = ThemeString.of("select.unselected", unselectedMarker, "•"), + captionBottom = captionBottom, + cursorStyle = ThemeStyle.of("select.cursor", selectedStyle), + selectedStyle = ThemeStyle.of("select.selected", cursorStyle), + unselectedTitleStyle = ThemeStyle.of("select.unselected-title", unselectedTitleStyle), + unselectedMarkerStyle = ThemeStyle.of("select.unselected-marker", unselectedMarkerStyle), + ) + + // TODO: docs + data class Entry( val title: String, val description: Widget? = null, val selected: Boolean = false, @@ -30,41 +65,49 @@ class SelectList( : this(title, description?.let(::Text), selected) } - private val layout = table { - title?.let(::captionTop) - captionBottom?.let(::captionBottom) - cellBorders = Borders.LEFT_RIGHT - tableBorders = Borders.NONE - borderType = BorderType.BLANK - padding = Padding(0) - val cursorBlank = " ".repeat(Span.word(cursorMarker.replace(" ", ".")).cellWidth) - val styledSelectedMarker = selectedStyle(selectedMarker) - val styledUnselectedMarker = unselectedMarkerStyle(unselectedMarker) - body { - for ((i, entry) in entries.withIndex()) { - row { - if (cursorIndex != null) { - cell(if (i == cursorIndex) cursorMarker else cursorBlank) - } - if (selectedMarker.isNotEmpty()) { - cell(if (entry.selected) styledSelectedMarker else styledUnselectedMarker) - } - val title = when { - entry.selected -> selectedStyle(entry.title) - else -> unselectedTitleStyle(entry.title) - } - cell(when { - entry.description != null -> verticalLayout { - cells(title, entry.description) + private val widget: MppAtomicRef = MppAtomicRef(null) + private fun layout(t: Terminal): Widget { + widget.value?.let { return it } + val w = table { + title?.let(::captionTop) + captionBottom?.let(::captionBottom) + cellBorders = Borders.LEFT_RIGHT + tableBorders = Borders.NONE + borderType = BorderType.BLANK + padding = Padding(0) + val cursorBlank = " ".repeat(Span.word(cursorMarker[t].replace(" ", ".")).cellWidth) + val cursor = cursorStyle[t](cursorMarker[t]) + val styledSelectedMarker = selectedStyle[t](selectedMarker[t]) + val styledUnselectedMarker = unselectedMarkerStyle[t](unselectedMarker[t]) + body { + for ((i, entry) in entries.withIndex()) { + row { + if (cursorIndex != null) { + cell(if (i == cursorIndex) cursor else cursorBlank) } + if (selectedMarker[t].isNotEmpty()) { + cell(if (entry.selected) styledSelectedMarker else styledUnselectedMarker) + } + val title = when { + entry.selected -> selectedStyle[t](entry.title) + i == cursorIndex && styleOnHover -> selectedStyle[t](entry.title) + else -> unselectedTitleStyle[t](entry.title) + } + cell(when { + entry.description != null -> verticalLayout { + cells(title, entry.description) + } - else -> Text(title) - }) + else -> Text(title) + }) + } } } } + if (widget.compareAndSet(null, w)) return w + return widget.value!! } - override fun measure(t: Terminal, width: Int): WidthRange = layout.measure(t, width) - override fun render(t: Terminal, width: Int): Lines = layout.render(t, width) + override fun measure(t: Terminal, width: Int): WidthRange = layout(t).measure(t, width) + override fun render(t: Terminal, width: Int): Lines = layout(t).render(t, width) } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index e7d15a258..e7e4b8d0b 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -1,6 +1,8 @@ package com.github.ajalt.mordant.input import com.github.ajalt.mordant.animation.animation +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.rendering.TextColors.brightWhite import com.github.ajalt.mordant.rendering.TextStyle import com.github.ajalt.mordant.rendering.TextStyles.bold import com.github.ajalt.mordant.rendering.TextStyles.dim @@ -15,13 +17,13 @@ private fun Terminal.animateSelectList( entries: List, title: Widget?, cursorIndex: Int, - cursorMarker: String, - selectedMarker: String, - unselectedMarker: String, + cursorMarker: String? = null, + selectedMarker: String? = null, + unselectedMarker: String? = null, captionBottom: Widget?, - selectedStyle: TextStyle, - unselectedTitleStyle: TextStyle, - unselectedMarkerStyle: TextStyle, + selectedStyle: TextStyle? = null, + unselectedTitleStyle: TextStyle? = null, + unselectedMarkerStyle: TextStyle? = null, clearOnExit: Boolean = true, ): List? { // TODO: descriptions @@ -32,6 +34,7 @@ private fun Terminal.animateSelectList( items, title = title, cursorIndex = i, + styleOnHover = singleSelect, selectedMarker = selectedMarker, cursorMarker = cursorMarker, unselectedMarker = unselectedMarker, @@ -51,7 +54,9 @@ private fun Terminal.animateSelectList( key == null -> return null key.isCtrlC() -> return null key == KeyboardEvent("ArrowUp") -> cursor = (cursor - 1).coerceAtLeast(0) - key == KeyboardEvent("ArrowDown") -> cursor = (cursor + 1).coerceAtMost(entries.lastIndex) + key == KeyboardEvent("ArrowDown") -> cursor = + (cursor + 1).coerceAtMost(entries.lastIndex) + !singleSelect && key == KeyboardEvent("x") -> { if (entry.selected || items.count { it.selected } < limit) { items[cursor] = entry.copy(selected = !entry.selected) @@ -74,7 +79,8 @@ private fun Terminal.animateSelectList( fun Terminal.interactiveSelectList( entries: List, title: String = "", - cursorMarker: String = "❯", + cursorMarker: String? = null, + // TODO add other style options startingCursorIndex: Int = 0, includeInstructions: Boolean = true, onlyShowActiveDescription: Boolean = false, // TODO @@ -84,18 +90,18 @@ fun Terminal.interactiveSelectList( singleSelect = true, limit = 1, entries = entries.map { SelectList.Entry(it) }, - title = Text(title), + title = Text(theme.style("select.title")(title)), cursorIndex = startingCursorIndex, cursorMarker = cursorMarker, selectedMarker = "", unselectedMarker = "", captionBottom = if (includeInstructions) { - // TODO: theme - Text(dim("${bold("↑")} up • ${bold("↓")} down • ${bold("enter")} select")) + Text( + theme.style("select.instructions")( + " ${brightWhite("↑")} up • ${brightWhite("↓")} down • ${brightWhite("enter")} select" + ) + ) } else null, - selectedStyle = TextStyle(), - unselectedTitleStyle = TextStyle(), - unselectedMarkerStyle = TextStyle(), clearOnExit = clearOnExit, )?.first()?.title } @@ -104,10 +110,8 @@ fun Terminal.interactiveMultiSelectList( entries: List, title: String = "", limit: Int = Int.MAX_VALUE, - cursorMarker: String = "❯", - selectedMarker: String = "✓", // may be empty - unselectedMarker: String = "•", // may be empty startingCursorIndex: Int = 0, + // TODO add other style options includeInstructions: Boolean = true, onlyShowActiveDescription: Boolean = false, // TODO clearOnExit: Boolean = true, @@ -116,18 +120,12 @@ fun Terminal.interactiveMultiSelectList( singleSelect = false, limit = limit, entries = entries, - title = Text(title), + title = Text(theme.style("select.title")(title)), cursorIndex = startingCursorIndex, - cursorMarker = cursorMarker, - selectedMarker = selectedMarker, - unselectedMarker = unselectedMarker, captionBottom = if (includeInstructions) { // TODO: theme - Text(dim("${("x")} toggle • ${("↑")} up • ${("↓")} down • ${("enter")} confirm")) + Text(dim(" ${brightWhite("x")} toggle • ${brightWhite("↑")} up • ${brightWhite("↓")} down • ${brightWhite("enter")} confirm")) } else null, - selectedStyle = TextStyle(), - unselectedTitleStyle = TextStyle(), - unselectedMarkerStyle = TextStyle(), clearOnExit = clearOnExit, )?.mapNotNull { if (it.selected) it.title else null } } diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index 3dd4fdb68..0161523dd 100644 --- a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -3,14 +3,14 @@ package com.github.ajalt.mordant.samples import com.github.ajalt.mordant.input.interactiveMultiSelectList import com.github.ajalt.mordant.input.interactiveSelectList import com.github.ajalt.mordant.terminal.Terminal -import com.github.ajalt.mordant.widgets.SelectList import com.github.ajalt.mordant.widgets.SelectList.Entry fun main() { val terminal = Terminal() + val theme = terminal.theme val size = terminal.interactiveSelectList( - listOf("Small", "Medium", "Large"), + listOf("Small", "Medium", "Large", "X-Large"), title = "Select a Pizza Size", ) if (size == null) { @@ -27,12 +27,12 @@ fun main() { Entry("Anchovies"), ), title = "Select Toppings", - limit = 3, + limit = 4, ) if (toppings == null) { terminal.danger("Aborted pizza order") return } val toppingString = if (toppings.isEmpty()) "no toppings" else toppings.joinToString() - terminal.success("You ordered a $size pizza with $toppingString") + terminal.success("You ordered a ${theme.info(size)} pizza with ${theme.info(toppingString)}") } From e13fdcddf143c4778428faf280d865ee643f9855 Mon Sep 17 00:00:00 2001 From: AJ Date: Wed, 5 Jun 2024 08:13:46 -0700 Subject: [PATCH 13/45] Add onlyShowActiveDescription --- .../mordant/input/InteractiveSelectList.kt | 63 ++++++++++++++----- .../com/github/ajalt/mordant/samples/main.kt | 13 ++-- 2 files changed, 53 insertions(+), 23 deletions(-) diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index e7e4b8d0b..377ee646a 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -1,10 +1,8 @@ package com.github.ajalt.mordant.input import com.github.ajalt.mordant.animation.animation -import com.github.ajalt.mordant.rendering.TextColors import com.github.ajalt.mordant.rendering.TextColors.brightWhite import com.github.ajalt.mordant.rendering.TextStyle -import com.github.ajalt.mordant.rendering.TextStyles.bold import com.github.ajalt.mordant.rendering.TextStyles.dim import com.github.ajalt.mordant.rendering.Widget import com.github.ajalt.mordant.terminal.Terminal @@ -16,15 +14,16 @@ private fun Terminal.animateSelectList( limit: Int, entries: List, title: Widget?, - cursorIndex: Int, - cursorMarker: String? = null, - selectedMarker: String? = null, - unselectedMarker: String? = null, + startingCursorIndex: Int, + cursorMarker: String?, + selectedMarker: String?, + unselectedMarker: String?, captionBottom: Widget?, - selectedStyle: TextStyle? = null, - unselectedTitleStyle: TextStyle? = null, - unselectedMarkerStyle: TextStyle? = null, - clearOnExit: Boolean = true, + selectedStyle: TextStyle?, + unselectedTitleStyle: TextStyle?, + unselectedMarkerStyle: TextStyle?, + clearOnExit: Boolean, + onlyShowActiveDescription: Boolean, ): List? { // TODO: descriptions val items = entries.toMutableList() @@ -44,8 +43,19 @@ private fun Terminal.animateSelectList( captionBottom = captionBottom, ) } + try { - var cursor = cursorIndex + var cursor = startingCursorIndex + fun updateCursor(newCursor: Int) { + cursor = newCursor.coerceIn(0, entries.lastIndex) + if (onlyShowActiveDescription) { + items.forEachIndexed { i, entry -> + items[i] = entry.copy( + description = if (i == cursor) entries[i].description else null + ) + } + } + } while (true) { a.update(cursor) val key = scope.readKey() @@ -53,9 +63,8 @@ private fun Terminal.animateSelectList( when { key == null -> return null key.isCtrlC() -> return null - key == KeyboardEvent("ArrowUp") -> cursor = (cursor - 1).coerceAtLeast(0) - key == KeyboardEvent("ArrowDown") -> cursor = - (cursor + 1).coerceAtMost(entries.lastIndex) + key == KeyboardEvent("ArrowUp") -> updateCursor(cursor - 1) + key == KeyboardEvent("ArrowDown") -> updateCursor(cursor + 1) !singleSelect && key == KeyboardEvent("x") -> { if (entry.selected || items.count { it.selected } < limit) { @@ -91,7 +100,7 @@ fun Terminal.interactiveSelectList( limit = 1, entries = entries.map { SelectList.Entry(it) }, title = Text(theme.style("select.title")(title)), - cursorIndex = startingCursorIndex, + startingCursorIndex = startingCursorIndex, cursorMarker = cursorMarker, selectedMarker = "", unselectedMarker = "", @@ -102,7 +111,12 @@ fun Terminal.interactiveSelectList( ) ) } else null, + + selectedStyle = null,//TODO + unselectedTitleStyle = null,//TODO + unselectedMarkerStyle = null,//TODO clearOnExit = clearOnExit, + onlyShowActiveDescription = onlyShowActiveDescription, )?.first()?.title } @@ -121,12 +135,27 @@ fun Terminal.interactiveMultiSelectList( limit = limit, entries = entries, title = Text(theme.style("select.title")(title)), - cursorIndex = startingCursorIndex, + startingCursorIndex = startingCursorIndex, + cursorMarker = null, + selectedMarker = null, + unselectedMarker = null, captionBottom = if (includeInstructions) { // TODO: theme - Text(dim(" ${brightWhite("x")} toggle • ${brightWhite("↑")} up • ${brightWhite("↓")} down • ${brightWhite("enter")} confirm")) + Text( + dim( + " ${brightWhite("x")} toggle • ${brightWhite("↑")} up • ${brightWhite("↓")} down • ${ + brightWhite( + "enter" + ) + } confirm" + ) + ) } else null, + selectedStyle = null,//TODO + unselectedTitleStyle = null,//TODO + unselectedMarkerStyle = null,//TODO clearOnExit = clearOnExit, + onlyShowActiveDescription = onlyShowActiveDescription, )?.mapNotNull { if (it.selected) it.title else null } } diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index 0161523dd..b3981e2dd 100644 --- a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -19,15 +19,16 @@ fun main() { } val toppings = terminal.interactiveMultiSelectList( listOf( - Entry("Pepperoni"), - Entry("Sausage"), - Entry("Mushrooms"), - Entry("Olives"), - Entry("Pineapple"), - Entry("Anchovies"), + Entry("Pepperoni", selected = true, description = "Spicy"), + Entry("Sausage", selected = true, description = "Spicy"), + Entry("Mushrooms", description = "Fresh, not canned"), + Entry("Olives", description = "Black olives"), + Entry("Pineapple", description = "Fresh, not canned"), + Entry("Anchovies", description = "Please don't"), ), title = "Select Toppings", limit = 4, + onlyShowActiveDescription = true, ) if (toppings == null) { terminal.danger("Aborted pizza order") From e226af5dc9e0ea75f8733df61a53bec512c6564c Mon Sep 17 00:00:00 2001 From: AJ Date: Wed, 5 Jun 2024 08:25:27 -0700 Subject: [PATCH 14/45] Fix frame clearing on non-rectangular animations --- CHANGELOG.md | 3 ++ .../ajalt/mordant/animation/Animation.kt | 34 ++++++++----------- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6df274428..3df79ab6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ ### Changed - Update Kotlin to 2.0.0 +### Fixed +- Fix animations to correctly clear the last frame when animating a non-rectangular widget that changes size. + ## 2.6.0 ### Added - Publish `iosArm64` and `iosX64` targets. diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt index cad513850..cf484983c 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt @@ -1,13 +1,7 @@ package com.github.ajalt.mordant.animation import com.github.ajalt.mordant.internal.* -import com.github.ajalt.mordant.internal.MppAtomicRef -import com.github.ajalt.mordant.internal.Size -import com.github.ajalt.mordant.internal.update -import com.github.ajalt.mordant.rendering.OverflowWrap -import com.github.ajalt.mordant.rendering.TextAlign -import com.github.ajalt.mordant.rendering.Whitespace -import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.rendering.* import com.github.ajalt.mordant.terminal.PrintRequest import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.terminal.TerminalInfo @@ -39,8 +33,10 @@ abstract class Animation( val terminal: Terminal, ) { private data class State( - val size: Size? = null, - val lastSize: Size? = null, + /** The length of each line of the last rendered widget */ + val size: List? = null, + /** The length of each line of the previous rendered widget */ + val lastSize: List? = null, val lastTerminalSize: Size? = null, val text: String? = null, val interceptorInstalled: Boolean = false, @@ -144,11 +140,9 @@ abstract class Animation( if (SYSCALL_HANDLER.fastIsTty()) terminal.info.updateTerminalSize() val rendered = renderData(data).render(terminal) - val height = rendered.height - val width = rendered.width val (old, _) = state.update { copy( - size = Size(width, height), + size = rendered.lines.map { it.lineWidth }, lastSize = size, interceptorInstalled = true, text = terminal.render(rendered) @@ -164,8 +158,8 @@ abstract class Animation( private fun getCursorMoves( firstDraw: Boolean, clearScreen: Boolean, - lastSize: Size?, - size: Size?, + lastSize: List?, + size: List?, terminalSize: Size, lastTerminalSize: Size?, extraUp: Int = 0, @@ -180,19 +174,21 @@ abstract class Animation( return@getMoves } + val lastWidth = lastSize.max() + val lastHeight = lastSize.size val terminalShrank = lastTerminalSize != null && terminalSize.width < lastTerminalSize.width - && terminalSize.width < lastSize.width + && terminalSize.width < lastWidth val widgetShrank = size != null && ( - size.width < lastSize.width - || size.height < lastSize.height + size.size < lastHeight + || size.zip(lastSize).any { (a, b) -> a < b } ) val up = if (terminalShrank) { // The terminal shrank and caused the text to wrap, we need to move back to the // start of the text - lastSize.height * (lastSize.width.toDouble() / terminalSize.width).toInt() + lastHeight * (lastWidth.toDouble() / terminalSize.width).toInt() } else { - (lastSize.height - 1).coerceAtLeast(0) + (lastHeight - 1).coerceAtLeast(0) } up(up + extraUp) From e996642cc9fba4d06b8bc7d3d91b6d856544a1fd Mon Sep 17 00:00:00 2001 From: AJ Date: Wed, 5 Jun 2024 09:13:56 -0700 Subject: [PATCH 15/45] Fix nested bold and dim --- CHANGELOG.md | 1 + .../ajalt/mordant/internal/AnsiCodes.kt | 1 + .../ajalt/mordant/internal/AnsiRender.kt | 32 ++++++-- .../github/ajalt/mordant/widgets/TextTest.kt | 81 ++++++++++++++----- .../mordant/input/InteractiveSelectList.kt | 11 ++- .../com/github/ajalt/mordant/samples/main.kt | 13 ++- 6 files changed, 100 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3df79ab6d..3f4f3edd7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ ### Fixed - Fix animations to correctly clear the last frame when animating a non-rectangular widget that changes size. +- Fix closing bold and dim styles when one is nested in the other. ## 2.6.0 ### Added diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiCodes.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiCodes.kt index 469a9cc84..a12c00108 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiCodes.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiCodes.kt @@ -1,5 +1,6 @@ package com.github.ajalt.mordant.internal +@Suppress("ConstPropertyName") internal object AnsiCodes { val fg16Range = 30..37 val fg16BrightRange = 90..97 diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt index d71e53132..4454e50b4 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/AnsiRender.kt @@ -66,8 +66,12 @@ internal fun downsample(style: TextStyle, level: AnsiLevel, hyperlinks: Boolean) ) AnsiLevel.ANSI256 -> style.copy( - fg = style.color?.let { if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() }, - bg = style.bgColor?.let { if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() }, + fg = style.color?.let { + if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() + }, + bg = style.bgColor?.let { + if (it is Ansi16 || it is Ansi256) it else it.toSRGB().clamp().toAnsi256() + }, hyperlink = style.hyperlink.takeIf { hyperlinks }, hyperlinkId = style.hyperlinkId.takeIf { hyperlinks } ) @@ -86,25 +90,40 @@ private fun makeTag(old: TextStyle, new: TextStyle): String { if (old == new) return "" val codes = mutableListOf() if (old.color != new.color) codes += new.color.toAnsi(fgColorSelector, fgColorReset, 0) - if (old.bgColor != new.bgColor) codes += new.bgColor.toAnsi(bgColorSelector, bgColorReset, fgBgOffset) + if (old.bgColor != new.bgColor) { + codes += new.bgColor.toAnsi(bgColorSelector, bgColorReset, fgBgOffset) + } fun style(old: Boolean?, new: Boolean?, open: Int, close: Int) { if (old != true && new == true) codes += open else if (old == true && new != true) codes += close } - style(old.bold, new.bold, AnsiCodes.boldOpen, AnsiCodes.boldAndDimClose) style(old.italic, new.italic, AnsiCodes.italicOpen, AnsiCodes.italicClose) style(old.underline, new.underline, AnsiCodes.underlineOpen, AnsiCodes.underlineClose) - style(old.dim, new.dim, AnsiCodes.dimOpen, AnsiCodes.boldAndDimClose) style(old.inverse, new.inverse, AnsiCodes.inverseOpen, AnsiCodes.inverseClose) - style(old.strikethrough, new.strikethrough, AnsiCodes.strikethroughOpen, AnsiCodes.strikethroughClose) + style( + old.strikethrough, new.strikethrough, + AnsiCodes.strikethroughOpen, AnsiCodes.strikethroughClose + ) + + // Since there's only one code for closing both bold and dim at the same time, we need to reopen + // the other if we had both and just closed one + if (old.bold == true && new.bold != true || old.dim == true && new.dim != true) { + codes += AnsiCodes.boldAndDimClose + if (new.bold == true) codes += AnsiCodes.boldOpen + if (new.dim == true) codes += AnsiCodes.dimOpen + } else { + style(old.bold, new.bold, AnsiCodes.boldOpen, AnsiCodes.boldAndDimClose) + style(old.dim, new.dim, AnsiCodes.dimOpen, AnsiCodes.boldAndDimClose) + } val csi = if (codes.isEmpty()) "" else codes.joinToString(";", prefix = CSI, postfix = "m") return when { old.hyperlink != new.hyperlink && new.hyperlink != HYPERLINK_RESET -> { csi + makeHyperlinkTag(new.hyperlink, new.hyperlinkId) } + else -> csi } } @@ -122,6 +141,7 @@ private fun Color?.toAnsi(select: Int, reset: Int, offset: Int): List { fgColorReset, bgColorReset -> listOf(reset) else -> listOf(it.code + offset) } + is Ansi256 -> listOf(select, selector256, it.code) // The ITU T.416 spec uses colons for the rgb separator as well as extra parameters for CMYK // and such. Most terminals only support the semicolon form, so that's what we use. diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt index 98a0f1604..c20f1dcd5 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/TextTest.kt @@ -10,9 +10,12 @@ import com.github.ajalt.mordant.rendering.* import com.github.ajalt.mordant.rendering.OverflowWrap.BREAK_WORD import com.github.ajalt.mordant.rendering.OverflowWrap.NORMAL import com.github.ajalt.mordant.rendering.TextColors.* +import com.github.ajalt.mordant.rendering.TextStyles.bold +import com.github.ajalt.mordant.rendering.TextStyles.dim import com.github.ajalt.mordant.rendering.Whitespace.PRE import com.github.ajalt.mordant.test.RenderingTest import com.github.ajalt.mordant.test.normalizeHyperlinks +import com.github.ajalt.mordant.test.visibleCrLf import io.kotest.data.blocking.forAll import io.kotest.data.row import kotlin.js.JsName @@ -28,26 +31,34 @@ class TextTest : RenderingTest() { @Test @JsName("override_width") - fun `override width`() = checkRender(Text(""" + fun `override width`() = checkRender( + Text( + """ Lorem ipsum dolor sit amet - """, whitespace = Whitespace.NORMAL, width = 12), """ + """, whitespace = Whitespace.NORMAL, width = 12 + ), """ ░Lorem ipsum ░dolor sit ░amet - """, width = 79) + """, width = 79 + ) @Test @JsName("hard_line_breaks") - fun `hard line breaks`() = checkRender(Text(""" + fun `hard line breaks`() = checkRender( + Text( + """ Lorem${NEL}ipsum dolor $LS sit $LS amet - """, whitespace = Whitespace.NORMAL), """ + """, whitespace = Whitespace.NORMAL + ), """ ░Lorem ░ipsum dolor ░sit ░amet - """) + """ + ) @Test fun tabs() = forAll( @@ -62,39 +73,56 @@ class TextTest : RenderingTest() { @Test @JsName("ansi_parsing") - fun `ansi parsing`() = checkRender(Text(""" + fun `ansi parsing`() = checkRender( + Text( + """ ${(red on white)("red ${blue("blue ${gray.bg("gray.bg")}")} red")} ${CSI}255munknown ${CSI}6ndevice status report - """.trimIndent(), whitespace = PRE), """ + """.trimIndent(), whitespace = PRE + ), """ ░${CSI}31;47mred ${CSI}34mblue ${CSI}100mgray.bg${CSI}31;47m red${CSI}39;49m ░unknown ░device status report - """, width = 79) + """, width = 79 + ) @Test @JsName("ansi_parsing_256") - fun `ansi parsing 256`() = checkRender(Text( - (TextColors.color(Ansi256(111)) on TextColors.color(Ansi256(222)))("red")), + fun `ansi parsing 256`() = checkRender( + Text( + (TextColors.color(Ansi256(111)) on TextColors.color(Ansi256(222)))("red") + ), "${CSI}38;5;111;48;5;222mred${CSI}39;49m" ) @Test @JsName("ansi_parsing_truecolor") - fun `ansi parsing truecolor`() = checkRender(Text( - (TextColors.rgb("#ff0000") on TextColors.rgb("#00ff00"))("red")), + fun `ansi parsing truecolor`() = checkRender( + Text( + (TextColors.rgb("#ff0000") on TextColors.rgb("#00ff00"))("red") + ), "${CSI}38;2;255;0;0;48;2;0;255;0mred${CSI}39;49m" ) @Test @JsName("ansi_parsing_with_styles") - fun `ansi parsing with styles`() = checkRender(Text(""" - ${TextStyle(RGB(1, 0, 0), white)("red ${TextStyle(blue)("blue ${TextStyle(bgColor = gray)("gray.bg")}")} red")} + fun `ansi parsing with styles`() = checkRender( + Text( + """ + ${ + TextStyle( + RGB(1, 0, 0), + white + )("red ${TextStyle(blue)("blue ${TextStyle(bgColor = gray)("gray.bg")}")} red") + } ${TextStyle(hyperlink = "foo.com")("foo.${TextStyle(hyperlink = "bar.com")("bar")}.com")}/baz - """.trimIndent(), whitespace = PRE), """ + """.trimIndent(), whitespace = PRE + ), """ ░${CSI}38;2;255;0;0;47mred ${CSI}34mblue ${CSI}100mgray.bg${CSI}38;2;255;0;0;47m red${CSI}39;49m ░${OSC}8;id=1;foo.com${ST}foo.${OSC}8;id=2;bar.com${ST}bar${OSC}8;id=1;foo.com$ST.com${OSC}8;;$ST/baz - """, width = 79) { it.normalizeHyperlinks() } + """, width = 79 + ) { it.normalizeHyperlinks() } @Test @JsName("replacing_whole_string_color") @@ -103,6 +131,16 @@ class TextTest : RenderingTest() { (green on gray)("text") ) + + @Test + @JsName("ansi_bold_and_dim") + fun `ansi bold and dim`() = checkRender( + Text(" ${dim("dim${bold("bold")}dim")} ".visibleCrLf()), + " ␛2mdim␛1mbold␛22;2mdim␛22m " + // bold not bold + ) + + @Test fun resets() = forAll( row(TextStyles.resetForeground, blue.bg), @@ -114,13 +152,15 @@ class TextTest : RenderingTest() { @Test @JsName("hyperlink_one_line") - fun `hyperlink one line`() = doHyperlinkTest("This is a link", + fun `hyperlink one line`() = doHyperlinkTest( + "This is a link", "${OSC}8;id=1;https://example.com${ST}This is a link${OSC}8;;$ST" ) @Test @JsName("hyperlink_word_wrap") - fun `hyperlink word wrap`() = doHyperlinkTest("This is a link", + fun `hyperlink word wrap`() = doHyperlinkTest( + "This is a link", """ ${OSC}8;id=1;https://example.com${ST}This is${OSC}8;;$ST ${OSC}8;id=1;https://example.com${ST}a link${OSC}8;;$ST @@ -130,7 +170,8 @@ class TextTest : RenderingTest() { @Test @JsName("hyperlink_break_word") - fun `hyperlink break word`() = doHyperlinkTest("This_is_a_link", + fun `hyperlink break word`() = doHyperlinkTest( + "This_is_a_link", """ ${OSC}8;id=1;https://example.com${ST}This_is_${OSC}8;;$ST ${OSC}8;id=1;https://example.com${ST}a_link${OSC}8;;$ST diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 377ee646a..3ab799446 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -9,6 +9,8 @@ import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.widgets.SelectList import com.github.ajalt.mordant.widgets.Text +private val keyStyle = TextStyle(brightWhite, bold = true) + private fun Terminal.animateSelectList( singleSelect: Boolean, limit: Int, @@ -107,7 +109,7 @@ fun Terminal.interactiveSelectList( captionBottom = if (includeInstructions) { Text( theme.style("select.instructions")( - " ${brightWhite("↑")} up • ${brightWhite("↓")} down • ${brightWhite("enter")} select" + " ${keyStyle("↑")} up • ${keyStyle("↓")} down • ${keyStyle("enter")} select" ) ) } else null, @@ -143,11 +145,8 @@ fun Terminal.interactiveMultiSelectList( // TODO: theme Text( dim( - " ${brightWhite("x")} toggle • ${brightWhite("↑")} up • ${brightWhite("↓")} down • ${ - brightWhite( - "enter" - ) - } confirm" + " ${keyStyle("x")} toggle • ${keyStyle("↑")} up • ${keyStyle("↓")} down" + + " • ${keyStyle("enter")} confirm" ) ) } else null, diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index b3981e2dd..635a342e0 100644 --- a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -19,16 +19,15 @@ fun main() { } val toppings = terminal.interactiveMultiSelectList( listOf( - Entry("Pepperoni", selected = true, description = "Spicy"), - Entry("Sausage", selected = true, description = "Spicy"), - Entry("Mushrooms", description = "Fresh, not canned"), - Entry("Olives", description = "Black olives"), - Entry("Pineapple", description = "Fresh, not canned"), - Entry("Anchovies", description = "Please don't"), + Entry("Pepperoni", selected = true), + Entry("Sausage", selected = true), + Entry("Mushrooms"), + Entry("Olives"), + Entry("Pineapple"), + Entry("Anchovies"), ), title = "Select Toppings", limit = 4, - onlyShowActiveDescription = true, ) if (toppings == null) { terminal.danger("Aborted pizza order") From 0eb5ee7f0005d1cdd943d645bca5d80c9e11a29e Mon Sep 17 00:00:00 2001 From: AJ Date: Wed, 5 Jun 2024 10:37:49 -0700 Subject: [PATCH 16/45] Add InteractiveSelectListBuilder --- .../github/ajalt/mordant/rendering/Theme.kt | 1 - .../mordant/input/InteractiveSelectList.kt | 474 +++++++++++++----- .../com/github/ajalt/mordant/samples/main.kt | 13 +- 3 files changed, 347 insertions(+), 141 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt index cead9b3e0..9f6499b0a 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/rendering/Theme.kt @@ -64,7 +64,6 @@ sealed class Theme( "select.selected" to TextStyle(DEFAULT_GREEN), "select.unselected-title" to DEFAULT_STYLE, "select.unselected-marker" to TextStyle(dim = true), - "select.instructions" to TextStyle(dim = true) ), mapOf( "list.number.separator" to ".", diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 3ab799446..6bacf0d02 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -8,153 +8,359 @@ import com.github.ajalt.mordant.rendering.Widget import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.widgets.SelectList import com.github.ajalt.mordant.widgets.Text +import kotlin.jvm.JvmName -private val keyStyle = TextStyle(brightWhite, bold = true) - -private fun Terminal.animateSelectList( - singleSelect: Boolean, - limit: Int, - entries: List, - title: Widget?, - startingCursorIndex: Int, - cursorMarker: String?, - selectedMarker: String?, - unselectedMarker: String?, - captionBottom: Widget?, - selectedStyle: TextStyle?, - unselectedTitleStyle: TextStyle?, - unselectedMarkerStyle: TextStyle?, - clearOnExit: Boolean, - onlyShowActiveDescription: Boolean, -): List? { - // TODO: descriptions - val items = entries.toMutableList() - enterRawMode()?.use { scope -> - val a = animation { i -> - SelectList( - items, - title = title, - cursorIndex = i, - styleOnHover = singleSelect, - selectedMarker = selectedMarker, - cursorMarker = cursorMarker, - unselectedMarker = unselectedMarker, - selectedStyle = selectedStyle, - unselectedTitleStyle = unselectedTitleStyle, - unselectedMarkerStyle = unselectedMarkerStyle, - captionBottom = captionBottom, - ) - } - - try { - var cursor = startingCursorIndex - fun updateCursor(newCursor: Int) { - cursor = newCursor.coerceIn(0, entries.lastIndex) - if (onlyShowActiveDescription) { - items.forEachIndexed { i, entry -> - items[i] = entry.copy( - description = if (i == cursor) entries[i].description else null - ) - } - } - } - while (true) { - a.update(cursor) - val key = scope.readKey() - val entry = items[cursor] - when { - key == null -> return null - key.isCtrlC() -> return null - key == KeyboardEvent("ArrowUp") -> updateCursor(cursor - 1) - key == KeyboardEvent("ArrowDown") -> updateCursor(cursor + 1) - - !singleSelect && key == KeyboardEvent("x") -> { - if (entry.selected || items.count { it.selected } < limit) { - items[cursor] = entry.copy(selected = !entry.selected) - } - } +/** + * Display a list of items and allow the user to select one with the arrow keys and enter. + * + * @return the selected item title, or null if the user canceled the selection + */ +inline fun Terminal.interactiveSelectList( + block: InteractiveSelectListBuilder.() -> Unit, +): String? { + return InteractiveSelectListBuilder(this).apply(block).runSingleSelect() +} - key == KeyboardEvent("Enter") -> { - if (singleSelect) return listOf(entry) - return items - } - } - } - } finally { - if (clearOnExit) a.clear() else a.stop() - } +/** + * Display a list of items and allow the user to select one with the arrow keys and enter. + * + * @param entries The list of items to select from + * @param title The title to display above the list + * @return the selected item title, or null if the user canceled the selection + */ +@JvmName("interactiveSelectListString") +fun Terminal.interactiveSelectList( + entries: List, + title: String = "", +): String? { + return interactiveSelectList { + entries(entries) + title(title) } - return null } +/** + * Display a list of items and allow the user to select one with the arrow keys and enter. + * + * @param entries The list of items to select from + * @param title The title to display above the list + * @return the selected item title, or null if the user canceled the selection + */ +@JvmName("interactiveSelectListEntry") fun Terminal.interactiveSelectList( - entries: List, + entries: List, title: String = "", - cursorMarker: String? = null, - // TODO add other style options - startingCursorIndex: Int = 0, - includeInstructions: Boolean = true, - onlyShowActiveDescription: Boolean = false, // TODO - clearOnExit: Boolean = true, ): String? { - return animateSelectList( - singleSelect = true, - limit = 1, - entries = entries.map { SelectList.Entry(it) }, - title = Text(theme.style("select.title")(title)), - startingCursorIndex = startingCursorIndex, - cursorMarker = cursorMarker, - selectedMarker = "", - unselectedMarker = "", - captionBottom = if (includeInstructions) { - Text( - theme.style("select.instructions")( - " ${keyStyle("↑")} up • ${keyStyle("↓")} down • ${keyStyle("enter")} select" - ) - ) - } else null, - - selectedStyle = null,//TODO - unselectedTitleStyle = null,//TODO - unselectedMarkerStyle = null,//TODO - clearOnExit = clearOnExit, - onlyShowActiveDescription = onlyShowActiveDescription, - )?.first()?.title + return interactiveSelectList { + entries(entries) + title(title) + } } +/** + * Display a list of items and allow the user to select zero or more with the arrow keys and enter. + * + * @return the selected item titles, or null if the user canceled the selection + */ +inline fun Terminal.interactiveMultiSelectList( + block: InteractiveSelectListBuilder.() -> Unit, +): List? { + return InteractiveSelectListBuilder(this).apply(block).runMultiSelect() +} + +/** + * Display a list of items and allow the user to select zero or more with the arrow keys and enter. + * + * @param entries The list of items to select from + * @param title The title to display above the list + * @return the selected item titles, or null if the user canceled the selection + */ +@JvmName("interactiveMultiSelectListEntry") fun Terminal.interactiveMultiSelectList( entries: List, title: String = "", - limit: Int = Int.MAX_VALUE, - startingCursorIndex: Int = 0, - // TODO add other style options - includeInstructions: Boolean = true, - onlyShowActiveDescription: Boolean = false, // TODO - clearOnExit: Boolean = true, ): List? { - return animateSelectList( - singleSelect = false, - limit = limit, - entries = entries, - title = Text(theme.style("select.title")(title)), - startingCursorIndex = startingCursorIndex, - cursorMarker = null, - selectedMarker = null, - unselectedMarker = null, - captionBottom = if (includeInstructions) { - // TODO: theme - Text( - dim( - " ${keyStyle("x")} toggle • ${keyStyle("↑")} up • ${keyStyle("↓")} down" + - " • ${keyStyle("enter")} confirm" - ) - ) - } else null, - selectedStyle = null,//TODO - unselectedTitleStyle = null,//TODO - unselectedMarkerStyle = null,//TODO - clearOnExit = clearOnExit, - onlyShowActiveDescription = onlyShowActiveDescription, - )?.mapNotNull { if (it.selected) it.title else null } + return interactiveMultiSelectList { + entries(entries) + title(title) + } } +/** + * Display a list of items and allow the user to select zero or more with the arrow keys and enter. + * + * @param entries The list of items to select from + * @param title The title to display above the list + * @return the selected item titles, or null if the user canceled the selection + */ +@JvmName("interactiveMultiSelectListString") +fun Terminal.interactiveMultiSelectList( + entries: List, + title: String = "", +): List? { + return interactiveMultiSelectList { + entries(entries) + title(title) + } +} + +/** + * Configure an interactive selection list. + * + * Run the selection with [runSingleSelect] or [runMultiSelect]. + */ +class InteractiveSelectListBuilder(private val terminal: Terminal) { + private var entries: MutableList = mutableListOf() + private var title: Widget? = null + private var limit: Int = Int.MAX_VALUE + private var startingCursorIndex: Int = 0 + private var onlyShowActiveDescription: Boolean = false + private var clearOnExit: Boolean = true + private var cursorMarker: String? = null + private var selectedMarker: String? = null + private var unselectedMarker: String? = null + private var selectedStyle: TextStyle? = null + private var unselectedTitleStyle: TextStyle? = null + private var unselectedMarkerStyle: TextStyle? = null + private var keyNext: KeyboardEvent = KeyboardEvent("ArrowDown") + private var keyPrev: KeyboardEvent = KeyboardEvent("ArrowUp") + private var keySubmit: KeyboardEvent = KeyboardEvent("Enter") + private var keyToggle: KeyboardEvent = KeyboardEvent("x") + private var instructions: Widget? = null + + /** Set the list of items to select from */ + fun entries(vararg entries: SelectList.Entry): InteractiveSelectListBuilder = apply { + entries(entries.toList()) + } + + /** Set the list of items to select from */ + @JvmName("entriesEntry") + fun entries(entries: List): InteractiveSelectListBuilder = apply { + this.entries = entries.toMutableList() + } + + /** Set the list of items to select from */ + fun entries(vararg entries: String): InteractiveSelectListBuilder = apply { + entries(entries.toList()) + } + + /** Set the list of items to select from */ + @JvmName("entriesString") + fun entries(entries: List): InteractiveSelectListBuilder = apply { + this.entries = entries.mapTo(mutableListOf()) { SelectList.Entry(it) } + } + + /** Add an item to the list of items to select from */ + fun addEntry(entry: SelectList.Entry): InteractiveSelectListBuilder = apply { + this.entries += entry + } + + /** Add an item to the list of items to select from */ + fun addEntry(entry: String): InteractiveSelectListBuilder = apply { + this.entries += SelectList.Entry(entry) + } + + /** Set the title to display above the list */ + fun title(title: String): InteractiveSelectListBuilder = apply { + this.title = when { + title.isEmpty() -> null + else -> Text(terminal.theme.style("select.title")(title)) + } + } + + /** Set the title to display above the list */ + fun title(title: Widget): InteractiveSelectListBuilder = apply { + this.title = title + } + + /** Set the maximum number of items that can be selected */ + fun limit(limit: Int): InteractiveSelectListBuilder = apply { + this.limit = limit + } + + /** Set the index of the item to start the cursor on */ + fun startingCursorIndex(startingCursorIndex: Int): InteractiveSelectListBuilder = apply { + this.startingCursorIndex = startingCursorIndex + } + + /** If true, only show the description of the highlighted item */ + fun onlyShowActiveDescription(onlyShowActiveDescription: Boolean): InteractiveSelectListBuilder { + return apply { this.onlyShowActiveDescription = onlyShowActiveDescription } + } + + /** If true, clear the list when the user submits the selections. If false, leave it on screen */ + fun clearOnExit(clearOnExit: Boolean): InteractiveSelectListBuilder = apply { + this.clearOnExit = clearOnExit + } + + /** + * Set the marker to display where the cursor is located. Defaults to the theme string + * `select.cursor` + */ + fun cursorMarker(cursorMarker: String): InteractiveSelectListBuilder = apply { + this.cursorMarker = cursorMarker + } + + /** + * Set the marker to display for selected items. Defaults to the theme string + * `select.selected` + */ + fun selectedMarker(selectedMarker: String): InteractiveSelectListBuilder = apply { + this.selectedMarker = selectedMarker + } + + /** + * Set the marker to display for unselected items. Defaults to the theme string + * `select.unselected` + */ + fun unselectedMarker(unselectedMarker: String): InteractiveSelectListBuilder = apply { + this.unselectedMarker = unselectedMarker + } + + /** + * Set the style to use to highlight the currently selected item. Defaults to the theme style + * `select.selected` + */ + fun selectedStyle(selectedStyle: TextStyle): InteractiveSelectListBuilder = apply { + this.selectedStyle = selectedStyle + } + + /** + * Set the style to use for the title of unselected items. Defaults to the theme style + * `select.unselected-title` + */ + fun unselectedTitleStyle(unselectedTitleStyle: TextStyle): InteractiveSelectListBuilder { + return apply { this.unselectedTitleStyle = unselectedTitleStyle } + } + + /** + * Set the style to use for the marker of unselected items. Defaults to the theme style + * `select.unselected-marker` + */ + fun unselectedMarkerStyle(unselectedMarkerStyle: TextStyle): InteractiveSelectListBuilder { + return apply { this.unselectedMarkerStyle = unselectedMarkerStyle } + } + + /** Set the key to move the cursor down */ + fun keyNext(keyNext: KeyboardEvent): InteractiveSelectListBuilder = apply { + this.keyNext = keyNext + } + + /** Set the key to move the cursor up */ + fun keyPrev(keyPrev: KeyboardEvent): InteractiveSelectListBuilder = apply { + this.keyPrev = keyPrev + } + + /** Set the key to submit the selection */ + fun keySubmit(keySubmit: KeyboardEvent): InteractiveSelectListBuilder = apply { + this.keySubmit = keySubmit + } + + /** Set the key to toggle the selection of an item */ + fun keyToggle(keyToggle: KeyboardEvent): InteractiveSelectListBuilder = apply { + this.keyToggle = keyToggle + } + + /** Set the instructions to display at the bottom of the list */ + fun instructions(instructions: Widget): InteractiveSelectListBuilder = apply { + this.instructions = instructions + } + + /** Set the instructions to display at the bottom of the list */ + fun instructions(instructions: String): InteractiveSelectListBuilder = apply { + this.instructions = Text(instructions) + } + + /** + * Run the select list in single select mode and return the selected item title, or `null` + * if the user canceled the selection + */ + fun runSingleSelect(): String? { + return runAnimation(singleSelect = true)?.first()?.title + } + + /** + * Run the select list in multi select mode and return the selected item titles, or `null` if the + * user canceled the selection + */ + fun runMultiSelect(): List? { + return runAnimation(singleSelect = false) + ?.mapNotNull { if (it.selected) it.title else null } + } + + private fun runAnimation(singleSelect: Boolean): List? { + require(entries.isNotEmpty()) { "Select list must have at least one entry" } + + val items = entries.toMutableList() + val caption = instructions?.let { + val s = TextStyle(brightWhite, bold = true) + val text = when { + singleSelect -> { + " ${s("↑")} up • ${s("↓")} down • ${s("enter")} select" + } + + else -> { + " ${s("x")} toggle • ${s("↑")} up • ${s("↓")} down • ${s("enter")} confirm" + } + } + Text(dim(text)) + } + + terminal.enterRawMode()?.use { scope -> + val a = terminal.animation { i -> + SelectList( + items, + title = title, + cursorIndex = i, + styleOnHover = singleSelect, + selectedMarker = selectedMarker, + cursorMarker = cursorMarker, + unselectedMarker = unselectedMarker, + selectedStyle = selectedStyle, + unselectedTitleStyle = unselectedTitleStyle, + unselectedMarkerStyle = unselectedMarkerStyle, + captionBottom = caption, + ) + } + + try { + var cursor = startingCursorIndex + fun updateCursor(newCursor: Int) { + cursor = newCursor.coerceIn(0, entries.lastIndex) + if (onlyShowActiveDescription) { + items.forEachIndexed { i, entry -> + items[i] = entry.copy( + description = if (i == cursor) entries[i].description else null + ) + } + } + } + while (true) { + a.update(cursor) + val key = scope.readKey() + val entry = items[cursor] + when { + key == null -> return null + key.isCtrlC() -> return null + key == keyPrev -> updateCursor(cursor - 1) + key == keyNext -> updateCursor(cursor + 1) + + !singleSelect && key == keyToggle -> { + if (entry.selected || items.count { it.selected } < limit) { + items[cursor] = entry.copy(selected = !entry.selected) + } + } + + key == keySubmit -> { + if (singleSelect) return listOf(entry) + return items + } + } + } + } finally { + if (clearOnExit) a.clear() else a.stop() + } + } + return null + } +} diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index 635a342e0..8c7135184 100644 --- a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -17,18 +17,19 @@ fun main() { terminal.danger("Aborted pizza order") return } - val toppings = terminal.interactiveMultiSelectList( - listOf( + val toppings = terminal.interactiveMultiSelectList { + entries( Entry("Pepperoni", selected = true), Entry("Sausage", selected = true), Entry("Mushrooms"), Entry("Olives"), Entry("Pineapple"), Entry("Anchovies"), - ), - title = "Select Toppings", - limit = 4, - ) + ) + title("Select Toppings") + limit(4) + } + if (toppings == null) { terminal.danger("Aborted pizza order") return From d122539e73d417a1cd439cc2ea8398df13b09035 Mon Sep 17 00:00:00 2001 From: AJ Date: Thu, 6 Jun 2024 10:02:38 -0700 Subject: [PATCH 17/45] Support filtering select list --- .../ajalt/mordant/input/KeyboardEvent.kt | 5 +- .../ajalt/mordant/widgets/SelectList.kt | 18 ++- .../mordant/input/InteractiveSelectList.kt | 116 ++++++++++++++---- 3 files changed, 109 insertions(+), 30 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt index 8656f5ae2..899a0897b 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt @@ -10,6 +10,5 @@ data class KeyboardEvent( // maybe add a `data` field for escape sequences? ) -fun KeyboardEvent.isCtrlC(): Boolean { - return key == "c" && ctrl && !alt && !shift -} +val KeyboardEvent.isCtrlC: Boolean + get() = key == "c" && ctrl && !alt && !shift diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt index ea0eadf41..f1a48e131 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.widgets -import com.github.ajalt.mordant.internal.DEFAULT_STYLE import com.github.ajalt.mordant.internal.MppAtomicRef import com.github.ajalt.mordant.internal.ThemeString import com.github.ajalt.mordant.internal.ThemeStyle @@ -12,12 +11,14 @@ import com.github.ajalt.mordant.terminal.Terminal /** * A list widget with selectable items. + * + * Use `interactiveSelectList` to create a list that can be interacted with. */ class SelectList private constructor( private val entries: List, private val title: Widget?, private val cursorIndex: Int?, - private val styleOnHover: Boolean , + private val styleOnHover: Boolean, private val cursorMarker: ThemeString, private val selectedMarker: ThemeString, private val unselectedMarker: ThemeString, @@ -55,14 +56,20 @@ class SelectList private constructor( unselectedMarkerStyle = ThemeStyle.of("select.unselected-marker", unselectedMarkerStyle), ) - // TODO: docs data class Entry( + /** The title of the entry. */ val title: String, + /** An optional description of the entry. */ val description: Widget? = null, + /** Whether this entry is marked as selected. */ val selected: Boolean = false, ) { constructor(title: String, description: String?, selected: Boolean = false) - : this(title, description?.let(::Text), selected) + : this( + title = title, + description = description?.let { Text(it, whitespace = Whitespace.NORMAL) }, + selected = selected + ) } private val widget: MppAtomicRef = MppAtomicRef(null) @@ -75,6 +82,7 @@ class SelectList private constructor( tableBorders = Borders.NONE borderType = BorderType.BLANK padding = Padding(0) + whitespace = Whitespace.NORMAL val cursorBlank = " ".repeat(Span.word(cursorMarker[t].replace(" ", ".")).cellWidth) val cursor = cursorStyle[t](cursorMarker[t]) val styledSelectedMarker = selectedStyle[t](selectedMarker[t]) @@ -98,7 +106,7 @@ class SelectList private constructor( cells(title, entry.description) } - else -> Text(title) + else -> title }) } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 6bacf0d02..5066eef5a 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -126,7 +126,10 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { private var keyPrev: KeyboardEvent = KeyboardEvent("ArrowUp") private var keySubmit: KeyboardEvent = KeyboardEvent("Enter") private var keyToggle: KeyboardEvent = KeyboardEvent("x") + private var keyFilter: KeyboardEvent = KeyboardEvent("/") + private var keyExitFilter: KeyboardEvent = KeyboardEvent("Escape") private var instructions: Widget? = null + private var filterable: Boolean = false /** Set the list of items to select from */ fun entries(vararg entries: SelectList.Entry): InteractiveSelectListBuilder = apply { @@ -261,6 +264,16 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { this.keyToggle = keyToggle } + /** Set the key to filter the list */ + fun keyFilter(keyFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { + this.keyFilter = keyFilter + } + + /** Set the key to stop filtering the list */ + fun keyExitFilter(keyExitFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { + this.keyExitFilter = keyExitFilter + } + /** Set the instructions to display at the bottom of the list */ fun instructions(instructions: Widget): InteractiveSelectListBuilder = apply { this.instructions = instructions @@ -271,6 +284,11 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { this.instructions = Text(instructions) } + /** Set whether the list should be filterable */ + fun filterable(filterable: Boolean): InteractiveSelectListBuilder = apply { + this.filterable = filterable + } + /** * Run the select list in single select mode and return the selected item title, or `null` * if the user canceled the selection @@ -288,30 +306,66 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { ?.mapNotNull { if (it.selected) it.title else null } } - private fun runAnimation(singleSelect: Boolean): List? { - require(entries.isNotEmpty()) { "Select list must have at least one entry" } - - val items = entries.toMutableList() - val caption = instructions?.let { - val s = TextStyle(brightWhite, bold = true) - val text = when { - singleSelect -> { - " ${s("↑")} up • ${s("↓")} down • ${s("enter")} select" - } + private fun keyName(key: KeyboardEvent): String { + var k = when (key.key) { + "Enter" -> "enter" + "Escape" -> "esc" + "ArrowUp" -> "↑" + "ArrowDown" -> "↓" + "ArrowLeft" -> "←" + "ArrowRight" -> "→" + else -> key.key + } + if (key.alt) k = "alt+$k" + if (key.ctrl) k = "ctrl+$k" + if (key.shift && key.key.length > 1) k = "shift+$k" + return k + } - else -> { - " ${s("x")} toggle • ${s("↑")} up • ${s("↓")} down • ${s("enter")} confirm" - } + private fun buildInstructions( + singleSelect: Boolean, filtering: Boolean, hasFilter: Boolean, + ): String { + val s = TextStyle(brightWhite, bold = true) + val parts = buildList { + add(keyName(keyPrev) to "up") + add(keyName(keyNext) to "down") + if (filtering) { + if (!singleSelect) add(keyName(keySubmit) to "apply filter") + } else if (filterable) { + add(keyName(keyFilter) to "filter") + } + if (filtering || hasFilter) { + add(keyName(keyExitFilter) to "clear filter") + } + if (singleSelect) { + add(keyName(keySubmit) to "select") + } else { + add(keyName(keyToggle) to "toggle") + add(keyName(keySubmit) to "confirm") } - Text(dim(text)) } + return dim(parts.joinToString(" • ") { "${s(it.first)} ${it.second}" }) + } + + private fun runAnimation(singleSelect: Boolean): List? { + require(entries.isNotEmpty()) { "Select list must have at least one entry" } terminal.enterRawMode()?.use { scope -> - val a = terminal.animation { i -> + val items = entries.toMutableList() + var cursor = startingCursorIndex + var filtering = false + val filter = StringBuilder() + val a = terminal.animation { + // TODO cursorStyle, apply filter on multi select SelectList( - items, - title = title, - cursorIndex = i, + entries = if (filter.isEmpty()) items else items.filter { + filter.toString().lowercase() in it.title.lowercase() + }, + title = when { + filtering -> Text("${terminal.theme.style("select.cursor")("/")} $filter") + else -> title + }, + cursorIndex = cursor, styleOnHover = singleSelect, selectedMarker = selectedMarker, cursorMarker = cursorMarker, @@ -319,12 +373,13 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { selectedStyle = selectedStyle, unselectedTitleStyle = unselectedTitleStyle, unselectedMarkerStyle = unselectedMarkerStyle, - captionBottom = caption, + captionBottom = instructions + ?: Text(buildInstructions(singleSelect, filtering, filter.isNotEmpty())), ) } try { - var cursor = startingCursorIndex + fun updateCursor(newCursor: Int) { cursor = newCursor.coerceIn(0, entries.lastIndex) if (onlyShowActiveDescription) { @@ -336,14 +391,31 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { } } while (true) { - a.update(cursor) + a.update(Unit) val key = scope.readKey() val entry = items[cursor] when { key == null -> return null - key.isCtrlC() -> return null + key.isCtrlC -> return null key == keyPrev -> updateCursor(cursor - 1) key == keyNext -> updateCursor(cursor + 1) + filterable && !filtering && key == keyFilter -> { + filtering = true + } + + filtering && key == keyExitFilter -> { + filtering = false + filter.clear() + } + + filtering && !singleSelect && key == keySubmit -> { + filtering = false + } + + filtering && !key.alt && !key.ctrl -> { + if (key.key == "Backspace") filter.deleteAt(filter.lastIndex) + else if (key.key.length == 1) filter.append(key.key) + } !singleSelect && key == keyToggle -> { if (entry.selected || items.count { it.selected } < limit) { From 8d64ffca0fb5aa84c41ada6ea217dfe17072e729 Mon Sep 17 00:00:00 2001 From: AJ Date: Thu, 6 Jun 2024 10:26:30 -0700 Subject: [PATCH 18/45] Move select list animation to commonMain --- .../ajalt/mordant/input/InputReceiver.kt | 10 + .../mordant/input/SelectListAnimation.kt | 396 ++++++++++++++++++ .../mordant/input/InputReceiverRunning.kt | 22 + .../mordant/input/InteractiveSelectList.kt | 343 +-------------- 4 files changed, 436 insertions(+), 335 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt create mode 100644 mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt new file mode 100644 index 000000000..11cfbe1a5 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -0,0 +1,10 @@ +package com.github.ajalt.mordant.input + +// TODO: docs +interface InputReceiver { + sealed class Status { + data object Continue : Status() + class Finished(val result: T) : Status() + } + fun onInput(event: KeyboardEvent): Status +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt new file mode 100644 index 000000000..091235c29 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt @@ -0,0 +1,396 @@ +package com.github.ajalt.mordant.input + +import com.github.ajalt.mordant.animation.animation +import com.github.ajalt.mordant.input.InputReceiver.Status +import com.github.ajalt.mordant.internal.MppAtomicRef +import com.github.ajalt.mordant.internal.update +import com.github.ajalt.mordant.rendering.TextColors.brightWhite +import com.github.ajalt.mordant.rendering.TextStyle +import com.github.ajalt.mordant.rendering.TextStyles.dim +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.widgets.SelectList +import com.github.ajalt.mordant.widgets.Text +import kotlin.jvm.JvmName + +private class SelectConfig( + var entries: MutableList = mutableListOf(), + var title: Widget? = null, + var limit: Int = Int.MAX_VALUE, + var startingCursorIndex: Int = 0, + var onlyShowActiveDescription: Boolean = false, + var clearOnExit: Boolean = true, + var cursorMarker: String? = null, + var selectedMarker: String? = null, + var unselectedMarker: String? = null, + var selectedStyle: TextStyle? = null, + var unselectedTitleStyle: TextStyle? = null, + var unselectedMarkerStyle: TextStyle? = null, + var keyNext: KeyboardEvent = KeyboardEvent("ArrowDown"), + var keyPrev: KeyboardEvent = KeyboardEvent("ArrowUp"), + var keySubmit: KeyboardEvent = KeyboardEvent("Enter"), + var keyToggle: KeyboardEvent = KeyboardEvent("x"), + var keyFilter: KeyboardEvent = KeyboardEvent("/"), + var keyExitFilter: KeyboardEvent = KeyboardEvent("Escape"), + var instructions: Widget? = null, + var filterable: Boolean = false, +) + +/** + * Configure an interactive selection list. + * + * Run the selection with `runSingleSelect` or `runMultiSelect`. On JS and Wasm, where those methods + * aren't available, you can use [createSingleSelectInputAnimation] and + * [createMultiSelectInputAnimation] and feed them keyboard events manually. + */ +class InteractiveSelectListBuilder(private val terminal: Terminal) { + private val config = SelectConfig() + + /** Set the list of items to select from */ + fun entries(vararg entries: SelectList.Entry): InteractiveSelectListBuilder = apply { + entries(entries.toList()) + } + + /** Set the list of items to select from */ + @JvmName("entriesEntry") + fun entries(entries: List): InteractiveSelectListBuilder = apply { + config.entries = entries.toMutableList() + } + + /** Set the list of items to select from */ + fun entries(vararg entries: String): InteractiveSelectListBuilder = apply { + entries(entries.toList()) + } + + /** Set the list of items to select from */ + @JvmName("entriesString") + fun entries(entries: List): InteractiveSelectListBuilder = apply { + config.entries = entries.mapTo(mutableListOf()) { SelectList.Entry(it) } + } + + /** Add an item to the list of items to select from */ + fun addEntry(entry: SelectList.Entry): InteractiveSelectListBuilder = apply { + config.entries += entry + } + + /** Add an item to the list of items to select from */ + fun addEntry(entry: String): InteractiveSelectListBuilder = apply { + config.entries += SelectList.Entry(entry) + } + + /** Set the title to display above the list */ + fun title(title: String): InteractiveSelectListBuilder = apply { + config.title = when { + title.isEmpty() -> null + else -> Text(terminal.theme.style("select.title")(title)) + } + } + + /** Set the title to display above the list */ + fun title(title: Widget): InteractiveSelectListBuilder = apply { + config.title = title + } + + /** Set the maximum number of items that can be selected */ + fun limit(limit: Int): InteractiveSelectListBuilder = apply { + config.limit = limit + } + + /** Set the index of the item to start the cursor on */ + fun startingCursorIndex(startingCursorIndex: Int): InteractiveSelectListBuilder = apply { + config.startingCursorIndex = startingCursorIndex + } + + /** If true, only show the description of the highlighted item */ + fun onlyShowActiveDescription(onlyShowActiveDescription: Boolean): InteractiveSelectListBuilder { + return apply { config.onlyShowActiveDescription = onlyShowActiveDescription } + } + + /** If true, clear the list when the user submits the selections. If false, leave it on screen */ + fun clearOnExit(clearOnExit: Boolean): InteractiveSelectListBuilder = apply { + config.clearOnExit = clearOnExit + } + + /** + * Set the marker to display where the cursor is located. Defaults to the theme string + * `select.cursor` + */ + fun cursorMarker(cursorMarker: String): InteractiveSelectListBuilder = apply { + config.cursorMarker = cursorMarker + } + + /** + * Set the marker to display for selected items. Defaults to the theme string + * `select.selected` + */ + fun selectedMarker(selectedMarker: String): InteractiveSelectListBuilder = apply { + config.selectedMarker = selectedMarker + } + + /** + * Set the marker to display for unselected items. Defaults to the theme string + * `select.unselected` + */ + fun unselectedMarker(unselectedMarker: String): InteractiveSelectListBuilder = apply { + config.unselectedMarker = unselectedMarker + } + + /** + * Set the style to use to highlight the currently selected item. Defaults to the theme style + * `select.selected` + */ + fun selectedStyle(selectedStyle: TextStyle): InteractiveSelectListBuilder = apply { + config.selectedStyle = selectedStyle + } + + /** + * Set the style to use for the title of unselected items. Defaults to the theme style + * `select.unselected-title` + */ + fun unselectedTitleStyle(unselectedTitleStyle: TextStyle): InteractiveSelectListBuilder { + return apply { config.unselectedTitleStyle = unselectedTitleStyle } + } + + /** + * Set the style to use for the marker of unselected items. Defaults to the theme style + * `select.unselected-marker` + */ + fun unselectedMarkerStyle(unselectedMarkerStyle: TextStyle): InteractiveSelectListBuilder { + return apply { config.unselectedMarkerStyle = unselectedMarkerStyle } + } + + /** Set the key to move the cursor down */ + fun keyNext(keyNext: KeyboardEvent): InteractiveSelectListBuilder = apply { + config.keyNext = keyNext + } + + /** Set the key to move the cursor up */ + fun keyPrev(keyPrev: KeyboardEvent): InteractiveSelectListBuilder = apply { + config.keyPrev = keyPrev + } + + /** Set the key to submit the selection */ + fun keySubmit(keySubmit: KeyboardEvent): InteractiveSelectListBuilder = apply { + config.keySubmit = keySubmit + } + + /** Set the key to toggle the selection of an item */ + fun keyToggle(keyToggle: KeyboardEvent): InteractiveSelectListBuilder = apply { + config.keyToggle = keyToggle + } + + /** Set the key to filter the list */ + fun keyFilter(keyFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { + config.keyFilter = keyFilter + } + + /** Set the key to stop filtering the list */ + fun keyExitFilter(keyExitFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { + config.keyExitFilter = keyExitFilter + } + + /** Set the instructions to display at the bottom of the list */ + fun instructions(instructions: Widget): InteractiveSelectListBuilder = apply { + config.instructions = instructions + } + + /** Set the instructions to display at the bottom of the list */ + fun instructions(instructions: String): InteractiveSelectListBuilder = apply { + config.instructions = Text(instructions) + } + + /** Set whether the list should be filterable */ + fun filterable(filterable: Boolean): InteractiveSelectListBuilder = apply { + config.filterable = filterable + } + + /** + * Run the select list in single select mode. + * + * The [result][InputReceiver.Status.Finished.result] will be the selected item title, or `null` + * if the user canceled the selection. + */ + fun createSingleSelectInputAnimation(): InputReceiver { + require(config.entries.isNotEmpty()) { "Select list must have at least one entry" } + return SingleSelectInputAnimation(SelectInputAnimation(terminal, config, true)) + } + + /** + * Run the select list in multi select mode. + * + * The [result][InputReceiver.Status.Finished.result] will be the selected item titles, or + * `null` if the user canceled the selection. + */ + fun createMultiSelectInputAnimation(): InputReceiver?> { + require(config.entries.isNotEmpty()) { "Select list must have at least one entry" } + return SelectInputAnimation(terminal, config, false) + } +} + +private open class SelectInputAnimation( + private val terminal: Terminal, + private val config: SelectConfig, + private val singleSelect: Boolean, +) : InputReceiver?> { + private data class State( + val items: List, + val cursor: Int, + val filtering: Boolean, + val filter: String, + val finished: Boolean, + val result: List?, + ) + + private val state = MppAtomicRef( + State( + config.entries, config.startingCursorIndex, false, "", false, null + ) + ) + + private val animation = terminal.animation { s -> + with(config) { + SelectList( + entries = if (s.filter.isEmpty()) s.items else s.items.filter { + s.filter.lowercase() in it.title.lowercase() + }, + title = when { + s.filtering -> Text("${terminal.theme.style("select.cursor")("/")} ${s.filter}") + else -> title + }, + cursorIndex = s.cursor, + styleOnHover = singleSelect, + selectedMarker = selectedMarker, + cursorMarker = cursorMarker, + unselectedMarker = unselectedMarker, + selectedStyle = selectedStyle, + unselectedTitleStyle = unselectedTitleStyle, + unselectedMarkerStyle = unselectedMarkerStyle, + captionBottom = instructions + ?: Text(buildInstructions(singleSelect, s.filtering, s.filter.isNotEmpty())), + ) + } + }.apply { update(state.value) } + + + override fun onInput(event: KeyboardEvent): Status?> { + val (_, s) = state.update { + with(config) { + fun updateCursor(newCursor: Int): State = copy( + cursor = newCursor.coerceIn(0, entries.lastIndex), + items = if (onlyShowActiveDescription) { + items.mapIndexed { i, entry -> + entry.copy( + description = if (i == cursor) entries[i].description else null + ) + } + } else items + ) + + val key = event + val entry = items[cursor] + when { + key.isCtrlC -> copy(finished = true) + key == keyPrev -> updateCursor(cursor - 1) + key == keyNext -> updateCursor(cursor + 1) + filterable && !filtering && key == keyFilter -> { + copy(filtering = true) + } + + filtering && key == keyExitFilter -> { + copy(items = entries, filtering = false) + } + + filtering && !singleSelect && key == keySubmit -> { + copy(filtering = false) + } + + filtering && !key.alt && !key.ctrl -> { + copy( + filter = when { + key.key == "Backspace" -> filter.dropLast(1) + key.key.length == 1 -> filter + key.key + else -> filter // ignore modifier keys + } + ) + } + + !singleSelect && key == keyToggle -> { + if (entry.selected || items.count { it.selected } < limit) { + copy( + items = items.toMutableList().also { + it[cursor] = entry.copy(selected = !entry.selected) + } + ) + } else { + this@update // can't select more items + } + } + + key == keySubmit -> { + if (singleSelect) copy(finished = true, result = listOf(entry.title)) + else copy( + finished = true, + result = items.filter { it.selected }.map { it.title }) + } + + else -> this@update // unmapped key, no state change + } + } + } + return if (s.finished) Status.Finished(s.result) + else Status.Continue + } + + private fun keyName(key: KeyboardEvent): String { + var k = when (key.key) { + "Enter" -> "enter" + "Escape" -> "esc" + "ArrowUp" -> "↑" + "ArrowDown" -> "↓" + "ArrowLeft" -> "←" + "ArrowRight" -> "→" + else -> key.key + } + if (key.alt) k = "alt+$k" + if (key.ctrl) k = "ctrl+$k" + if (key.shift && key.key.length > 1) k = "shift+$k" + return k + } + + private fun buildInstructions( + singleSelect: Boolean, filtering: Boolean, hasFilter: Boolean, + ): String = with(config) { + val s = TextStyle(brightWhite, bold = true) + val parts = buildList { + add(keyName(keyPrev) to "up") + add(keyName(keyNext) to "down") + if (filtering) { + if (!singleSelect) add(keyName(keySubmit) to "apply filter") + } else if (filterable) { + add(keyName(keyFilter) to "filter") + } + if (filtering || hasFilter) { + add(keyName(keyExitFilter) to "clear filter") + } + if (singleSelect) { + add(keyName(keySubmit) to "select") + } else { + add(keyName(keyToggle) to "toggle") + add(keyName(keySubmit) to "confirm") + } + } + + return dim(parts.joinToString(" • ") { "${s(it.first)} ${it.second}" }) + } +} + +private class SingleSelectInputAnimation( + private val animation: SelectInputAnimation, +) : InputReceiver { + override fun onInput(event: KeyboardEvent): Status { + return when (val status = animation.onInput(event)) { + is Status.Finished -> Status.Finished(status.result?.firstOrNull()) + is Status.Continue -> Status.Continue + } + } +} diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt new file mode 100644 index 000000000..97a8c3076 --- /dev/null +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -0,0 +1,22 @@ +package com.github.ajalt.mordant.input + +import com.github.ajalt.mordant.terminal.Terminal + +/** + * Read input from the [terminal], and feed to this [InputReceiver] until it returns a result. + * + * @return the result of the completed receiver, or `null` if the terminal is not interactive or the + * input could not be read. + */ +fun InputReceiver.run(terminal: Terminal): T? { + terminal.enterRawMode()?.use { rawMode -> + while (true) { + val event = rawMode.readKey() ?: return null + when (val status = onInput(event)) { + is InputReceiver.Status.Continue -> continue + is InputReceiver.Status.Finished -> return status.result + } + } + } + return null +} diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 5066eef5a..9f3fe26a6 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -18,7 +18,10 @@ import kotlin.jvm.JvmName inline fun Terminal.interactiveSelectList( block: InteractiveSelectListBuilder.() -> Unit, ): String? { - return InteractiveSelectListBuilder(this).apply(block).runSingleSelect() + return InteractiveSelectListBuilder(this) + .apply(block) + .createSingleSelectInputAnimation() + .run(this) } /** @@ -65,7 +68,10 @@ fun Terminal.interactiveSelectList( inline fun Terminal.interactiveMultiSelectList( block: InteractiveSelectListBuilder.() -> Unit, ): List? { - return InteractiveSelectListBuilder(this).apply(block).runMultiSelect() + return InteractiveSelectListBuilder(this) + .apply(block) + .createMultiSelectInputAnimation() + .run(this) } /** @@ -103,336 +109,3 @@ fun Terminal.interactiveMultiSelectList( title(title) } } - -/** - * Configure an interactive selection list. - * - * Run the selection with [runSingleSelect] or [runMultiSelect]. - */ -class InteractiveSelectListBuilder(private val terminal: Terminal) { - private var entries: MutableList = mutableListOf() - private var title: Widget? = null - private var limit: Int = Int.MAX_VALUE - private var startingCursorIndex: Int = 0 - private var onlyShowActiveDescription: Boolean = false - private var clearOnExit: Boolean = true - private var cursorMarker: String? = null - private var selectedMarker: String? = null - private var unselectedMarker: String? = null - private var selectedStyle: TextStyle? = null - private var unselectedTitleStyle: TextStyle? = null - private var unselectedMarkerStyle: TextStyle? = null - private var keyNext: KeyboardEvent = KeyboardEvent("ArrowDown") - private var keyPrev: KeyboardEvent = KeyboardEvent("ArrowUp") - private var keySubmit: KeyboardEvent = KeyboardEvent("Enter") - private var keyToggle: KeyboardEvent = KeyboardEvent("x") - private var keyFilter: KeyboardEvent = KeyboardEvent("/") - private var keyExitFilter: KeyboardEvent = KeyboardEvent("Escape") - private var instructions: Widget? = null - private var filterable: Boolean = false - - /** Set the list of items to select from */ - fun entries(vararg entries: SelectList.Entry): InteractiveSelectListBuilder = apply { - entries(entries.toList()) - } - - /** Set the list of items to select from */ - @JvmName("entriesEntry") - fun entries(entries: List): InteractiveSelectListBuilder = apply { - this.entries = entries.toMutableList() - } - - /** Set the list of items to select from */ - fun entries(vararg entries: String): InteractiveSelectListBuilder = apply { - entries(entries.toList()) - } - - /** Set the list of items to select from */ - @JvmName("entriesString") - fun entries(entries: List): InteractiveSelectListBuilder = apply { - this.entries = entries.mapTo(mutableListOf()) { SelectList.Entry(it) } - } - - /** Add an item to the list of items to select from */ - fun addEntry(entry: SelectList.Entry): InteractiveSelectListBuilder = apply { - this.entries += entry - } - - /** Add an item to the list of items to select from */ - fun addEntry(entry: String): InteractiveSelectListBuilder = apply { - this.entries += SelectList.Entry(entry) - } - - /** Set the title to display above the list */ - fun title(title: String): InteractiveSelectListBuilder = apply { - this.title = when { - title.isEmpty() -> null - else -> Text(terminal.theme.style("select.title")(title)) - } - } - - /** Set the title to display above the list */ - fun title(title: Widget): InteractiveSelectListBuilder = apply { - this.title = title - } - - /** Set the maximum number of items that can be selected */ - fun limit(limit: Int): InteractiveSelectListBuilder = apply { - this.limit = limit - } - - /** Set the index of the item to start the cursor on */ - fun startingCursorIndex(startingCursorIndex: Int): InteractiveSelectListBuilder = apply { - this.startingCursorIndex = startingCursorIndex - } - - /** If true, only show the description of the highlighted item */ - fun onlyShowActiveDescription(onlyShowActiveDescription: Boolean): InteractiveSelectListBuilder { - return apply { this.onlyShowActiveDescription = onlyShowActiveDescription } - } - - /** If true, clear the list when the user submits the selections. If false, leave it on screen */ - fun clearOnExit(clearOnExit: Boolean): InteractiveSelectListBuilder = apply { - this.clearOnExit = clearOnExit - } - - /** - * Set the marker to display where the cursor is located. Defaults to the theme string - * `select.cursor` - */ - fun cursorMarker(cursorMarker: String): InteractiveSelectListBuilder = apply { - this.cursorMarker = cursorMarker - } - - /** - * Set the marker to display for selected items. Defaults to the theme string - * `select.selected` - */ - fun selectedMarker(selectedMarker: String): InteractiveSelectListBuilder = apply { - this.selectedMarker = selectedMarker - } - - /** - * Set the marker to display for unselected items. Defaults to the theme string - * `select.unselected` - */ - fun unselectedMarker(unselectedMarker: String): InteractiveSelectListBuilder = apply { - this.unselectedMarker = unselectedMarker - } - - /** - * Set the style to use to highlight the currently selected item. Defaults to the theme style - * `select.selected` - */ - fun selectedStyle(selectedStyle: TextStyle): InteractiveSelectListBuilder = apply { - this.selectedStyle = selectedStyle - } - - /** - * Set the style to use for the title of unselected items. Defaults to the theme style - * `select.unselected-title` - */ - fun unselectedTitleStyle(unselectedTitleStyle: TextStyle): InteractiveSelectListBuilder { - return apply { this.unselectedTitleStyle = unselectedTitleStyle } - } - - /** - * Set the style to use for the marker of unselected items. Defaults to the theme style - * `select.unselected-marker` - */ - fun unselectedMarkerStyle(unselectedMarkerStyle: TextStyle): InteractiveSelectListBuilder { - return apply { this.unselectedMarkerStyle = unselectedMarkerStyle } - } - - /** Set the key to move the cursor down */ - fun keyNext(keyNext: KeyboardEvent): InteractiveSelectListBuilder = apply { - this.keyNext = keyNext - } - - /** Set the key to move the cursor up */ - fun keyPrev(keyPrev: KeyboardEvent): InteractiveSelectListBuilder = apply { - this.keyPrev = keyPrev - } - - /** Set the key to submit the selection */ - fun keySubmit(keySubmit: KeyboardEvent): InteractiveSelectListBuilder = apply { - this.keySubmit = keySubmit - } - - /** Set the key to toggle the selection of an item */ - fun keyToggle(keyToggle: KeyboardEvent): InteractiveSelectListBuilder = apply { - this.keyToggle = keyToggle - } - - /** Set the key to filter the list */ - fun keyFilter(keyFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { - this.keyFilter = keyFilter - } - - /** Set the key to stop filtering the list */ - fun keyExitFilter(keyExitFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { - this.keyExitFilter = keyExitFilter - } - - /** Set the instructions to display at the bottom of the list */ - fun instructions(instructions: Widget): InteractiveSelectListBuilder = apply { - this.instructions = instructions - } - - /** Set the instructions to display at the bottom of the list */ - fun instructions(instructions: String): InteractiveSelectListBuilder = apply { - this.instructions = Text(instructions) - } - - /** Set whether the list should be filterable */ - fun filterable(filterable: Boolean): InteractiveSelectListBuilder = apply { - this.filterable = filterable - } - - /** - * Run the select list in single select mode and return the selected item title, or `null` - * if the user canceled the selection - */ - fun runSingleSelect(): String? { - return runAnimation(singleSelect = true)?.first()?.title - } - - /** - * Run the select list in multi select mode and return the selected item titles, or `null` if the - * user canceled the selection - */ - fun runMultiSelect(): List? { - return runAnimation(singleSelect = false) - ?.mapNotNull { if (it.selected) it.title else null } - } - - private fun keyName(key: KeyboardEvent): String { - var k = when (key.key) { - "Enter" -> "enter" - "Escape" -> "esc" - "ArrowUp" -> "↑" - "ArrowDown" -> "↓" - "ArrowLeft" -> "←" - "ArrowRight" -> "→" - else -> key.key - } - if (key.alt) k = "alt+$k" - if (key.ctrl) k = "ctrl+$k" - if (key.shift && key.key.length > 1) k = "shift+$k" - return k - } - - private fun buildInstructions( - singleSelect: Boolean, filtering: Boolean, hasFilter: Boolean, - ): String { - val s = TextStyle(brightWhite, bold = true) - val parts = buildList { - add(keyName(keyPrev) to "up") - add(keyName(keyNext) to "down") - if (filtering) { - if (!singleSelect) add(keyName(keySubmit) to "apply filter") - } else if (filterable) { - add(keyName(keyFilter) to "filter") - } - if (filtering || hasFilter) { - add(keyName(keyExitFilter) to "clear filter") - } - if (singleSelect) { - add(keyName(keySubmit) to "select") - } else { - add(keyName(keyToggle) to "toggle") - add(keyName(keySubmit) to "confirm") - } - } - - return dim(parts.joinToString(" • ") { "${s(it.first)} ${it.second}" }) - } - - private fun runAnimation(singleSelect: Boolean): List? { - require(entries.isNotEmpty()) { "Select list must have at least one entry" } - terminal.enterRawMode()?.use { scope -> - val items = entries.toMutableList() - var cursor = startingCursorIndex - var filtering = false - val filter = StringBuilder() - val a = terminal.animation { - // TODO cursorStyle, apply filter on multi select - SelectList( - entries = if (filter.isEmpty()) items else items.filter { - filter.toString().lowercase() in it.title.lowercase() - }, - title = when { - filtering -> Text("${terminal.theme.style("select.cursor")("/")} $filter") - else -> title - }, - cursorIndex = cursor, - styleOnHover = singleSelect, - selectedMarker = selectedMarker, - cursorMarker = cursorMarker, - unselectedMarker = unselectedMarker, - selectedStyle = selectedStyle, - unselectedTitleStyle = unselectedTitleStyle, - unselectedMarkerStyle = unselectedMarkerStyle, - captionBottom = instructions - ?: Text(buildInstructions(singleSelect, filtering, filter.isNotEmpty())), - ) - } - - try { - - fun updateCursor(newCursor: Int) { - cursor = newCursor.coerceIn(0, entries.lastIndex) - if (onlyShowActiveDescription) { - items.forEachIndexed { i, entry -> - items[i] = entry.copy( - description = if (i == cursor) entries[i].description else null - ) - } - } - } - while (true) { - a.update(Unit) - val key = scope.readKey() - val entry = items[cursor] - when { - key == null -> return null - key.isCtrlC -> return null - key == keyPrev -> updateCursor(cursor - 1) - key == keyNext -> updateCursor(cursor + 1) - filterable && !filtering && key == keyFilter -> { - filtering = true - } - - filtering && key == keyExitFilter -> { - filtering = false - filter.clear() - } - - filtering && !singleSelect && key == keySubmit -> { - filtering = false - } - - filtering && !key.alt && !key.ctrl -> { - if (key.key == "Backspace") filter.deleteAt(filter.lastIndex) - else if (key.key.length == 1) filter.append(key.key) - } - - !singleSelect && key == keyToggle -> { - if (entry.selected || items.count { it.selected } < limit) { - items[cursor] = entry.copy(selected = !entry.selected) - } - } - - key == keySubmit -> { - if (singleSelect) return listOf(entry) - return items - } - } - } - } finally { - if (clearOnExit) a.clear() else a.stop() - } - } - return null - } -} From bfcf44bd6f1aebc673f0a80e1bbb80214a6faafb Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 7 Jun 2024 10:20:05 -0700 Subject: [PATCH 19/45] Add select list animation tests --- .../ajalt/mordant/animation/Animation.kt | 2 +- .../ajalt/mordant/input/InputReceiver.kt | 3 +- .../mordant/input/SelectListAnimation.kt | 225 +++++++---- .../ajalt/mordant/terminal/TerminalCursor.kt | 51 +-- .../ajalt/mordant/widgets/SelectList.kt | 18 +- .../mordant/input/SelectListAnimationTest.kt | 379 ++++++++++++++++++ .../ajalt/mordant/test/RenderingTest.kt | 9 +- .../github/ajalt/mordant/test/TestUtils.kt | 28 +- .../ajalt/mordant/widgets/SelectListTest.kt | 14 +- .../mordant/input/InteractiveSelectList.kt | 6 - .../com/github/ajalt/mordant/samples/main.kt | 16 +- 11 files changed, 602 insertions(+), 149 deletions(-) create mode 100644 mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt index cf484983c..d998efbe3 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt @@ -174,7 +174,7 @@ abstract class Animation( return@getMoves } - val lastWidth = lastSize.max() + val lastWidth = lastSize.maxOrNull() ?: 0 val lastHeight = lastSize.size val terminalShrank = lastTerminalSize != null && terminalSize.width < lastTerminalSize.width diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index 11cfbe1a5..e636d6c81 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -4,7 +4,8 @@ package com.github.ajalt.mordant.input interface InputReceiver { sealed class Status { data object Continue : Status() - class Finished(val result: T) : Status() + data class Finished(val result: T) : Status() } fun onInput(event: KeyboardEvent): Status + fun cancel() } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt index 091235c29..2d9c6f95f 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt @@ -27,12 +27,20 @@ private class SelectConfig( var unselectedTitleStyle: TextStyle? = null, var unselectedMarkerStyle: TextStyle? = null, var keyNext: KeyboardEvent = KeyboardEvent("ArrowDown"), + var descNext: String = "down", var keyPrev: KeyboardEvent = KeyboardEvent("ArrowUp"), + var descPrev: String = "up", var keySubmit: KeyboardEvent = KeyboardEvent("Enter"), + var descSubmit: String = "select", + var descConfirm: String = "confirm", + var descApplyFilter: String = "set filter", var keyToggle: KeyboardEvent = KeyboardEvent("x"), + var descToggle: String = "toggle", var keyFilter: KeyboardEvent = KeyboardEvent("/"), + var descFilter: String = "filter", var keyExitFilter: KeyboardEvent = KeyboardEvent("Escape"), - var instructions: Widget? = null, + var descExitFilter: String = "clear filter", + var showInstructions: Boolean = true, var filterable: Boolean = false, ) @@ -68,6 +76,24 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { config.entries = entries.mapTo(mutableListOf()) { SelectList.Entry(it) } } + /** Add an item to the list of items to select from */ + fun addEntry( + title: String, + description: String, + selected: Boolean = false, + ): InteractiveSelectListBuilder = apply { + config.entries += SelectList.Entry(title, description, selected) + } + + /** Add an item to the list of items to select from */ + fun addEntry( + title: String, + description: Widget? = null, + selected: Boolean = false, + ): InteractiveSelectListBuilder = apply { + config.entries += SelectList.Entry(title, description, selected) + } + /** Add an item to the list of items to select from */ fun addEntry(entry: SelectList.Entry): InteractiveSelectListBuilder = apply { config.entries += entry @@ -164,39 +190,74 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { config.keyNext = keyNext } + /** Set the description of the key to move the cursor down */ + fun descNext(descNext: String): InteractiveSelectListBuilder = apply { + config.descNext = descNext + } + /** Set the key to move the cursor up */ fun keyPrev(keyPrev: KeyboardEvent): InteractiveSelectListBuilder = apply { config.keyPrev = keyPrev } + /** Set the description of the key to move the cursor up */ + fun descPrev(descPrev: String): InteractiveSelectListBuilder = apply { + config.descPrev = descPrev + } + /** Set the key to submit the selection */ fun keySubmit(keySubmit: KeyboardEvent): InteractiveSelectListBuilder = apply { config.keySubmit = keySubmit } - /** Set the key to toggle the selection of an item */ + /** Set the description of the key to submit the selection in single select mode */ + fun descSubmit(descSubmit: String): InteractiveSelectListBuilder = apply { + config.descSubmit = descSubmit + } + + /** Set the description of the key to submit the selection in multi select mode */ + fun descConfirm(descConfirm: String): InteractiveSelectListBuilder = apply { + config.descConfirm = descConfirm + } + + /** Set the description of the key to apply the filter when filtering in multi select mode */ + fun descApplyFilter(descApplyFilter: String): InteractiveSelectListBuilder = apply { + config.descApplyFilter = descApplyFilter + } + + /** Set the key to toggle the selection of an item in multi select mode */ fun keyToggle(keyToggle: KeyboardEvent): InteractiveSelectListBuilder = apply { config.keyToggle = keyToggle } + /** Set the description of the key to toggle the selection of an item in multi select mode */ + fun descToggle(descToggle: String): InteractiveSelectListBuilder = apply { + config.descToggle = descToggle + } + /** Set the key to filter the list */ fun keyFilter(keyFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { config.keyFilter = keyFilter } + /** Set the description of the key to filter the list */ + fun descFilter(descFilter: String): InteractiveSelectListBuilder = apply { + config.descFilter = descFilter + } + /** Set the key to stop filtering the list */ fun keyExitFilter(keyExitFilter: KeyboardEvent): InteractiveSelectListBuilder = apply { config.keyExitFilter = keyExitFilter } - /** Set the instructions to display at the bottom of the list */ - fun instructions(instructions: Widget): InteractiveSelectListBuilder = apply { - config.instructions = instructions + /** Set the description of the key to stop filtering the list */ + fun descExitFilter(descExitFilter: String): InteractiveSelectListBuilder = apply { + config.descExitFilter = descExitFilter } - /** Set the instructions to display at the bottom of the list */ - fun instructions(instructions: String): InteractiveSelectListBuilder = apply { - config.instructions = Text(instructions) + /** Whether to show instructions at the bottom of the list */ + fun showInstructions(showInstructions: Boolean): InteractiveSelectListBuilder = apply { + config.showInstructions = showInstructions } /** Set whether the list should be filterable */ @@ -235,23 +296,31 @@ private open class SelectInputAnimation( private data class State( val items: List, val cursor: Int, - val filtering: Boolean, - val filter: String, - val finished: Boolean, - val result: List?, - ) - - private val state = MppAtomicRef( - State( - config.entries, config.startingCursorIndex, false, "", false, null - ) - ) + val filtering: Boolean = false, + val applyFilter: Boolean = false, + val filter: String = "", + val finished: Boolean = false, + val result: List? = null, + ) { + val filteredItems: List + get() = if (!applyFilter || filter.isEmpty()) items else items.filter { + filter.lowercase() in it.title.lowercase() + } + } + + private val state = MppAtomicRef(State(config.entries, config.startingCursorIndex)) private val animation = terminal.animation { s -> with(config) { SelectList( - entries = if (s.filter.isEmpty()) s.items else s.items.filter { - s.filter.lowercase() in it.title.lowercase() + entries = if (onlyShowActiveDescription) { + s.filteredItems.mapIndexed { i, entry -> + entry.copy( + description = if (i == s.cursor) entry.description else null + ) + } + } else { + s.filteredItems }, title = when { s.filtering -> Text("${terminal.theme.style("select.cursor")("/")} ${s.filter}") @@ -259,66 +328,87 @@ private open class SelectInputAnimation( }, cursorIndex = s.cursor, styleOnHover = singleSelect, - selectedMarker = selectedMarker, - cursorMarker = cursorMarker, - unselectedMarker = unselectedMarker, + cursorMarker = if (singleSelect || !s.filtering) cursorMarker else " ", + selectedMarker = if (singleSelect) "" else selectedMarker, + unselectedMarker = if (singleSelect) "" else unselectedMarker, selectedStyle = selectedStyle, unselectedTitleStyle = unselectedTitleStyle, unselectedMarkerStyle = unselectedMarkerStyle, - captionBottom = instructions - ?: Text(buildInstructions(singleSelect, s.filtering, s.filter.isNotEmpty())), + captionBottom = when { + showInstructions -> { + Text(buildInstructions(singleSelect, s.filtering, s.filter.isNotEmpty())) + } + + else -> null + }, ) } }.apply { update(state.value) } + override fun cancel() { + if (config.clearOnExit) animation.clear() + else animation.stop() + } override fun onInput(event: KeyboardEvent): Status?> { val (_, s) = state.update { with(config) { - fun updateCursor(newCursor: Int): State = copy( - cursor = newCursor.coerceIn(0, entries.lastIndex), - items = if (onlyShowActiveDescription) { - items.mapIndexed { i, entry -> - entry.copy( - description = if (i == cursor) entries[i].description else null - ) - } - } else items - ) + val filteredItems = filteredItems + fun updateCursor( + newCursor: Int, + items: List = filteredItems, + ): Int { + return newCursor.coerceAtMost(items.lastIndex).coerceAtLeast(0) + } val key = event - val entry = items[cursor] + val entryIndex = when { + filteredItems.isEmpty() -> 0 + applyFilter -> items.indexOf(filteredItems[cursor]) + else -> cursor + } + val entry = items[entryIndex] when { key.isCtrlC -> copy(finished = true) - key == keyPrev -> updateCursor(cursor - 1) - key == keyNext -> updateCursor(cursor + 1) + key == keyPrev -> copy(cursor = updateCursor(cursor - 1)) + key == keyNext -> copy(cursor = updateCursor(cursor + 1)) filterable && !filtering && key == keyFilter -> { - copy(filtering = true) + copy(filtering = true, applyFilter = true) } - filtering && key == keyExitFilter -> { - copy(items = entries, filtering = false) + key == keyExitFilter -> { + copy( + filtering = false, applyFilter = false, filter = "", cursor = entryIndex + ) } - filtering && !singleSelect && key == keySubmit -> { + filtering && key == keySubmit && !singleSelect -> { copy(filtering = false) } + key == keySubmit -> { + if (singleSelect) copy(finished = true, result = listOf(entry.title)) + else copy( + finished = true, + result = items.filter { it.selected }.map { it.title }) + } + filtering && !key.alt && !key.ctrl -> { - copy( + val s = copy( filter = when { key.key == "Backspace" -> filter.dropLast(1) key.key.length == 1 -> filter + key.key else -> filter // ignore modifier keys } ) + s.copy(cursor = updateCursor(s.cursor, s.filteredItems)) } !singleSelect && key == keyToggle -> { if (entry.selected || items.count { it.selected } < limit) { copy( items = items.toMutableList().also { - it[cursor] = entry.copy(selected = !entry.selected) + it[entryIndex] = entry.copy(selected = !entry.selected) } ) } else { @@ -326,19 +416,20 @@ private open class SelectInputAnimation( } } - key == keySubmit -> { - if (singleSelect) copy(finished = true, result = listOf(entry.title)) - else copy( - finished = true, - result = items.filter { it.selected }.map { it.title }) - } - else -> this@update // unmapped key, no state change } } } - return if (s.finished) Status.Finished(s.result) - else Status.Continue + animation.update(s) + return when { + s.finished -> { + if (config.clearOnExit) animation.clear() + else animation.stop() + Status.Finished(s.result) + } + + else -> Status.Continue + } } private fun keyName(key: KeyboardEvent): String { @@ -362,22 +453,19 @@ private open class SelectInputAnimation( ): String = with(config) { val s = TextStyle(brightWhite, bold = true) val parts = buildList { - add(keyName(keyPrev) to "up") - add(keyName(keyNext) to "down") - if (filtering) { - if (!singleSelect) add(keyName(keySubmit) to "apply filter") - } else if (filterable) { - add(keyName(keyFilter) to "filter") - } - if (filtering || hasFilter) { - add(keyName(keyExitFilter) to "clear filter") + if (!singleSelect && !filtering) add(keyName(keyToggle) to descToggle) + if (singleSelect || !filtering) { + add(keyName(keyPrev) to descPrev) + add(keyName(keyNext) to descNext) } - if (singleSelect) { - add(keyName(keySubmit) to "select") - } else { - add(keyName(keyToggle) to "toggle") - add(keyName(keySubmit) to "confirm") + if (filterable && !filtering) add(keyName(keyFilter) to descFilter) + if (filtering || hasFilter) add(keyName(keyExitFilter) to descExitFilter) + val submitDesc = when { + singleSelect -> descSubmit + filtering -> descApplyFilter + else -> descConfirm } + add(keyName(keySubmit) to submitDesc) } return dim(parts.joinToString(" • ") { "${s(it.first)} ${it.second}" }) @@ -387,6 +475,7 @@ private open class SelectInputAnimation( private class SingleSelectInputAnimation( private val animation: SelectInputAnimation, ) : InputReceiver { + override fun cancel() = animation.cancel() override fun onInput(event: KeyboardEvent): Status { return when (val status = animation.onInput(event)) { is Status.Finished -> Status.Finished(status.result?.firstOrNull()) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt index 0712ad38c..1b4692abc 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/terminal/TerminalCursor.kt @@ -150,10 +150,6 @@ private class AnsiMovements : CursorMovements { } } - override fun startOfLine() { - print("\r") - } - override fun setPosition(x: Int, y: Int) { require(x >= 0) { "Invalid cursor column $x; value cannot be negative" } require(y >= 0) { "Invalid cursor column $y; value cannot be negative" } @@ -162,43 +158,20 @@ private class AnsiMovements : CursorMovements { csi("${y + 1};${x + 1}H") } - override fun clearScreen() { - csi("2J") - } - - override fun clearScreenAfterCursor() { - csi("0J") - } - - override fun clearScreenBeforeCursor() { - csi("1J") - } - - override fun clearLineAfterCursor() { - csi("0K") - } - - override fun clearLineBeforeCursor() { - csi("1K") - } + override fun startOfLine() = print("\r") + override fun clearScreen() = csi("2J") + override fun clearScreenAfterCursor() = csi("0J") + override fun clearScreenBeforeCursor() = csi("1J") + override fun clearLineAfterCursor() = csi("0K") + override fun clearLineBeforeCursor() = csi("1K") + override fun clearLine() = csi("2K") + override fun savePosition() = csi("s") + override fun restorePosition() = csi("u") - override fun clearLine() { - csi("2K") + private fun csi(command: String) = print(CSI + command) + private fun print(text: String) { + builder.append(text) } - - override fun savePosition() { - csi("s") - } - - override fun restorePosition() { - csi("u") - } - - private fun csi(command: String) { - print(CSI + command) - } - - private fun print(text: String) = builder.append(text) } internal object DisabledTerminalCursor : TerminalCursor { diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt index f1a48e131..e2448d06b 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/widgets/SelectList.kt @@ -23,8 +23,8 @@ class SelectList private constructor( private val selectedMarker: ThemeString, private val unselectedMarker: ThemeString, private val captionBottom: Widget?, - private val cursorStyle: ThemeStyle, private val selectedStyle: ThemeStyle, + private val cursorStyle: ThemeStyle, private val unselectedTitleStyle: ThemeStyle, private val unselectedMarkerStyle: ThemeStyle, ) : Widget { @@ -50,8 +50,8 @@ class SelectList private constructor( selectedMarker = ThemeString.of("select.selected", selectedMarker, "✓"), unselectedMarker = ThemeString.of("select.unselected", unselectedMarker, "•"), captionBottom = captionBottom, - cursorStyle = ThemeStyle.of("select.cursor", selectedStyle), - selectedStyle = ThemeStyle.of("select.selected", cursorStyle), + selectedStyle = ThemeStyle.of("select.selected", selectedStyle), + cursorStyle = ThemeStyle.of("select.cursor", cursorStyle), unselectedTitleStyle = ThemeStyle.of("select.unselected-title", unselectedTitleStyle), unselectedMarkerStyle = ThemeStyle.of("select.unselected-marker", unselectedMarkerStyle), ) @@ -67,7 +67,7 @@ class SelectList private constructor( constructor(title: String, description: String?, selected: Boolean = false) : this( title = title, - description = description?.let { Text(it, whitespace = Whitespace.NORMAL) }, + description = description?.let { Text(it, whitespace = Whitespace.PRE_WRAP) }, selected = selected ) } @@ -82,15 +82,18 @@ class SelectList private constructor( tableBorders = Borders.NONE borderType = BorderType.BLANK padding = Padding(0) - whitespace = Whitespace.NORMAL - val cursorBlank = " ".repeat(Span.word(cursorMarker[t].replace(" ", ".")).cellWidth) + whitespace = Whitespace.PRE_WRAP + val cursorBlank = when { + cursorMarker[t].isEmpty() -> "" + else -> " ".repeat(Span.word(cursorMarker[t].replace(" ", ".")).cellWidth) + } val cursor = cursorStyle[t](cursorMarker[t]) val styledSelectedMarker = selectedStyle[t](selectedMarker[t]) val styledUnselectedMarker = unselectedMarkerStyle[t](unselectedMarker[t]) body { for ((i, entry) in entries.withIndex()) { row { - if (cursorIndex != null) { + if (cursorIndex != null && cursorBlank.isNotEmpty()) { cell(if (i == cursorIndex) cursor else cursorBlank) } if (selectedMarker[t].isNotEmpty()) { @@ -103,6 +106,7 @@ class SelectList private constructor( } cell(when { entry.description != null -> verticalLayout { + whitespace = Whitespace.PRE_WRAP cells(title, entry.description) } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt new file mode 100644 index 000000000..d88ba747f --- /dev/null +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt @@ -0,0 +1,379 @@ +package com.github.ajalt.mordant.input + +import com.github.ajalt.mordant.rendering.AnsiLevel +import com.github.ajalt.mordant.terminal.Terminal +import com.github.ajalt.mordant.terminal.TerminalRecorder +import com.github.ajalt.mordant.test.latestOutput +import com.github.ajalt.mordant.test.shouldMatchRender +import com.github.ajalt.mordant.widgets.SelectList +import com.github.ajalt.mordant.widgets.Text +import io.kotest.matchers.shouldBe +import kotlin.js.JsName +import kotlin.test.Test + +class SelectListAnimationTest { + private val rec = TerminalRecorder( + width = 24, ansiLevel = AnsiLevel.NONE, inputInteractive = true, outputInteractive = true + ) + private val t = Terminal(terminalInterface = rec) + private val b = InteractiveSelectListBuilder(t).showInstructions(false) + private val down = KeyboardEvent("ArrowDown") + private val up = KeyboardEvent("ArrowUp") + private val slash = KeyboardEvent("/") + private val enter = KeyboardEvent("Enter") + private val esc = KeyboardEvent("Escape") + private val x = KeyboardEvent("x") + + @Test + @JsName("single_select_instructions") + fun `single select instructions`() { + val a = b.entries("a", "b") + .title("title") + .showInstructions(true) + .filterable(true) + .createSingleSelectInputAnimation() + + rec.latestOutput() shouldMatchRender """ + ░title + ░❯ a + ░ b + ░↑ up • ↓ down • / filter • enter select + """ + + a.onInput(slash) + rec.latestOutput() shouldMatchRender """ + ░/ ░ + ░❯ a + ░ b + ░↑ up • ↓ down • esc clear filter • enter select + """ + + a.onInput(esc) + rec.latestOutput() shouldMatchRender """ + ░title + ░❯ a + ░ b + ░↑ up • ↓ down • / filter • enter select + """ + + a.cancel() + b.filterable(false) + .createSingleSelectInputAnimation() + rec.latestOutput() shouldMatchRender """ + ░title + ░❯ a + ░ b + ░↑ up • ↓ down • enter select + """ + } + + + @Test + @JsName("multi_select_instructions") + fun `multi select instructions`() { + val a = b.entries("a", "b") + .title("title") + .showInstructions(true) + .filterable(true) + .createMultiSelectInputAnimation() + + rec.latestOutput() shouldMatchRender """ + ░title + ░❯ • a + ░ • b + ░x toggle • ↑ up • ↓ down • / filter • enter confirm + """ + + a.onInput(slash) + rec.latestOutput() shouldMatchRender """ + ░/ ░ + ░ • a + ░ • b + ░esc clear filter • enter set filter + """ + + a.onInput(esc) + rec.latestOutput() shouldMatchRender """ + ░title + ░❯ • a + ░ • b + ░x toggle • ↑ up • ↓ down • / filter • enter confirm + """ + + a.onInput(slash) + a.onInput(x) + a.onInput(enter) + rec.latestOutput() shouldMatchRender """ + ░title + ░ + ░x toggle • ↑ up • ↓ down • / filter • esc clear filter • enter confirm + """ + + a.cancel() + b.filterable(false) + .createMultiSelectInputAnimation() + rec.latestOutput() shouldMatchRender """ + ░title + ░❯ • a + ░ • b + ░x toggle • ↑ up • ↓ down • enter confirm + """ + } + + @Test + @JsName("cursor_movement") + fun `cursor movement`() { + val a = b.entries("a", "b", "c") + .createSingleSelectInputAnimation() + + a.onInput(down) shouldBe InputReceiver.Status.Continue + rec.latestOutput() shouldMatchRender """ + ░ a + ░❯ b + ░ c + """ + + a.onInput(down) shouldBe InputReceiver.Status.Continue + rec.latestOutput() shouldMatchRender """ + ░ a + ░ b + ░❯ c + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░ a + ░ b + ░❯ c + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░ a + ░❯ b + ░ c + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░❯ a + ░ b + ░ c + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░❯ a + ░ b + ░ c + """ + } + + @Test + @JsName("filtered_cursor_movement") + fun `filtered cursor movement`() { + val a = b.entries("1", "ax", "2", "bx", "3", "cx", "4") + .filterable(true) + .createSingleSelectInputAnimation() + + a.onInput(slash) + a.onInput(x) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░❯ ax + ░ bx + ░ cx + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ ax + ░❯ bx + ░ cx + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ ax + ░ bx + ░❯ cx + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ ax + ░ bx + ░❯ cx + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ ax + ░❯ bx + ░ cx + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░❯ ax + ░ bx + ░ cx + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░❯ ax + ░ bx + ░ cx + """ + + a.onInput(down) + a.onInput(enter) shouldBe InputReceiver.Status.Finished("bx") + } + + @Test + @JsName("filtered_to_empty") + fun `filtered to empty`() { + val a = b.entries("a") + .filterable(true) + .createSingleSelectInputAnimation() + a.onInput(slash) + a.onInput(x) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ + """ + } + + @Test + @JsName("always_show_descriptions") + fun `always show descriptions`() { + b + .addEntry("a", "adesc") + .addEntry("b", Text("bdesc")) + .createSingleSelectInputAnimation() + + rec.latestOutput() shouldMatchRender """ + ░❯ a ░ + ░ adesc░ + ░ b ░ + ░ bdesc░ + """ + } + + @Test + @JsName("only_show_active_description") + fun `only show active description`() { + val a = b + .addEntry("ax", "adesc") + .addEntry("b", Text("bdesc")) + .addEntry(SelectList.Entry("cx", "cdesc")) + .onlyShowActiveDescription(true) + .filterable(true) + .createSingleSelectInputAnimation() + + rec.latestOutput() shouldMatchRender """ + ░❯ ax ░ + ░ adesc░ + ░ b ░ + ░ cx ░ + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░ ax ░ + ░❯ b ░ + ░ bdesc░ + ░ cx ░ + """ + + a.onInput(down) + rec.latestOutput() shouldMatchRender """ + ░ ax ░ + ░ b ░ + ░❯ cx ░ + ░ cdesc░ + """ + + a.onInput(slash) + a.onInput(x) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ ax ░ + ░❯ cx ░ + ░ cdesc░ + """ + + a.onInput(up) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░❯ ax ░ + ░ adesc░ + ░ cx ░ + """ + } + + @Test + @JsName("filtering_multi_select") + fun `filtering multi select`() { + val a = b.entries("ax", "b", "cx") + .filterable(true) + .createMultiSelectInputAnimation() + + rec.latestOutput() shouldMatchRender """ + ░❯ • ax + ░ • b + ░ • cx + """ + + a.onInput(x) + rec.latestOutput() shouldMatchRender """ + ░❯ ✓ ax + ░ • b + ░ • cx + """ + + a.onInput(slash) + a.onInput(x) + rec.latestOutput() shouldMatchRender """ + ░/ x + ░ ✓ ax + ░ • cx + """ + + a.onInput(enter) + rec.latestOutput() shouldMatchRender """ + ░❯ ✓ ax + ░ • cx + """ + + a.onInput(down) + a.onInput(x) + rec.latestOutput() shouldMatchRender """ + ░ ✓ ax + ░❯ ✓ cx + """ + + a.onInput(esc) + rec.latestOutput() shouldMatchRender """ + ░ ✓ ax + ░ • b + ░❯ ✓ cx + """ + + a.onInput(enter) shouldBe InputReceiver.Status.Finished(listOf("ax", "cx")) + } + +} diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt index aa28a3213..ce8af9ae2 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/RenderingTest.kt @@ -22,13 +22,6 @@ abstract class RenderingTest( ) { val t = Terminal(AnsiLevel.TRUECOLOR, theme, width, height, hyperlinks, tabWidth) val actual = transformActual(t.render(widget)) - try { - val trimmed = if (trimMargin) expected.trimMargin("░") else expected - actual shouldBe trimmed.replace("░", "") - } catch (e: Throwable) { - println() - println(actual) - throw e - } + actual.shouldMatchRender(expected, trimMargin) } } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt index 39085d587..7a677d805 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt @@ -3,6 +3,7 @@ package com.github.ajalt.mordant.test import com.github.ajalt.mordant.internal.CR_IMPLIES_LF import com.github.ajalt.mordant.internal.CSI import com.github.ajalt.mordant.terminal.TerminalRecorder +import io.kotest.matchers.shouldBe fun String.normalizeHyperlinks(): String { var i = 1 @@ -12,14 +13,33 @@ fun String.normalizeHyperlinks(): String { return regex.replace(this) { ";id=${map[it.value]};" } } -fun String.visibleCrLf(): String { - return replace("\r", "␍").replace("\n", "␊").replace(CSI, "␛") +fun String.visibleCrLf(keepBreaks: Boolean = false): String { + return replace("\r", "␍").replace("\n", if (keepBreaks) "\n" else "␊").replace(CSI, "␛") } +private val upMove = Regex("${Regex.escape(CSI)}\\d+A") + // This handles the difference in wasm movements and the other targets fun TerminalRecorder.normalizedOutput(): String { - return if (CR_IMPLIES_LF) output().replace("\r${CSI}1A", "\r") else output() + return if (CR_IMPLIES_LF) output().replace(upMove, "\r") else output() } + fun TerminalRecorder.latestOutput(): String { - return normalizedOutput().substringAfter("${CSI}0J").substringAfter('\r') + return normalizedOutput() + // remove everything before the last cursor movement + .let { it.split(upMove).lastOrNull() ?: it }.substringAfter("\r") + .replace("${CSI}0J", "") // remove clear screen command +} + +infix fun String.shouldMatchRender(expected: String) = shouldMatchRender(expected, true) + +fun String.shouldMatchRender(expected: String, trimMargin: Boolean) { + try { + val trimmed = if (trimMargin) expected.trimMargin("░") else expected + this shouldBe trimmed.replace("░", "") + } catch (e: Throwable) { + println() + println(this) + throw e + } } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt index b6aaa0048..6a9c3e382 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/widgets/SelectListTest.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.widgets -import com.github.ajalt.mordant.rendering.TextColors import com.github.ajalt.mordant.rendering.TextColors.* import com.github.ajalt.mordant.rendering.TextStyle import com.github.ajalt.mordant.rendering.Widget @@ -66,19 +65,20 @@ class SelectListTest : RenderingTest() { @JsName("styles_with_descriptions") fun `styles with descriptions`() = doTest( """ - ░ ${blue("•")} ${red("foo")} - ░ desc1 + ░ ${blue("•")} ${red("foo")} ░ + ░ desc1 ░ ░ line2 ░ line3 - ░❯ ${green("✓")} ${green("bar")} - ░ desc2 - ░ ${blue("•")} ${red("baz")} + ░${magenta("❯")} ${green("✓")} ${green("bar")} ░ + ░ desc2 ░ + ░ ${blue("•")} ${red("baz")} ░ """, Entry("foo", description = "desc1\n line2\n line3"), Entry("bar", selected = true, description = "desc2"), Entry("baz"), cursorIndex = 1, selectedStyle = green, + cursorStyle = magenta, unselectedTitleStyle = red, unselectedMarkerStyle = blue, ) @@ -93,6 +93,7 @@ class SelectListTest : RenderingTest() { unselectedMarker: String = "•", captionBottom: Widget? = null, selectedStyle: TextStyle = TextStyle(), + cursorStyle: TextStyle = TextStyle(), unselectedTitleStyle: TextStyle = TextStyle(), unselectedMarkerStyle: TextStyle = TextStyle(), ) = checkRender( @@ -105,6 +106,7 @@ class SelectListTest : RenderingTest() { unselectedMarker = unselectedMarker, captionBottom = captionBottom, selectedStyle = selectedStyle, + cursorStyle = cursorStyle, unselectedTitleStyle = unselectedTitleStyle, unselectedMarkerStyle = unselectedMarkerStyle, ), expected diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 9f3fe26a6..e8d70c3ef 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -1,13 +1,7 @@ package com.github.ajalt.mordant.input -import com.github.ajalt.mordant.animation.animation -import com.github.ajalt.mordant.rendering.TextColors.brightWhite -import com.github.ajalt.mordant.rendering.TextStyle -import com.github.ajalt.mordant.rendering.TextStyles.dim -import com.github.ajalt.mordant.rendering.Widget import com.github.ajalt.mordant.terminal.Terminal import com.github.ajalt.mordant.widgets.SelectList -import com.github.ajalt.mordant.widgets.Text import kotlin.jvm.JvmName /** diff --git a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index 8c7135184..643fff9af 100644 --- a/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/select/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -3,7 +3,6 @@ package com.github.ajalt.mordant.samples import com.github.ajalt.mordant.input.interactiveMultiSelectList import com.github.ajalt.mordant.input.interactiveSelectList import com.github.ajalt.mordant.terminal.Terminal -import com.github.ajalt.mordant.widgets.SelectList.Entry fun main() { @@ -18,16 +17,15 @@ fun main() { return } val toppings = terminal.interactiveMultiSelectList { - entries( - Entry("Pepperoni", selected = true), - Entry("Sausage", selected = true), - Entry("Mushrooms"), - Entry("Olives"), - Entry("Pineapple"), - Entry("Anchovies"), - ) + addEntry("Pepperoni", selected = true) + addEntry("Sausage", selected = true) + addEntry("Mushrooms") + addEntry("Olives") + addEntry("Pineapple") + addEntry("Anchovies") title("Select Toppings") limit(4) + filterable(true) } if (toppings == null) { From 18ea7b5e39a2cf2ca4b8cccd8c8e7248d65f4ccf Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 8 Jun 2024 09:23:59 -0700 Subject: [PATCH 20/45] Fix windows keyboard events for shift+key --- .../syscalls/SyscallHandler.windows.kt | 47 ++++++++++++++++--- 1 file changed, 41 insertions(+), 6 deletions(-) diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index 476001df1..c84b7ecdf 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -34,18 +34,53 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { // ignore key up events if (event != null && event.bKeyDown) { val virtualName = WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) + val shift = event.dwControlKeyState and SHIFT_PRESSED != 0u + val key = when { + virtualName != null && virtualName.length == 1 && shift -> { + if (virtualName[0] in 'a'..'z') virtualName.uppercase() + else shiftNumberKey(virtualName) + } + virtualName != null -> virtualName + event.uChar.code != 0 -> event.uChar.toString() + else -> "Unidentified" + } return KeyboardEvent( - key = when { - virtualName != null -> virtualName - event.uChar.code != 0 -> event.uChar.toString() - else -> "Unidentified" - }, + key = key, ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, - shift = event.dwControlKeyState and SHIFT_PRESSED != 0u, + shift = shift, ) } } return null } + + +} + +private fun shiftNumberKey(virtualName: String): String { + return when (virtualName[0]) { + '1' -> "!" + '2' -> "@" + '3' -> "#" + '4' -> "$" + '5' -> "%" + '6' -> "^" + '7' -> "&" + '8' -> "*" + '9' -> "(" + '0' -> ")" + '-' -> "_" + '=' -> "+" + '`' -> "~" + '[' -> "{" + ']' -> "}" + '\\' -> "|" + ';' -> ":" + '\'' -> "\"" + ',' -> "<" + '.' -> ">" + '/' -> "?" + else -> virtualName + } } From 7e489bc1cecf3ed5e77ac833db5b501ff77e3e21 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 8 Jun 2024 09:45:40 -0700 Subject: [PATCH 21/45] Catch raw mode error when forcing interactive --- .../ajalt/mordant/internal/syscalls/SyscallHandler.kt | 4 ++-- .../internal/syscalls/jna/SyscallHandler.jna.windows.kt | 8 ++++++-- .../nativeimage/SyscallHandler.nativeimage.windows.kt | 4 ++-- .../internal/syscalls/SyscallHandler.native.windows.kt | 4 ++-- .../com/github/ajalt/mordant/input/KeyboardInput.kt | 2 +- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt index 9bb05c898..fbec7d9cc 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt @@ -11,7 +11,7 @@ internal interface SyscallHandler { fun getTerminalSize(): Size? fun fastIsTty(): Boolean = true fun readKeyEvent(timeout: Duration): KeyboardEvent? - fun enterRawMode(): AutoCloseable + fun enterRawMode(): AutoCloseable? } internal object DumbSyscallHandler : SyscallHandler { @@ -20,5 +20,5 @@ internal object DumbSyscallHandler : SyscallHandler { override fun stderrInteractive(): Boolean = false override fun getTerminalSize(): Size? = null override fun readKeyEvent(timeout: Duration): KeyboardEvent? = null - override fun enterRawMode(): AutoCloseable = AutoCloseable { } + override fun enterRawMode(): AutoCloseable? = null } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt index 8615a1583..160a48937 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt @@ -283,9 +283,13 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { return csbi.srWindow?.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } - override fun enterRawMode(): AutoCloseable { + override fun enterRawMode(): AutoCloseable? { val originalMode = IntByReference() - kernel.GetConsoleMode(stdinHandle, originalMode) + try { + kernel.GetConsoleMode(stdinHandle, originalMode) + } catch (e: LastErrorException) { + return null + } // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index a94581626..c7f07956b 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -162,10 +162,10 @@ internal object SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { } } - override fun enterRawMode(): AutoCloseable { + override fun enterRawMode(): AutoCloseable? { val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) val originalMode = StackValue.get(CIntPointer::class.java) - WinKernel32Lib.GetConsoleMode(stdinHandle, originalMode) + if (!WinKernel32Lib.GetConsoleMode(stdinHandle, originalMode)) return null // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index 983f01ad6..9abdf05ff 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -52,10 +52,10 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { ) } - override fun enterRawMode(): AutoCloseable = memScoped { + override fun enterRawMode(): AutoCloseable? = memScoped { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) val originalMode = alloc() - GetConsoleMode(stdinHandle, originalMode.ptr) + if (GetConsoleMode(stdinHandle, originalMode.ptr) == 0) return null // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt index b973f6f34..816bde8cd 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt @@ -7,7 +7,7 @@ import kotlin.time.Duration // TODO: docs, tests fun Terminal.enterRawMode(): RawModeScope? { if (!info.inputInteractive) return null - return RawModeScope(SYSCALL_HANDLER.enterRawMode()) + return SYSCALL_HANDLER.enterRawMode()?.let(::RawModeScope) } class RawModeScope internal constructor( From 156deba22990d86a91daac9f401d301a8f3bc371 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 8 Jun 2024 09:59:20 -0700 Subject: [PATCH 22/45] Update graal metadata --- .../com.github.ajalt.mordant/mordant/native-image.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties index d5e51785f..d623a464a 100644 --- a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties +++ b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties @@ -1 +1 @@ -Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.MppInternal_jvmKt +Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandler_nativeimage_windowsKt,com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandler_nativeimage_posixKt From c4170143381caefea8a37f98fe243d7aa19e46c8 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 8 Jun 2024 11:47:48 -0700 Subject: [PATCH 23/45] Disable raw mode on graal nativeimage --- .../ajalt/mordant/internal/MppInternal.jvm.kt | 31 ++++---- .../syscalls/jna/SyscallHandler.jna.linux.kt | 8 +- .../syscalls/jna/SyscallHandler.jna.macos.kt | 8 +- .../SyscallHandler.nativeimage.posix.kt | 77 ++++++++++++------- .../SyscallHandler.nativeimage.windows.kt | 2 +- .../internal/syscalls/SyscallHandler.posix.kt | 6 +- .../syscalls/SyscallHanlder.native.posix.kt | 4 +- .../graalvm/src/test/kotlin/GraalSmokeTest.kt | 22 +++++- 8 files changed, 100 insertions(+), 58 deletions(-) diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt index d20a944b9..f69d75461 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.jvm.kt @@ -5,8 +5,8 @@ import com.github.ajalt.mordant.internal.syscalls.SyscallHandler import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaLinux import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaMacos import com.github.ajalt.mordant.internal.syscalls.jna.SyscallHandlerJnaWindows -import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImageWindows import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImagePosix +import com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImageWindows import com.github.ajalt.mordant.terminal.* import java.io.File import java.lang.management.ManagementFactory @@ -123,22 +123,21 @@ internal actual fun sendInterceptedPrintRequest( } internal actual fun getSyscallHandler(): SyscallHandler { - return System.getProperty("os.name").let { os -> - try { - // Inlined version of ImageInfo.inImageCode() - val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") - val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" - when { - isNativeImage && os.startsWith("Windows") -> SyscallHandlerNativeImageWindows - isNativeImage && (os == "Linux" || os == "Mac OS X") -> SyscallHandlerNativeImagePosix - os.startsWith("Windows") -> SyscallHandlerJnaWindows - os == "Linux" -> SyscallHandlerJnaLinux - os == "Mac OS X" -> SyscallHandlerJnaMacos - else -> DumbSyscallHandler - } - } catch (e: UnsatisfiedLinkError) { - DumbSyscallHandler + return try { + // Inlined version of ImageInfo.inImageCode() + val imageCode = System.getProperty("org.graalvm.nativeimage.imagecode") + val isNativeImage = imageCode == "buildtime" || imageCode == "runtime" + val os = System.getProperty("os.name") + when { + isNativeImage && os.startsWith("Windows") -> SyscallHandlerNativeImageWindows() + isNativeImage && (os == "Linux" || os == "Mac OS X") -> SyscallHandlerNativeImagePosix() + os.startsWith("Windows") -> SyscallHandlerJnaWindows + os == "Linux" -> SyscallHandlerJnaLinux + os == "Mac OS X" -> SyscallHandlerJnaMacos + else -> DumbSyscallHandler } + } catch (e: UnsatisfiedLinkError) { + DumbSyscallHandler } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt index f531f66d2..5254aab40 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt @@ -81,9 +81,13 @@ internal object SyscallHandlerJnaLinux : SyscallHandlerJvmPosix() { } } - override fun getStdinTermios(): Termios { + override fun getStdinTermios(): Termios? { val termios = PosixLibC.termios() - libC.tcgetattr(STDIN_FILENO, termios) + try { + libC.tcgetattr(STDIN_FILENO, termios) + } catch (e: LastErrorException) { + return null + } return Termios( iflag = termios.c_iflag.toUInt(), oflag = termios.c_oflag.toUInt(), diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt index 85a93fa82..84c7664ef 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt @@ -92,9 +92,13 @@ internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() { return getSttySize(100) } - override fun getStdinTermios(): Termios { + override fun getStdinTermios(): Termios? { val termios = MacosLibC.termios() - libC.tcgetattr(STDIN_FILENO, termios) + try { + libC.tcgetattr(STDIN_FILENO, termios) + } catch (e: LastErrorException) { + return null + } return Termios( iflag = termios.c_iflag.toInt().toUInt(), oflag = termios.c_oflag.toInt().toUInt(), diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt index 81e45b9c5..9dd9ba4a8 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt @@ -9,7 +9,9 @@ import org.graalvm.nativeimage.c.CContext import org.graalvm.nativeimage.c.constant.CConstant import org.graalvm.nativeimage.c.function.CFunction import org.graalvm.nativeimage.c.struct.CField +import org.graalvm.nativeimage.c.struct.CFieldAddress import org.graalvm.nativeimage.c.struct.CStruct +import org.graalvm.nativeimage.c.type.CCharPointer import org.graalvm.word.PointerBase @CContext(PosixLibC.Directives::class) @@ -18,7 +20,7 @@ import org.graalvm.word.PointerBase private object PosixLibC { class Directives : CContext.Directives { - override fun getHeaderFiles() = listOf("", "") + override fun getHeaderFiles() = listOf("", "", "") } @CConstant("TIOCGWINSZ") @@ -27,6 +29,9 @@ private object PosixLibC { @CConstant("TCSADRAIN") external fun TCSADRAIN(): Int + @CConstant("NCCS") + external fun NCCS(): Int + @CStruct("winsize", addStructKeyword = true) interface winsize : PointerBase { @@ -59,9 +64,8 @@ private object PosixLibC { @set:CField("c_line") var c_line: Byte - @get:CField("c_cc") - @set:CField("c_cc") - var c_cc: ByteArray + @get:CFieldAddress("c_cc") + val c_cc: CCharPointer @get:CField("c_ispeed") @set:CField("c_ispeed") @@ -79,14 +83,14 @@ private object PosixLibC { external fun ioctl(fd: Int, cmd: Int, winSize: winsize?): Int @CFunction("tcgetattr") - external fun tcgetattr(fd: Int, termios: termios) + external fun tcgetattr(fd: Int, termios: termios): Int @CFunction("tcsetattr") external fun tcsetattr(fd: Int, cmd: Int, termios: termios) } @Platforms(Platform.LINUX::class, Platform.MACOS::class) -internal object SyscallHandlerNativeImagePosix : SyscallHandlerJvmPosix() { +internal class SyscallHandlerNativeImagePosix : SyscallHandlerJvmPosix() { override fun isatty(fd: Int): Boolean = PosixLibC.isatty(fd) override fun getTerminalSize(): Size? { @@ -98,31 +102,48 @@ internal object SyscallHandlerNativeImagePosix : SyscallHandlerJvmPosix() { } } - override fun getStdinTermios(): Termios { - val termios = StackValue.get(PosixLibC.termios::class.java) - PosixLibC.tcgetattr(STDIN_FILENO, termios) - return Termios( - iflag = termios.c_iflag.toUInt(), - oflag = termios.c_oflag.toUInt(), - cflag = termios.c_cflag.toUInt(), - lflag = termios.c_lflag.toUInt(), - cline = termios.c_line, - cc = termios.c_cc.copyOf(), - ispeed = termios.c_ispeed.toUInt(), - ospeed = termios.c_ospeed.toUInt(), + override fun getStdinTermios(): Termios? { + throw NotImplementedError( + "Raw mode is not currently supported for native-image. If you are familiar with " + + "GraalVM native-image and would like to contribute, see the commented out " + + "code in the file SyscallHandler.nativeimage.posix" ) + /* + This fails with the following error: + + Error: Expected Object but got Word for call argument in + com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandlerNativeImagePosix.getStdinTermios(SyscallHandler.nativeimage.posix.kt:117). + One possible cause for this error is when word values are passed into lambdas as parameters + or from variables in an enclosing scope, which is not supported, but can be solved by + instead using explicit classes (including anonymous classes). + + I've tried all the ways I can think of to declare tcgetattr and tcsetattr, but the error + persists. + */ +// val termios = StackValue.get(PosixLibC.termios::class.java) +// if(PosixLibC.tcgetattr(STDIN_FILENO, termios) != 0) return null +// return Termios( +// iflag = termios.c_iflag.toUInt(), +// oflag = termios.c_oflag.toUInt(), +// cflag = termios.c_cflag.toUInt(), +// lflag = termios.c_lflag.toUInt(), +// cline = termios.c_line, +// cc = ByteArray(PosixLibC.NCCS()) { termios.c_cc.read(it) }, +// ispeed = termios.c_ispeed.toUInt(), +// ospeed = termios.c_ospeed.toUInt(), +// ) } override fun setStdinTermios(termios: Termios) { - val nativeTermios = StackValue.get(PosixLibC.termios::class.java) - nativeTermios.c_iflag = termios.iflag.toInt() - nativeTermios.c_oflag = termios.oflag.toInt() - nativeTermios.c_cflag = termios.cflag.toInt() - nativeTermios.c_lflag = termios.lflag.toInt() - nativeTermios.c_line = termios.cline - termios.cc.copyInto(nativeTermios.c_cc) - nativeTermios.c_ispeed = termios.ispeed.toInt() - nativeTermios.c_ospeed = termios.ospeed.toInt() - PosixLibC.tcsetattr(STDIN_FILENO, PosixLibC.TCSADRAIN(), nativeTermios) +// val nativeTermios = StackValue.get(PosixLibC.termios::class.java) +// nativeTermios.c_iflag = termios.iflag.toInt() +// nativeTermios.c_oflag = termios.oflag.toInt() +// nativeTermios.c_cflag = termios.cflag.toInt() +// nativeTermios.c_lflag = termios.lflag.toInt() +// nativeTermios.c_line = termios.cline +// termios.cc.forEachIndexed { i, b -> nativeTermios.c_cc.write(i, b) } +// nativeTermios.c_ispeed = termios.ispeed.toInt() +// nativeTermios.c_ospeed = termios.ospeed.toInt() +// PosixLibC.tcsetattr(STDIN_FILENO, PosixLibC.TCSADRAIN(), nativeTermios) } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index c7f07956b..2d4fb2cc6 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -135,7 +135,7 @@ private object WinKernel32Lib { } @Platforms(Platform.WINDOWS::class) -internal object SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { +internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { override fun stdoutInteractive(): Boolean { val handle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE()) diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index fe5728d9d..85dea1dc1 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -143,7 +143,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { val ospeed: UInt, ) - protected abstract fun getStdinTermios(): Termios + protected abstract fun getStdinTermios(): Termios? protected abstract fun setStdinTermios(termios: Termios) protected abstract fun isatty(fd: Int): Boolean protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? @@ -154,8 +154,8 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // https://www.man7.org/linux/man-pages/man3/termios.3.html // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html - override fun enterRawMode(): AutoCloseable { - val orig = getStdinTermios() + override fun enterRawMode(): AutoCloseable? { + val orig = getStdinTermios() ?: return null val new = Termios( iflag = orig.iflag and (ICRNL or IGNCR or INPCK or ISTRIP or IXON).inv(), // we leave OPOST on so we don't change \r\n handling diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt index bcfebd561..834cf5c51 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -21,9 +21,9 @@ internal object SyscallHandlerNativePosix: SyscallHandlerPosix() { } } - override fun getStdinTermios(): Termios = memScoped { + override fun getStdinTermios(): Termios? = memScoped { val termios = alloc() - tcgetattr(STDIN_FILENO, termios.ptr) + if (tcgetattr(STDIN_FILENO, termios.ptr) != 0) return null return Termios( iflag = termios.c_iflag, oflag = termios.c_oflag, diff --git a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt index 7cf3626e0..bedc084eb 100644 --- a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt +++ b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt @@ -2,6 +2,7 @@ package com.github.ajalt.mordant.graalvm import com.github.ajalt.mordant.animation.progress.animateOnThread import com.github.ajalt.mordant.animation.progress.execute +import com.github.ajalt.mordant.input.enterRawMode import com.github.ajalt.mordant.markdown.Markdown import com.github.ajalt.mordant.rendering.AnsiLevel import com.github.ajalt.mordant.rendering.TextStyles.bold @@ -12,11 +13,23 @@ import com.github.ajalt.mordant.widgets.progress.progressBarLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test import java.util.concurrent.TimeUnit +import kotlin.test.Ignore +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.milliseconds +/** + * Smoke tests for the GraalVM platform. + * + * They just make sure nothing crashes; the actual output is verified in the normal test suite. + */ class GraalSmokeTest { + @Test + fun `terminal detection test`() { + Terminal() + } + @Test fun `progress animation test`() { - // Just make sure it doesn't crash, exact output is verified in the normal test suite val t = Terminal(interactive = true, ansiLevel = AnsiLevel.TRUECOLOR) val animation = progressBarLayout { progressBar() }.animateOnThread(t, total = 1) val future = animation.execute() @@ -25,10 +38,11 @@ class GraalSmokeTest { future.get(1000, TimeUnit.MILLISECONDS) } + @Ignore("Raw mode is currently unsupported on native-image") @Test - fun `terminal detection test`() { - // Just make sure that the terminal detection doesn't crash. - Terminal() + fun `raw mode test`() { + val t = Terminal(interactive = true) + assertNull(t.enterRawMode()?.use {}) } @Test From b3f671b1a2a29bb9d4fbd32c13e9f43d98b811f3 Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 9 Jun 2024 09:50:01 -0700 Subject: [PATCH 24/45] Move some common native functionality into syscall handlers --- .../SyscallHandler.nativeimage.windows.kt | 6 ++-- .../mordant/internal/MppInternal.mingw.kt | 22 +------------- .../syscalls/SyscallHandler.native.windows.kt | 25 +++++++++++++--- .../internal/syscalls/SyscallHandler.posix.kt | 6 ++-- .../com/github/ajalt/mordant/internal/tty.kt | 29 +++++++++---------- 5 files changed, 41 insertions(+), 47 deletions(-) diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index 2d4fb2cc6..b3e6c65f8 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -167,9 +167,9 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { val originalMode = StackValue.get(CIntPointer::class.java) if (!WinKernel32Lib.GetConsoleMode(stdinHandle, originalMode)) return null - // only ENABLE_PROCESSED_INPUT means echo and line input modes are disabled. Could add - // ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those events. - // TODO: handle errors remove ENABLE_PROCESSED_INPUT to intercept ctrl-c + // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add + // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those + // events. WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT()) return AutoCloseable { WinKernel32Lib.SetConsoleMode(stdinHandle, originalMode.read()) } diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt index 9a42daafd..329fc316c 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.mingw.kt @@ -2,27 +2,7 @@ package com.github.ajalt.mordant.internal import com.github.ajalt.mordant.internal.syscalls.SyscallHandler import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativeWindows -import kotlinx.cinterop.* -import platform.windows.* - -// https://docs.microsoft.com/en-us/windows/console/getconsolemode -internal actual fun ttySetEcho(echo: Boolean) = memScoped { - val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - if (stdinHandle == INVALID_HANDLE_VALUE) { - return@memScoped - } - val lpMode = alloc() - if (GetConsoleMode(stdinHandle, lpMode.ptr) == 0) { - return@memScoped - } - - val newMode = if (echo) { - lpMode.value or ENABLE_ECHO_INPUT.convert() - } else { - lpMode.value and ENABLE_ECHO_INPUT.inv().convert() - } - SetConsoleMode(stdinHandle, newMode) -} +internal actual fun ttySetEcho(echo: Boolean) = SyscallHandlerNativeWindows.ttySetEcho(echo) internal actual fun hasFileSystem(): Boolean = true internal actual fun getSyscallHandler(): SyscallHandler = SyscallHandlerNativeWindows diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index 9abdf05ff..affbe30a3 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -1,4 +1,3 @@ - package com.github.ajalt.mordant.internal.syscalls import com.github.ajalt.mordant.internal.Size @@ -54,15 +53,33 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { override fun enterRawMode(): AutoCloseable? = memScoped { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - val originalMode = alloc() - if (GetConsoleMode(stdinHandle, originalMode.ptr) == 0) return null + val originalMode = getConsoleMode(stdinHandle) ?: return null // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those // events. SetConsoleMode(stdinHandle, 0u) - return AutoCloseable { SetConsoleMode(stdinHandle, originalMode.value) } + return AutoCloseable { SetConsoleMode(stdinHandle, originalMode) } } + fun ttySetEcho(echo: Boolean) = memScoped { + val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) + val lpMode = getConsoleMode(stdinHandle) ?: return@memScoped + val newMode = if (echo) { + lpMode or ENABLE_ECHO_INPUT.convert() + } else { + lpMode and ENABLE_ECHO_INPUT.inv().convert() + } + SetConsoleMode(stdinHandle, newMode) + } + + // https://docs.microsoft.com/en-us/windows/console/getconsolemode + private fun MemScope.getConsoleMode(handle: HANDLE?): UInt? { + if (handle == null || handle == INVALID_HANDLE_VALUE) return null + val lpMode = alloc() + // "If the function succeeds, the return value is nonzero." + if (GetConsoleMode(handle, lpMode.ptr) == 0) return null + return lpMode.value + } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 85dea1dc1..c4a66a510 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -132,7 +132,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { private const val TCSAFLUSH: UInt = 0x2u } - protected class Termios( + data class Termios( val iflag: UInt, val oflag: UInt, val cflag: UInt, @@ -143,8 +143,8 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { val ospeed: UInt, ) - protected abstract fun getStdinTermios(): Termios? - protected abstract fun setStdinTermios(termios: Termios) + abstract fun getStdinTermios(): Termios? + abstract fun setStdinTermios(termios: Termios) protected abstract fun isatty(fd: Int): Boolean protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt index 25e36e6c9..d395ab56b 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/tty.kt @@ -1,21 +1,18 @@ package com.github.ajalt.mordant.internal -import kotlinx.cinterop.* -import platform.posix.* +import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerNativePosix +import kotlinx.cinterop.UnsafeNumber +import platform.posix.ECHO // https://www.gnu.org/software/libc/manual/html_node/getpass.html -@OptIn(UnsafeNumber::class) // https://youtrack.jetbrains.com/issue/KT-60572 -internal actual fun ttySetEcho(echo: Boolean) = memScoped { - val termios = alloc() - if (tcgetattr(STDOUT_FILENO, termios.ptr) != 0) { - return@memScoped - } - - termios.c_lflag = if (echo) { - termios.c_lflag or ECHO.convert() - } else { - termios.c_lflag and ECHO.inv().convert() - } - - tcsetattr(0, TCSAFLUSH, termios.ptr) +internal actual fun ttySetEcho(echo: Boolean) { + val termios = SyscallHandlerNativePosix.getStdinTermios() ?: return + SyscallHandlerNativePosix.setStdinTermios( + termios.copy( + lflag = when { + echo -> termios.lflag or ECHO.toUInt() + else -> termios.lflag and ECHO.inv().toUInt() + } + ) + ) } From 04ea2568141d6abd5db80880197b203035696bf0 Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 10 Jun 2024 12:01:02 -0700 Subject: [PATCH 25/45] Add mouse events --- .../github/ajalt/mordant/input/InputEvent.kt | 35 +++++ .../ajalt/mordant/input/InputReceiver.kt | 4 +- .../ajalt/mordant/input/KeyboardEvent.kt | 14 -- .../ajalt/mordant/input/MouseTracking.kt | 28 ++++ .../mordant/input/SelectListAnimation.kt | 7 +- .../com/github/ajalt/mordant/internal/Utf8.kt | 37 +++++ .../internal/syscalls/SyscallHandler.kt | 11 +- .../mordant/input/SelectListAnimationTest.kt | 78 +++++------ .../syscalls/SyscallHandler.jsCommon.kt | 7 +- .../jna/SyscallHandler.jna.windows.kt | 61 +++++---- .../SyscallHandler.nativeimage.windows.kt | 82 ++++++++--- .../syscalls/SyscallHandler.native.windows.kt | 51 ++++--- .../mordant/input/InputReceiverRunning.kt | 11 +- .../mordant/input/InteractiveSelectList.kt | 4 +- .../ajalt/mordant/input/KeyboardInput.kt | 20 --- .../com/github/ajalt/mordant/input/RawMode.kt | 55 ++++++++ .../internal/syscalls/SyscallHandler.posix.kt | 123 ++++++++--------- .../syscalls/SyscallHandler.windows.kt | 128 ++++++++++++++---- 18 files changed, 512 insertions(+), 244 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt delete mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/MouseTracking.kt create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt delete mode 100644 mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt create mode 100644 mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt new file mode 100644 index 000000000..094e8f0de --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt @@ -0,0 +1,35 @@ +package com.github.ajalt.mordant.input + +sealed class InputEvent + +// TODO: docs +// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent +data class KeyboardEvent( + val key: String, + val ctrl: Boolean = false, + val alt: Boolean = false, // `Option ⌥` key on mac + val shift: Boolean = false, + // maybe add a `data` field for escape sequences? +): InputEvent() + +val KeyboardEvent.isCtrlC: Boolean + get() = key == "c" && ctrl && !alt && !shift + + +// TODO: docs +// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent +data class MouseEvent( + val x: Int, + val y: Int, + val buttons: Int, + val ctrl: Boolean = false, + val alt: Boolean = false, + val shift: Boolean = false, +): InputEvent() + +val MouseEvent.leftPressed: Boolean get() = buttons and 1 != 0 +val MouseEvent.rightPressed: Boolean get() = buttons and 2 != 0 +val MouseEvent.middlePressed: Boolean get() = buttons and 4 != 0 +val MouseEvent.mouse4Pressed: Boolean get() = buttons and 8 != 0 +val MouseEvent.mouse5Pressed: Boolean get() = buttons and 16 != 0 + diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index e636d6c81..572d758a9 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -6,6 +6,6 @@ interface InputReceiver { data object Continue : Status() data class Finished(val result: T) : Status() } - fun onInput(event: KeyboardEvent): Status - fun cancel() + fun onEvent(event: InputEvent): Status = Status.Continue + fun cancel() {} } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt deleted file mode 100644 index 899a0897b..000000000 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/KeyboardEvent.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.github.ajalt.mordant.input - -// TODO: docs -// https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent -data class KeyboardEvent( - val key: String, - val ctrl: Boolean = false, - val alt: Boolean = false, // `Option ⌥` key on mac - val shift: Boolean = false, - // maybe add a `data` field for escape sequences? -) - -val KeyboardEvent.isCtrlC: Boolean - get() = key == "c" && ctrl && !alt && !shift diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/MouseTracking.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/MouseTracking.kt new file mode 100644 index 000000000..5b9ab9068 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/MouseTracking.kt @@ -0,0 +1,28 @@ +package com.github.ajalt.mordant.input + +// https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking +enum class MouseTracking { + /** + * Disable mouse tracking + */ + Off, + + /** + * Normal tracking mode sends an escape sequence on both button press and + * release. Modifier key (shift, ctrl, meta) information is also sent. + */ + Normal, + + /** + * Button-event tracking is essentially the same as normal tracking, but + * xterm also reports button-motion events. Motion events are reported + * only if the mouse pointer has moved to a different character cell. + */ + Button, + + /** + * Any-event mode is the same as button-event mode, except that all motion + * events are reported, even if no mouse button is down. + */ + Any +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt index 2d9c6f95f..4f8309d48 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt @@ -350,7 +350,8 @@ private open class SelectInputAnimation( else animation.stop() } - override fun onInput(event: KeyboardEvent): Status?> { + override fun onEvent(event: InputEvent): Status?> { + if (event !is KeyboardEvent) return Status.Continue val (_, s) = state.update { with(config) { val filteredItems = filteredItems @@ -476,8 +477,8 @@ private class SingleSelectInputAnimation( private val animation: SelectInputAnimation, ) : InputReceiver { override fun cancel() = animation.cancel() - override fun onInput(event: KeyboardEvent): Status { - return when (val status = animation.onInput(event)) { + override fun onEvent(event: InputEvent): Status { + return when (val status = animation.onEvent(event)) { is Status.Finished -> Status.Finished(status.result?.firstOrNull()) is Status.Continue -> Status.Continue } 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 new file mode 100644 index 000000000..a7cce3cba --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/Utf8.kt @@ -0,0 +1,37 @@ +package com.github.ajalt.mordant.internal + +/** Read bytes from a UTF-8 encoded stream, and return the next codepoint. */ +fun readBytesAsUtf8(readByte: () -> Int): Int { + val byte = readByte() + var byteLength = 0 + var codepoint = 0 + when { + byte and 0b1000_0000 == 0x00 -> { + return byte // 1-byte character + } + + byte and 0b1110_0000 == 0b1100_0000 -> { + codepoint = byte and 0b11111 + byteLength = 2 + } + + byte and 0b1111_0000 == 0b1110_0000 -> { + codepoint = byte and 0b1111 + byteLength = 3 + } + + byte and 0b1111_1000 == 0b1111_0000 -> { + codepoint = byte and 0b111 + byteLength = 4 + } + + else -> error("Invalid UTF-8 byte") + } + + repeat(byteLength - 1) { + val next = readByte() + if (next and 0b1100_0000 != 0b1000_0000) error("Invalid UTF-8 byte") + codepoint = codepoint shl 6 or (next and 0b0011_1111) + } + return codepoint +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt index fbec7d9cc..5cf658c5c 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt @@ -1,6 +1,7 @@ package com.github.ajalt.mordant.internal.syscalls -import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.InputEvent +import com.github.ajalt.mordant.input.MouseTracking import com.github.ajalt.mordant.internal.Size import kotlin.time.Duration @@ -10,8 +11,8 @@ internal interface SyscallHandler { fun stderrInteractive(): Boolean fun getTerminalSize(): Size? fun fastIsTty(): Boolean = true - fun readKeyEvent(timeout: Duration): KeyboardEvent? - fun enterRawMode(): AutoCloseable? + fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? + fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? } internal object DumbSyscallHandler : SyscallHandler { @@ -19,6 +20,6 @@ internal object DumbSyscallHandler : SyscallHandler { override fun stdinInteractive(): Boolean = false override fun stderrInteractive(): Boolean = false override fun getTerminalSize(): Size? = null - override fun readKeyEvent(timeout: Duration): KeyboardEvent? = null - override fun enterRawMode(): AutoCloseable? = null + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? = null + override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? = null } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt index d88ba747f..34ebb09af 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt @@ -40,7 +40,7 @@ class SelectListAnimationTest { ░↑ up • ↓ down • / filter • enter select """ - a.onInput(slash) + a.onEvent(slash) rec.latestOutput() shouldMatchRender """ ░/ ░ ░❯ a @@ -48,7 +48,7 @@ class SelectListAnimationTest { ░↑ up • ↓ down • esc clear filter • enter select """ - a.onInput(esc) + a.onEvent(esc) rec.latestOutput() shouldMatchRender """ ░title ░❯ a @@ -84,7 +84,7 @@ class SelectListAnimationTest { ░x toggle • ↑ up • ↓ down • / filter • enter confirm """ - a.onInput(slash) + a.onEvent(slash) rec.latestOutput() shouldMatchRender """ ░/ ░ ░ • a @@ -92,7 +92,7 @@ class SelectListAnimationTest { ░esc clear filter • enter set filter """ - a.onInput(esc) + a.onEvent(esc) rec.latestOutput() shouldMatchRender """ ░title ░❯ • a @@ -100,9 +100,9 @@ class SelectListAnimationTest { ░x toggle • ↑ up • ↓ down • / filter • enter confirm """ - a.onInput(slash) - a.onInput(x) - a.onInput(enter) + a.onEvent(slash) + a.onEvent(x) + a.onEvent(enter) rec.latestOutput() shouldMatchRender """ ░title ░ @@ -126,42 +126,42 @@ class SelectListAnimationTest { val a = b.entries("a", "b", "c") .createSingleSelectInputAnimation() - a.onInput(down) shouldBe InputReceiver.Status.Continue + a.onEvent(down) shouldBe InputReceiver.Status.Continue rec.latestOutput() shouldMatchRender """ ░ a ░❯ b ░ c """ - a.onInput(down) shouldBe InputReceiver.Status.Continue + a.onEvent(down) shouldBe InputReceiver.Status.Continue rec.latestOutput() shouldMatchRender """ ░ a ░ b ░❯ c """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░ a ░ b ░❯ c """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░ a ░❯ b ░ c """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░❯ a ░ b ░ c """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░❯ a ░ b @@ -176,8 +176,8 @@ class SelectListAnimationTest { .filterable(true) .createSingleSelectInputAnimation() - a.onInput(slash) - a.onInput(x) + a.onEvent(slash) + a.onEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax @@ -185,7 +185,7 @@ class SelectListAnimationTest { ░ cx """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -193,7 +193,7 @@ class SelectListAnimationTest { ░ cx """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -201,7 +201,7 @@ class SelectListAnimationTest { ░❯ cx """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -209,7 +209,7 @@ class SelectListAnimationTest { ░❯ cx """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -217,7 +217,7 @@ class SelectListAnimationTest { ░ cx """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax @@ -225,7 +225,7 @@ class SelectListAnimationTest { ░ cx """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax @@ -233,8 +233,8 @@ class SelectListAnimationTest { ░ cx """ - a.onInput(down) - a.onInput(enter) shouldBe InputReceiver.Status.Finished("bx") + a.onEvent(down) + a.onEvent(enter) shouldBe InputReceiver.Status.Finished("bx") } @Test @@ -243,14 +243,14 @@ class SelectListAnimationTest { val a = b.entries("a") .filterable(true) .createSingleSelectInputAnimation() - a.onInput(slash) - a.onInput(x) + a.onEvent(slash) + a.onEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░ """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ @@ -291,7 +291,7 @@ class SelectListAnimationTest { ░ cx ░ """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░ ax ░ ░❯ b ░ @@ -299,7 +299,7 @@ class SelectListAnimationTest { ░ cx ░ """ - a.onInput(down) + a.onEvent(down) rec.latestOutput() shouldMatchRender """ ░ ax ░ ░ b ░ @@ -307,8 +307,8 @@ class SelectListAnimationTest { ░ cdesc░ """ - a.onInput(slash) - a.onInput(x) + a.onEvent(slash) + a.onEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax ░ @@ -316,7 +316,7 @@ class SelectListAnimationTest { ░ cdesc░ """ - a.onInput(up) + a.onEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax ░ @@ -338,42 +338,42 @@ class SelectListAnimationTest { ░ • cx """ - a.onInput(x) + a.onEvent(x) rec.latestOutput() shouldMatchRender """ ░❯ ✓ ax ░ • b ░ • cx """ - a.onInput(slash) - a.onInput(x) + a.onEvent(slash) + a.onEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░ ✓ ax ░ • cx """ - a.onInput(enter) + a.onEvent(enter) rec.latestOutput() shouldMatchRender """ ░❯ ✓ ax ░ • cx """ - a.onInput(down) - a.onInput(x) + a.onEvent(down) + a.onEvent(x) rec.latestOutput() shouldMatchRender """ ░ ✓ ax ░❯ ✓ cx """ - a.onInput(esc) + a.onEvent(esc) rec.latestOutput() shouldMatchRender """ ░ ✓ ax ░ • b ░❯ ✓ cx """ - a.onInput(enter) shouldBe InputReceiver.Status.Finished(listOf("ax", "cx")) + a.onEvent(enter) shouldBe InputReceiver.Status.Finished(listOf("ax", "cx")) } } diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt index 039bbd128..07b02c5a2 100644 --- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt @@ -1,6 +1,7 @@ package com.github.ajalt.mordant.internal.syscalls -import com.github.ajalt.mordant.input.KeyboardEvent +import com.github.ajalt.mordant.input.InputEvent +import com.github.ajalt.mordant.input.MouseTracking import com.github.ajalt.mordant.internal.Size import com.github.ajalt.mordant.internal.browserPrintln import com.github.ajalt.mordant.terminal.PrintTerminalCursor @@ -17,10 +18,10 @@ internal interface SyscallHandlerJsCommon: SyscallHandler { fun readFileIfExists(filename: String): String? // The public interface never is in nonJsMain, so these will never be called - override fun readKeyEvent(timeout: Duration): KeyboardEvent? { + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? { throw UnsupportedOperationException("Reading keyboard is not supported on this platform") } - override fun enterRawMode(): AutoCloseable { + override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable { throw UnsupportedOperationException("Raw mode is not supported on this platform") } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt index 160a48937..58387d7f9 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt @@ -17,9 +17,6 @@ private interface WinKernel32Lib : Library { const val STD_INPUT_HANDLE = -10 const val STD_OUTPUT_HANDLE = -11 const val STD_ERROR_HANDLE = -12 - - // https://learn.microsoft.com/en-us/windows/console/setconsolemode - const val ENABLE_PROCESSED_INPUT = 0x0001 } class HANDLE : PointerType() @@ -283,40 +280,58 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { return csbi.srWindow?.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } - override fun enterRawMode(): AutoCloseable? { + + override fun getStdinConsoleMode(): UInt? { val originalMode = IntByReference() try { kernel.GetConsoleMode(stdinHandle, originalMode) } catch (e: LastErrorException) { return null } + return originalMode.value.toUInt() + } - // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add - // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those - // events. - kernel.SetConsoleMode(stdinHandle, 0) - - return AutoCloseable { kernel.SetConsoleMode(stdinHandle, originalMode.value) } + override fun setStdinConsoleMode(dwMode: UInt): Boolean { + try { + kernel.SetConsoleMode(stdinHandle, dwMode.toInt()) + return true + } catch (e: LastErrorException) { + throw e + return false + } } - override fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? { + override fun readRawEvent(dwMilliseconds: Int): EventRecord? { val waitResult = kernel.WaitForSingleObject(stdinHandle.pointer, dwMilliseconds) - if (waitResult != 0) { - return null - } + if (waitResult != 0) return null val inputEvents = arrayOfNulls(1) val eventsRead = IntByReference() kernel.ReadConsoleInput(stdinHandle, inputEvents, inputEvents.size, eventsRead) - if (eventsRead.value == 0) { - return null + val inputEvent = inputEvents[0] ?: return null + return when (inputEvent.EventType) { + WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> { + val keyEvent = inputEvent.Event!!.KeyEvent!! + EventRecord.Key( + bKeyDown = keyEvent.bKeyDown, + wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), + uChar = keyEvent.uChar!!.UnicodeChar, + dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), + ) + } + + WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> { + val mouseEvent = inputEvent.Event!!.MouseEvent!! + EventRecord.Mouse( + dwMousePositionX = mouseEvent.dwMousePosition!!.X.toInt(), + dwMousePositionY = mouseEvent.dwMousePosition!!.Y.toInt(), + dwButtonState = mouseEvent.dwButtonState.toUInt(), + dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(), + dwEventFlags = mouseEvent.dwEventFlags.toUInt(), + ) + } + + else -> null } - val keyEvent = inputEvents[0]!!.Event!!.KeyEvent!! - return KeyEventRecord( - bKeyDown = keyEvent.bKeyDown, - wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), - uChar = keyEvent.uChar!!.UnicodeChar, - dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), - ) } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index b3e6c65f8..0d610f5bf 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -1,5 +1,6 @@ package com.github.ajalt.mordant.internal.syscalls.nativeimage +import com.github.ajalt.mordant.input.MouseTracking import com.github.ajalt.mordant.internal.Size import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWindows import org.graalvm.nativeimage.Platform @@ -61,8 +62,17 @@ private object WinKernel32Lib { val AsciiChar: Byte } + @CStruct("COORD") + interface COORD : PointerBase { + @get:CField("X") + var X: Short + + @get:CField("Y") + var Y: Short + } + @CStruct("KEY_EVENT_RECORD") - interface KEY_EVENT_RECORD : PointerBase{ + interface KEY_EVENT_RECORD : PointerBase { @get:CField("bKeyDown") val bKeyDown: Boolean @@ -82,10 +92,28 @@ private object WinKernel32Lib { val dwControlKeyState: Int } + @CStruct("MOUSE_EVENT_RECORD") + interface MOUSE_EVENT_RECORD : PointerBase { + @get:CField("dwMousePosition") + var dwMousePosition: COORD + + @get:CField("dwButtonState") + var dwButtonState: Int + + @get:CField("dwControlKeyState") + var dwControlKeyState: Int + + @get:CField("dwEventFlags") + var dwEventFlags: Int + } + @CStruct("Event") interface EventUnion : PointerBase { @get:CFieldAddress("KeyEvent") val KeyEvent: KEY_EVENT_RECORD + + @get:CFieldAddress("MouseEvent") + val MouseEvent: MOUSE_EVENT_RECORD // ... other fields omitted until we need them } @@ -114,7 +142,7 @@ private object WinKernel32Lib { external fun GetConsoleMode(hConsoleHandle: PointerBase?, lpMode: CIntPointer?): Boolean @CFunction("SetConsoleMode") - external fun SetConsoleMode(hConsoleHandle: PointerBase?, dwMode: Int) + external fun SetConsoleMode(hConsoleHandle: PointerBase?, dwMode: Int): Boolean @CFunction("GetConsoleScreenBufferInfo") external fun GetConsoleScreenBufferInfo( @@ -162,20 +190,19 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { } } - override fun enterRawMode(): AutoCloseable? { + override fun getStdinConsoleMode(): UInt? { val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) - val originalMode = StackValue.get(CIntPointer::class.java) - if (!WinKernel32Lib.GetConsoleMode(stdinHandle, originalMode)) return null - - // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add - // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those - // events. - WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT()) + val lpMode = StackValue.get(CIntPointer::class.java) + if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) return null + return lpMode.read().toUInt() + } - return AutoCloseable { WinKernel32Lib.SetConsoleMode(stdinHandle, originalMode.read()) } + override fun setStdinConsoleMode(dwMode: UInt): Boolean { + val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) + return WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT()) } - override fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? { + override fun readRawEvent(dwMilliseconds: Int): EventRecord? { val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds) if (waitResult != 0) { @@ -187,12 +214,29 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { if (eventsRead.read() == 0) { return null } - val keyEvent = inputEvents.Event.KeyEvent - return KeyEventRecord( - bKeyDown = keyEvent.bKeyDown, - wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), - uChar = keyEvent.uChar!!.UnicodeChar, - dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), - ) + return when (inputEvents.EventType) { + WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> { + val keyEvent = inputEvents.Event.KeyEvent + EventRecord.Key( + bKeyDown = keyEvent.bKeyDown, + wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), + uChar = keyEvent.uChar!!.UnicodeChar, + dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), + ) + } + + WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> { + val mouseEvent = inputEvents.Event.MouseEvent + EventRecord.Mouse( + dwMousePositionX = mouseEvent.dwMousePosition.X.toInt(), + dwMousePositionY = mouseEvent.dwMousePosition.Y.toInt(), + dwButtonState = mouseEvent.dwButtonState.toUInt(), + dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(), + dwEventFlags = mouseEvent.dwEventFlags.toUInt(), + ) + } + + else -> null + } } } diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index affbe30a3..f84732ad8 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -32,7 +32,8 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { csbi.srWindow.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } - override fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? = memScoped { + // TODO: implement mouse events + override fun readRawEvent(dwMilliseconds: Int): EventRecord? = memScoped { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) val waitResult = WaitForSingleObject(stdinHandle, dwMilliseconds.toUInt()) if (waitResult != 0u) return null @@ -42,25 +43,41 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { if (eventsRead.value == 0u) { return null } - val keyEvent = inputEvents[0].Event.KeyEvent - return KeyEventRecord( - bKeyDown = keyEvent.bKeyDown != 0, - wVirtualKeyCode = keyEvent.wVirtualKeyCode, - uChar = keyEvent.uChar.UnicodeChar.toInt().toChar(), - dwControlKeyState = keyEvent.dwControlKeyState, - ) + val inputEvent = inputEvents[0] + return when (inputEvent.EventType.toInt()) { + KEY_EVENT -> { + val keyEvent = inputEvent.Event.KeyEvent + EventRecord.Key( + bKeyDown = keyEvent.bKeyDown != 0, + wVirtualKeyCode = keyEvent.wVirtualKeyCode, + uChar = keyEvent.uChar.UnicodeChar.toInt().toChar(), + dwControlKeyState = keyEvent.dwControlKeyState, + ) + } + + MOUSE_EVENT -> { + val mouseEvent = inputEvent.Event.MouseEvent + EventRecord.Mouse( + dwMousePositionX = mouseEvent.dwMousePosition.X.toInt(), + dwMousePositionY = mouseEvent.dwMousePosition.Y.toInt(), + dwButtonState = mouseEvent.dwButtonState, + dwControlKeyState = mouseEvent.dwControlKeyState, + dwEventFlags = mouseEvent.dwEventFlags, + ) + } + + else -> null + } } - override fun enterRawMode(): AutoCloseable? = memScoped { + override fun getStdinConsoleMode(): UInt? { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - val originalMode = getConsoleMode(stdinHandle) ?: return null - - // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add - // ENABLE_PROCESSED_INPUT, ENABLE_MOUSE_INPUT or ENABLE_WINDOW_INPUT if we want those - // events. - SetConsoleMode(stdinHandle, 0u) + return getConsoleMode(stdinHandle) + } - return AutoCloseable { SetConsoleMode(stdinHandle, originalMode) } + override fun setStdinConsoleMode(dwMode: UInt): Boolean { + val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) + return SetConsoleMode(stdinHandle, 0u) != 0 } fun ttySetEcho(echo: Boolean) = memScoped { @@ -75,7 +92,7 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { } // https://docs.microsoft.com/en-us/windows/console/getconsolemode - private fun MemScope.getConsoleMode(handle: HANDLE?): UInt? { + private fun getConsoleMode(handle: HANDLE?): UInt? = memScoped { if (handle == null || handle == INVALID_HANDLE_VALUE) return null val lpMode = alloc() // "If the function succeeds, the return value is nonzero." diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt index 97a8c3076..09ab59eb5 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -8,11 +8,14 @@ import com.github.ajalt.mordant.terminal.Terminal * @return the result of the completed receiver, or `null` if the terminal is not interactive or the * input could not be read. */ -fun InputReceiver.run(terminal: Terminal): T? { - terminal.enterRawMode()?.use { rawMode -> +fun InputReceiver.receiveInput( + terminal: Terminal, + mouseTracking: MouseTracking = MouseTracking.Off, +): T? { + terminal.enterRawMode(mouseTracking)?.use { rawMode -> while (true) { - val event = rawMode.readKey() ?: return null - when (val status = onInput(event)) { + val event = rawMode.readEvent() ?: return null + when (val status = onEvent(event)) { is InputReceiver.Status.Continue -> continue is InputReceiver.Status.Finished -> return status.result } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index e8d70c3ef..c230232ea 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -15,7 +15,7 @@ inline fun Terminal.interactiveSelectList( return InteractiveSelectListBuilder(this) .apply(block) .createSingleSelectInputAnimation() - .run(this) + .receiveInput(this) } /** @@ -65,7 +65,7 @@ inline fun Terminal.interactiveMultiSelectList( return InteractiveSelectListBuilder(this) .apply(block) .createMultiSelectInputAnimation() - .run(this) + .receiveInput(this) } /** diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt deleted file mode 100644 index 816bde8cd..000000000 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/KeyboardInput.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.ajalt.mordant.input - -import com.github.ajalt.mordant.internal.SYSCALL_HANDLER -import com.github.ajalt.mordant.terminal.Terminal -import kotlin.time.Duration - -// TODO: docs, tests -fun Terminal.enterRawMode(): RawModeScope? { - if (!info.inputInteractive) return null - return SYSCALL_HANDLER.enterRawMode()?.let(::RawModeScope) -} - -class RawModeScope internal constructor( - closeable: AutoCloseable, -) : AutoCloseable by closeable { - // TODO: docs, tests - fun readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { - return SYSCALL_HANDLER.readKeyEvent(timeout) - } -} diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt new file mode 100644 index 000000000..e8e9c5ed9 --- /dev/null +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt @@ -0,0 +1,55 @@ +package com.github.ajalt.mordant.input + +import com.github.ajalt.mordant.internal.SYSCALL_HANDLER +import com.github.ajalt.mordant.terminal.Terminal +import kotlin.time.Duration +import kotlin.time.TimeSource + +// TODO: docs, tests +fun Terminal.enterRawMode(mouseTracking: MouseTracking = MouseTracking.Off): RawModeScope? { + if (!info.inputInteractive) return null + return SYSCALL_HANDLER.enterRawMode(mouseTracking)?.let { RawModeScope(it, mouseTracking) } +} + +class RawModeScope internal constructor( + closeable: AutoCloseable, + private val mouseTracking: MouseTracking, +) : AutoCloseable by closeable { + // TODO: docs, tests + fun readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { + while (true) { + return when (val event = readEvent(timeout)) { + is KeyboardEvent -> event + is MouseEvent -> continue + null -> null + } + } + } + + fun readMouse(timeout: Duration = Duration.INFINITE): MouseEvent? { + while (true) { + return when (val event = readEvent(timeout)) { + is MouseEvent -> event + is KeyboardEvent -> continue + null -> null + } + } + } + + fun readEvent(timeout: Duration = Duration.INFINITE): InputEvent? { + val t0 = TimeSource.Monotonic.markNow() + do { + val event = SYSCALL_HANDLER.readInputEvent(timeout - t0.elapsedNow(), mouseTracking) + return when (event) { + is KeyboardEvent -> event + is MouseEvent -> { + if (mouseTracking != MouseTracking.Off) event + else continue + } + + null -> null + } + } while (t0.elapsedNow() < timeout) + return null + } +} diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index c4a66a510..5011c6c9f 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -1,6 +1,11 @@ package com.github.ajalt.mordant.internal.syscalls +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 kotlin.time.ComparableTimeMark import kotlin.time.Duration import kotlin.time.TimeSource @@ -154,7 +159,8 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // https://www.man7.org/linux/man-pages/man3/termios.3.html // https://viewsourcecode.org/snaptoken/kilo/02.enteringRawMode.html - override fun enterRawMode(): AutoCloseable? { + // https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h2-Mouse-Tracking + override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? { val orig = getStdinTermios() ?: return null val new = Termios( iflag = orig.iflag and (ICRNL or IGNCR or INPCK or ISTRIP or IXON).inv(), @@ -171,37 +177,23 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { ospeed = orig.ospeed, ) setStdinTermios(new) - return AutoCloseable { setStdinTermios(orig) } + 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) + } } /* 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 - - ESC letter - ESC [ letter - ESC [ modifier letter - ESC [ 1 ; modifier letter - ESC [ num char - ESC [ num ; modifier char - ESC O letter - ESC O modifier letter - ESC O 1 ; modifier letter - ESC N letter - ESC [ [ num ; modifier char - ESC [ [ 1 ; modifier letter - ESC ESC [ num char - ESC ESC O letter - - - char is usually ~ but $ and ^ also happen with rxvt - - modifier is 1 + - (shift * 1) + - (left_alt * 2) + - (ctrl * 4) + - (right_alt * 8) - - two leading ESCs apparently mean the same as one leading ESC */ - override fun readKeyEvent(timeout: Duration): KeyboardEvent? { + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? { val t0 = TimeSource.Monotonic.markNow() var ctrl = false var alt = false @@ -209,7 +201,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { var escaped = false var name: String? = null val s = StringBuilder() - var ch: Char = ' ' + var ch = ' ' fun readTimeout(): Boolean { ch = readRawByte(t0, timeout) ?: return true @@ -227,7 +219,9 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { alt = false, shift = false ) - if (ch == ESC) readTimeout() + if (ch == ESC) { + if (readTimeout()) return null + } } if (escaped && (ch == 'O' || ch == '[')) { @@ -251,43 +245,19 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // ESC [ modifier letter // ESC [ [ modifier letter // ESC [ [ num char + // For mouse events: + // ESC [ M byte byte byte if (readTimeout()) return null if (ch == '[') { // escape codes might have a second bracket code.append(ch) if (readTimeout()) return null + } else if (ch == 'M') { + // mouse event + return processMouseEvent(t0, timeout) } - /* - * Here and later we try to buffer just enough data to get - * a complete ascii sequence. - * - * We have basically two classes of ascii characters to process: - * - * - * 1. `\x1b[24;5~` should be parsed as { code: '[24~', modifier: 5 } - * - * This particular example is featuring Ctrl+F12 in xterm. - * - * - `;5` part is optional, e.g. it could be `\x1b[24~` - * - first part can contain one or two digits - * - there is also special case when there can be 3 digits - * but without modifier. They are the case of paste bracket mode - * - * So the generic regexp is like /^(?:\d\d?(;\d)?[~^$]|\d{3}~)$/ - * - * - * 2. `\x1b[1;5H` should be parsed as { code: '[H', modifier: 5 } - * - * This particular example is featuring Ctrl+Home in xterm. - * - * - `1;5` part is optional, e.g. it could be `\x1b[H` - * - `1;` part is optional, e.g. it could be `\x1b[5H` - * - * So the generic regexp is like /^((\d;)?\d)?[A-Za-z]$/ - * - */ val cmdStart = s.length - 1 // leading digits @@ -306,10 +276,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { } } - /* - * We buffered enough data, now trying to extract code - * and modifier from it - */ + // We buffered enough data, now extract code and modifier from it val cmd = s.substring(cmdStart) var match = Regex("""(\d\d?)(?:;(\d))?([~^$])|(\d{3}~)""").matchEntire(cmd) if (match != null) { @@ -535,9 +502,8 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { alt = escaped } else if (escaped) { // Escape sequence timeout - TODO("Escape sequence timeout") -// name = ch.length ? undefined : "escape" -// alt = true + if (name == null) name = "Escape" + alt = true } return KeyboardEvent( @@ -547,5 +513,34 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { shift = shift, ) } + + private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): MouseEvent? { + // Mouse event coordinates are raw values, not decimal text, and they're sometimes utf-8 + // encoded to fit larger values. + val cb = (readUtf8Byte(t0, timeout) ?: return null) - ' '.code + val cx = (readUtf8Byte(t0, timeout) ?: return null) - ' '.code - 1 + val cy = (readUtf8Byte(t0, timeout) ?: return null) - ' '.code - 1 + val shift = (cb and 4) != 0 + val alt = (cb and 8) != 0 + val ctrl = (cb and 16) != 0 + val buttons = when (cb and 3) { + 0 -> 1 + 1 -> 2 + 2 -> 4 + else -> 0 + } + return MouseEvent( + x = cx, + y = cy, + buttons = buttons, + ctrl = ctrl, + alt = alt, + shift = shift, + ) + } + + private fun readUtf8Byte(t0: ComparableTimeMark, timeout: Duration): Int? { + return runCatching { readBytesAsUtf8 { readRawByte(t0, timeout)!!.code } }.getOrNull() + } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index c84b7ecdf..f59d7b1e2 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -1,6 +1,9 @@ package com.github.ajalt.mordant.internal.syscalls +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 kotlin.time.Duration import kotlin.time.TimeSource @@ -14,48 +17,115 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { const val SHIFT_PRESSED: UInt = 0x0010u val CTRL_PRESSED_MASK = (RIGHT_CTRL_PRESSED or LEFT_CTRL_PRESSED) val ALT_PRESSED_MASK = (RIGHT_ALT_PRESSED or LEFT_ALT_PRESSED) + + // https://learn.microsoft.com/en-us/windows/console/mouse-event-record-str + const val MOUSE_MOVED: UInt = 0x0001u + const val DOUBLE_CLICK: UInt = 0x0002u + const val MOUSE_WHEELED: UInt = 0x0004u + const val MOUSE_HWHEELED: UInt = 0x0008u + const val FROM_LEFT_1ST_BUTTON_PRESSED: UInt = 0x0001u + const val RIGHTMOST_BUTTON_PRESSED: UInt = 0x0002u + const val FROM_LEFT_2ND_BUTTON_PRESSED: UInt = 0x0004u + const val FROM_LEFT_3RD_BUTTON_PRESSED: UInt = 0x0008u + const val FROM_LEFT_4TH_BUTTON_PRESSED: UInt = 0x0010u + + + // https://learn.microsoft.com/en-us/windows/console/setconsolemode + const val ENABLE_PROCESSED_INPUT = 0x0001u + const val ENABLE_MOUSE_INPUT = 0x0010u + const val ENABLE_EXTENDED_FLAGS = 0x0080u + const val ENABLE_WINDOW_INPUT = 0x0008u + const val ENABLE_QUICK_EDIT_MODE = 0x0040u } - protected data class KeyEventRecord( - val bKeyDown: Boolean, - val wVirtualKeyCode: UShort, - val uChar: Char, - val dwControlKeyState: UInt, - ) + protected sealed class EventRecord { + data class Key( + val bKeyDown: Boolean, + val wVirtualKeyCode: UShort, + val uChar: Char, + val dwControlKeyState: UInt, + ) : EventRecord() - protected abstract fun readRawKeyEvent(dwMilliseconds: Int): KeyEventRecord? + data class Mouse( + val dwMousePositionX: Int, + val dwMousePositionY: Int, + val dwButtonState: UInt, + val dwControlKeyState: UInt, + val dwEventFlags: UInt, + ) : EventRecord() + } + + protected abstract fun readRawEvent(dwMilliseconds: Int): EventRecord? + protected abstract fun getStdinConsoleMode(): UInt? + protected abstract fun setStdinConsoleMode(dwMode: UInt): Boolean + + final override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? { + val originalMode = getStdinConsoleMode() ?: return null + // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add + // ENABLE_PROCESSED_INPUT or ENABLE_WINDOW_INPUT if we want those events. + val dwMode = when (mouseTracking) { + MouseTracking.Off -> 0u + else -> ENABLE_MOUSE_INPUT or ENABLE_EXTENDED_FLAGS + } + if (!setStdinConsoleMode(dwMode)) return null + return AutoCloseable { setStdinConsoleMode(originalMode) } + } - override fun readKeyEvent(timeout: Duration): KeyboardEvent? { + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? { val t0 = TimeSource.Monotonic.markNow() while (t0.elapsedNow() < timeout) { val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds .coerceIn(0, Int.MAX_VALUE.toLong()).toInt() - val event = readRawKeyEvent(dwMilliseconds) - // ignore key up events - if (event != null && event.bKeyDown) { - val virtualName = WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) - val shift = event.dwControlKeyState and SHIFT_PRESSED != 0u - val key = when { - virtualName != null && virtualName.length == 1 && shift -> { - if (virtualName[0] in 'a'..'z') virtualName.uppercase() - else shiftNumberKey(virtualName) - } - virtualName != null -> virtualName - event.uChar.code != 0 -> event.uChar.toString() - else -> "Unidentified" - } - return KeyboardEvent( - key = key, - ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, - alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, - shift = shift, - ) - } + val event = readRawEvent(dwMilliseconds) ?: return null + return when (event) { + is EventRecord.Key -> processKeyEvent(event) + is EventRecord.Mouse -> processMouseEvent(event, mouseTracking) + } ?: continue // skip events that don't match the tracking mode etc. } return null } + private fun processKeyEvent(event: EventRecord.Key): KeyboardEvent? { + if (!event.bKeyDown) return null // ignore key up events + val virtualName = WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) + val shift = event.dwControlKeyState and SHIFT_PRESSED != 0u + val key = when { + virtualName != null && virtualName.length == 1 && shift -> { + if (virtualName[0] in 'a'..'z') virtualName.uppercase() + else shiftNumberKey(virtualName) + } + virtualName != null -> virtualName + event.uChar.code != 0 -> event.uChar.toString() + else -> "Unidentified" + } + return KeyboardEvent( + key = key, + ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, + alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, + shift = shift, + ) + } + + private fun processMouseEvent( + event: EventRecord.Mouse, + tracking: MouseTracking, + ): MouseEvent? { + val eventFlags = event.dwEventFlags + val buttons = event.dwButtonState + if (tracking == MouseTracking.Off + || tracking == MouseTracking.Normal && eventFlags == MOUSE_MOVED + || tracking == MouseTracking.Button && eventFlags == MOUSE_MOVED && buttons == 0u + ) return null + return MouseEvent( + x = event.dwMousePositionX, + y = event.dwMousePositionY, + buttons = buttons.toInt(), // Windows uses the same flags for buttons as browsers do + ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, + alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, + shift = event.dwControlKeyState and SHIFT_PRESSED != 0u, + ) + } } private fun shiftNumberKey(virtualName: String): String { From 58cf653fbed3af28a101b5b369b44bd801dd24c4 Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 10 Jun 2024 14:56:36 -0700 Subject: [PATCH 26/45] Handle mouse event filtering on windows --- .../internal/syscalls/SyscallHandler.kt | 12 +++- .../com/github/ajalt/mordant/input/RawMode.kt | 11 +-- .../internal/syscalls/SyscallHandler.posix.kt | 71 ++++++++++--------- .../syscalls/SyscallHandler.windows.kt | 59 +++++++-------- 4 files changed, 85 insertions(+), 68 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt index 5cf658c5c..47e1777a1 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt @@ -5,13 +5,19 @@ import com.github.ajalt.mordant.input.MouseTracking import com.github.ajalt.mordant.internal.Size import kotlin.time.Duration +internal sealed class SysInputEvent { + data class Success(val event: InputEvent) : SysInputEvent() + data object Fail : SysInputEvent() + data object Retry: SysInputEvent() +} + internal interface SyscallHandler { fun stdoutInteractive(): Boolean fun stdinInteractive(): Boolean fun stderrInteractive(): Boolean fun getTerminalSize(): Size? fun fastIsTty(): Boolean = true - fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? + fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? } @@ -20,6 +26,8 @@ internal object DumbSyscallHandler : SyscallHandler { override fun stdinInteractive(): Boolean = false override fun stderrInteractive(): Boolean = false override fun getTerminalSize(): Size? = null - override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? = null override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? = null + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent { + return SysInputEvent.Fail + } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt index e8e9c5ed9..2dad6a0c1 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt @@ -1,6 +1,7 @@ package com.github.ajalt.mordant.input import com.github.ajalt.mordant.internal.SYSCALL_HANDLER +import com.github.ajalt.mordant.internal.syscalls.SysInputEvent import com.github.ajalt.mordant.terminal.Terminal import kotlin.time.Duration import kotlin.time.TimeSource @@ -41,13 +42,13 @@ class RawModeScope internal constructor( do { val event = SYSCALL_HANDLER.readInputEvent(timeout - t0.elapsedNow(), mouseTracking) return when (event) { - is KeyboardEvent -> event - is MouseEvent -> { - if (mouseTracking != MouseTracking.Off) event - else continue + is SysInputEvent.Success -> { + if (event.event is MouseEvent && mouseTracking == MouseTracking.Off) continue + event.event } - null -> null + SysInputEvent.Fail -> null + SysInputEvent.Retry -> continue } } while (t0.elapsedNow() < timeout) return null diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 5011c6c9f..476adf52f 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.internal.syscalls -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 @@ -137,6 +136,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { private const val TCSAFLUSH: UInt = 0x2u } + @Suppress("ArrayInDataClass") data class Termios( val iflag: UInt, val oflag: UInt, @@ -193,7 +193,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { 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? { + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent { val t0 = TimeSource.Monotonic.markNow() var ctrl = false var alt = false @@ -209,18 +209,20 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { return false } - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail if (ch == ESC) { escaped = true - if (readTimeout()) return KeyboardEvent( - key = "Escape", - ctrl = false, - alt = false, - shift = false + if (readTimeout()) return SysInputEvent.Success( + KeyboardEvent( + key = "Escape", + ctrl = false, + alt = false, + shift = false + ) ) if (ch == ESC) { - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail } } @@ -232,11 +234,11 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { if (ch == 'O') { // ESC O letter // ESC O modifier letter - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail if (ch in '0'..'9') { modifier = ch.code - 1 - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail } code.append(ch) @@ -247,12 +249,12 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // ESC [ [ num char // For mouse events: // ESC [ M byte byte byte - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail if (ch == '[') { // escape codes might have a second bracket code.append(ch) - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail } else if (ch == 'M') { // mouse event return processMouseEvent(t0, timeout) @@ -263,16 +265,16 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // leading digits repeat(3) { if (ch in '0'..'9') { - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail } } // modifier if (ch == ';') { - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail if (ch in '0'..'9') { - if (readTimeout()) return null + if (readTimeout()) return SysInputEvent.Fail } } @@ -506,36 +508,41 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { alt = true } - return KeyboardEvent( - key = name ?: ch.toString(), - ctrl = ctrl, - alt = alt, - shift = shift, + return SysInputEvent.Success( + KeyboardEvent( + key = name ?: ch.toString(), + ctrl = ctrl, + alt = alt, + shift = shift, + ) ) } - private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): MouseEvent? { + private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): SysInputEvent { // Mouse event coordinates are raw values, not decimal text, and they're sometimes utf-8 // encoded to fit larger values. - val cb = (readUtf8Byte(t0, timeout) ?: return null) - ' '.code - val cx = (readUtf8Byte(t0, timeout) ?: return null) - ' '.code - 1 - val cy = (readUtf8Byte(t0, timeout) ?: return null) - ' '.code - 1 + val cb = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - ' '.code + val cx = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - ' '.code - 1 + val cy = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - ' '.code - 1 val shift = (cb and 4) != 0 val alt = (cb and 8) != 0 val ctrl = (cb and 16) != 0 + // TODO: mouse wheel events val buttons = when (cb and 3) { 0 -> 1 1 -> 2 2 -> 4 else -> 0 } - return MouseEvent( - x = cx, - y = cy, - buttons = buttons, - ctrl = ctrl, - alt = alt, - shift = shift, + return SysInputEvent.Success( + MouseEvent( + x = cx, + y = cy, + buttons = buttons, + ctrl = ctrl, + alt = alt, + shift = shift, + ) ) } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index f59d7b1e2..8100dd4f3 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.internal.syscalls -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 @@ -71,64 +70,66 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { return AutoCloseable { setStdinConsoleMode(originalMode) } } - override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): InputEvent? { + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent { val t0 = TimeSource.Monotonic.markNow() - while (t0.elapsedNow() < timeout) { - val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds - .coerceIn(0, Int.MAX_VALUE.toLong()).toInt() - val event = readRawEvent(dwMilliseconds) ?: return null - return when (event) { - is EventRecord.Key -> processKeyEvent(event) - is EventRecord.Mouse -> processMouseEvent(event, mouseTracking) - } ?: continue // skip events that don't match the tracking mode etc. + val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds + .coerceIn(0, Int.MAX_VALUE.toLong()).toInt() + return when (val event = readRawEvent(dwMilliseconds)) { + null -> SysInputEvent.Fail + is EventRecord.Key -> processKeyEvent(event) + is EventRecord.Mouse -> processMouseEvent(event, mouseTracking) } - return null } - private fun processKeyEvent(event: EventRecord.Key): KeyboardEvent? { - if (!event.bKeyDown) return null // ignore key up events + private fun processKeyEvent(event: EventRecord.Key): SysInputEvent { + if (!event.bKeyDown) return SysInputEvent.Retry // ignore key up events val virtualName = WindowsVirtualKeyCodeToKeyEvent.getName(event.wVirtualKeyCode) val shift = event.dwControlKeyState and SHIFT_PRESSED != 0u val key = when { virtualName != null && virtualName.length == 1 && shift -> { if (virtualName[0] in 'a'..'z') virtualName.uppercase() - else shiftNumberKey(virtualName) + else shiftVcodeToKey(virtualName) } virtualName != null -> virtualName event.uChar.code != 0 -> event.uChar.toString() else -> "Unidentified" } - return KeyboardEvent( - key = key, - ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, - alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, - shift = shift, + return SysInputEvent.Success( + KeyboardEvent( + key = key, + ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, + alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, + shift = shift, + ) ) } private fun processMouseEvent( event: EventRecord.Mouse, tracking: MouseTracking, - ): MouseEvent? { + ): SysInputEvent { val eventFlags = event.dwEventFlags val buttons = event.dwButtonState if (tracking == MouseTracking.Off || tracking == MouseTracking.Normal && eventFlags == MOUSE_MOVED || tracking == MouseTracking.Button && eventFlags == MOUSE_MOVED && buttons == 0u - ) return null - return MouseEvent( - x = event.dwMousePositionX, - y = event.dwMousePositionY, - buttons = buttons.toInt(), // Windows uses the same flags for buttons as browsers do - ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, - alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, - shift = event.dwControlKeyState and SHIFT_PRESSED != 0u, + ) return SysInputEvent.Retry + return SysInputEvent.Success( + MouseEvent( + x = event.dwMousePositionX, + y = event.dwMousePositionY, + // TODO: mouse wheel events + buttons = buttons.toInt(), // Windows uses the same flags for buttons as browsers do + ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, + alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, + shift = event.dwControlKeyState and SHIFT_PRESSED != 0u, + ) ) } } -private fun shiftNumberKey(virtualName: String): String { +private fun shiftVcodeToKey(virtualName: String): String { return when (virtualName[0]) { '1' -> "!" '2' -> "@" From ef3ddc055fa439dd116999ccf53636cf10369e6e Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 10 Jun 2024 15:47:45 -0700 Subject: [PATCH 27/45] Add drawing sample --- samples/drawing/README.md | 3 + samples/drawing/build.gradle.kts | 11 ++++ .../com/github/ajalt/mordant/samples/main.kt | 62 +++++++++++++++++++ settings.gradle.kts | 1 + 4 files changed, 77 insertions(+) create mode 100644 samples/drawing/README.md create mode 100644 samples/drawing/build.gradle.kts create mode 100644 samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt diff --git a/samples/drawing/README.md b/samples/drawing/README.md new file mode 100644 index 000000000..5d5ff39bc --- /dev/null +++ b/samples/drawing/README.md @@ -0,0 +1,3 @@ +# Drawing + +This sample shows how to read mouse input events to draw on the screen. \ No newline at end of file diff --git a/samples/drawing/build.gradle.kts b/samples/drawing/build.gradle.kts new file mode 100644 index 000000000..88a0734bd --- /dev/null +++ b/samples/drawing/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("mordant-mpp-sample-conventions") +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(project(":extensions:mordant-coroutines")) + } + } +} diff --git a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt new file mode 100644 index 000000000..0439ab4cb --- /dev/null +++ b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -0,0 +1,62 @@ +package com.github.ajalt.mordant.samples + +import com.github.ajalt.colormath.Color +import com.github.ajalt.colormath.calculate.wcagContrastRatio +import com.github.ajalt.colormath.model.HSL +import com.github.ajalt.colormath.model.Oklab +import com.github.ajalt.colormath.model.RGB +import com.github.ajalt.colormath.transform.interpolate +import com.github.ajalt.colormath.transform.interpolator +import com.github.ajalt.mordant.animation.coroutines.animateInCoroutine +import com.github.ajalt.mordant.animation.textAnimation +import com.github.ajalt.mordant.input.* +import com.github.ajalt.mordant.rendering.AnsiLevel +import com.github.ajalt.mordant.rendering.TextColors +import com.github.ajalt.mordant.terminal.Terminal +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch + +suspend fun main() = coroutineScope { + val terminal = Terminal(ansiLevel = AnsiLevel.TRUECOLOR, interactive = true) + var hue = 0 + val canvas = List(terminal.info.height - 1) { + MutableList(terminal.info.width) { RGB("#000") } + } + val animation = terminal.textAnimation { + buildString { + for ((y, row) in canvas.withIndex()) { + for ((x, color) in row.withIndex()) { + append(TextColors.color(color).bg(" ")) + canvas[y][x] = Oklab.interpolator { + stop(color) + stop(RGB("#000")) + }.interpolate(0.025) + } + append("\n") + } + } + }.animateInCoroutine() + + launch { animation.execute() } + + object : InputReceiver { + override fun onEvent(event: InputEvent): InputReceiver.Status { + return when (event) { + is KeyboardEvent -> when { + event.isCtrlC -> InputReceiver.Status.Finished(Unit) + else -> InputReceiver.Status.Continue + } + + is MouseEvent -> { + if (event.leftPressed) { + canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) + hue += 2 + } + InputReceiver.Status.Continue + } + } + } + }.receiveInput(terminal, MouseTracking.Button) // TODO add a lambda extension to Terminal? + + animation.clear() +} diff --git a/settings.gradle.kts b/settings.gradle.kts index f77ebd691..030546635 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -4,6 +4,7 @@ include( "mordant", "extensions:mordant-coroutines", "samples:detection", + "samples:drawing", "samples:hexviewer", "samples:markdown", "samples:progress", From 99f63edb8ba46f2f444e8a4c4d3595cee8755cab Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 10 Jun 2024 15:59:38 -0700 Subject: [PATCH 28/45] Add Terminal.receiveEvents --- .../ajalt/mordant/input/InputReceiver.kt | 3 ++ .../mordant/input/InputReceiverRunning.kt | 38 +++++++++++++++++++ .../com/github/ajalt/mordant/samples/main.kt | 28 ++++++-------- 3 files changed, 53 insertions(+), 16 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index 572d758a9..a3156c470 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -3,6 +3,9 @@ package com.github.ajalt.mordant.input // TODO: docs interface InputReceiver { sealed class Status { + companion object { + val Finished = Finished(Unit) + } data object Continue : Status() data class Finished(val result: T) : Status() } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt index 09ab59eb5..3abad4779 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -23,3 +23,41 @@ fun InputReceiver.receiveInput( } return null } + +// TODO: docs +inline fun Terminal.receiveKeyEvents( + crossinline block: (KeyboardEvent) -> InputReceiver.Status, +): T? { + return receiveEvents(MouseTracking.Off) { event -> + when (event) { + is KeyboardEvent -> block(event) + else -> InputReceiver.Status.Continue + } + } +} + +inline fun Terminal.receiveMouseEvents( + mouseTracking: MouseTracking = MouseTracking.Normal, + crossinline block: (MouseEvent) -> InputReceiver.Status, +): T? { + require(mouseTracking != MouseTracking.Off) { + "Mouse tracking must be enabled to receive mouse events" + } + return receiveEvents(mouseTracking) { event -> + when (event) { + is MouseEvent -> block(event) + else -> InputReceiver.Status.Continue + } + } +} + +inline fun Terminal.receiveEvents( + mouseTracking: MouseTracking = MouseTracking.Normal, + crossinline block: (InputEvent) -> InputReceiver.Status, +): T? { + return object : InputReceiver { + override fun onEvent(event: InputEvent): InputReceiver.Status { + return block(event) + } + }.receiveInput(this, mouseTracking) +} diff --git a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index 0439ab4cb..cef8d3bba 100644 --- a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -1,11 +1,9 @@ package com.github.ajalt.mordant.samples import com.github.ajalt.colormath.Color -import com.github.ajalt.colormath.calculate.wcagContrastRatio import com.github.ajalt.colormath.model.HSL import com.github.ajalt.colormath.model.Oklab import com.github.ajalt.colormath.model.RGB -import com.github.ajalt.colormath.transform.interpolate import com.github.ajalt.colormath.transform.interpolator import com.github.ajalt.mordant.animation.coroutines.animateInCoroutine import com.github.ajalt.mordant.animation.textAnimation @@ -39,24 +37,22 @@ suspend fun main() = coroutineScope { launch { animation.execute() } - object : InputReceiver { - override fun onEvent(event: InputEvent): InputReceiver.Status { - return when (event) { - is KeyboardEvent -> when { - event.isCtrlC -> InputReceiver.Status.Finished(Unit) - else -> InputReceiver.Status.Continue - } + terminal.receiveEvents(MouseTracking.Button) { event -> + when (event) { + is KeyboardEvent -> when { + event.isCtrlC -> InputReceiver.Status.Finished + else -> InputReceiver.Status.Continue + } - is MouseEvent -> { - if (event.leftPressed) { - canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) - hue += 2 - } - InputReceiver.Status.Continue + is MouseEvent -> { + if (event.leftPressed) { + canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) + hue += 2 } + InputReceiver.Status.Continue } } - }.receiveInput(terminal, MouseTracking.Button) // TODO add a lambda extension to Terminal? + } animation.clear() } From b1e103341d8afb72e99073e69de521059a1dfc7e Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 10 Jun 2024 16:47:15 -0700 Subject: [PATCH 29/45] Implement mouse wheel events --- .../github/ajalt/mordant/input/InputEvent.kt | 17 +++++---- .../jna/SyscallHandler.jna.windows.kt | 4 +- .../SyscallHandler.nativeimage.windows.kt | 4 +- .../syscalls/SyscallHandler.native.windows.kt | 4 +- .../internal/syscalls/SyscallHandler.posix.kt | 14 +++---- .../syscalls/SyscallHandler.windows.kt | 38 ++++++++++++------- .../com/github/ajalt/mordant/samples/main.kt | 2 +- 7 files changed, 47 insertions(+), 36 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt index 094e8f0de..fbef7637b 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt @@ -21,15 +21,16 @@ val KeyboardEvent.isCtrlC: Boolean data class MouseEvent( val x: Int, val y: Int, - val buttons: Int, + val left: Boolean = false, + val right: Boolean = false, + val middle: Boolean = false, + val mouse4: Boolean = false, + val mouse5: Boolean = false, + val wheelUp: Boolean = false, + val wheelDown: Boolean = false, + val wheelLeft: Boolean = false, + val wheelRight: Boolean = false, val ctrl: Boolean = false, val alt: Boolean = false, val shift: Boolean = false, ): InputEvent() - -val MouseEvent.leftPressed: Boolean get() = buttons and 1 != 0 -val MouseEvent.rightPressed: Boolean get() = buttons and 2 != 0 -val MouseEvent.middlePressed: Boolean get() = buttons and 4 != 0 -val MouseEvent.mouse4Pressed: Boolean get() = buttons and 8 != 0 -val MouseEvent.mouse5Pressed: Boolean get() = buttons and 16 != 0 - diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt index 58387d7f9..717da220a 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt @@ -322,8 +322,8 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> { val mouseEvent = inputEvent.Event!!.MouseEvent!! EventRecord.Mouse( - dwMousePositionX = mouseEvent.dwMousePosition!!.X.toInt(), - dwMousePositionY = mouseEvent.dwMousePosition!!.Y.toInt(), + dwMousePositionX = mouseEvent.dwMousePosition!!.X, + dwMousePositionY = mouseEvent.dwMousePosition!!.Y, dwButtonState = mouseEvent.dwButtonState.toUInt(), dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(), dwEventFlags = mouseEvent.dwEventFlags.toUInt(), diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index 0d610f5bf..140163acb 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -228,8 +228,8 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> { val mouseEvent = inputEvents.Event.MouseEvent EventRecord.Mouse( - dwMousePositionX = mouseEvent.dwMousePosition.X.toInt(), - dwMousePositionY = mouseEvent.dwMousePosition.Y.toInt(), + dwMousePositionX = mouseEvent.dwMousePosition.X, + dwMousePositionY = mouseEvent.dwMousePosition.Y, dwButtonState = mouseEvent.dwButtonState.toUInt(), dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(), dwEventFlags = mouseEvent.dwEventFlags.toUInt(), diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index f84732ad8..e442961b0 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -58,8 +58,8 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { MOUSE_EVENT -> { val mouseEvent = inputEvent.Event.MouseEvent EventRecord.Mouse( - dwMousePositionX = mouseEvent.dwMousePosition.X.toInt(), - dwMousePositionY = mouseEvent.dwMousePosition.Y.toInt(), + dwMousePositionX = mouseEvent.dwMousePosition.X, + dwMousePositionY = mouseEvent.dwMousePosition.Y, dwButtonState = mouseEvent.dwButtonState, dwControlKeyState = mouseEvent.dwControlKeyState, dwEventFlags = mouseEvent.dwEventFlags, diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 476adf52f..0d0b12284 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -527,18 +527,16 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { val shift = (cb and 4) != 0 val alt = (cb and 8) != 0 val ctrl = (cb and 16) != 0 - // TODO: mouse wheel events - val buttons = when (cb and 3) { - 0 -> 1 - 1 -> 2 - 2 -> 4 - else -> 0 - } + // cb == 3 means "a button was released", but there's no way to know which one -_- return SysInputEvent.Success( MouseEvent( x = cx, y = cy, - buttons = buttons, + left = cb == 0, + right = cb == 1, + middle = cb == 2, + wheelUp = cb == 64, + wheelDown = cb == 65, ctrl = ctrl, alt = alt, shift = shift, diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index 8100dd4f3..352645f7e 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -22,11 +22,11 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { const val DOUBLE_CLICK: UInt = 0x0002u const val MOUSE_WHEELED: UInt = 0x0004u const val MOUSE_HWHEELED: UInt = 0x0008u - const val FROM_LEFT_1ST_BUTTON_PRESSED: UInt = 0x0001u - const val RIGHTMOST_BUTTON_PRESSED: UInt = 0x0002u - const val FROM_LEFT_2ND_BUTTON_PRESSED: UInt = 0x0004u - const val FROM_LEFT_3RD_BUTTON_PRESSED: UInt = 0x0008u - const val FROM_LEFT_4TH_BUTTON_PRESSED: UInt = 0x0010u + const val FROM_LEFT_1ST_BUTTON_PRESSED: Int = 0x0001 + const val RIGHTMOST_BUTTON_PRESSED: Int = 0x0002 + const val FROM_LEFT_2ND_BUTTON_PRESSED: Int = 0x0004 + const val FROM_LEFT_3RD_BUTTON_PRESSED: Int = 0x0008 + const val FROM_LEFT_4TH_BUTTON_PRESSED: Int = 0x0010 // https://learn.microsoft.com/en-us/windows/console/setconsolemode @@ -46,8 +46,8 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { ) : EventRecord() data class Mouse( - val dwMousePositionX: Int, - val dwMousePositionY: Int, + val dwMousePositionX: Short, + val dwMousePositionY: Short, val dwButtonState: UInt, val dwControlKeyState: UInt, val dwEventFlags: UInt, @@ -110,17 +110,29 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { tracking: MouseTracking, ): SysInputEvent { val eventFlags = event.dwEventFlags - val buttons = event.dwButtonState + val buttons = event.dwButtonState.toInt() if (tracking == MouseTracking.Off || tracking == MouseTracking.Normal && eventFlags == MOUSE_MOVED - || tracking == MouseTracking.Button && eventFlags == MOUSE_MOVED && buttons == 0u + || tracking == MouseTracking.Button && eventFlags == MOUSE_MOVED && buttons == 0 ) return SysInputEvent.Retry + return SysInputEvent.Success( MouseEvent( - x = event.dwMousePositionX, - y = event.dwMousePositionY, - // TODO: mouse wheel events - buttons = buttons.toInt(), // Windows uses the same flags for buttons as browsers do + x = event.dwMousePositionX.toInt(), + y = event.dwMousePositionY.toInt(), + left = buttons and FROM_LEFT_1ST_BUTTON_PRESSED != 0, + right = buttons and RIGHTMOST_BUTTON_PRESSED != 0, + middle = buttons and FROM_LEFT_2ND_BUTTON_PRESSED != 0, + mouse4 = buttons and FROM_LEFT_3RD_BUTTON_PRESSED != 0, + mouse5 = buttons and FROM_LEFT_4TH_BUTTON_PRESSED != 0, + // If the high word of the dwButtonState member contains a positive value, the wheel + // was rotated forward, away from the user. + wheelUp = eventFlags and MOUSE_WHEELED != 0u && buttons shr 16 > 0, + wheelDown = eventFlags and MOUSE_WHEELED != 0u && buttons shr 16 <= 0, + // If the high word of the dwButtonState member contains a positive value, the wheel + // was rotated to the right. + wheelLeft = eventFlags and MOUSE_HWHEELED != 0u && buttons shr 16 <= 0, + wheelRight = eventFlags and MOUSE_HWHEELED != 0u && buttons shr 16 > 0, ctrl = event.dwControlKeyState and CTRL_PRESSED_MASK != 0u, alt = event.dwControlKeyState and ALT_PRESSED_MASK != 0u, shift = event.dwControlKeyState and SHIFT_PRESSED != 0u, diff --git a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index cef8d3bba..0e59e2c21 100644 --- a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -45,7 +45,7 @@ suspend fun main() = coroutineScope { } is MouseEvent -> { - if (event.leftPressed) { + if (event.left) { canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) hue += 2 } From 75d58f6adf7e27178e5da290fef2c896248b05ec Mon Sep 17 00:00:00 2001 From: AJ Date: Mon, 10 Jun 2024 17:21:44 -0700 Subject: [PATCH 30/45] Add workaround for partial mouse events on posix --- .../internal/syscalls/SyscallHandler.posix.kt | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 0d0b12284..6829361ed 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -7,6 +7,8 @@ import com.github.ajalt.mordant.internal.CSI import com.github.ajalt.mordant.internal.readBytesAsUtf8 import kotlin.time.ComparableTimeMark import kotlin.time.Duration +import kotlin.time.Duration.Companion.microseconds +import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource internal abstract class SyscallHandlerPosix : SyscallHandler { @@ -521,9 +523,12 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): SysInputEvent { // Mouse event coordinates are raw values, not decimal text, and they're sometimes utf-8 // encoded to fit larger values. - val cb = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - ' '.code - val cx = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - ' '.code - 1 - val cy = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - ' '.code - 1 + val cb = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) + val cx = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - 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 = (readUtf8Byte(t0, timeout.coerceAtMost(1.milliseconds)) ?: 33) - 33 val shift = (cb and 4) != 0 val alt = (cb and 8) != 0 val ctrl = (cb and 16) != 0 @@ -532,9 +537,10 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { MouseEvent( x = cx, y = cy, - left = cb == 0, - right = cb == 1, - middle = cb == 2, + // On button-motion events, xterm adds 32 to cb + left = cb and 3 == 0, + right = cb and 3 == 1, + middle = cb and 3 == 2, wheelUp = cb == 64, wheelDown = cb == 65, ctrl = ctrl, From 8b0010ce448259190d99a2847cdf816e289743d3 Mon Sep 17 00:00:00 2001 From: AJ Date: Tue, 11 Jun 2024 17:23:36 -0700 Subject: [PATCH 31/45] Add kdocs --- .../github/ajalt/mordant/input/InputEvent.kt | 69 +++++++++++++++++-- .../ajalt/mordant/input/InputReceiver.kt | 4 +- .../syscalls/SyscallHandler.native.windows.kt | 1 - .../mordant/internal/MppInternal.native.kt | 1 - .../mordant/input/InputReceiverRunning.kt | 27 +++++++- .../com/github/ajalt/mordant/input/RawMode.kt | 28 +++++++- 6 files changed, 117 insertions(+), 13 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt index fbef7637b..eeca0d2a0 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputEvent.kt @@ -2,35 +2,92 @@ package com.github.ajalt.mordant.input sealed class InputEvent -// TODO: docs +/** + * An event representing a single key press, including modifiers keys. + * + * This class uses the same format as the web + * [KeyboardEvent](https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent). + * + * Keep in mind that the not all modifier combinations or special keys are reported by all + * terminals. + */ // https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent data class KeyboardEvent( + /** + * A string describing the key pressed. + * + * - If the key is a printable character, this will be the character itself. + * - If the key is a special key, this will be a string describing the key, like `"ArrowLeft"`. + * The full list of possible values is available at + * [MDN](https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_key_values), but + * not all terminals will report all keys. + * - If the key cannot be identified, the value is `"Unidentified"`. + * + */ val key: String, + /** Whether the `Control` key is pressed */ val ctrl: Boolean = false, + /** Whether the Alt key (`Option ⌥` on macOS) is pressed */ val alt: Boolean = false, // `Option ⌥` key on mac + /** Whether the Shift key is pressed */ val shift: Boolean = false, - // maybe add a `data` field for escape sequences? -): InputEvent() +) : InputEvent() +/** Whether this event represents a `Ctrl+C` key press. */ val KeyboardEvent.isCtrlC: Boolean get() = key == "c" && ctrl && !alt && !shift -// TODO: docs -// https://developer.mozilla.org/en-US/docs/Web/API/MouseEvent +/** + * An event representing a single mouse event. + * + * This includes button press, release, and movement events. + */ data class MouseEvent( + /** + * The x coordinate of the mouse event, where `0` is the leftmost column. + */ val x: Int, + /** + * The y coordinate of the mouse event, where `0` is the top row. + */ val y: Int, + /** `true` if the left mouse button is pressed */ val left: Boolean = false, + /** `true` if the right mouse button is pressed */ val right: Boolean = false, + /** `true` if the middle mouse button (usually clicking the mouse wheel) is pressed */ val middle: Boolean = false, + /** + * `true` if the fourth mouse button is pressed. This is often the "browse forward" button, and + * isn't reported on all platforms. + */ val mouse4: Boolean = false, + /** + * `true` if the fifth mouse button is pressed. This is often the "browse backward" button, and + * isn't reported on all platforms. + */ val mouse5: Boolean = false, + /** + * `true` if the mouse wheel moved up (away from the user). + */ val wheelUp: Boolean = false, + /** + * `true` if the mouse wheel moved down (towards the user). + */ val wheelDown: Boolean = false, + /** + * `true` if the horizontal mouse wheel moved left. This is not reported on all platforms. + */ val wheelLeft: Boolean = false, + /** + * `true` if the horizontal mouse wheel moved right. This is not reported on all platforms. + */ val wheelRight: Boolean = false, + /** `true` if the `Control` key is pressed */ val ctrl: Boolean = false, + /** `true` if the Alt key (`Option ⌥` on macOS) is pressed */ val alt: Boolean = false, + /** `true` if the Shift key is pressed */ val shift: Boolean = false, -): InputEvent() +) : InputEvent() diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index a3156c470..7a34937b9 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -1,6 +1,8 @@ package com.github.ajalt.mordant.input -// TODO: docs +/** + * An object that can receive input events. + */ interface InputReceiver { sealed class Status { companion object { diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index e442961b0..1c2dec9b0 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -32,7 +32,6 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { csbi.srWindow.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } - // TODO: implement mouse events override fun readRawEvent(dwMilliseconds: Int): EventRecord? = memScoped { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) val waitResult = WaitForSingleObject(stdinHandle, dwMilliseconds.toUInt()) diff --git a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt index 5199f1e1a..98567f997 100644 --- a/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt +++ b/mordant/src/nativeMain/kotlin/com/github/ajalt/mordant/internal/MppInternal.native.kt @@ -62,7 +62,6 @@ internal actual fun printStderr(message: String, newline: Boolean) { fflush(stderr) } -// TODO: use the syscall handler for this? internal expect fun ttySetEcho(echo: Boolean) internal actual fun readLineOrNullMpp(hideInput: Boolean): String? { diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt index 3abad4779..5c29f4e00 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -3,7 +3,8 @@ package com.github.ajalt.mordant.input import com.github.ajalt.mordant.terminal.Terminal /** - * Read input from the [terminal], and feed to this [InputReceiver] until it returns a result. + * Enter raw mode, read input from the [terminal] for this [InputReceiver] until it returns a + * result, then exit raw mode. * * @return the result of the completed receiver, or `null` if the terminal is not interactive or the * input could not be read. @@ -24,7 +25,13 @@ fun InputReceiver.receiveInput( return null } -// TODO: docs +/** + * Enter raw mode, read input and pass any [KeyboardEvent]s to [block] until it returns a + * result, then exit raw mode. + * + * @return the result of the completed receiver, or `null` if the terminal is not interactive or the + * input could not be read. + */ inline fun Terminal.receiveKeyEvents( crossinline block: (KeyboardEvent) -> InputReceiver.Status, ): T? { @@ -36,6 +43,14 @@ inline fun Terminal.receiveKeyEvents( } } +/** + * Enter raw mode, read input and pass any [MouseEvent]s to [block] until it returns a + * result, then exit raw mode. + * + * @param mouseTracking The type of mouse tracking to enable. + * @return the result of the completed receiver, or `null` if the terminal is not interactive or the + * input could not be read. + */ inline fun Terminal.receiveMouseEvents( mouseTracking: MouseTracking = MouseTracking.Normal, crossinline block: (MouseEvent) -> InputReceiver.Status, @@ -51,6 +66,14 @@ inline fun Terminal.receiveMouseEvents( } } +/** + * Enter raw mode, read input and pass any [InputEvent]s to [block] until it returns a + * result, then exit raw mode. + * + * @param mouseTracking The type of mouse tracking to enable. + * @return the result of the completed receiver, or `null` if the terminal is not interactive or the + * input could not be read. + */ inline fun Terminal.receiveEvents( mouseTracking: MouseTracking = MouseTracking.Normal, crossinline block: (InputEvent) -> InputReceiver.Status, diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt index 2dad6a0c1..df33fdacf 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt @@ -6,7 +6,14 @@ import com.github.ajalt.mordant.terminal.Terminal import kotlin.time.Duration import kotlin.time.TimeSource -// TODO: docs, tests +/** + * Enter raw mode on the terminal, disabling line buffering and echoing to enable reading individual + * input events. + * + * @param mouseTracking What type of mouse events to listen for. + * @return A scope that will restore the terminal to its previous state when closed, or `null` if + * the terminal is not interactive. + */ fun Terminal.enterRawMode(mouseTracking: MouseTracking = MouseTracking.Off): RawModeScope? { if (!info.inputInteractive) return null return SYSCALL_HANDLER.enterRawMode(mouseTracking)?.let { RawModeScope(it, mouseTracking) } @@ -16,7 +23,12 @@ class RawModeScope internal constructor( closeable: AutoCloseable, private val mouseTracking: MouseTracking, ) : AutoCloseable by closeable { - // TODO: docs, tests + /** + * Read a single keyboard event from the terminal, ignoring any mouse events that arrive first. + * + * @param timeout The maximum amount of time to wait for an event. + * @return The event, or `null` if no event was received before the timeout. + */ fun readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { while (true) { return when (val event = readEvent(timeout)) { @@ -27,6 +39,12 @@ class RawModeScope internal constructor( } } + /** + * Read a single mouse event from the terminal, ignoring any keyboard events that arrive first. + * + * @param timeout The maximum amount of time to wait for an event. + * @return The event, or `null` if no event was received before the timeout. + */ fun readMouse(timeout: Duration = Duration.INFINITE): MouseEvent? { while (true) { return when (val event = readEvent(timeout)) { @@ -37,6 +55,12 @@ class RawModeScope internal constructor( } } + /** + * Read a single input event from the terminal. + * + * @param timeout The maximum amount of time to wait for an event. + * @return The event, or `null` if no event was received before the timeout. + */ fun readEvent(timeout: Duration = Duration.INFINITE): InputEvent? { val t0 = TimeSource.Monotonic.markNow() do { From be7fa434d82b8460d5c6c60ef0cce17b94f889d0 Mon Sep 17 00:00:00 2001 From: AJ Date: Thu, 13 Jun 2024 18:53:46 -0700 Subject: [PATCH 32/45] Always try to read at least one raw byte --- .../ajalt/mordant/input/InputReceiver.kt | 2 +- .../mordant/input/SelectListAnimation.kt | 6 +- .../mordant/input/SelectListAnimationTest.kt | 78 +++++++++---------- .../syscalls/SyscallHandler.jsCommon.kt | 2 +- .../syscalls/SyscallHandler.jvm.posix.kt | 4 +- .../mordant/input/InputReceiverRunning.kt | 8 +- .../mordant/input/InteractiveSelectList.kt | 4 +- .../syscalls/SyscallHanlder.native.posix.kt | 7 +- 8 files changed, 55 insertions(+), 56 deletions(-) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index 7a34937b9..d915b4713 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -11,6 +11,6 @@ interface InputReceiver { data object Continue : Status() data class Finished(val result: T) : Status() } - fun onEvent(event: InputEvent): Status = Status.Continue + fun receiveEvent(event: InputEvent): Status = Status.Continue fun cancel() {} } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt index 4f8309d48..7dac6b4ff 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt @@ -350,7 +350,7 @@ private open class SelectInputAnimation( else animation.stop() } - override fun onEvent(event: InputEvent): Status?> { + override fun receiveEvent(event: InputEvent): Status?> { if (event !is KeyboardEvent) return Status.Continue val (_, s) = state.update { with(config) { @@ -477,8 +477,8 @@ private class SingleSelectInputAnimation( private val animation: SelectInputAnimation, ) : InputReceiver { override fun cancel() = animation.cancel() - override fun onEvent(event: InputEvent): Status { - return when (val status = animation.onEvent(event)) { + override fun receiveEvent(event: InputEvent): Status { + return when (val status = animation.receiveEvent(event)) { is Status.Finished -> Status.Finished(status.result?.firstOrNull()) is Status.Continue -> Status.Continue } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt index 34ebb09af..03424b257 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt @@ -40,7 +40,7 @@ class SelectListAnimationTest { ░↑ up • ↓ down • / filter • enter select """ - a.onEvent(slash) + a.receiveEvent(slash) rec.latestOutput() shouldMatchRender """ ░/ ░ ░❯ a @@ -48,7 +48,7 @@ class SelectListAnimationTest { ░↑ up • ↓ down • esc clear filter • enter select """ - a.onEvent(esc) + a.receiveEvent(esc) rec.latestOutput() shouldMatchRender """ ░title ░❯ a @@ -84,7 +84,7 @@ class SelectListAnimationTest { ░x toggle • ↑ up • ↓ down • / filter • enter confirm """ - a.onEvent(slash) + a.receiveEvent(slash) rec.latestOutput() shouldMatchRender """ ░/ ░ ░ • a @@ -92,7 +92,7 @@ class SelectListAnimationTest { ░esc clear filter • enter set filter """ - a.onEvent(esc) + a.receiveEvent(esc) rec.latestOutput() shouldMatchRender """ ░title ░❯ • a @@ -100,9 +100,9 @@ class SelectListAnimationTest { ░x toggle • ↑ up • ↓ down • / filter • enter confirm """ - a.onEvent(slash) - a.onEvent(x) - a.onEvent(enter) + a.receiveEvent(slash) + a.receiveEvent(x) + a.receiveEvent(enter) rec.latestOutput() shouldMatchRender """ ░title ░ @@ -126,42 +126,42 @@ class SelectListAnimationTest { val a = b.entries("a", "b", "c") .createSingleSelectInputAnimation() - a.onEvent(down) shouldBe InputReceiver.Status.Continue + a.receiveEvent(down) shouldBe InputReceiver.Status.Continue rec.latestOutput() shouldMatchRender """ ░ a ░❯ b ░ c """ - a.onEvent(down) shouldBe InputReceiver.Status.Continue + a.receiveEvent(down) shouldBe InputReceiver.Status.Continue rec.latestOutput() shouldMatchRender """ ░ a ░ b ░❯ c """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░ a ░ b ░❯ c """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░ a ░❯ b ░ c """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░❯ a ░ b ░ c """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░❯ a ░ b @@ -176,8 +176,8 @@ class SelectListAnimationTest { .filterable(true) .createSingleSelectInputAnimation() - a.onEvent(slash) - a.onEvent(x) + a.receiveEvent(slash) + a.receiveEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax @@ -185,7 +185,7 @@ class SelectListAnimationTest { ░ cx """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -193,7 +193,7 @@ class SelectListAnimationTest { ░ cx """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -201,7 +201,7 @@ class SelectListAnimationTest { ░❯ cx """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -209,7 +209,7 @@ class SelectListAnimationTest { ░❯ cx """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax @@ -217,7 +217,7 @@ class SelectListAnimationTest { ░ cx """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax @@ -225,7 +225,7 @@ class SelectListAnimationTest { ░ cx """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax @@ -233,8 +233,8 @@ class SelectListAnimationTest { ░ cx """ - a.onEvent(down) - a.onEvent(enter) shouldBe InputReceiver.Status.Finished("bx") + a.receiveEvent(down) + a.receiveEvent(enter) shouldBe InputReceiver.Status.Finished("bx") } @Test @@ -243,14 +243,14 @@ class SelectListAnimationTest { val a = b.entries("a") .filterable(true) .createSingleSelectInputAnimation() - a.onEvent(slash) - a.onEvent(x) + a.receiveEvent(slash) + a.receiveEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░ """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░/ x ░ @@ -291,7 +291,7 @@ class SelectListAnimationTest { ░ cx ░ """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░ ax ░ ░❯ b ░ @@ -299,7 +299,7 @@ class SelectListAnimationTest { ░ cx ░ """ - a.onEvent(down) + a.receiveEvent(down) rec.latestOutput() shouldMatchRender """ ░ ax ░ ░ b ░ @@ -307,8 +307,8 @@ class SelectListAnimationTest { ░ cdesc░ """ - a.onEvent(slash) - a.onEvent(x) + a.receiveEvent(slash) + a.receiveEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░ ax ░ @@ -316,7 +316,7 @@ class SelectListAnimationTest { ░ cdesc░ """ - a.onEvent(up) + a.receiveEvent(up) rec.latestOutput() shouldMatchRender """ ░/ x ░❯ ax ░ @@ -338,42 +338,42 @@ class SelectListAnimationTest { ░ • cx """ - a.onEvent(x) + a.receiveEvent(x) rec.latestOutput() shouldMatchRender """ ░❯ ✓ ax ░ • b ░ • cx """ - a.onEvent(slash) - a.onEvent(x) + a.receiveEvent(slash) + a.receiveEvent(x) rec.latestOutput() shouldMatchRender """ ░/ x ░ ✓ ax ░ • cx """ - a.onEvent(enter) + a.receiveEvent(enter) rec.latestOutput() shouldMatchRender """ ░❯ ✓ ax ░ • cx """ - a.onEvent(down) - a.onEvent(x) + a.receiveEvent(down) + a.receiveEvent(x) rec.latestOutput() shouldMatchRender """ ░ ✓ ax ░❯ ✓ cx """ - a.onEvent(esc) + a.receiveEvent(esc) rec.latestOutput() shouldMatchRender """ ░ ✓ ax ░ • b ░❯ ✓ cx """ - a.onEvent(enter) shouldBe InputReceiver.Status.Finished(listOf("ax", "cx")) + a.receiveEvent(enter) shouldBe InputReceiver.Status.Finished(listOf("ax", "cx")) } } diff --git a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt index 07b02c5a2..60603bac7 100644 --- a/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt +++ b/mordant/src/jsCommonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jsCommon.kt @@ -18,7 +18,7 @@ internal interface SyscallHandlerJsCommon: SyscallHandler { 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? { + override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent { throw UnsupportedOperationException("Reading keyboard is not supported on this platform") } override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable { diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt index 19cea4af2..688a6df50 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt @@ -5,10 +5,10 @@ import kotlin.time.Duration internal abstract class SyscallHandlerJvmPosix : SyscallHandlerPosix() { override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { - while (t0.elapsedNow() < timeout) { + do { val c = System.`in`.read().takeIf { it >= 0 }?.toChar() if (c != null) return c - } + } while (t0.elapsedNow() < timeout) return null } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt index 5c29f4e00..bfb189aa5 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -9,14 +9,14 @@ import com.github.ajalt.mordant.terminal.Terminal * @return the result of the completed receiver, or `null` if the terminal is not interactive or the * input could not be read. */ -fun InputReceiver.receiveInput( +fun InputReceiver.receiveEvents( terminal: Terminal, mouseTracking: MouseTracking = MouseTracking.Off, ): T? { terminal.enterRawMode(mouseTracking)?.use { rawMode -> while (true) { val event = rawMode.readEvent() ?: return null - when (val status = onEvent(event)) { + when (val status = receiveEvent(event)) { is InputReceiver.Status.Continue -> continue is InputReceiver.Status.Finished -> return status.result } @@ -79,8 +79,8 @@ inline fun Terminal.receiveEvents( crossinline block: (InputEvent) -> InputReceiver.Status, ): T? { return object : InputReceiver { - override fun onEvent(event: InputEvent): InputReceiver.Status { + override fun receiveEvent(event: InputEvent): InputReceiver.Status { return block(event) } - }.receiveInput(this, mouseTracking) + }.receiveEvents(this, mouseTracking) } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index c230232ea..504b6dbcf 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -15,7 +15,7 @@ inline fun Terminal.interactiveSelectList( return InteractiveSelectListBuilder(this) .apply(block) .createSingleSelectInputAnimation() - .receiveInput(this) + .receiveEvents(this) } /** @@ -65,7 +65,7 @@ inline fun Terminal.interactiveMultiSelectList( return InteractiveSelectListBuilder(this) .apply(block) .createMultiSelectInputAnimation() - .receiveInput(this) + .receiveEvents(this) } /** diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt index 834cf5c51..4e05a0515 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -1,4 +1,3 @@ - package com.github.ajalt.mordant.internal.syscalls import com.github.ajalt.mordant.internal.Size @@ -7,7 +6,7 @@ import platform.posix.* import kotlin.time.ComparableTimeMark import kotlin.time.Duration -internal object SyscallHandlerNativePosix: SyscallHandlerPosix() { +internal object SyscallHandlerNativePosix : SyscallHandlerPosix() { override fun isatty(fd: Int): Boolean { return platform.posix.isatty(fd) != 0 } @@ -50,12 +49,12 @@ internal object SyscallHandlerNativePosix: SyscallHandlerPosix() { } override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? = memScoped { - while (t0.elapsedNow() < timeout) { + do { val c = alloc() val read = read(platform.posix.STDIN_FILENO, c.ptr, 1u) if (read < 0) return null if (read > 0) return c.value.toInt().toChar() - } + } while (t0.elapsedNow() < timeout) return null } } From 387df0a584392a08096c2bbedf649d8fe57ffadf Mon Sep 17 00:00:00 2001 From: AJ Date: Fri, 14 Jun 2024 17:55:44 -0700 Subject: [PATCH 33/45] Add raw mode docs --- docs/img/select_list.gif | Bin 0 -> 48855 bytes docs/input.md | 251 ++++++++++++++++++ mkdocs.yml | 1 + .../ajalt/mordant/input/InputReceiver.kt | 3 + .../mordant/input/SelectListAnimation.kt | 3 +- .../mordant/input/InputReceiverRunning.kt | 4 +- .../mordant/input/InteractiveSelectList.kt | 4 +- 7 files changed, 261 insertions(+), 5 deletions(-) create mode 100644 docs/img/select_list.gif create mode 100644 docs/input.md diff --git a/docs/img/select_list.gif b/docs/img/select_list.gif new file mode 100644 index 0000000000000000000000000000000000000000..8f48e01754e5613e4b6ae1fc991865aa32b839de GIT binary patch literal 48855 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 AF8}}l literal 0 HcmV?d00001 diff --git a/docs/input.md b/docs/input.md new file mode 100644 index 000000000..a62df0e3b --- /dev/null +++ b/docs/input.md @@ -0,0 +1,251 @@ +# Reading Keyboard and Mouse Input With Raw Mode + +Normally when reading input from the user with functions like [readLineOrNull], the terminal +will wait for the user to press enter before sending the input. But if you want to read keys as +soon as they are pressed, you can use "raw mode", which disables line buffering and echo. Once raw +mode is active, you can read key and mouse events. + +## Reading Events + +Mordant provides a few ways to read input events, depending on how much control you need. + +!!! warning inline end + + Enabling raw mode disables control character processing, which means that you will need to handle + events like `ctrl-c` manually if you want your users to be able to exit your program. + +### Reading Events with lambdas + +The simplest method is to use one of [receiveEvents], [receiveKeyEvents], or [receiveMouseEvents], +depending on which type of events you want to read. These functions will handle setting up raw mode +and restoring the terminal to its original state when they are done. + +=== "Example of receiveKeyEvents" + ```kotlin + terminal.receiveKeyEvents { event -> + when { + event.isCtrlC -> InputReceiver.Status.Finished + else -> { + terminal.info("You pressed ${event.key}") + InputReceiver.Status.Continue + } + } + } + ``` + +=== "Example of receiveMouseEvents" + ```kotlin + terminal.receiveMouseEvents { event -> + when { + event.right -> InputReceiver.Status.Finished + else -> { + if (event.left) terminal.info("You clicked at ${event.x}, ${event.y}") + InputReceiver.Status.Continue + } + } + } + ``` + +=== "Example of receiveEvents" + ```kotlin + terminal.receiveEvents { event -> + when(event) { + is KeyboardEvent -> when { + event.isCtrlC -> InputReceiver.Status.Finished + else -> { + terminal.info("You pressed ${event.key}") + InputReceiver.Status.Continue + } + } + is MouseEvent -> { + if (event.left) terminal.info("You clicked at ${event.x}, ${event.y}") + InputReceiver.Status.Continue + } + } + } + ``` + +See the API docs on [KeyboardEvent] and [MouseEvent] for more details on the properties of these +events. + +!!! tip + + For mouse events, only button presses are reported. If you want mouse movement or drag events, + you can pass one of the [MouseTracking] values to [receiveMouseEvents] and [receiveEvents]. + +### Reading Events with a class + +If you have a class that you want to use to handle input events, you can use implement +[InputReceiver] and call [InputReceiver.receiveEvents]. + +```kotlin +class MyReceiver : InputReceiver { + override fun receiveEvent(event: InputEvent): InputReceiver.Status { + if (event is KeyboardEvent) { + if (event.isCtrlC) { + return InputReceiver.Status.Finished + } else { + terminal.info("You pressed ${event.key}") + } + } + return InputReceiver.Status.Continue + } +} +MyReceiver().receiveEvents(terminal) +``` + +### Reading Events Manually + +If you need maximum control, you can enter raw mode manually with [enterRawMode] and read events one +at a time with [readKey], [readMouse], or [readEvent]. The object returned by `enterRawMode` will +restore the terminal to its original state when closed. + +```kotlin +terminal.enterRawMode()?.use { rawMode -> + while (true) { + val event = rawMode.readKey() + if (event == null || event.isCtrlC) break + terminal.info("You pressed: ${event.isCtrlC}") + } +} +``` + +## Raw Mode Details + +The exact behavior of which keys and mouse events are reported is highly dependent on the terminal +app and operating system. Some things to keep in mind: + +- Many special keys and modifier key combinations are not reported, especially on operating systems + other than Windows. +- Some key combinations aren't reported because they're intercepted by the terminal app to perform + actions like switching tabs or closing the window. +- 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. + +## Interactive List Selection + +Mordant includes a SelectList widget that you can use to create a list of items that the user can +select from with the arrow keys and enter. + +![](img/select_list.gif) + +### Selecting a single item + +If you want to select one item from a list, you can use the [interactiveSelectList] function or +the [InteractiveSelectListBuilder] class. + +=== "Example with interactiveSelectList" + ```kotlin + val selection = terminal.interactiveSelectList( + listOf("Small", "Medium", "Large", "X-Large"), + title = "Select a Pizza Size", + ) + if (selection == null) { + terminal.danger("Aborted pizza order") + } else { + terminal.success("You ordered a $selection pizza") + } + ``` + +=== "Example with interactiveSelectList DSL" + ```kotlin + val selection = terminal.interactiveSelectList { + addEntry("Small") + addEntry("Medium") + addEntry("Large") + title("Select Pizza Size") + } + if (selection == null) { + terminal.danger("Aborted pizza order") + } else { + terminal.success("You ordered a $selection pizza") + } + ``` + +=== "Example with InteractiveSelectListBuilder" + ```kotlin + val selection = InteractiveSelectListBuilder(terminal) + .entries("Small", "Medium", "Large") + .title("Select Pizza Size") + .createSingleSelectInputAnimation() + .receiveEvents() + if (selection == null) { + terminal.danger("Aborted pizza order") + } else { + terminal.success("You ordered a $selection pizza") + } + ``` + +### Selecting multiple items + +If you want to select multiple items from a list, you can use the [interactiveMultiSelectList] +function. + +=== "Example with interactiveMultiSelectList" + ```kotlin + val selection = terminal.interactiveMultiSelectList( + listOf("Pepperoni", "Sausage", "Mushrooms", "Olives"), + title = "Select Toppings", + ) + if (selection.isEmpty()) { + terminal.danger("Aborted pizza order") + } else { + terminal.success("You ordered a pizza with ${selection.joinToString()}") + } + ``` + +=== "Example with interactiveMultiSelectList DSL" + ```kotlin + val selection = terminal.interactiveMultiSelectList { + addEntry("Pepperoni", selected=true) + addEntry("Sausage", selected=true) + addEntry("Mushrooms") + addEntry("Olives") + title("Select Toppings") + limit(2) + filterable(true) + } + if (selection == null) { + terminal.danger("Aborted pizza order") + } else { + terminal.success("You ordered a pizza with ${selection.joinToString()}") + } + ``` + +=== "Example with InteractiveSelectListBuilder" + ```kotlin + val selection = InteractiveSelectListBuilder(terminal) + .entries("Pepperoni", "Sausage", "Mushrooms", "Olives") + .title("Select Toppings") + .limit(2) + .filterable(true) + .createMultiSelectInputAnimation() + .receiveEvents() + if (selection == null) { + terminal.danger("Aborted pizza order") + } else { + terminal.success("You ordered a pizza with ${selection.joinToString()}") + } + ``` + +[InputEvent]: api/mordant/com.github.ajalt.mordant.input/-input-event/index.html +[InputReceiver.receiveEvents]: api/mordant/com.github.ajalt.mordant.input +[InputReceiver]: api/mordant/com.github.ajalt.mordant.input/-input-receiver/index.html +[KeyboardEvent]: api/mordant/com.github.ajalt.mordant.input/-keyboard-event/index.html +[MouseEvent]: api/mordant/com.github.ajalt.mordant.input/-mouse-event/index.html +[MouseTracking]: api/mordant/com.github.ajalt.mordant.input/-mouse-tracking/index.html +[enterRawMode]: api/mordant/com.github.ajalt.mordant.input/enter-raw-mode.html +[readEvent]: api/mordant/com.github.ajalt.mordant.input/-raw-mode-scope/read-event.html +[readKey]: api/mordant/com.github.ajalt.mordant.input/-raw-mode-scope/read-key.html +[readLineOrNull]: api/mordant/com.github.ajalt.mordant.terminal/-terminal/read-line-or-null.html +[readMouse]: api/mordant/com.github.ajalt.mordant.input/-raw-mode-scope/read-mouse.html +[receiveEvents]: api/mordant/com.github.ajalt.mordant.input/receive-events.html +[receiveKeyEvents]: api/mordant/com.github.ajalt.mordant.input/receive-key-events.html +[receiveMouseEvents]: api/mordant/com.github.ajalt.mordant.input/receive-mouse-events.html +[InteractiveSelectListBuilder]: api/mordant/com.github.ajalt.mordant.input/-interactive-select-list-builder/index.html +[interactiveSelectList]: api/mordant/com.github.ajalt.mordant.input/interactive-select-list.html +[interactiveMultiSelectList]: api/mordant/com.github.ajalt.mordant.input/interactive-multi-select-list.html diff --git a/mkdocs.yml b/mkdocs.yml index 43152e2a6..39e309626 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -56,5 +56,6 @@ markdown_extensions: nav: - 'Getting Started': guide.md - 'Progress Bars': progress.md + - 'Keyboard and Mouse Input': input.md - 'API Reference': api/index.html - 'Releases': changelog.md diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index d915b4713..6ba5631bb 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -1,5 +1,7 @@ package com.github.ajalt.mordant.input +import com.github.ajalt.mordant.terminal.Terminal + /** * An object that can receive input events. */ @@ -11,6 +13,7 @@ interface InputReceiver { data object Continue : Status() data class Finished(val result: T) : Status() } + val terminal: Terminal fun receiveEvent(event: InputEvent): Status = Status.Continue fun cancel() {} } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt index 7dac6b4ff..93c378a15 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt @@ -289,7 +289,7 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { } private open class SelectInputAnimation( - private val terminal: Terminal, + final override val terminal: Terminal, private val config: SelectConfig, private val singleSelect: Boolean, ) : InputReceiver?> { @@ -476,6 +476,7 @@ private open class SelectInputAnimation( private class SingleSelectInputAnimation( private val animation: SelectInputAnimation, ) : InputReceiver { + override val terminal: Terminal get() = animation.terminal override fun cancel() = animation.cancel() override fun receiveEvent(event: InputEvent): Status { return when (val status = animation.receiveEvent(event)) { diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt index bfb189aa5..1dbe44d0b 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -10,7 +10,6 @@ import com.github.ajalt.mordant.terminal.Terminal * input could not be read. */ fun InputReceiver.receiveEvents( - terminal: Terminal, mouseTracking: MouseTracking = MouseTracking.Off, ): T? { terminal.enterRawMode(mouseTracking)?.use { rawMode -> @@ -79,8 +78,9 @@ inline fun Terminal.receiveEvents( crossinline block: (InputEvent) -> InputReceiver.Status, ): T? { return object : InputReceiver { + override val terminal: Terminal get() = this@receiveEvents override fun receiveEvent(event: InputEvent): InputReceiver.Status { return block(event) } - }.receiveEvents(this, mouseTracking) + }.receiveEvents(mouseTracking) } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt index 504b6dbcf..c87349f3d 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InteractiveSelectList.kt @@ -15,7 +15,7 @@ inline fun Terminal.interactiveSelectList( return InteractiveSelectListBuilder(this) .apply(block) .createSingleSelectInputAnimation() - .receiveEvents(this) + .receiveEvents() } /** @@ -65,7 +65,7 @@ inline fun Terminal.interactiveMultiSelectList( return InteractiveSelectListBuilder(this) .apply(block) .createMultiSelectInputAnimation() - .receiveEvents(this) + .receiveEvents() } /** From 834fd6e45cbdac4ae57da810edcc2af135d04fd5 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 09:37:07 -0700 Subject: [PATCH 34/45] Add StoppableAnimation interface --- .../ajalt/mordant/animation/Animation.kt | 6 +++--- .../mordant/animation/RefreshableAnimation.kt | 6 +++--- .../mordant/animation/StoppableAnimation.kt | 16 ++++++++++++++ .../ajalt/mordant/input/InputReceiver.kt | 21 +++++++++++++++++-- .../mordant/input/SelectListAnimation.kt | 17 +++++++-------- .../mordant/input/InputReceiverRunning.kt | 5 +++-- 6 files changed, 52 insertions(+), 19 deletions(-) create mode 100644 mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/StoppableAnimation.kt diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt index d998efbe3..622858673 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/Animation.kt @@ -31,7 +31,7 @@ abstract class Animation( @Deprecated("This parameter is ignored; animations never print a trailing linebreak.") private val trailingLinebreak: Boolean = true, val terminal: Terminal, -) { +): StoppableAnimation { private data class State( /** The length of each line of the last rendered widget */ val size: List? = null, @@ -88,7 +88,7 @@ abstract class Animation( * * Future calls to [update] will cause the animation to resume. */ - fun clear() { + final override fun clear() { val (old, _) = doStop(clearSize = true, newline = false) getCursorMoves( firstDraw = false, @@ -110,7 +110,7 @@ abstract class Animation( * * Future calls to [update] will cause the animation to start again. */ - fun stop() { + final override fun stop() { doStop(clearSize = false, newline = true) } diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt index 1d614d760..bf1124a81 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/RefreshableAnimation.kt @@ -25,14 +25,14 @@ interface Refreshable { * * Implementations will need to handle concurrently updating their state. */ -interface RefreshableAnimation : Refreshable { +interface RefreshableAnimation : Refreshable, StoppableAnimation { /** * Stop this animation and remove it from the screen. * * Future calls to [refresh] will cause the animation to resume. */ - fun clear() + override fun clear() /** * Stop this animation without removing it from the screen. @@ -42,7 +42,7 @@ interface RefreshableAnimation : Refreshable { * * Future calls to [refresh] will cause the animation to start again. */ - fun stop() + override fun stop() /** * The rate, in Hz, that this animation should be refreshed, or 0 if it should not be refreshed diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/StoppableAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/StoppableAnimation.kt new file mode 100644 index 000000000..5bf2a9285 --- /dev/null +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/animation/StoppableAnimation.kt @@ -0,0 +1,16 @@ +package com.github.ajalt.mordant.animation + +interface StoppableAnimation { + /** + * Stop this animation without removing it from the screen. + * + * Anything printed to the terminal after this call will be printed below this last frame of + * this animation. + */ + fun stop() + + /** + * Stop this animation and remove it from the screen. + */ + fun clear() +} diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt index 6ba5631bb..2d7ed4208 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/InputReceiver.kt @@ -1,5 +1,6 @@ package com.github.ajalt.mordant.input +import com.github.ajalt.mordant.animation.StoppableAnimation import com.github.ajalt.mordant.terminal.Terminal /** @@ -10,10 +11,26 @@ interface InputReceiver { companion object { val Finished = Finished(Unit) } + data object Continue : Status() data class Finished(val result: T) : Status() } + + /** + * The terminal that this receiver is reading input from. + */ val terminal: Terminal - fun receiveEvent(event: InputEvent): Status = Status.Continue - fun cancel() {} + + /** + * Receive an input event. + * + * @param event The input event to process + * @return [Status.Continue] to continue receiving events, or [Status.Finished] to stop. + */ + fun receiveEvent(event: InputEvent): Status } + +/** + * An [InputReceiver] that is also an [StoppableAnimation]. + */ +interface InputReceiverAnimation : InputReceiver, StoppableAnimation diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt index 93c378a15..cf1c28fad 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/input/SelectListAnimation.kt @@ -271,7 +271,7 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { * The [result][InputReceiver.Status.Finished.result] will be the selected item title, or `null` * if the user canceled the selection. */ - fun createSingleSelectInputAnimation(): InputReceiver { + fun createSingleSelectInputAnimation(): InputReceiverAnimation { require(config.entries.isNotEmpty()) { "Select list must have at least one entry" } return SingleSelectInputAnimation(SelectInputAnimation(terminal, config, true)) } @@ -282,7 +282,7 @@ class InteractiveSelectListBuilder(private val terminal: Terminal) { * The [result][InputReceiver.Status.Finished.result] will be the selected item titles, or * `null` if the user canceled the selection. */ - fun createMultiSelectInputAnimation(): InputReceiver?> { + fun createMultiSelectInputAnimation(): InputReceiverAnimation?> { require(config.entries.isNotEmpty()) { "Select list must have at least one entry" } return SelectInputAnimation(terminal, config, false) } @@ -292,7 +292,7 @@ private open class SelectInputAnimation( final override val terminal: Terminal, private val config: SelectConfig, private val singleSelect: Boolean, -) : InputReceiver?> { +) : InputReceiverAnimation?> { private data class State( val items: List, val cursor: Int, @@ -345,10 +345,8 @@ private open class SelectInputAnimation( } }.apply { update(state.value) } - override fun cancel() { - if (config.clearOnExit) animation.clear() - else animation.stop() - } + override fun stop() = animation.stop() + override fun clear() = animation.clear() override fun receiveEvent(event: InputEvent): Status?> { if (event !is KeyboardEvent) return Status.Continue @@ -475,9 +473,10 @@ private open class SelectInputAnimation( private class SingleSelectInputAnimation( private val animation: SelectInputAnimation, -) : InputReceiver { +) : InputReceiverAnimation { override val terminal: Terminal get() = animation.terminal - override fun cancel() = animation.cancel() + override fun stop() = animation.stop() + override fun clear() = animation.clear() override fun receiveEvent(event: InputEvent): Status { return when (val status = animation.receiveEvent(event)) { is Status.Finished -> Status.Finished(status.result?.firstOrNull()) diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt index 1dbe44d0b..fdd28f417 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt @@ -3,14 +3,15 @@ package com.github.ajalt.mordant.input import com.github.ajalt.mordant.terminal.Terminal /** - * Enter raw mode, read input from the [terminal] for this [InputReceiver] until it returns a + * Enter raw mode, read input from the terminal for this [InputReceiver] until it returns a * result, then exit raw mode. * + * @param mouseTracking The type of mouse tracking to enable. * @return the result of the completed receiver, or `null` if the terminal is not interactive or the * input could not be read. */ fun InputReceiver.receiveEvents( - mouseTracking: MouseTracking = MouseTracking.Off, + mouseTracking: MouseTracking = MouseTracking.Normal, ): T? { terminal.enterRawMode(mouseTracking)?.use { rawMode -> while (true) { From 68d3433201ba291ea62f5b49b92ec5c39b479bc7 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 10:28:02 -0700 Subject: [PATCH 35/45] Throw exceptions when raw mode fails --- .../internal/syscalls/SyscallHandler.kt | 11 ++- .../mordant/input/SelectListAnimationTest.kt | 4 +- .../syscalls/SyscallHandler.jvm.posix.kt | 8 +- .../syscalls/jna/SyscallHandler.jna.linux.kt | 8 +- .../syscalls/jna/SyscallHandler.jna.macos.kt | 8 +- .../jna/SyscallHandler.jna.windows.kt | 55 ++++------- .../SyscallHandler.nativeimage.posix.kt | 90 +++++++++--------- .../SyscallHandler.nativeimage.windows.kt | 23 +++-- .../syscalls/SyscallHandler.native.windows.kt | 22 +++-- .../com/github/ajalt/mordant/input/RawMode.kt | 92 +++++++++++++------ ...putReceiverRunning.kt => ReceiveEvents.kt} | 29 +++--- .../internal/syscalls/SyscallHandler.posix.kt | 62 +++++++------ .../syscalls/SyscallHandler.windows.kt | 13 ++- .../syscalls/SyscallHanlder.native.posix.kt | 12 ++- 14 files changed, 229 insertions(+), 208 deletions(-) rename mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/{InputReceiverRunning.kt => ReceiveEvents.kt} (76%) diff --git a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt index 47e1777a1..97d40eedf 100644 --- a/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt +++ b/mordant/src/commonMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.kt @@ -7,8 +7,7 @@ import kotlin.time.Duration internal sealed class SysInputEvent { data class Success(val event: InputEvent) : SysInputEvent() - data object Fail : SysInputEvent() - data object Retry: SysInputEvent() + data object Retry : SysInputEvent() } internal interface SyscallHandler { @@ -18,7 +17,7 @@ internal interface SyscallHandler { fun getTerminalSize(): Size? fun fastIsTty(): Boolean = true fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent - fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? + fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable } internal object DumbSyscallHandler : SyscallHandler { @@ -26,8 +25,10 @@ internal object DumbSyscallHandler : SyscallHandler { override fun stdinInteractive(): Boolean = false override fun stderrInteractive(): Boolean = false override fun getTerminalSize(): Size? = null - override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? = null + override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable { + throw UnsupportedOperationException("Cannot enter raw mode on this system") + } override fun readInputEvent(timeout: Duration, mouseTracking: MouseTracking): SysInputEvent { - return SysInputEvent.Fail + throw UnsupportedOperationException("Cannot read input on this system") } } diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt index 03424b257..79c8c6dbe 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/input/SelectListAnimationTest.kt @@ -56,7 +56,7 @@ class SelectListAnimationTest { ░↑ up • ↓ down • / filter • enter select """ - a.cancel() + a.clear() b.filterable(false) .createSingleSelectInputAnimation() rec.latestOutput() shouldMatchRender """ @@ -109,7 +109,7 @@ class SelectListAnimationTest { ░x toggle • ↑ up • ↓ down • / filter • esc clear filter • enter confirm """ - a.cancel() + a.clear() b.filterable(false) .createMultiSelectInputAnimation() rec.latestOutput() shouldMatchRender """ diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt index 688a6df50..0f4bf9dbf 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.jvm.posix.kt @@ -4,11 +4,11 @@ import kotlin.time.ComparableTimeMark import kotlin.time.Duration internal abstract class SyscallHandlerJvmPosix : SyscallHandlerPosix() { - override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? { + override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char { do { - val c = System.`in`.read().takeIf { it >= 0 }?.toChar() - if (c != null) return c + val c = System.`in`.read() + if (c >= 0) return c.toChar() } while (t0.elapsedNow() < timeout) - return null + throw RuntimeException("Timeout reading from stdin (timeout=$timeout)") } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt index 5254aab40..f531f66d2 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt @@ -81,13 +81,9 @@ internal object SyscallHandlerJnaLinux : SyscallHandlerJvmPosix() { } } - override fun getStdinTermios(): Termios? { + override fun getStdinTermios(): Termios { val termios = PosixLibC.termios() - try { - libC.tcgetattr(STDIN_FILENO, termios) - } catch (e: LastErrorException) { - return null - } + libC.tcgetattr(STDIN_FILENO, termios) return Termios( iflag = termios.c_iflag.toUInt(), oflag = termios.c_oflag.toUInt(), diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt index 84c7664ef..85a93fa82 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt @@ -92,13 +92,9 @@ internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() { return getSttySize(100) } - override fun getStdinTermios(): Termios? { + override fun getStdinTermios(): Termios { val termios = MacosLibC.termios() - try { - libC.tcgetattr(STDIN_FILENO, termios) - } catch (e: LastErrorException) { - return null - } + libC.tcgetattr(STDIN_FILENO, termios) return Termios( iflag = termios.c_iflag.toInt().toUInt(), oflag = termios.c_oflag.toInt().toUInt(), diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt index 717da220a..760c623f4 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.windows.kt @@ -245,32 +245,18 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { private val stdoutHandle = kernel.GetStdHandle(WinKernel32Lib.STD_OUTPUT_HANDLE) private val stdinHandle = kernel.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE) private val stderrHandle = kernel.GetStdHandle(WinKernel32Lib.STD_ERROR_HANDLE) - override fun stdoutInteractive(): Boolean { + private fun handleInteractive(handle: WinKernel32Lib.HANDLE): Boolean { return try { - kernel.GetConsoleMode(stdoutHandle, IntByReference()) + kernel.GetConsoleMode(handle, IntByReference()) true } catch (e: LastErrorException) { false } } - override fun stdinInteractive(): Boolean { - return try { - kernel.GetConsoleMode(stdinHandle, IntByReference()) - true - } catch (e: LastErrorException) { - false - } - } - - override fun stderrInteractive(): Boolean { - return try { - kernel.GetConsoleMode(stderrHandle, IntByReference()) - true - } catch (e: LastErrorException) { - false - } - } + override fun stdoutInteractive(): Boolean = handleInteractive(stdoutHandle) + override fun stdinInteractive(): Boolean = handleInteractive(stdinHandle) + override fun stderrInteractive(): Boolean = handleInteractive(stderrHandle) override fun getTerminalSize(): Size? { val csbi = WinKernel32Lib.CONSOLE_SCREEN_BUFFER_INFO() @@ -281,33 +267,26 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { } - override fun getStdinConsoleMode(): UInt? { + override fun getStdinConsoleMode(): UInt { val originalMode = IntByReference() - try { - kernel.GetConsoleMode(stdinHandle, originalMode) - } catch (e: LastErrorException) { - return null - } + kernel.GetConsoleMode(stdinHandle, originalMode) return originalMode.value.toUInt() } - override fun setStdinConsoleMode(dwMode: UInt): Boolean { - try { - kernel.SetConsoleMode(stdinHandle, dwMode.toInt()) - return true - } catch (e: LastErrorException) { - throw e - return false - } + override fun setStdinConsoleMode(dwMode: UInt) { + kernel.SetConsoleMode(stdinHandle, dwMode.toInt()) } - override fun readRawEvent(dwMilliseconds: Int): EventRecord? { + override fun readRawEvent(dwMilliseconds: Int): EventRecord { val waitResult = kernel.WaitForSingleObject(stdinHandle.pointer, dwMilliseconds) - if (waitResult != 0) return null + if (waitResult != 0) { + throw RuntimeException("Timeout reading from console input") + } val inputEvents = arrayOfNulls(1) val eventsRead = IntByReference() kernel.ReadConsoleInput(stdinHandle, inputEvents, inputEvents.size, eventsRead) - val inputEvent = inputEvents[0] ?: return null + val inputEvent = inputEvents[0] + ?: throw RuntimeException("Error reading from console input") return when (inputEvent.EventType) { WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> { val keyEvent = inputEvent.Event!!.KeyEvent!! @@ -330,7 +309,9 @@ internal object SyscallHandlerJnaWindows : SyscallHandlerWindows() { ) } - else -> null + else -> throw RuntimeException( + "Error reading from console input: unexpected event type ${inputEvent.EventType}" + ) } } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt index 9dd9ba4a8..5bca46268 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt @@ -9,9 +9,7 @@ import org.graalvm.nativeimage.c.CContext import org.graalvm.nativeimage.c.constant.CConstant import org.graalvm.nativeimage.c.function.CFunction import org.graalvm.nativeimage.c.struct.CField -import org.graalvm.nativeimage.c.struct.CFieldAddress import org.graalvm.nativeimage.c.struct.CStruct -import org.graalvm.nativeimage.c.type.CCharPointer import org.graalvm.word.PointerBase @CContext(PosixLibC.Directives::class) @@ -26,11 +24,11 @@ private object PosixLibC { @CConstant("TIOCGWINSZ") external fun TIOCGWINSZ(): Int - @CConstant("TCSADRAIN") - external fun TCSADRAIN(): Int - - @CConstant("NCCS") - external fun NCCS(): Int +// @CConstant("TCSADRAIN") +// external fun TCSADRAIN(): Int +// +// @CConstant("NCCS") +// external fun NCCS(): Int @CStruct("winsize", addStructKeyword = true) interface winsize : PointerBase { @@ -42,39 +40,39 @@ private object PosixLibC { val ws_col: Short } - @CStruct("termios", addStructKeyword = true) - interface termios : PointerBase { - @get:CField("c_iflag") - @set:CField("c_iflag") - var c_iflag: Int - - @get:CField("c_oflag") - @set:CField("c_oflag") - var c_oflag: Int - - @get:CField("c_cflag") - @set:CField("c_cflag") - var c_cflag: Int - - @get:CField("c_lflag") - @set:CField("c_lflag") - var c_lflag: Int - - @get:CField("c_line") - @set:CField("c_line") - var c_line: Byte - - @get:CFieldAddress("c_cc") - val c_cc: CCharPointer - - @get:CField("c_ispeed") - @set:CField("c_ispeed") - var c_ispeed: Int - - @get:CField("c_ospeed") - @set:CField("c_ospeed") - var c_ospeed: Int - } +// @CStruct("termios", addStructKeyword = true) +// interface termios : PointerBase { +// @get:CField("c_iflag") +// @set:CField("c_iflag") +// var c_iflag: Int +// +// @get:CField("c_oflag") +// @set:CField("c_oflag") +// var c_oflag: Int +// +// @get:CField("c_cflag") +// @set:CField("c_cflag") +// var c_cflag: Int +// +// @get:CField("c_lflag") +// @set:CField("c_lflag") +// var c_lflag: Int +// +// @get:CField("c_line") +// @set:CField("c_line") +// var c_line: Byte +// +// @get:CFieldAddress("c_cc") +// val c_cc: CCharPointer +// +// @get:CField("c_ispeed") +// @set:CField("c_ispeed") +// var c_ispeed: Int +// +// @get:CField("c_ospeed") +// @set:CField("c_ospeed") +// var c_ospeed: Int +// } @CFunction("isatty") external fun isatty(fd: Int): Boolean @@ -82,11 +80,11 @@ private object PosixLibC { @CFunction("ioctl") external fun ioctl(fd: Int, cmd: Int, winSize: winsize?): Int - @CFunction("tcgetattr") - external fun tcgetattr(fd: Int, termios: termios): Int - - @CFunction("tcsetattr") - external fun tcsetattr(fd: Int, cmd: Int, termios: termios) +// @CFunction("tcgetattr") +// external fun tcgetattr(fd: Int, termios: termios): Int +// +// @CFunction("tcsetattr") +// external fun tcsetattr(fd: Int, cmd: Int, termios: termios) } @Platforms(Platform.LINUX::class, Platform.MACOS::class) @@ -102,7 +100,7 @@ internal class SyscallHandlerNativeImagePosix : SyscallHandlerJvmPosix() { } } - override fun getStdinTermios(): Termios? { + override fun getStdinTermios(): Termios { throw NotImplementedError( "Raw mode is not currently supported for native-image. If you are familiar with " + "GraalVM native-image and would like to contribute, see the commented out " + diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index 140163acb..763cf18ff 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -1,6 +1,5 @@ package com.github.ajalt.mordant.internal.syscalls.nativeimage -import com.github.ajalt.mordant.input.MouseTracking import com.github.ajalt.mordant.internal.Size import com.github.ajalt.mordant.internal.syscalls.SyscallHandlerWindows import org.graalvm.nativeimage.Platform @@ -190,29 +189,33 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { } } - override fun getStdinConsoleMode(): UInt? { + override fun getStdinConsoleMode(): UInt { val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) val lpMode = StackValue.get(CIntPointer::class.java) - if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) return null + if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) { + throw RuntimeException("Error reading console mode") + } return lpMode.read().toUInt() } - override fun setStdinConsoleMode(dwMode: UInt): Boolean { + override fun setStdinConsoleMode(dwMode: UInt) { val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) - return WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT()) + if (!WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT())) { + throw RuntimeException("Error setting console mode") + } } - override fun readRawEvent(dwMilliseconds: Int): EventRecord? { + override fun readRawEvent(dwMilliseconds: Int): EventRecord { val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds) if (waitResult != 0) { - return null + throw RuntimeException("Error reading from console input: waitResult=$waitResult") } val inputEvents = StackValue.get(WinKernel32Lib.INPUT_RECORD::class.java) val eventsRead = StackValue.get(CIntPointer::class.java) WinKernel32Lib.ReadConsoleInput(stdinHandle, inputEvents, 1, eventsRead) if (eventsRead.read() == 0) { - return null + throw RuntimeException("Error reading from console input") } return when (inputEvents.EventType) { WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> { @@ -236,7 +239,9 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { ) } - else -> null + else -> throw RuntimeException( + "Error reading from console input: unexpected event type ${inputEvents.EventType}" + ) } } } diff --git a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt index 1c2dec9b0..4f86ad41b 100644 --- a/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt +++ b/mordant/src/mingwMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.native.windows.kt @@ -32,15 +32,17 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { csbi.srWindow.run { Size(width = Right - Left + 1, height = Bottom - Top + 1) } } - override fun readRawEvent(dwMilliseconds: Int): EventRecord? = memScoped { + override fun readRawEvent(dwMilliseconds: Int): EventRecord = memScoped { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) val waitResult = WaitForSingleObject(stdinHandle, dwMilliseconds.toUInt()) - if (waitResult != 0u) return null + if (waitResult != 0u) { + throw RuntimeException("Timeout reading from console input") + } val inputEvents = allocArray(1) val eventsRead = alloc() ReadConsoleInput!!(stdinHandle, inputEvents, 1u, eventsRead.ptr) if (eventsRead.value == 0u) { - return null + throw RuntimeException("Error reading from console input") } val inputEvent = inputEvents[0] return when (inputEvent.EventType.toInt()) { @@ -65,18 +67,22 @@ internal object SyscallHandlerNativeWindows : SyscallHandlerWindows() { ) } - else -> null + else -> throw RuntimeException( + "Error reading from console input: unexpected event type ${inputEvent.EventType}" + ) } } - override fun getStdinConsoleMode(): UInt? { + override fun getStdinConsoleMode(): UInt { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - return getConsoleMode(stdinHandle) + return getConsoleMode(stdinHandle) ?: throw RuntimeException("Error getting console mode") } - override fun setStdinConsoleMode(dwMode: UInt): Boolean { + override fun setStdinConsoleMode(dwMode: UInt) { val stdinHandle = GetStdHandle(STD_INPUT_HANDLE) - return SetConsoleMode(stdinHandle, 0u) != 0 + if(SetConsoleMode(stdinHandle, 0u) == 0) { + throw RuntimeException("Error setting console mode") + } } fun ttySetEcho(echo: Boolean) = memScoped { diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt index df33fdacf..8fa04e720 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/RawMode.kt @@ -6,6 +6,21 @@ import com.github.ajalt.mordant.terminal.Terminal import kotlin.time.Duration import kotlin.time.TimeSource +/** + * Enter raw mode on the terminal, disabling line buffering and echoing to enable reading individual + * input events. + * + * @param mouseTracking What type of mouse events to listen for. + * @return A scope that will restore the terminal to its previous state when closed + * @throws RuntimeException if the terminal is not interactive or raw mode cannot be entered. + */ +fun Terminal.enterRawMode(mouseTracking: MouseTracking = MouseTracking.Off): RawModeScope { + if (!info.inputInteractive) { + throw IllegalStateException("Cannot enter raw mode on a non-interactive terminal") + } + return RawModeScope(SYSCALL_HANDLER.enterRawMode(mouseTracking), mouseTracking) +} + /** * Enter raw mode on the terminal, disabling line buffering and echoing to enable reading individual * input events. @@ -14,9 +29,10 @@ import kotlin.time.TimeSource * @return A scope that will restore the terminal to its previous state when closed, or `null` if * the terminal is not interactive. */ -fun Terminal.enterRawMode(mouseTracking: MouseTracking = MouseTracking.Off): RawModeScope? { - if (!info.inputInteractive) return null - return SYSCALL_HANDLER.enterRawMode(mouseTracking)?.let { RawModeScope(it, mouseTracking) } +fun Terminal.enterRawModeOrNull(mouseTracking: MouseTracking = MouseTracking.Off): RawModeScope? { + return runCatching { + RawModeScope(SYSCALL_HANDLER.enterRawMode(mouseTracking), mouseTracking) + }.getOrNull() } class RawModeScope internal constructor( @@ -27,54 +43,72 @@ class RawModeScope internal constructor( * Read a single keyboard event from the terminal, ignoring any mouse events that arrive first. * * @param timeout The maximum amount of time to wait for an event. - * @return The event, or `null` if no event was received before the timeout. + * @throws RuntimeException if no event is received before the timeout, or input cannot be read. */ - fun readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent? { + fun readKey(timeout: Duration = Duration.INFINITE): KeyboardEvent { while (true) { - return when (val event = readEvent(timeout)) { - is KeyboardEvent -> event - is MouseEvent -> continue - null -> null - } + val event = readEvent(timeout) + if (event is KeyboardEvent) return event } } /** - * Read a single mouse event from the terminal, ignoring any keyboard events that arrive first. + * Read a single keyboard event from the terminal, ignoring any mouse events that arrive first. * * @param timeout The maximum amount of time to wait for an event. * @return The event, or `null` if no event was received before the timeout. */ - fun readMouse(timeout: Duration = Duration.INFINITE): MouseEvent? { + fun readKeyOrNull(timeout: Duration = Duration.INFINITE): KeyboardEvent? { + return runCatching { readKey(timeout) }.getOrNull() + } + + /** + * Read a single mouse event from the terminal, ignoring any keyboard events that arrive first. + * + * @param timeout The maximum amount of time to wait for an event. + * @throws RuntimeException if no event is received before the timeout, or input cannot be read. + */ + fun readMouse(timeout: Duration = Duration.INFINITE): MouseEvent { while (true) { - return when (val event = readEvent(timeout)) { - is MouseEvent -> event - is KeyboardEvent -> continue - null -> null - } + val event = readEvent(timeout) + if (event is MouseEvent) return event } } /** - * Read a single input event from the terminal. + * Read a single mouse event from the terminal, ignoring any keyboard events that arrive first. * * @param timeout The maximum amount of time to wait for an event. * @return The event, or `null` if no event was received before the timeout. */ - fun readEvent(timeout: Duration = Duration.INFINITE): InputEvent? { + fun readMouseOrNull(timeout: Duration = Duration.INFINITE): MouseEvent? { + return runCatching { readMouse(timeout) }.getOrNull() + } + + /** + * Read a single input event from the terminal. + * + * @param timeout The maximum amount of time to wait for an event. + * @throws RuntimeException if no event is received before the timeout, or input cannot be read. + */ + fun readEvent(timeout: Duration = Duration.INFINITE): InputEvent { val t0 = TimeSource.Monotonic.markNow() do { val event = SYSCALL_HANDLER.readInputEvent(timeout - t0.elapsedNow(), mouseTracking) - return when (event) { - is SysInputEvent.Success -> { - if (event.event is MouseEvent && mouseTracking == MouseTracking.Off) continue - event.event - } - - SysInputEvent.Fail -> null - SysInputEvent.Retry -> continue - } + if (event !is SysInputEvent.Success) continue + if (event.event is MouseEvent && mouseTracking == MouseTracking.Off) continue + return event.event } while (t0.elapsedNow() < timeout) - return null + throw RuntimeException("Timeout while waiting for input") + } + + /** + * Read a single input event from the terminal. + * + * @param timeout The maximum amount of time to wait for an event. + * @return The event, or `null` if no event was received before the timeout. + */ + fun readEventOrNull(timeout: Duration = Duration.INFINITE): InputEvent? { + return runCatching { readEvent(timeout) }.getOrNull() } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/ReceiveEvents.kt similarity index 76% rename from mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt rename to mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/ReceiveEvents.kt index fdd28f417..0703764c9 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/InputReceiverRunning.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/ReceiveEvents.kt @@ -7,34 +7,33 @@ import com.github.ajalt.mordant.terminal.Terminal * result, then exit raw mode. * * @param mouseTracking The type of mouse tracking to enable. - * @return the result of the completed receiver, or `null` if the terminal is not interactive or the - * input could not be read. + * @return the result of the completed receiver. + * @throws RuntimeException if the terminal is not interactive or the input could not be read. */ fun InputReceiver.receiveEvents( mouseTracking: MouseTracking = MouseTracking.Normal, -): T? { - terminal.enterRawMode(mouseTracking)?.use { rawMode -> +): T { + terminal.enterRawMode(mouseTracking).use { rawMode -> while (true) { - val event = rawMode.readEvent() ?: return null + val event = rawMode.readEvent() when (val status = receiveEvent(event)) { is InputReceiver.Status.Continue -> continue is InputReceiver.Status.Finished -> return status.result } } } - return null } /** * Enter raw mode, read input and pass any [KeyboardEvent]s to [block] until it returns a * result, then exit raw mode. * - * @return the result of the completed receiver, or `null` if the terminal is not interactive or the - * input could not be read. + * @return the result of the completed receiver. + * @throws RuntimeException if the terminal is not interactive or the input could not be read. */ inline fun Terminal.receiveKeyEvents( crossinline block: (KeyboardEvent) -> InputReceiver.Status, -): T? { +): T { return receiveEvents(MouseTracking.Off) { event -> when (event) { is KeyboardEvent -> block(event) @@ -48,13 +47,13 @@ inline fun Terminal.receiveKeyEvents( * result, then exit raw mode. * * @param mouseTracking The type of mouse tracking to enable. - * @return the result of the completed receiver, or `null` if the terminal is not interactive or the - * input could not be read. + * @return the result of the completed receiver. + * @throws RuntimeException if the terminal is not interactive or the input could not be read. */ inline fun Terminal.receiveMouseEvents( mouseTracking: MouseTracking = MouseTracking.Normal, crossinline block: (MouseEvent) -> InputReceiver.Status, -): T? { +): T { require(mouseTracking != MouseTracking.Off) { "Mouse tracking must be enabled to receive mouse events" } @@ -71,13 +70,13 @@ inline fun Terminal.receiveMouseEvents( * result, then exit raw mode. * * @param mouseTracking The type of mouse tracking to enable. - * @return the result of the completed receiver, or `null` if the terminal is not interactive or the - * input could not be read. + * @return the result of the completed receiver. + * @throws RuntimeException if the terminal is not interactive or the input could not be read. */ inline fun Terminal.receiveEvents( mouseTracking: MouseTracking = MouseTracking.Normal, crossinline block: (InputEvent) -> InputReceiver.Status, -): T? { +): T { return object : InputReceiver { override val terminal: Terminal get() = this@receiveEvents override fun receiveEvent(event: InputEvent): InputReceiver.Status { diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 6829361ed..9ef1634c1 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -7,7 +7,6 @@ import com.github.ajalt.mordant.internal.CSI import com.github.ajalt.mordant.internal.readBytesAsUtf8 import kotlin.time.ComparableTimeMark import kotlin.time.Duration -import kotlin.time.Duration.Companion.microseconds import kotlin.time.Duration.Companion.milliseconds import kotlin.time.TimeSource @@ -150,10 +149,10 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { val ospeed: UInt, ) - abstract fun getStdinTermios(): Termios? + abstract fun getStdinTermios(): Termios abstract fun setStdinTermios(termios: Termios) protected abstract fun isatty(fd: Int): Boolean - protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? + protected abstract fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char override fun stdoutInteractive(): Boolean = isatty(STDOUT_FILENO) override fun stdinInteractive(): Boolean = isatty(STDIN_FILENO) @@ -162,8 +161,8 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // https://www.man7.org/linux/man-pages/man3/termios.3.html // 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() ?: return null + override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable { + val orig = getStdinTermios() val new = Termios( iflag = orig.iflag and (ICRNL or IGNCR or INPCK or ISTRIP or IXON).inv(), // we leave OPOST on so we don't change \r\n handling @@ -205,26 +204,29 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { val s = StringBuilder() var ch = ' ' - fun readTimeout(): Boolean { - ch = readRawByte(t0, timeout) ?: return true + fun read() { + ch = readRawByte(t0, timeout) s.append(ch) - return false } - if (readTimeout()) return SysInputEvent.Fail + read() if (ch == ESC) { escaped = true - if (readTimeout()) return SysInputEvent.Success( - KeyboardEvent( - key = "Escape", - ctrl = false, - alt = false, - shift = false + try { + read() + } catch (e: RuntimeException) { + return SysInputEvent.Success( + KeyboardEvent( + key = "Escape", + ctrl = false, + alt = false, + shift = false + ) ) - ) + } if (ch == ESC) { - if (readTimeout()) return SysInputEvent.Fail + read() } } @@ -236,11 +238,11 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { if (ch == 'O') { // ESC O letter // ESC O modifier letter - if (readTimeout()) return SysInputEvent.Fail + read() if (ch in '0'..'9') { modifier = ch.code - 1 - if (readTimeout()) return SysInputEvent.Fail + read() } code.append(ch) @@ -251,12 +253,12 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // ESC [ [ num char // For mouse events: // ESC [ M byte byte byte - if (readTimeout()) return SysInputEvent.Fail + read() if (ch == '[') { // escape codes might have a second bracket code.append(ch) - if (readTimeout()) return SysInputEvent.Fail + read() } else if (ch == 'M') { // mouse event return processMouseEvent(t0, timeout) @@ -267,16 +269,16 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { // leading digits repeat(3) { if (ch in '0'..'9') { - if (readTimeout()) return SysInputEvent.Fail + read() } } // modifier if (ch == ';') { - if (readTimeout()) return SysInputEvent.Fail + read() if (ch in '0'..'9') { - if (readTimeout()) return SysInputEvent.Fail + read() } } @@ -523,12 +525,14 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { private fun processMouseEvent(t0: ComparableTimeMark, timeout: Duration): SysInputEvent { // Mouse event coordinates are raw values, not decimal text, and they're sometimes utf-8 // encoded to fit larger values. - val cb = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - val cx = (readUtf8Byte(t0, timeout) ?: return SysInputEvent.Fail) - 33 + val cb = readUtf8Byte(t0, timeout) + val cx = readUtf8Byte(t0, timeout) - 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 = (readUtf8Byte(t0, timeout.coerceAtMost(1.milliseconds)) ?: 33) - 33 + val cy = runCatching { + readUtf8Byte(t0, timeout.coerceAtMost(1.milliseconds)) - 33 + }.getOrElse { 0 } val shift = (cb and 4) != 0 val alt = (cb and 8) != 0 val ctrl = (cb and 16) != 0 @@ -550,8 +554,8 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { ) } - private fun readUtf8Byte(t0: ComparableTimeMark, timeout: Duration): Int? { - return runCatching { readBytesAsUtf8 { readRawByte(t0, timeout)!!.code } }.getOrNull() + private fun readUtf8Byte(t0: ComparableTimeMark, timeout: Duration): Int { + return readBytesAsUtf8 { readRawByte(t0, timeout).code } } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt index 352645f7e..b452a44ad 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.windows.kt @@ -54,19 +54,19 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { ) : EventRecord() } - protected abstract fun readRawEvent(dwMilliseconds: Int): EventRecord? - protected abstract fun getStdinConsoleMode(): UInt? - protected abstract fun setStdinConsoleMode(dwMode: UInt): Boolean + protected abstract fun readRawEvent(dwMilliseconds: Int): EventRecord + protected abstract fun getStdinConsoleMode(): UInt + protected abstract fun setStdinConsoleMode(dwMode: UInt) - final override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable? { - val originalMode = getStdinConsoleMode() ?: return null + final override fun enterRawMode(mouseTracking: MouseTracking): AutoCloseable { + val originalMode = getStdinConsoleMode() // dwMode=0 means ctrl-c processing, echo, and line input modes are disabled. Could add // ENABLE_PROCESSED_INPUT or ENABLE_WINDOW_INPUT if we want those events. val dwMode = when (mouseTracking) { MouseTracking.Off -> 0u else -> ENABLE_MOUSE_INPUT or ENABLE_EXTENDED_FLAGS } - if (!setStdinConsoleMode(dwMode)) return null + setStdinConsoleMode(dwMode) return AutoCloseable { setStdinConsoleMode(originalMode) } } @@ -75,7 +75,6 @@ internal abstract class SyscallHandlerWindows : SyscallHandler { val dwMilliseconds = (timeout - t0.elapsedNow()).inWholeMilliseconds .coerceIn(0, Int.MAX_VALUE.toLong()).toInt() return when (val event = readRawEvent(dwMilliseconds)) { - null -> SysInputEvent.Fail is EventRecord.Key -> processKeyEvent(event) is EventRecord.Mouse -> processMouseEvent(event, mouseTracking) } diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt index 4e05a0515..34a2bc29a 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -20,9 +20,11 @@ internal object SyscallHandlerNativePosix : SyscallHandlerPosix() { } } - override fun getStdinTermios(): Termios? = memScoped { + override fun getStdinTermios(): Termios = memScoped { val termios = alloc() - if (tcgetattr(STDIN_FILENO, termios.ptr) != 0) return null + if (tcgetattr(STDIN_FILENO, termios.ptr) != 0) { + throw RuntimeException("Error reading terminal attributes") + } return Termios( iflag = termios.c_iflag, oflag = termios.c_oflag, @@ -48,13 +50,13 @@ internal object SyscallHandlerNativePosix : SyscallHandlerPosix() { tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) } - override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char? = memScoped { + override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char = memScoped { do { val c = alloc() val read = read(platform.posix.STDIN_FILENO, c.ptr, 1u) - if (read < 0) return null + if (read < 0) throw RuntimeException("Error reading from stdin") if (read > 0) return c.value.toInt().toChar() } while (t0.elapsedNow() < timeout) - return null + throw RuntimeException("Timeout reading from stdin (timeout=$timeout)") } } From 224e5f4c45f7558efc92284dbc6ea1f88567095f Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 11:06:09 -0700 Subject: [PATCH 36/45] Add coroutine extenstion for raw mode --- docs/input.md | 49 +++++++++++++++++-- .../input/coroutines/ReceiveEventsFlow.kt | 40 +++++++++++++++ .../com/github/ajalt/mordant/samples/main.kt | 31 ++++++------ 3 files changed, 100 insertions(+), 20 deletions(-) create mode 100755 extensions/mordant-coroutines/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/coroutines/ReceiveEventsFlow.kt diff --git a/docs/input.md b/docs/input.md index a62df0e3b..4e45a0230 100644 --- a/docs/input.md +++ b/docs/input.md @@ -14,11 +14,52 @@ Mordant provides a few ways to read input events, depending on how much control Enabling raw mode disables control character processing, which means that you will need to handle events like `ctrl-c` manually if you want your users to be able to exit your program. -### Reading Events with lambdas -The simplest method is to use one of [receiveEvents], [receiveKeyEvents], or [receiveMouseEvents], -depending on which type of events you want to read. These functions will handle setting up raw mode -and restoring the terminal to its original state when they are done. +### Reading Events with Coroutine Flows + +[//]: # (TODO: refs) The simplest way to read events is to include the `mordant-coroutines` module, +and can use [receiveEventsFlow], [receiveKeyEventsFlow], and [receiveMouseEventsFlow] to receive +events as a [Flow]. These functions will handle setting up raw mode and restoring the terminal to +its original state when they are done. + +=== "Example of receiveEventsFlow" + + ```kotlin + terminal.receiveKeyEventsFlow() + .takeWhile { !it.isCtrlC } + .collect { event -> + terminal.info("You pressed ${event.key}") + } + ``` + +=== "Example of receiveMouseEventsFlow" + + ```kotlin + terminal.receiveMouseEventsFlow() + .takeWhile { !it.right } + .filter { it.left } + .collect { event -> + terminal.info("You clicked at ${event.x}, ${event.y}") + } + ``` + +=== "Example of receiveEventsFlow" + + ```kotlin + terminal.receiveEventsFlow() + .takeWhile { it !is KeyboardEvent || it.isCtrlC } + .collect { event -> + when (event) { + is KeyboardEvent -> terminal.info("You pressed ${event.key}") + is MouseEvent -> terminal.info("You clicked at ${event.x}, ${event.y}") + } + } + ``` + +### Reading Events with Callbacks + +If you don't want to use coroutines, you can use a callback lambda with one of [receiveEvents], +[receiveKeyEvents], or [receiveMouseEvents], depending on which type of events you want to read. === "Example of receiveKeyEvents" ```kotlin diff --git a/extensions/mordant-coroutines/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/coroutines/ReceiveEventsFlow.kt b/extensions/mordant-coroutines/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/coroutines/ReceiveEventsFlow.kt new file mode 100755 index 000000000..4293d2717 --- /dev/null +++ b/extensions/mordant-coroutines/src/nonJsMain/kotlin/com/github/ajalt/mordant/input/coroutines/ReceiveEventsFlow.kt @@ -0,0 +1,40 @@ +package com.github.ajalt.mordant.input.coroutines + +import com.github.ajalt.mordant.input.* +import com.github.ajalt.mordant.terminal.Terminal +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flow + +/** + * Enter raw mode, emit input events until the flow in cancelled, then exit raw mode. + * + * @param mouseTracking The type of mouse tracking to enable. + */ +fun Terminal.receiveEventsFlow( + mouseTracking: MouseTracking = MouseTracking.Normal, +): Flow = flow { + enterRawMode(mouseTracking).use { + while (true) emit(it.readEvent()) + } +} + +/** + * Enter raw mode, emit [KeyboardEvent]s until the flow in cancelled, then exit raw mode. + */ +fun Terminal.receiveKeyEventsFlow( +): Flow = receiveEventsFlow(MouseTracking.Off).filterIsInstance() + +/** + * Enter raw mode, emit [MouseEvent]s until the flow in cancelled, then exit raw mode. + * + * @param mouseTracking The type of mouse tracking to enable. + */ +fun Terminal.receiveMouseEventsFlow( + mouseTracking: MouseTracking = MouseTracking.Normal, +): Flow { + require(mouseTracking != MouseTracking.Off) { + "Mouse tracking must be enabled to receive mouse events" + } + return receiveEventsFlow(mouseTracking).filterIsInstance() +} diff --git a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt index 0e59e2c21..7e0e6d5c0 100644 --- a/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt +++ b/samples/drawing/src/commonMain/kotlin/com/github/ajalt/mordant/samples/main.kt @@ -7,11 +7,18 @@ import com.github.ajalt.colormath.model.RGB import com.github.ajalt.colormath.transform.interpolator import com.github.ajalt.mordant.animation.coroutines.animateInCoroutine import com.github.ajalt.mordant.animation.textAnimation -import com.github.ajalt.mordant.input.* +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.input.coroutines.receiveEventsFlow +import com.github.ajalt.mordant.input.isCtrlC import com.github.ajalt.mordant.rendering.AnsiLevel import com.github.ajalt.mordant.rendering.TextColors import com.github.ajalt.mordant.terminal.Terminal import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch suspend fun main() = coroutineScope { @@ -37,22 +44,14 @@ suspend fun main() = coroutineScope { launch { animation.execute() } - terminal.receiveEvents(MouseTracking.Button) { event -> - when (event) { - is KeyboardEvent -> when { - event.isCtrlC -> InputReceiver.Status.Finished - else -> InputReceiver.Status.Continue - } - - is MouseEvent -> { - if (event.left) { - canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) - hue += 2 - } - InputReceiver.Status.Continue - } + terminal.receiveEventsFlow(MouseTracking.Button) + .takeWhile { it !is KeyboardEvent || !it.isCtrlC } + .filterIsInstance() + .filter { it.left } + .collect { event -> + canvas[event.y][event.x] = HSL(hue.toDouble(), 1, .5) + hue += 2 } - } animation.clear() } From 10ab0a2c3769e418b989babf04dc1a25d0126788 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 11:10:39 -0700 Subject: [PATCH 37/45] Run nativeimage test on all platforms --- .github/workflows/build.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6ee07d910..fbeebe722 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,7 +20,7 @@ jobs: os: [macos-latest, windows-latest, ubuntu-latest] include: - os: ubuntu-latest - EXTRA_GRADLE_ARGS: :test:graalvm:nativeTest :test:proguard:r8jar apiCheck + EXTRA_GRADLE_ARGS: :test:proguard:r8jar apiCheck runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v4 @@ -39,6 +39,7 @@ jobs: ./gradlew :mordant:check :extensions:mordant-coroutines:check + :test:graalvm:nativeTest ${{matrix.EXTRA_GRADLE_ARGS}} --stacktrace - name: Run R8 Jar From 9824387f944aa0de60875177b843bac7116dac1a Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 11:13:28 -0700 Subject: [PATCH 38/45] Update markdown dependency --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 623b180f9..213f10848 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ coroutines = "1.8.1" [libraries] colormath = "com.github.ajalt.colormath:colormath:3.5.0" -markdown = "org.jetbrains:markdown:0.7.0" +markdown = "org.jetbrains:markdown:0.7.3" jna-core = "net.java.dev.jna:jna:5.14.0" # compileOnly @@ -14,7 +14,7 @@ graalvm-svm = "org.graalvm.nativeimage:svm:23.1.0" coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" } # used in tests -kotest = "io.kotest:kotest-assertions-core:5.9.0" +kotest = "io.kotest:kotest-assertions-core:5.9.1" systemrules = "com.github.stefanbirkner:system-rules:1.19.0" r8 = "com.android.tools:r8:8.3.37" coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "coroutines" } From f6555b705fa37a64f9ce432ded53793cde5b4dff Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 11:29:30 -0700 Subject: [PATCH 39/45] Update CHANGELOG --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f4f3edd7..fe4fa3bb9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,10 @@ # Changelog ## Unreleased +### Added +- Added raw mode support for reading keyboard and mouse events. See the docs at [https://ajalt.github.io/mordant/](https://ajalt.github.io/mordant/input/) for details. This feature is currently supported on all targets except JS, wasmJS, and Graal Native Image. +- Added `Termianl.interactiveSelectList`, `Terminal.interactiveMultiSelectList`, and `InteractiveSelectListBuilder` that let you pick one or more items from a list using the arrow keys. + ### Changed - Update Kotlin to 2.0.0 From 173b976c6865de864161a7aaf5feeba68a4e091e Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 11:36:23 -0700 Subject: [PATCH 40/45] Dump API --- .../api/mordant-coroutines.api | 8 + mordant/api/mordant.api | 231 +++++++++++++++++- .../com/github/ajalt/mordant/internal/Utf8.kt | 2 +- 3 files changed, 238 insertions(+), 3 deletions(-) diff --git a/extensions/mordant-coroutines/api/mordant-coroutines.api b/extensions/mordant-coroutines/api/mordant-coroutines.api index c856e66a4..a34dae03e 100644 --- a/extensions/mordant-coroutines/api/mordant-coroutines.api +++ b/extensions/mordant-coroutines/api/mordant-coroutines.api @@ -40,3 +40,11 @@ public final class com/github/ajalt/mordant/animation/coroutines/CoroutineProgre public abstract interface class com/github/ajalt/mordant/animation/coroutines/CoroutineProgressTaskAnimator : com/github/ajalt/mordant/animation/coroutines/CoroutineAnimator, com/github/ajalt/mordant/animation/progress/ProgressTask { } +public final class com/github/ajalt/mordant/input/coroutines/ReceiveEventsFlowKt { + public static final fun receiveEventsFlow (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun receiveEventsFlow$default (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; + public static final fun receiveKeyEventsFlow (Lcom/github/ajalt/mordant/terminal/Terminal;)Lkotlinx/coroutines/flow/Flow; + public static final fun receiveMouseEventsFlow (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;)Lkotlinx/coroutines/flow/Flow; + public static synthetic fun receiveMouseEventsFlow$default (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;ILjava/lang/Object;)Lkotlinx/coroutines/flow/Flow; +} + diff --git a/mordant/api/mordant.api b/mordant/api/mordant.api index c2b2907c1..55e0b5704 100644 --- a/mordant/api/mordant.api +++ b/mordant/api/mordant.api @@ -1,4 +1,4 @@ -public abstract class com/github/ajalt/mordant/animation/Animation { +public abstract class com/github/ajalt/mordant/animation/Animation : com/github/ajalt/mordant/animation/StoppableAnimation { public fun (ZLcom/github/ajalt/mordant/terminal/Terminal;)V public synthetic fun (ZLcom/github/ajalt/mordant/terminal/Terminal;ILkotlin/jvm/internal/DefaultConstructorMarker;)V public final fun clear ()V @@ -51,7 +51,7 @@ public final class com/github/ajalt/mordant/animation/Refreshable$DefaultImpls { public static synthetic fun refresh$default (Lcom/github/ajalt/mordant/animation/Refreshable;ZILjava/lang/Object;)V } -public abstract interface class com/github/ajalt/mordant/animation/RefreshableAnimation : com/github/ajalt/mordant/animation/Refreshable { +public abstract interface class com/github/ajalt/mordant/animation/RefreshableAnimation : com/github/ajalt/mordant/animation/Refreshable, com/github/ajalt/mordant/animation/StoppableAnimation { public abstract fun clear ()V public abstract fun getFps ()I public abstract fun stop ()V @@ -67,6 +67,11 @@ public final class com/github/ajalt/mordant/animation/RefreshableAnimationKt { public static final fun getRefreshPeriod (Lcom/github/ajalt/mordant/animation/RefreshableAnimation;)J } +public abstract interface class com/github/ajalt/mordant/animation/StoppableAnimation { + public abstract fun clear ()V + public abstract fun stop ()V +} + public final class com/github/ajalt/mordant/animation/progress/BaseBlockingAnimator : com/github/ajalt/mordant/animation/progress/BlockingAnimator { public fun (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/animation/RefreshableAnimation;)V public fun clear ()V @@ -191,6 +196,203 @@ public final class com/github/ajalt/mordant/animation/progress/ThreadProgressTas public static fun getFps (Lcom/github/ajalt/mordant/animation/progress/ThreadProgressTaskAnimator;)I } +public abstract class com/github/ajalt/mordant/input/InputEvent { +} + +public final class com/github/ajalt/mordant/input/InputEventKt { + public static final fun isCtrlC (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Z +} + +public abstract interface class com/github/ajalt/mordant/input/InputReceiver { + public abstract fun getTerminal ()Lcom/github/ajalt/mordant/terminal/Terminal; + public abstract fun receiveEvent (Lcom/github/ajalt/mordant/input/InputEvent;)Lcom/github/ajalt/mordant/input/InputReceiver$Status; +} + +public abstract class com/github/ajalt/mordant/input/InputReceiver$Status { + public static final field Companion Lcom/github/ajalt/mordant/input/InputReceiver$Status$Companion; +} + +public final class com/github/ajalt/mordant/input/InputReceiver$Status$Companion { + public final fun getFinished ()Lcom/github/ajalt/mordant/input/InputReceiver$Status$Finished; +} + +public final class com/github/ajalt/mordant/input/InputReceiver$Status$Continue : com/github/ajalt/mordant/input/InputReceiver$Status { + public static final field INSTANCE Lcom/github/ajalt/mordant/input/InputReceiver$Status$Continue; + public fun equals (Ljava/lang/Object;)Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/input/InputReceiver$Status$Finished : com/github/ajalt/mordant/input/InputReceiver$Status { + public fun (Ljava/lang/Object;)V + public final fun component1 ()Ljava/lang/Object; + public final fun copy (Ljava/lang/Object;)Lcom/github/ajalt/mordant/input/InputReceiver$Status$Finished; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/input/InputReceiver$Status$Finished;Ljava/lang/Object;ILjava/lang/Object;)Lcom/github/ajalt/mordant/input/InputReceiver$Status$Finished; + public fun equals (Ljava/lang/Object;)Z + public final fun getResult ()Ljava/lang/Object; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/github/ajalt/mordant/input/InputReceiverAnimation : com/github/ajalt/mordant/animation/StoppableAnimation, com/github/ajalt/mordant/input/InputReceiver { +} + +public final class com/github/ajalt/mordant/input/InteractiveSelectListBuilder { + public fun (Lcom/github/ajalt/mordant/terminal/Terminal;)V + public final fun addEntry (Lcom/github/ajalt/mordant/widgets/SelectList$Entry;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun addEntry (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun addEntry (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;Z)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun addEntry (Ljava/lang/String;Ljava/lang/String;Z)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public static synthetic fun addEntry$default (Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;ZILjava/lang/Object;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public static synthetic fun addEntry$default (Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder;Ljava/lang/String;Ljava/lang/String;ZILjava/lang/Object;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun clearOnExit (Z)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun createMultiSelectInputAnimation ()Lcom/github/ajalt/mordant/input/InputReceiverAnimation; + public final fun createSingleSelectInputAnimation ()Lcom/github/ajalt/mordant/input/InputReceiverAnimation; + public final fun cursorMarker (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descApplyFilter (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descConfirm (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descExitFilter (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descFilter (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descNext (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descPrev (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descSubmit (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun descToggle (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun entries ([Lcom/github/ajalt/mordant/widgets/SelectList$Entry;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun entries ([Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun entriesEntry (Ljava/util/List;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun entriesString (Ljava/util/List;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun filterable (Z)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun keyExitFilter (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun keyFilter (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun keyNext (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun keyPrev (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun keySubmit (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun keyToggle (Lcom/github/ajalt/mordant/input/KeyboardEvent;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun limit (I)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun onlyShowActiveDescription (Z)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun selectedMarker (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun selectedStyle (Lcom/github/ajalt/mordant/rendering/TextStyle;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun showInstructions (Z)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun startingCursorIndex (I)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun title (Lcom/github/ajalt/mordant/rendering/Widget;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun title (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun unselectedMarker (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun unselectedMarkerStyle (Lcom/github/ajalt/mordant/rendering/TextStyle;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; + public final fun unselectedTitleStyle (Lcom/github/ajalt/mordant/rendering/TextStyle;)Lcom/github/ajalt/mordant/input/InteractiveSelectListBuilder; +} + +public final class com/github/ajalt/mordant/input/InteractiveSelectListKt { + public static final fun interactiveMultiSelectList (Lcom/github/ajalt/mordant/terminal/Terminal;Lkotlin/jvm/functions/Function1;)Ljava/util/List; + public static final fun interactiveMultiSelectListEntry (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;)Ljava/util/List; + public static synthetic fun interactiveMultiSelectListEntry$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Ljava/util/List; + public static final fun interactiveMultiSelectListString (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;)Ljava/util/List; + public static synthetic fun interactiveMultiSelectListString$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Ljava/util/List; + public static final fun interactiveSelectList (Lcom/github/ajalt/mordant/terminal/Terminal;Lkotlin/jvm/functions/Function1;)Ljava/lang/String; + public static final fun interactiveSelectListEntry (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;)Ljava/lang/String; + public static synthetic fun interactiveSelectListEntry$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; + public static final fun interactiveSelectListString (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;)Ljava/lang/String; + public static synthetic fun interactiveSelectListString$default (Lcom/github/ajalt/mordant/terminal/Terminal;Ljava/util/List;Ljava/lang/String;ILjava/lang/Object;)Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/input/KeyboardEvent : com/github/ajalt/mordant/input/InputEvent { + public fun (Ljava/lang/String;ZZZ)V + public synthetic fun (Ljava/lang/String;ZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Z + public final fun component3 ()Z + public final fun component4 ()Z + public final fun copy (Ljava/lang/String;ZZZ)Lcom/github/ajalt/mordant/input/KeyboardEvent; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/input/KeyboardEvent;Ljava/lang/String;ZZZILjava/lang/Object;)Lcom/github/ajalt/mordant/input/KeyboardEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getAlt ()Z + public final fun getCtrl ()Z + public final fun getKey ()Ljava/lang/String; + public final fun getShift ()Z + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/input/MouseEvent : com/github/ajalt/mordant/input/InputEvent { + public fun (IIZZZZZZZZZZZZ)V + public synthetic fun (IIZZZZZZZZZZZZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()I + public final fun component10 ()Z + public final fun component11 ()Z + public final fun component12 ()Z + public final fun component13 ()Z + public final fun component14 ()Z + public final fun component2 ()I + public final fun component3 ()Z + public final fun component4 ()Z + public final fun component5 ()Z + public final fun component6 ()Z + public final fun component7 ()Z + public final fun component8 ()Z + public final fun component9 ()Z + public final fun copy (IIZZZZZZZZZZZZ)Lcom/github/ajalt/mordant/input/MouseEvent; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/input/MouseEvent;IIZZZZZZZZZZZZILjava/lang/Object;)Lcom/github/ajalt/mordant/input/MouseEvent; + public fun equals (Ljava/lang/Object;)Z + public final fun getAlt ()Z + public final fun getCtrl ()Z + public final fun getLeft ()Z + public final fun getMiddle ()Z + public final fun getMouse4 ()Z + public final fun getMouse5 ()Z + public final fun getRight ()Z + public final fun getShift ()Z + public final fun getWheelDown ()Z + public final fun getWheelLeft ()Z + public final fun getWheelRight ()Z + public final fun getWheelUp ()Z + public final fun getX ()I + public final fun getY ()I + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public final class com/github/ajalt/mordant/input/MouseTracking : java/lang/Enum { + public static final field Any Lcom/github/ajalt/mordant/input/MouseTracking; + public static final field Button Lcom/github/ajalt/mordant/input/MouseTracking; + public static final field Normal Lcom/github/ajalt/mordant/input/MouseTracking; + public static final field Off Lcom/github/ajalt/mordant/input/MouseTracking; + public static fun getEntries ()Lkotlin/enums/EnumEntries; + public static fun valueOf (Ljava/lang/String;)Lcom/github/ajalt/mordant/input/MouseTracking; + public static fun values ()[Lcom/github/ajalt/mordant/input/MouseTracking; +} + +public final class com/github/ajalt/mordant/input/RawModeKt { + public static final fun enterRawMode (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/RawModeScope; + public static synthetic fun enterRawMode$default (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;ILjava/lang/Object;)Lcom/github/ajalt/mordant/input/RawModeScope; + public static final fun enterRawModeOrNull (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;)Lcom/github/ajalt/mordant/input/RawModeScope; + public static synthetic fun enterRawModeOrNull$default (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;ILjava/lang/Object;)Lcom/github/ajalt/mordant/input/RawModeScope; +} + +public final class com/github/ajalt/mordant/input/RawModeScope : java/lang/AutoCloseable { + public fun close ()V + public final fun readEvent-LRDsOJo (J)Lcom/github/ajalt/mordant/input/InputEvent; + public static synthetic fun readEvent-LRDsOJo$default (Lcom/github/ajalt/mordant/input/RawModeScope;JILjava/lang/Object;)Lcom/github/ajalt/mordant/input/InputEvent; + public final fun readEventOrNull-LRDsOJo (J)Lcom/github/ajalt/mordant/input/InputEvent; + public static synthetic fun readEventOrNull-LRDsOJo$default (Lcom/github/ajalt/mordant/input/RawModeScope;JILjava/lang/Object;)Lcom/github/ajalt/mordant/input/InputEvent; + public final fun readKey-LRDsOJo (J)Lcom/github/ajalt/mordant/input/KeyboardEvent; + public static synthetic fun readKey-LRDsOJo$default (Lcom/github/ajalt/mordant/input/RawModeScope;JILjava/lang/Object;)Lcom/github/ajalt/mordant/input/KeyboardEvent; + public final fun readKeyOrNull-LRDsOJo (J)Lcom/github/ajalt/mordant/input/KeyboardEvent; + public static synthetic fun readKeyOrNull-LRDsOJo$default (Lcom/github/ajalt/mordant/input/RawModeScope;JILjava/lang/Object;)Lcom/github/ajalt/mordant/input/KeyboardEvent; + public final fun readMouse-LRDsOJo (J)Lcom/github/ajalt/mordant/input/MouseEvent; + public static synthetic fun readMouse-LRDsOJo$default (Lcom/github/ajalt/mordant/input/RawModeScope;JILjava/lang/Object;)Lcom/github/ajalt/mordant/input/MouseEvent; + public final fun readMouseOrNull-LRDsOJo (J)Lcom/github/ajalt/mordant/input/MouseEvent; + public static synthetic fun readMouseOrNull-LRDsOJo$default (Lcom/github/ajalt/mordant/input/RawModeScope;JILjava/lang/Object;)Lcom/github/ajalt/mordant/input/MouseEvent; +} + +public final class com/github/ajalt/mordant/input/ReceiveEventsKt { + public static final fun receiveEvents (Lcom/github/ajalt/mordant/input/InputReceiver;Lcom/github/ajalt/mordant/input/MouseTracking;)Ljava/lang/Object; + public static final fun receiveEvents (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static synthetic fun receiveEvents$default (Lcom/github/ajalt/mordant/input/InputReceiver;Lcom/github/ajalt/mordant/input/MouseTracking;ILjava/lang/Object;)Ljava/lang/Object; + public static synthetic fun receiveEvents$default (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; + public static final fun receiveKeyEvents (Lcom/github/ajalt/mordant/terminal/Terminal;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static final fun receiveMouseEvents (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object; + public static synthetic fun receiveMouseEvents$default (Lcom/github/ajalt/mordant/terminal/Terminal;Lcom/github/ajalt/mordant/input/MouseTracking;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object; +} + public final class com/github/ajalt/mordant/markdown/Markdown : com/github/ajalt/mordant/rendering/Widget { public fun (Ljava/lang/String;ZLjava/lang/Boolean;)V public synthetic fun (Ljava/lang/String;ZLjava/lang/Boolean;ILkotlin/jvm/internal/DefaultConstructorMarker;)V @@ -1297,6 +1499,31 @@ public final class com/github/ajalt/mordant/widgets/ProgressLayoutKt { public static final fun progressLayout (Lkotlin/jvm/functions/Function1;)Lcom/github/ajalt/mordant/widgets/ProgressLayout; } +public final class com/github/ajalt/mordant/widgets/SelectList : com/github/ajalt/mordant/rendering/Widget { + public fun (Ljava/util/List;Lcom/github/ajalt/mordant/rendering/Widget;IZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;)V + public synthetic fun (Ljava/util/List;Lcom/github/ajalt/mordant/rendering/Widget;IZLjava/lang/String;Ljava/lang/String;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;Lcom/github/ajalt/mordant/rendering/TextStyle;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun measure (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/WidthRange; + public fun render (Lcom/github/ajalt/mordant/terminal/Terminal;I)Lcom/github/ajalt/mordant/rendering/Lines; +} + +public final class com/github/ajalt/mordant/widgets/SelectList$Entry { + public fun (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;Z)V + public synthetic fun (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public fun (Ljava/lang/String;Ljava/lang/String;Z)V + public synthetic fun (Ljava/lang/String;Ljava/lang/String;ZILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Lcom/github/ajalt/mordant/rendering/Widget; + public final fun component3 ()Z + public final fun copy (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;Z)Lcom/github/ajalt/mordant/widgets/SelectList$Entry; + public static synthetic fun copy$default (Lcom/github/ajalt/mordant/widgets/SelectList$Entry;Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/Widget;ZILjava/lang/Object;)Lcom/github/ajalt/mordant/widgets/SelectList$Entry; + public fun equals (Ljava/lang/Object;)Z + public final fun getDescription ()Lcom/github/ajalt/mordant/rendering/Widget; + public final fun getSelected ()Z + public final fun getTitle ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/github/ajalt/mordant/widgets/Spinner : com/github/ajalt/mordant/rendering/Widget { public static final field Companion Lcom/github/ajalt/mordant/widgets/Spinner$Companion; public fun (Ljava/lang/String;Lcom/github/ajalt/mordant/rendering/TextStyle;II)V 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 a7cce3cba..98662751e 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,7 +1,7 @@ package com.github.ajalt.mordant.internal /** Read bytes from a UTF-8 encoded stream, and return the next codepoint. */ -fun readBytesAsUtf8(readByte: () -> Int): Int { +internal fun readBytesAsUtf8(readByte: () -> Int): Int { val byte = readByte() var byteLength = 0 var codepoint = 0 From 8261cad07c0f69d37f5ddf7151ddfb2362ce9548 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 14:05:09 -0700 Subject: [PATCH 41/45] Convert number types on posix native --- .../syscalls/SyscallHanlder.native.posix.kt | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt index 34a2bc29a..1b6ad2fc0 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -26,27 +26,27 @@ internal object SyscallHandlerNativePosix : SyscallHandlerPosix() { throw RuntimeException("Error reading terminal attributes") } return Termios( - iflag = termios.c_iflag, - oflag = termios.c_oflag, - cflag = termios.c_cflag, - lflag = termios.c_lflag, - cline = termios.c_line.toByte(), - cc = ByteArray(NCCS) { termios.c_cc[it].toByte() }, - ispeed = termios.c_ispeed, - ospeed = termios.c_ospeed, + iflag = termios.c_iflag.convert(), + oflag = termios.c_oflag.convert(), + cflag = termios.c_cflag.convert(), + lflag = termios.c_lflag.convert(), + cline = termios.c_line.convert(), + cc = ByteArray(NCCS) { termios.c_cc[it].convert() }, + ispeed = termios.c_ispeed.convert(), + ospeed = termios.c_ospeed.convert(), ) } override fun setStdinTermios(termios: Termios): Unit = memScoped { val nativeTermios = alloc() - nativeTermios.c_iflag = termios.iflag - nativeTermios.c_oflag = termios.oflag - nativeTermios.c_cflag = termios.cflag - nativeTermios.c_lflag = termios.lflag - nativeTermios.c_line = termios.cline.toUByte() - repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].toUByte() } - nativeTermios.c_ispeed = termios.ispeed - nativeTermios.c_ospeed = termios.ospeed + nativeTermios.c_iflag = termios.iflag.convert() + nativeTermios.c_oflag = termios.oflag.convert() + nativeTermios.c_cflag = termios.cflag.convert() + nativeTermios.c_lflag = termios.lflag.convert() + nativeTermios.c_line = termios.cline.convert() + repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].convert() } + nativeTermios.c_ispeed = termios.ispeed.convert() + nativeTermios.c_ospeed = termios.ospeed.convert() tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) } From 70c6cb68967911ee15019289dc480b0ffe08ff9e Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 14:27:14 -0700 Subject: [PATCH 42/45] Ignore termios fields we don't modify --- .../syscalls/jna/SyscallHandler.jna.linux.kt | 7 +------ .../syscalls/jna/SyscallHandler.jna.macos.kt | 10 +--------- .../SyscallHandler.nativeimage.posix.kt | 19 ++++++++++--------- .../internal/syscalls/SyscallHandler.posix.kt | 6 ------ .../syscalls/SyscallHanlder.native.posix.kt | 15 ++++++++------- 5 files changed, 20 insertions(+), 37 deletions(-) diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt index f531f66d2..2d3997dd5 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.linux.kt @@ -89,23 +89,18 @@ internal object SyscallHandlerJnaLinux : SyscallHandlerJvmPosix() { oflag = termios.c_oflag.toUInt(), cflag = termios.c_cflag.toUInt(), lflag = termios.c_lflag.toUInt(), - cline = termios.c_line, cc = termios.c_cc.copyOf(), - ispeed = termios.c_ispeed.toUInt(), - ospeed = termios.c_ospeed.toUInt(), ) } override fun setStdinTermios(termios: Termios) { val nativeTermios = PosixLibC.termios() + libC.tcgetattr(STDIN_FILENO, nativeTermios) nativeTermios.c_iflag = termios.iflag.toInt() nativeTermios.c_oflag = termios.oflag.toInt() nativeTermios.c_cflag = termios.cflag.toInt() nativeTermios.c_lflag = termios.lflag.toInt() - nativeTermios.c_line = termios.cline termios.cc.copyInto(nativeTermios.c_cc) - nativeTermios.c_ispeed = termios.ispeed.toInt() - nativeTermios.c_ospeed = termios.ospeed.toInt() libC.tcsetattr(STDIN_FILENO, TCSADRAIN, nativeTermios) } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt index 85a93fa82..a872ad489 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/jna/SyscallHandler.jna.macos.kt @@ -46,9 +46,6 @@ private interface MacosLibC : Library { @JvmField var c_lflag: NativeLong = NativeLong() - @JvmField - var c_line: Byte = 0 - @JvmField var c_cc: ByteArray = ByteArray(20) @@ -100,23 +97,18 @@ internal object SyscallHandlerJnaMacos : SyscallHandlerJvmPosix() { oflag = termios.c_oflag.toInt().toUInt(), cflag = termios.c_cflag.toInt().toUInt(), lflag = termios.c_lflag.toInt().toUInt(), - cline = termios.c_line, cc = termios.c_cc.copyOf(), - ispeed = termios.c_ispeed.toInt().toUInt(), - ospeed = termios.c_ospeed.toInt().toUInt(), ) } override fun setStdinTermios(termios: Termios) { val nativeTermios = MacosLibC.termios() + libC.tcgetattr(STDIN_FILENO, nativeTermios) nativeTermios.c_iflag = NativeLong(termios.iflag.toLong()) nativeTermios.c_oflag = NativeLong(termios.oflag.toLong()) nativeTermios.c_cflag = NativeLong(termios.cflag.toLong()) nativeTermios.c_lflag = NativeLong(termios.lflag.toLong()) - nativeTermios.c_line = termios.cline termios.cc.copyInto(nativeTermios.c_cc) - nativeTermios.c_ispeed = NativeLong(termios.ispeed.toLong()) - nativeTermios.c_ospeed = NativeLong(termios.ospeed.toLong()) libC.tcsetattr(STDIN_FILENO, TCSANOW, nativeTermios) } } diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt index 5bca46268..16292e65c 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.posix.kt @@ -84,7 +84,7 @@ private object PosixLibC { // external fun tcgetattr(fd: Int, termios: termios): Int // // @CFunction("tcsetattr") -// external fun tcsetattr(fd: Int, cmd: Int, termios: termios) +// external fun tcsetattr(fd: Int, cmd: Int, termios: termios): Int } @Platforms(Platform.LINUX::class, Platform.MACOS::class) @@ -119,29 +119,30 @@ internal class SyscallHandlerNativeImagePosix : SyscallHandlerJvmPosix() { persists. */ // val termios = StackValue.get(PosixLibC.termios::class.java) -// if(PosixLibC.tcgetattr(STDIN_FILENO, termios) != 0) return null +// if (PosixLibC.tcgetattr(STDIN_FILENO, termios) != 0) { +// throw RuntimeException("Error reading terminal attributes") +// } // return Termios( // iflag = termios.c_iflag.toUInt(), // oflag = termios.c_oflag.toUInt(), // cflag = termios.c_cflag.toUInt(), // lflag = termios.c_lflag.toUInt(), -// cline = termios.c_line, // cc = ByteArray(PosixLibC.NCCS()) { termios.c_cc.read(it) }, -// ispeed = termios.c_ispeed.toUInt(), -// ospeed = termios.c_ospeed.toUInt(), // ) } override fun setStdinTermios(termios: Termios) { // val nativeTermios = StackValue.get(PosixLibC.termios::class.java) +// if (PosixLibC.tcgetattr(STDIN_FILENO, nativeTermios) != 0) { +// throw RuntimeException("Error reading terminal attributes") +// } // nativeTermios.c_iflag = termios.iflag.toInt() // nativeTermios.c_oflag = termios.oflag.toInt() // nativeTermios.c_cflag = termios.cflag.toInt() // nativeTermios.c_lflag = termios.lflag.toInt() -// nativeTermios.c_line = termios.cline // termios.cc.forEachIndexed { i, b -> nativeTermios.c_cc.write(i, b) } -// nativeTermios.c_ispeed = termios.ispeed.toInt() -// nativeTermios.c_ospeed = termios.ospeed.toInt() -// PosixLibC.tcsetattr(STDIN_FILENO, PosixLibC.TCSADRAIN(), nativeTermios) +// if (PosixLibC.tcsetattr(STDIN_FILENO, PosixLibC.TCSADRAIN(), nativeTermios) != 0) { +// throw RuntimeException("Error setting terminal attributes") +// } } } diff --git a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt index 9ef1634c1..19e518c77 100644 --- a/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt +++ b/mordant/src/nonJsMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHandler.posix.kt @@ -143,10 +143,7 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { val oflag: UInt, val cflag: UInt, val lflag: UInt, - val cline: Byte, val cc: ByteArray, - val ispeed: UInt, - val ospeed: UInt, ) abstract fun getStdinTermios(): Termios @@ -169,13 +166,10 @@ internal abstract class SyscallHandlerPosix : SyscallHandler { oflag = orig.oflag, cflag = orig.cflag or CS8, lflag = orig.lflag and (ECHO or ICANON or IEXTEN or ISIG).inv(), - cline = orig.cline, cc = orig.cc.copyOf().also { it[VMIN] = 0 // min wait time on read it[VTIME] = 1 // max wait time on read, in 10ths of a second }, - ispeed = orig.ispeed, - ospeed = orig.ospeed, ) setStdinTermios(new) when (mouseTracking) { diff --git a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt index 1b6ad2fc0..75929cfcc 100644 --- a/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt +++ b/mordant/src/posixMain/kotlin/com/github/ajalt/mordant/internal/syscalls/SyscallHanlder.native.posix.kt @@ -30,24 +30,25 @@ internal object SyscallHandlerNativePosix : SyscallHandlerPosix() { oflag = termios.c_oflag.convert(), cflag = termios.c_cflag.convert(), lflag = termios.c_lflag.convert(), - cline = termios.c_line.convert(), cc = ByteArray(NCCS) { termios.c_cc[it].convert() }, - ispeed = termios.c_ispeed.convert(), - ospeed = termios.c_ospeed.convert(), ) } override fun setStdinTermios(termios: Termios): Unit = memScoped { val nativeTermios = alloc() + // different platforms have different fields in termios, so we need to read the current + // struct before we set the fields we carre about. + if (tcgetattr(STDIN_FILENO, nativeTermios.ptr) != 0) { + throw RuntimeException("Error reading terminal attributes") + } nativeTermios.c_iflag = termios.iflag.convert() nativeTermios.c_oflag = termios.oflag.convert() nativeTermios.c_cflag = termios.cflag.convert() nativeTermios.c_lflag = termios.lflag.convert() - nativeTermios.c_line = termios.cline.convert() repeat(NCCS) { nativeTermios.c_cc[it] = termios.cc[it].convert() } - nativeTermios.c_ispeed = termios.ispeed.convert() - nativeTermios.c_ospeed = termios.ospeed.convert() - tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) + if (tcsetattr(platform.posix.STDIN_FILENO, TCSADRAIN, nativeTermios.ptr) != 0) { + throw RuntimeException("Error setting terminal attributes") + } } override fun readRawByte(t0: ComparableTimeMark, timeout: Duration): Char = memScoped { From 1d1dcc5c782a0626bbaa904f5215e23130d770ad Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 14:54:15 -0700 Subject: [PATCH 43/45] Fix tests on wasmJs --- .../kotlin/com/github/ajalt/mordant/test/TestUtils.kt | 2 +- test/graalvm/src/test/kotlin/GraalSmokeTest.kt | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt index 7a677d805..96b813781 100644 --- a/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt +++ b/mordant/src/commonTest/kotlin/com/github/ajalt/mordant/test/TestUtils.kt @@ -21,7 +21,7 @@ private val upMove = Regex("${Regex.escape(CSI)}\\d+A") // This handles the difference in wasm movements and the other targets fun TerminalRecorder.normalizedOutput(): String { - return if (CR_IMPLIES_LF) output().replace(upMove, "\r") else output() + return if (CR_IMPLIES_LF) output().replace("\r${CSI}1A", "\r") else output() } fun TerminalRecorder.latestOutput(): String { diff --git a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt index bedc084eb..4477762c0 100644 --- a/test/graalvm/src/test/kotlin/GraalSmokeTest.kt +++ b/test/graalvm/src/test/kotlin/GraalSmokeTest.kt @@ -12,6 +12,7 @@ import com.github.ajalt.mordant.widgets.progress.progressBar import com.github.ajalt.mordant.widgets.progress.progressBarLayout import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertThrows import java.util.concurrent.TimeUnit import kotlin.test.Ignore import kotlin.test.assertNull @@ -42,7 +43,9 @@ class GraalSmokeTest { @Test fun `raw mode test`() { val t = Terminal(interactive = true) - assertNull(t.enterRawMode()?.use {}) + assertThrows { + t.enterRawMode().use {} + } } @Test From e8b9e367d67b7f76c38f2dc83a784e2cd74bb211 Mon Sep 17 00:00:00 2001 From: AJ Date: Sat, 15 Jun 2024 15:05:20 -0700 Subject: [PATCH 44/45] Update graal metadata --- .../com.github.ajalt.mordant/mordant/native-image.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties index d623a464a..c0b7416ab 100644 --- a/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties +++ b/mordant/src/jvmMain/resources/META-INF/native-image/com.github.ajalt.mordant/mordant/native-image.properties @@ -1 +1 @@ -Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandler_nativeimage_windowsKt,com.github.ajalt.mordant.internal.syscalls.nativeimage.SyscallHandler_nativeimage_posixKt +Args = --initialize-at-build-time=com.github.ajalt.mordant.internal.MppInternal_jvmKt,com.github.ajalt.mordant.internal.MppInternalKt From d72e1ce7f1df86961029c8a283a8bb6cf13f4f46 Mon Sep 17 00:00:00 2001 From: AJ Date: Sun, 16 Jun 2024 10:09:40 -0700 Subject: [PATCH 45/45] Disable raw mode on windows nativeimage --- .../SyscallHandler.nativeimage.windows.kt | 290 +++++++++--------- 1 file changed, 150 insertions(+), 140 deletions(-) diff --git a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt index 763cf18ff..67a58b6d6 100644 --- a/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt +++ b/mordant/src/jvmMain/kotlin/com/github/ajalt/mordant/internal/syscalls/nativeimage/SyscallHandler.nativeimage.windows.kt @@ -32,8 +32,8 @@ private object WinKernel32Lib { @CConstant("STD_ERROR_HANDLE") external fun STD_ERROR_HANDLE(): Int - @CConstant("ENABLE_PROCESSED_INPUT") - external fun ENABLE_PROCESSED_INPUT(): Int +// @CConstant("ENABLE_PROCESSED_INPUT") +// external fun ENABLE_PROCESSED_INPUT(): Int @CStruct("CONSOLE_SCREEN_BUFFER_INFO") interface CONSOLE_SCREEN_BUFFER_INFO : PointerBase { @@ -52,86 +52,86 @@ private object WinKernel32Lib { } - @CStruct("uChar") - interface UnionChar : PointerBase { - @get:CFieldAddress("UnicodeChar") - val UnicodeChar: Char - - @get:CFieldAddress("AsciiChar") - val AsciiChar: Byte - } - - @CStruct("COORD") - interface COORD : PointerBase { - @get:CField("X") - var X: Short - - @get:CField("Y") - var Y: Short - } - - @CStruct("KEY_EVENT_RECORD") - interface KEY_EVENT_RECORD : PointerBase { - @get:CField("bKeyDown") - val bKeyDown: Boolean - - @get:CField("wRepeatCount") - val wRepeatCount: Short - - @get:CField("wVirtualKeyCode") - val wVirtualKeyCode: Short - - @get:CField("wVirtualScanCode") - val wVirtualScanCode: Short - - @get:CField("uChar") - val uChar: UnionChar? - - @get:CField("dwControlKeyState") - val dwControlKeyState: Int - } - - @CStruct("MOUSE_EVENT_RECORD") - interface MOUSE_EVENT_RECORD : PointerBase { - @get:CField("dwMousePosition") - var dwMousePosition: COORD - - @get:CField("dwButtonState") - var dwButtonState: Int - - @get:CField("dwControlKeyState") - var dwControlKeyState: Int - - @get:CField("dwEventFlags") - var dwEventFlags: Int - } - - @CStruct("Event") - interface EventUnion : PointerBase { - @get:CFieldAddress("KeyEvent") - val KeyEvent: KEY_EVENT_RECORD - - @get:CFieldAddress("MouseEvent") - val MouseEvent: MOUSE_EVENT_RECORD - // ... other fields omitted until we need them - } - - @CStruct("INPUT_RECORD") - interface INPUT_RECORD : PointerBase { - companion object { - const val KEY_EVENT: Short = 0x0001 - const val MOUSE_EVENT: Short = 0x0002 - const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004 - const val MENU_EVENT: Short = 0x0008 - const val FOCUS_EVENT: Short = 0x0010 - } - - @get:CField("EventType") - val EventType: Short - - @get:CField("Event") - val Event: EventUnion - } +// @CStruct("uChar") +// interface UnionChar : PointerBase { +// @get:CFieldAddress("UnicodeChar") +// val UnicodeChar: Char +// +// @get:CFieldAddress("AsciiChar") +// val AsciiChar: Byte +// } +// +// @CStruct("COORD") +// interface COORD : PointerBase { +// @get:CField("X") +// var X: Short +// +// @get:CField("Y") +// var Y: Short +// } +// +// @CStruct("KEY_EVENT_RECORD") +// interface KEY_EVENT_RECORD : PointerBase { +// @get:CField("bKeyDown") +// val bKeyDown: Boolean +// +// @get:CField("wRepeatCount") +// val wRepeatCount: Short +// +// @get:CField("wVirtualKeyCode") +// val wVirtualKeyCode: Short +// +// @get:CField("wVirtualScanCode") +// val wVirtualScanCode: Short +// +// @get:CField("uChar") +// val uChar: UnionChar? +// +// @get:CField("dwControlKeyState") +// val dwControlKeyState: Int +// } +// +// @CStruct("MOUSE_EVENT_RECORD") +// interface MOUSE_EVENT_RECORD : PointerBase { +// @get:CField("dwMousePosition") +// var dwMousePosition: COORD +// +// @get:CField("dwButtonState") +// var dwButtonState: Int +// +// @get:CField("dwControlKeyState") +// var dwControlKeyState: Int +// +// @get:CField("dwEventFlags") +// var dwEventFlags: Int +// } +// +// @CStruct("Event") +// interface EventUnion : PointerBase { +// @get:CFieldAddress("KeyEvent") +// val KeyEvent: KEY_EVENT_RECORD +// +// @get:CFieldAddress("MouseEvent") +// val MouseEvent: MOUSE_EVENT_RECORD +// // ... other fields omitted until we need them +// } +// +// @CStruct("INPUT_RECORD") +// interface INPUT_RECORD : PointerBase { +// companion object { +// const val KEY_EVENT: Short = 0x0001 +// const val MOUSE_EVENT: Short = 0x0002 +// const val WINDOW_BUFFER_SIZE_EVENT: Short = 0x0004 +// const val MENU_EVENT: Short = 0x0008 +// const val FOCUS_EVENT: Short = 0x0010 +// } +// +// @get:CField("EventType") +// val EventType: Short +// +// @get:CField("Event") +// val Event: EventUnion +// } @CFunction("GetStdHandle") @@ -148,17 +148,17 @@ private object WinKernel32Lib { hConsoleOutput: PointerBase?, lpConsoleScreenBufferInfo: Long, ): Boolean - - @CFunction("WaitForSingleObject") - external fun WaitForSingleObject(hHandle: PointerBase?, dwMilliseconds: Int): Int - - @CFunction("ReadConsoleInput") - external fun ReadConsoleInput( - hConsoleOutput: PointerBase?, - lpBuffer: INPUT_RECORD, - nLength: Int, - lpNumberOfEventsRead: CIntPointer?, - ) +// +// @CFunction("WaitForSingleObject") +// external fun WaitForSingleObject(hHandle: PointerBase?, dwMilliseconds: Int): Int +// +// @CFunction("ReadConsoleInput") +// external fun ReadConsoleInput( +// hConsoleOutput: PointerBase?, +// lpBuffer: INPUT_RECORD, +// nLength: Int, +// lpNumberOfEventsRead: CIntPointer?, +// ) } @Platforms(Platform.WINDOWS::class) @@ -190,58 +190,68 @@ internal class SyscallHandlerNativeImageWindows : SyscallHandlerWindows() { } override fun getStdinConsoleMode(): UInt { - val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) - val lpMode = StackValue.get(CIntPointer::class.java) - if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) { - throw RuntimeException("Error reading console mode") - } - return lpMode.read().toUInt() + throw NotImplementedError( + "Raw mode is not currently supported for native-image. If you are familiar with " + + "GraalVM native-image and would like to contribute, see the commented out " + + "code in the file SyscallHandler.nativeimage.posix" + ) +// val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) +// val lpMode = StackValue.get(CIntPointer::class.java) +// if (!WinKernel32Lib.GetConsoleMode(stdinHandle, lpMode)) { +// throw RuntimeException("Error reading console mode") +// } +// return lpMode.read().toUInt() } override fun setStdinConsoleMode(dwMode: UInt) { - val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) - if (!WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT())) { - throw RuntimeException("Error setting console mode") - } +// val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) +// if (!WinKernel32Lib.SetConsoleMode(stdinHandle, WinKernel32Lib.ENABLE_PROCESSED_INPUT())) { +// throw RuntimeException("Error setting console mode") +// } } override fun readRawEvent(dwMilliseconds: Int): EventRecord { - val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) - val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds) - if (waitResult != 0) { - throw RuntimeException("Error reading from console input: waitResult=$waitResult") - } - val inputEvents = StackValue.get(WinKernel32Lib.INPUT_RECORD::class.java) - val eventsRead = StackValue.get(CIntPointer::class.java) - WinKernel32Lib.ReadConsoleInput(stdinHandle, inputEvents, 1, eventsRead) - if (eventsRead.read() == 0) { - throw RuntimeException("Error reading from console input") - } - return when (inputEvents.EventType) { - WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> { - val keyEvent = inputEvents.Event.KeyEvent - EventRecord.Key( - bKeyDown = keyEvent.bKeyDown, - wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), - uChar = keyEvent.uChar!!.UnicodeChar, - dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), - ) - } - - WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> { - val mouseEvent = inputEvents.Event.MouseEvent - EventRecord.Mouse( - dwMousePositionX = mouseEvent.dwMousePosition.X, - dwMousePositionY = mouseEvent.dwMousePosition.Y, - dwButtonState = mouseEvent.dwButtonState.toUInt(), - dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(), - dwEventFlags = mouseEvent.dwEventFlags.toUInt(), - ) - } - - else -> throw RuntimeException( - "Error reading from console input: unexpected event type ${inputEvents.EventType}" - ) - } + throw NotImplementedError( + "Raw mode is not currently supported for native-image. If you are familiar with " + + "GraalVM native-image and would like to contribute, see the commented out " + + "code in the file SyscallHandler.nativeimage.posix" + ) +// val stdinHandle = WinKernel32Lib.GetStdHandle(WinKernel32Lib.STD_INPUT_HANDLE()) +// val waitResult = WinKernel32Lib.WaitForSingleObject(stdinHandle, dwMilliseconds) +// if (waitResult != 0) { +// throw RuntimeException("Error reading from console input: waitResult=$waitResult") +// } +// val inputEvents = StackValue.get(WinKernel32Lib.INPUT_RECORD::class.java) +// val eventsRead = StackValue.get(CIntPointer::class.java) +// WinKernel32Lib.ReadConsoleInput(stdinHandle, inputEvents, 1, eventsRead) +// if (eventsRead.read() == 0) { +// throw RuntimeException("Error reading from console input") +// } +// return when (inputEvents.EventType) { +// WinKernel32Lib.INPUT_RECORD.KEY_EVENT -> { +// val keyEvent = inputEvents.Event.KeyEvent +// EventRecord.Key( +// bKeyDown = keyEvent.bKeyDown, +// wVirtualKeyCode = keyEvent.wVirtualKeyCode.toUShort(), +// uChar = keyEvent.uChar!!.UnicodeChar, +// dwControlKeyState = keyEvent.dwControlKeyState.toUInt(), +// ) +// } +// +// WinKernel32Lib.INPUT_RECORD.MOUSE_EVENT -> { +// val mouseEvent = inputEvents.Event.MouseEvent +// EventRecord.Mouse( +// dwMousePositionX = mouseEvent.dwMousePosition.X, +// dwMousePositionY = mouseEvent.dwMousePosition.Y, +// dwButtonState = mouseEvent.dwButtonState.toUInt(), +// dwControlKeyState = mouseEvent.dwControlKeyState.toUInt(), +// dwEventFlags = mouseEvent.dwEventFlags.toUInt(), +// ) +// } +// +// else -> throw RuntimeException( +// "Error reading from console input: unexpected event type ${inputEvents.EventType}" +// ) +// } } }