Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Raw Mode Support #175

Merged
merged 45 commits into from
Jun 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
772a23e
Add raw mode to windows JNA
ajalt May 29, 2024
d04c77b
Add raw mode to linux JNA
ajalt May 31, 2024
71ce14f
Add native impls of input functions
ajalt Jun 1, 2024
276e6b0
Add raw mode to mac JNA and native
ajalt Jun 2, 2024
d2de034
Move common mpp code to SyscallHandler interface
ajalt Jun 2, 2024
e296039
Add graal syscall handlers
ajalt Jun 3, 2024
a3368f8
Move input package to nonJsMain source set
ajalt Jun 3, 2024
e486c27
Add JS and Wasm syscall handlers
ajalt Jun 3, 2024
7b65f5a
Add interactive list widgets
ajalt Jun 4, 2024
370066c
Add multi select list
ajalt Jun 4, 2024
1813aee
Prefer virtual key code to char codes on windows
ajalt Jun 4, 2024
b93dc44
Add theme styles to select list
ajalt Jun 4, 2024
e13fdcd
Add onlyShowActiveDescription
ajalt Jun 5, 2024
e226af5
Fix frame clearing on non-rectangular animations
ajalt Jun 5, 2024
e996642
Fix nested bold and dim
ajalt Jun 5, 2024
0eb5ee7
Add InteractiveSelectListBuilder
ajalt Jun 5, 2024
d122539
Support filtering select list
ajalt Jun 6, 2024
8d64ffc
Move select list animation to commonMain
ajalt Jun 6, 2024
bfcf44b
Add select list animation tests
ajalt Jun 7, 2024
18ea7b5
Fix windows keyboard events for shift+key
ajalt Jun 8, 2024
7e489bc
Catch raw mode error when forcing interactive
ajalt Jun 8, 2024
156deba
Update graal metadata
ajalt Jun 8, 2024
c417014
Disable raw mode on graal nativeimage
ajalt Jun 8, 2024
b3f671b
Move some common native functionality into syscall handlers
ajalt Jun 9, 2024
04ea256
Add mouse events
ajalt Jun 10, 2024
58cf653
Handle mouse event filtering on windows
ajalt Jun 10, 2024
ef3ddc0
Add drawing sample
ajalt Jun 10, 2024
99f63ed
Add Terminal.receiveEvents
ajalt Jun 10, 2024
b1e1033
Implement mouse wheel events
ajalt Jun 10, 2024
75d58f6
Add workaround for partial mouse events on posix
ajalt Jun 11, 2024
8b0010c
Add kdocs
ajalt Jun 12, 2024
be7fa43
Always try to read at least one raw byte
ajalt Jun 14, 2024
387df0a
Add raw mode docs
ajalt Jun 15, 2024
834fd6e
Add StoppableAnimation interface
ajalt Jun 15, 2024
68d3433
Throw exceptions when raw mode fails
ajalt Jun 15, 2024
224e5f4
Add coroutine extenstion for raw mode
ajalt Jun 15, 2024
10ab0a2
Run nativeimage test on all platforms
ajalt Jun 15, 2024
9824387
Update markdown dependency
ajalt Jun 15, 2024
f6555b7
Update CHANGELOG
ajalt Jun 15, 2024
173b976
Dump API
ajalt Jun 15, 2024
8261cad
Convert number types on posix native
ajalt Jun 15, 2024
70c6cb6
Ignore termios fields we don't modify
ajalt Jun 15, 2024
1d1dcc5
Fix tests on wasmJs
ajalt Jun 15, 2024
e8b9e36
Update graal metadata
ajalt Jun 15, 2024
d72e1ce
Disable raw mode on windows nativeimage
ajalt Jun 16, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -39,6 +39,7 @@ jobs:
./gradlew
:mordant:check
:extensions:mordant-coroutines:check
:test:graalvm:nativeTest
${{matrix.EXTRA_GRADLE_ARGS}}
--stacktrace
- name: Run R8 Jar
Expand Down
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
# 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

### 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
- Publish `iosArm64` and `iosX64` targets.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Binary file added docs/img/select_list.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
292 changes: 292 additions & 0 deletions docs/input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,292 @@
# 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 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
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<Unit> {
override fun receiveEvent(event: InputEvent): InputReceiver.Status<Unit> {
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
8 changes: 8 additions & 0 deletions extensions/mordant-coroutines/api/mordant-coroutines.api
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Original file line number Diff line number Diff line change
@@ -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<InputEvent> = 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<KeyboardEvent> = 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<MouseEvent> {
require(mouseTracking != MouseTracking.Off) {
"Mouse tracking must be enabled to receive mouse events"
}
return receiveEventsFlow(mouseTracking).filterIsInstance()
}
Loading