diff --git a/docs/plan.md b/docs/plan.md index 1156ca4..4095df9 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -56,6 +56,7 @@ This board tracks implementation direction for `moonbit-community/tty`. Use | TTY-19 | done | OSC dynamic color queries | `docs/plans/2026-05-27-osc-dynamic-colors.md`, `color/`, `internal/vt/`, `internal/input/`, root package | root `Tty` can query default foreground, default background, and cursor colors while preserving interleaved input | `moon fmt`, `moon test color`, `moon test internal/vt`, `moon test internal/input`, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | | TTY-20 | done | DEC auto wrap mode commands | `docs/plans/2026-05-29-decawm-auto-wrap.md`, `internal/vt/`, root package | root `Tty` can enable and disable DECAWM without adding terminal state or a capability query | `moon fmt`, `moon test internal/vt`, `moon test .`, `moon check`, `moon info`, `git diff --check` | | TTY-21 | done | Windows console input polling | `docs/plans/2026-06-08-windows-console-input-polling.md`, root package | Windows `Tty::read_event` reads console input records directly and reports resize events without public API changes | `moon fmt`, `moon check`, `moon test .`, `moon test internal/input`, `moon test`, `moon info`, `git diff --check`, Windows manual resize smoke pending | +| TTY-22 | done | Windows input record enum | `docs/plans/2026-06-09-windows-input-record-enum.md`, root package | Windows `INPUT_RECORD` bytes parse into private typed variants, including native mouse records | `moon fmt`, targeted Windows wbtests, `moon test .`, `moon test`, `moon check`, `moon info`, `git diff --check` | | MVP-2 | done | queued input and shell commands in agent demo | `docs/plans/2026-05-21-agent-queued-shell.md`, `examples/agent` | `Tab` queues input for delayed submission and `!cmd` runs through `moonbitlang/async/process` without background tasks writing directly to tty | `moon fmt`, `moon check examples/agent`, `moon test examples/agent`, `moon check`, `moon test`, `moon info`, `git diff --check` | ## Current Rules diff --git a/docs/plans/2026-06-09-windows-input-record-enum.md b/docs/plans/2026-06-09-windows-input-record-enum.md new file mode 100644 index 0000000..bc4b52f --- /dev/null +++ b/docs/plans/2026-06-09-windows-input-record-enum.md @@ -0,0 +1,467 @@ +# Windows Input Record Enum + +## Goal + +Parse Windows console `INPUT_RECORD` values on the MoonBit side as a private +record enum, and handle native mouse records produced by `ReadConsoleInputW`. + +## Target Files + +- `win32_input.c`: copy one Windows `INPUT_RECORD` into a caller-provided byte + buffer instead of flattening selected fields into many FFI refs. +- `win32_input.mbt`: parse the raw Windows record layout into a private enum + and dispatch key, mouse, focus, and resize records through typed variants. +- `win32_input_wbtest.mbt`: update helpers and add record-layout and mouse + regression coverage. +- `docs/plan.md`: mark the task active while this implementation is in flight. + +## Accepted Design + +- Keep the implementation Windows-only and private to the root package. +- Treat the Windows SDK `INPUT_RECORD` layout as the private on-Windows ABI + boundary. +- The C FFI reads with `PeekConsoleInputW` / `ReadConsoleInputW` and copies the + resulting `INPUT_RECORD` bytes into a MoonBit-owned buffer. +- MoonBit parses that buffer into: + - `Win32InputRecord::Key(Win32KeyRecord)` + - `Win32InputRecord::Mouse(Win32MouseRecord)` + - `Win32InputRecord::WindowBufferSize(Win32Coord)` + - `Win32InputRecord::Focus(Bool)` + - `Win32InputRecord::Unsupported(Int)` +- Existing key-record behavior is preserved while moving from the old flat + record struct to the `Key` payload. +- Native `MOUSE_EVENT_RECORD` values map to existing public + `@input.MouseEvent` values and are queued as `Input(Mouse(...))`. +- Keep SGR/VT mouse byte decoding unchanged for terminals that report mouse + input as VT sequences. +- The direct-event vs decoded-byte select lives inside + `Win32ConsoleInputSource::read_decoded_input_event`. +- `Win32ConsoleInputSource::read_event` starts the record producer and delegates + event selection to `read_decoded_input_event`. + +## Public API / Interface Diff + +- No public MoonBit API change. +- `pkg.generated.mbti` should remain unchanged after `moon info`. +- New enum, record payload types, byte-layout helpers, and FFI functions remain + private to the root package. + +## Invariants + +- C does not expose raw `INPUT_RECORD` memory beyond copying it into a buffer + supplied by MoonBit. +- MoonBit layout parsing is guarded by buffer-size checks and stays under + `#cfg(platform="windows")`. +- Unsupported record types are still consumed and ignored so they cannot stall + polling. +- Key repeats, AltGr text, keypad metadata, Ctrl-space, Ctrl-left-bracket, + key-up dropping, and query-source preservation remain unchanged. +- This task does not change the existing pipe-based byte delivery or attempt to + solve cancellation after `ReadConsoleInputW` consumes a record. + +## Acceptance Criteria + +- Existing Windows key/focus/resize white-box tests continue to pass. +- New tests prove the raw Windows record bytes parse into typed enum variants. +- New tests prove native mouse move/button records become public mouse events. +- `examples/input` should no longer lose native mouse records through the + unsupported-record branch on Windows console input. + +## Validation Plan + +- `moon fmt` +- Targeted Windows white-box tests for input-record parsing and mouse mapping. +- Existing targeted Windows input-source regression tests. +- `moon test .` +- `moon test` +- `moon check` +- `moon check --target all` +- `moon info` +- Review `.mbti` diff. +- `git diff --check` + +## Result + +- Implemented the private Windows record split: + - C now copies the SDK `INPUT_RECORD` bytes into a MoonBit-owned buffer. + - MoonBit parses those bytes into private `Key`, `Mouse`, + `WindowBufferSize`, `Focus`, and `Unsupported` variants. + - Existing key handling now consumes `Win32KeyRecord`. + - Native `MOUSE_EVENT_RECORD` values update a mutable + `Win32MouseButtonState` and queue existing public `MouseEvent` values. +- Added white-box coverage for raw key, mouse, and focus record parsing. +- Added white-box coverage for native mouse move, press, drag, release, and + wheel mapping. +- Moved the direct-event vs decoded-input select into + `Win32ConsoleInputSource::read_decoded_input_event`, without changing the + existing pipe-based byte delivery. + +## Validation Results + +- Passed: `moon fmt` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-build` +- Passed: `moon check --target-dir .moon-check-build` +- Passed: `moon test . --target-dir .moon-test-dot-build` +- Passed: `moon test --target-dir .moon-test-all-build` +- Passed: `moon check --target all --target-dir .moon-check-all-build` +- Passed: `moon info --target-dir .moon-info-build` +- Passed: `clang.exe -fsyntax-only -I C:\Users\mbt\.moon\include win32_input.c` +- Passed: `git diff --check` +- Note: full native test runs emit warnings from upstream async/runtime C stubs + on Windows; no warnings remain from this package's MoonBit changes. +- Re-ran after the select-only follow-up: + - Passed: `moon fmt` + - Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-build` + - Passed: `moon check --target-dir .moon-check-build` + - Note: `moon check` currently reports private unused-field warnings for the + Windows `INPUT_RECORD` enum payloads. + +## Public API Audit + +- No public MoonBit API change intended. +- `moon info` produced no intended `.mbti` public API diff. + +## Follow-up: Flatten Windows Input Backend + +### Goal + +Make `Tty.reader` the single byte-stream `EventReader` owner on Windows by +flattening the Windows console input source state into a private Windows input +backend stored by `Tty`. + +### Accepted Design + +- Keep the change Windows-only and private to the root package. +- Replace `Win32ConsoleInputSource` with a private `WindowsInput` enum: + - `ByteStream` for non-console Windows handles that should read directly from + the original input byte stream. + - `Console(...)` for Win32 console handles that use `ReadConsoleInputW`, + pipe text bytes into `Tty.reader`, and queue direct events. +- Keep `Tty.reader` as the only `@input.EventReader` field. +- In the console case, construct `Tty.reader` from the console byte pipe read + end; in the fallback case, construct it from the original input reader. +- Keep existing pipe-based byte delivery and current cancellation behavior; do + not introduce a queue-backed byte reader in this refactor. +- Do not add Windows prefixes to `Tty` field names solely because the struct is + under `#cfg(platform="windows")`. + +### Public API / Interface Diff + +- No public MoonBit API change intended. +- `pkg.generated.mbti` should remain unchanged after `moon info`. + +### Validation Plan + +- `moon fmt` +- `moon test . --filter "win32*" --target-dir .moon-test-win32-flatten-build` +- `moon check --target-dir .moon-check-flatten-build` +- `moon info --target-dir .moon-info-flatten-build` +- Review `.mbti` diff. + +### Result + +- Replaced `Win32ConsoleInputSource` with the private Windows-only + `WindowsInput` backend enum. +- `Tty` now owns the active `EventReader` on Windows: + - `ByteStream` uses the original input byte reader. + - `Console` uses the console byte pipe read end. +- Moved Windows console record reading and record dispatch helpers onto `Tty`. +- Kept existing pipe-based text-byte delivery and direct event queue behavior. + +### Validation Results + +- Passed: `moon fmt` +- Passed: `moon check --target-dir .moon-check-flatten-build` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-flatten-build` +- Passed: `moon info --target-dir .moon-info-flatten-build` +- Note: `moon info` still rewrites the pre-existing `Fd::fd` generated mbti + spelling from `Int` to `@types.Fd`; this flatten refactor does not intend to + include that generated public-interface churn. + +## Follow-up: Split Win32 Input Parser Package + +### Goal + +Move the pure Windows `INPUT_RECORD` parsing and native key/mouse mapping code +out of the root tty package into `internal/win32`, while keeping root `Tty` +responsible for terminal handles, backend selection, async record reading, and +platform FFI. + +### Accepted Design + +- Add a new internal package at `internal/win32`. +- Move raw input record modeling, byte-layout parsing, key mapping, mouse + mapping, modifier mapping, and surrogate helpers into that package. +- Use package-local names without redundant `Win32` prefixes where the package + path already supplies the Win32 context: + - `RawInputRecord` + - `InputRecord` + - `KeyRecord` + - `MouseRecord` + - `MouseButtonState` +- Keep `ReadConsoleInputW` and `sizeof(INPUT_RECORD)` FFI wrappers in the root + tty package. Root passes an `@win32.RawInputRecord` buffer to C and then asks + the internal package to parse it. +- Keep the current pipe-based text-byte delivery and direct event queue + behavior unchanged. +- Keep root tests for `Tty`/backend behavior in the root white-box test file, + and move raw record layout tests to `internal/win32`. + +### Public API / Interface Diff + +- No root public MoonBit API change intended. +- Root `pkg.generated.mbti` should remain unchanged after `moon info`. +- The new `internal/win32` package will expose an internal-only interface for + root tty code and white-box tests. + +### Validation Plan + +- `moon fmt` +- `moon test internal/win32 --target-dir .moon-test-internal-win32-build` +- `moon test . --filter "win32*" --target-dir .moon-test-win32-split-build` +- `moon check --target-dir .moon-check-win32-split-build` +- `moon info --target-dir .moon-info-win32-split-build` +- Review `.mbti` diff. +- `git diff --check` + +### Result + +- Added `internal/win32` for pure Windows `INPUT_RECORD` modeling, parsing, + key mapping, mouse mapping, and modifier/surrogate helpers. +- Root `win32_input.mbt` now keeps only the Windows input backend, `Tty` + dispatch helpers, and the `ReadConsoleInputW` / `sizeof(INPUT_RECORD)` FFI + wrapper. +- Root passes an `@win32.RawInputRecord` buffer to C and parses it through the + internal package. +- Moved raw record layout white-box tests into `internal/win32`. +- Kept root white-box tests focused on `Tty` backend behavior. + +### Validation Results + +- Passed: `moon fmt` +- Passed: `moon test internal/win32 --target-dir .moon-test-internal-win32-build` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-split-build` +- Passed: `moon check --target-dir .moon-check-win32-split-build` +- Passed: `moon info --target-dir .moon-info-win32-split-build` +- Passed: `git diff --check` +- Note: `moon info` still rewrites the pre-existing root `Fd::fd` generated + mbti spelling from `Int` to `@types.Fd`; this split restored the root + generated interface to avoid unrelated public API churn. + +### Follow-up Result + +- Removed the internal `RawInputRecord` wrapper. +- Root now allocates a `Bytes` buffer for `ReadConsoleInputW`, passes that + buffer directly through the FFI borrow boundary, and calls + `@win32.InputRecord::parse(bytes)`. +- `internal/win32` now exposes `InputRecord::parse(Bytes) -> InputRecord`. + +### Follow-up Validation Results + +- Passed: `moon fmt` +- Passed: `moon check --target-dir .moon-check-win32-parse-bytes-build` +- Passed: `moon test internal/win32 --target-dir .moon-test-internal-win32-parse-bytes-build` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-parse-bytes-build` +- Passed: `moon info --target-dir .moon-info-win32-parse-bytes-build` + +## Follow-up: Drain Windows Console Records + +### Goal + +Reduce split VT input tails on Windows console input by draining the currently +available `ReadConsoleInputW` records before a short-lived record producer can +be cancelled by the direct-event vs decoded-byte race. + +### Accepted Design + +- Keep the change Windows-only and private to the root package. +- Keep the existing byte pipe and shared `EventReader`; do not introduce a new + byte-reader abstraction or persistent producer in this follow-up. +- In the Windows record producer, treat one polling cycle as: + - read records with the existing non-blocking `PeekConsoleInputW` / + `ReadConsoleInputW` FFI wrapper until it returns `None`; + - dispatch each record through the existing key, mouse, focus, resize, and + unsupported handlers in record order; + - sleep only when no record was available in that cycle. +- Queue direct events with synchronous `Queue::try_put` on the existing + unbounded direct-event queue, so putting a native mouse/focus/resize/key event + is not itself an async cancellation point. +- This reduces cancellation leaving an already-available VT sequence tail in + the Win32 console queue. It still does not guarantee none-or-all delivery if + the terminal or ConPTY delivers later records after the escape timeout. + +### Public API / Interface Diff + +- No public MoonBit API change intended. +- `pkg.generated.mbti` should remain unchanged after `moon info`. + +### Validation Plan + +- `moon fmt` +- `moon test . --filter "win32*" --target-dir .moon-test-win32-drain-build` +- `moon test internal/input --filter "decode delayed *mouse*" --target-dir .moon-test-input-delayed-mouse-drain-build` +- `moon check --target-dir .moon-check-win32-drain-build` +- `moon info --target-dir .moon-info-win32-drain-build` +- Review `.mbti` diff. +- `git diff --check` + +### Result + +- Added a private `Tty::drain_records` helper for Windows console input. +- The record producer now drains all currently available `INPUT_RECORD` values + before sleeping on an empty poll. +- Direct native events are queued with synchronous `Queue::try_put` on the + existing unbounded event queue, so native event delivery no longer introduces + an async cancellation point. +- Kept byte delivery on the existing pipe and did not change the input decoder + public or private API. + +### Validation Results + +- Passed: `moon fmt` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-drain-build` +- Passed: `moon test internal/input --filter "decode delayed *mouse*" --target-dir .moon-test-input-delayed-mouse-drain-build` +- Passed: `moon check --target-dir .moon-check-win32-drain-build` +- Passed: `moon info --target-dir .moon-info-win32-drain-build` +- Passed: `git diff --check` +- Note: `moon info` still rewrites the pre-existing root `Fd::fd` generated + mbti spelling from `Int` to `@types.Fd`; this follow-up restored the root + generated interface to avoid unrelated public API churn. + +## Follow-up: Use ByteQueue Source For Windows Console Input + +### Goal + +Stop Windows console input from exposing VT sequence tails such as `[3;20M` +when `ReadConsoleInputW` key records are translated to bytes for the shared +input decoder. + +### Accepted Design + +- Add a separate internal package at `internal/io`. +- Expose an opaque internal `ByteQueue` that implements `@async/io.Reader` and + also provides synchronous byte enqueue helpers for producers. +- Keep the queue unbounded for the Windows console byte path so producer-side + `try_put` calls are synchronous and do not suspend. +- The reader blocks on `Queue::get` for the first byte, then drains any + immediately queued bytes with `try_get` into the caller's buffer. +- Replace the Windows console byte pipe with a single stored + `byte_queue : @internal_io.ByteQueue`. +- Use the same `ByteQueue` as the reader passed to the single `Tty.reader` and + as the producer-side byte sink for Windows key records. +- Keep direct native events on the existing event queue. +- Keep the existing drain loop over currently available `INPUT_RECORD` values. +- Close the byte queue with `@async/io.ReaderClosed` so the reader reports EOF + to `EventReader` callers. + +### Public API / Interface Diff + +- No root public MoonBit API change intended. +- Root `pkg.generated.mbti` should remain unchanged after `moon info`. +- `internal/io` exposes an internal-only reader type for use by root Windows + input code and tests. + +### Validation Plan + +- `moon fmt` +- `moon test internal/io --target-dir .moon-test-internal-io-bytequeue-build` +- `moon test . --filter "win32*" --target-dir .moon-test-win32-bytequeue-shape-build` +- `moon test internal/input --filter "decode delayed *mouse*" --target-dir .moon-test-input-delayed-mouse-bytequeue-shape-build` +- `moon check --target-dir .moon-check-bytequeue-shape-build` +- `moon info --target-dir .moon-info-bytequeue-shape-build` +- Review `.mbti` diff. +- `git diff --check` + +### Result + +- Added `internal/io` with an opaque internal `ByteQueue`. +- `ByteQueue` implements `@async/io.Reader`, blocks for the first byte, and + drains already queued bytes with `try_get`. +- `ByteQueue` also exposes producer-side `put_byte`, `put_data`, and `close` + helpers. +- Windows console input now stores one `ByteQueue` instead of an unbuffered pipe + or separate queue/reader fields. +- Key records that should go through the shared input decoder now enqueue UTF-8 + bytes synchronously through `ByteQueue`. +- Record dispatch for key, mouse, focus, resize, and unsupported records is now + synchronous after the `ReadConsoleInputW` poll succeeds, removing the pipe + write cancellation point that could leave VT mouse tails in the console queue. +- Added Windows white-box coverage proving native direct events can be queued + before a complete SGR mouse byte sequence without splitting the sequence. + +### Validation Results + +- Passed: `moon fmt` +- Passed: `moon test internal/io --target-dir .moon-test-internal-io-bytequeue-build` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-bytequeue-shape-build` +- Passed: `moon test internal/input --filter "decode delayed *mouse*" --target-dir .moon-test-input-delayed-mouse-bytequeue-shape-build` +- Passed: `moon check --target-dir .moon-check-bytequeue-shape-build` +- Passed: `moon info --target-dir .moon-info-bytequeue-shape-build` +- Passed: `git diff --check` +- Note: `moon info` still rewrites the pre-existing root `Fd::fd` generated + mbti spelling from `Int` to `@types.Fd`; this follow-up restored the root + generated interface to avoid unrelated public API churn. + +## Follow-up: Keep Win32 Parser Imports Used On Non-Windows CI + +### Goal + +Fix `moon check --deny-warn` on macOS and Ubuntu after adding the Windows +console byte queue, without suppressing unused-package warnings. + +### Accepted Design + +- Treat `internal/win32` as a pure parser/mapping package that can compile on + all native platforms. +- Remove Windows platform gates from the pure `internal/win32` record, key, and + mouse parser/mapping declarations and tests. +- Keep root Windows console backend, `ReadConsoleInputW` FFI, and actual tty + dispatch Windows-only. +- Remove the previous non-Windows `internal/win32` import anchor files because + the package will now use `@public_input` directly on every platform. +- Add a root non-Windows private import anchor that references + `@internal_io.ByteQueue::new` and `@win32.InputRecord::parse`. +- Do not add warning suppression and do not change the root public API. + +### Public API / Interface Diff + +- No root public MoonBit API change intended. +- `internal/win32` generated interface should stay on the same parser/mapping + API across platforms. + +### Validation Plan + +- `moon fmt` +- `moon check --deny-warn` +- `moon check --deny-warn --target all` +- `moon test internal/win32 --target-dir .moon-test-internal-win32-ci-anchor-build` +- `moon test internal/io --target-dir .moon-test-internal-io-ci-anchor-build` +- `moon test . --filter "win32*" --target-dir .moon-test-win32-ci-anchor-build` +- `moon info --target-dir .moon-info-ci-anchor-build` +- Review `.mbti` diff. +- `git diff --check` + +### Result + +- Removed Windows platform gates from the pure `internal/win32` parser, + key-mapping, mouse-mapping, and parser test declarations. +- Removed the earlier non-Windows `internal/win32` import anchor files. +- Added a root non-Windows private import anchor for `@internal_io` and + `@win32`. +- Kept root Windows console backend and `ReadConsoleInputW` FFI code + Windows-only. + +### Validation Results + +- Passed: `moon fmt` +- Passed: `moon check --deny-warn --target-dir .moon-check-deny-ci-anchor-build` +- Passed: `moon check --deny-warn --target all --target-dir .moon-check-all-deny-ci-anchor-build` +- Passed: `moon test internal/win32 --target-dir .moon-test-internal-win32-ci-anchor-build` +- Passed: `moon test internal/io --target-dir .moon-test-internal-io-ci-anchor-build` +- Passed: `moon test . --filter "win32*" --target-dir .moon-test-win32-ci-anchor-build` +- Passed: `moon info --target-dir .moon-info-ci-anchor-build` +- Passed: `git diff --check` +- `.mbti` review: `internal/win32` and `internal/io` generated interfaces did + not change. `moon info` still rewrites the pre-existing root `Fd::fd` + generated mbti spelling from `Int` to `@types.Fd`; this follow-up restored + the root generated interface to avoid unrelated public API churn. diff --git a/examples/raw/main.mbt b/examples/raw/main.mbt index 8d0062a..db52483 100644 --- a/examples/raw/main.mbt +++ b/examples/raw/main.mbt @@ -5,15 +5,26 @@ let number_to_xdigit : ReadOnlyArray[Byte] = [ ] ///| -fn byte_to_hex_line(byte : Byte) -> Bytes { - let buf : FixedArray[Byte] = FixedArray::make(6, 0) - buf[0] = b'0' - buf[1] = b'x' - buf[2] = number_to_xdigit[byte.to_int() / 16] - buf[3] = number_to_xdigit[byte.to_int() % 16] - buf[4] = b'\r' - buf[5] = b'\n' - buf.unsafe_reinterpret_as_bytes() +fn byte_to_printable_char(byte : Byte) -> Char? { + if byte >= b' ' && byte <= b'~' { + Some(byte.to_char()) + } else { + None + } +} + +///| +fn byte_to_raw_line(byte : Byte) -> String { + let line = StringBuilder(size_hint=16) + line.write_string("0x") + line.write_char(number_to_xdigit[byte.to_int() / 16].to_char()) + line.write_char(number_to_xdigit[byte.to_int() % 16].to_char()) + if byte_to_printable_char(byte) is Some(char) { + line.write_string(" ") + line.write_string(char.escape(quote=true)) + } + line.write_string("\r\n") + line.to_string() } ///| @@ -34,7 +45,7 @@ async fn run_raw_loop(tty : @tty.Tty) -> Unit { break } tty.write("read ") - tty.write(byte_to_hex_line(byte)) + tty.write(byte_to_raw_line(byte)) } } diff --git a/examples/raw/main_wbtest.mbt b/examples/raw/main_wbtest.mbt index 074422b..b16c94f 100644 --- a/examples/raw/main_wbtest.mbt +++ b/examples/raw/main_wbtest.mbt @@ -1,6 +1,8 @@ ///| -test "byte to hex line" { - assert_true(byte_to_hex_line(b'\x00') == b"0x00\r\n") - assert_true(byte_to_hex_line(b'\xab') == b"0xab\r\n") - assert_true(byte_to_hex_line(b'\xff') == b"0xff\r\n") +test "byte to raw line prints printable ascii char" { + assert_true(byte_to_raw_line(b'\x00') == "0x00\r\n") + assert_true(byte_to_raw_line(b'a') == "0x61 'a'\r\n") + assert_true(byte_to_raw_line(b' ') == "0x20 ' '\r\n") + assert_true(byte_to_raw_line(b'\\') == "0x5c '\\\\'\r\n") + assert_true(byte_to_raw_line(b'\xab') == "0xab\r\n") } diff --git a/internal/input/decoder_test.mbt b/internal/input/decoder_test.mbt index dd12abb..7407e6f 100644 --- a/internal/input/decoder_test.mbt +++ b/internal/input/decoder_test.mbt @@ -436,6 +436,54 @@ async test "flush escape key" { }) } +///| +async test "decode delayed escape exposes csi mouse tail as text" { + with_delayed_input(b"\x1b", b"[3;71M", 50, events => { + @debug.assert_eq( + events.read_event(esc_timeout_ms=10), + input_key(@public_input.KeyEvent::new(Escape)), + ) + for char in "[3;71M" { + @debug.assert_eq( + events.read_event(esc_timeout_ms=10), + input_key(@public_input.KeyEvent::new(Char(char), text=[char])), + ) + } + }) +} + +///| +async test "decode delayed escape exposes sgr mouse tail as text" { + with_delayed_input(b"\x1b", b"[<3;71;12M", 50, events => { + @debug.assert_eq( + events.read_event(esc_timeout_ms=10), + input_key(@public_input.KeyEvent::new(Escape)), + ) + for char in "[<3;71;12M" { + @debug.assert_eq( + events.read_event(esc_timeout_ms=10), + input_key(@public_input.KeyEvent::new(Char(char), text=[char])), + ) + } + }) +} + +///| +async test "decode delayed sgr mouse prefix consumes csi prefix as unknown" { + with_delayed_input(b"\x1b[<", b"3;71;12M", 50, events => { + @debug.assert_eq( + events.read_event(esc_timeout_ms=10), + input_unknown(b"\x1b[<"), + ) + for char in "3;71;12M" { + @debug.assert_eq( + events.read_event(esc_timeout_ms=10), + input_key(@public_input.KeyEvent::new(Char(char), text=[char])), + ) + } + }) +} + ///| async test "decode escape followed by non printable byte" { with_input(b"\x1b\x80", events => { diff --git a/internal/io/byte_queue.mbt b/internal/io/byte_queue.mbt new file mode 100644 index 0000000..93e9883 --- /dev/null +++ b/internal/io/byte_queue.mbt @@ -0,0 +1,69 @@ +///| +#warnings("-14") +struct ByteQueue { + queue : @async.Queue[Byte] + read_buf : @async/io.ReaderBuffer +} + +///| +#warnings("-14") +pub fn ByteQueue::new() -> ByteQueue { + { queue: Queue(kind=Unbounded), read_buf: @async/io.ReaderBuffer::new() } +} + +///| +pub fn ByteQueue::close(self : ByteQueue) -> Unit { + self.queue.close(error=@async/io.ReaderClosed, clear=true) +} + +///| +pub fn ByteQueue::put_byte(self : ByteQueue, byte : Byte) -> Unit { + guard (try! self.queue.try_put(byte)) +} + +///| +pub fn ByteQueue::put_data(self : ByteQueue, data : &@async/io.Data) -> Unit { + for byte in data.binary() { + self.put_byte(byte) + } +} + +///| +pub impl @async/io.Reader for ByteQueue with fn _get_internal_buffer(self) { + self.read_buf +} + +///| +pub impl @async/io.Reader for ByteQueue with fn _direct_read( + self, + dst, + offset~, + max_len~, +) { + if max_len <= 0 { + return 0 + } + let first = try self.queue.get() catch { + @async/io.ReaderClosed => return 0 + error => raise error + } noraise { + byte => byte + } + dst[offset] = first + let mut len = 1 + while len < max_len { + let next = try self.queue.try_get() catch { + @async/io.ReaderClosed => None + error => raise error + } noraise { + byte => byte + } + if next is Some(byte) { + dst[offset + len] = byte + len += 1 + } else { + break + } + } + len +} diff --git a/internal/io/byte_queue_test.mbt b/internal/io/byte_queue_test.mbt new file mode 100644 index 0000000..fc4145f --- /dev/null +++ b/internal/io/byte_queue_test.mbt @@ -0,0 +1,45 @@ +///| +async test "byte queue drains queued bytes" { + let queue = ByteQueue::new() + queue.put_byte(b'a') + queue.put_byte(b'b') + queue.put_byte(b'c') + let dst = FixedArray::make(4, b'\x00') + let n = queue.read(dst, max_len=4) + @debug.assert_eq(n, 3) + @debug.assert_eq(dst.unsafe_reinterpret_as_bytes()[:n].to_owned(), b"abc") +} + +///| +async test "byte queue waits for first byte" { + let queue = ByteQueue::new() + let dst = FixedArray::make(2, b'\x00') + @async.with_task_group() <| group => { + group.spawn_bg() <| () => { + @async.sleep(10) + queue.put_byte(b'x') + } + let n = queue.read(dst, max_len=2) + @debug.assert_eq(n, 1) + @debug.assert_eq(dst.unsafe_reinterpret_as_bytes()[:n].to_owned(), b"x") + } +} + +///| +async test "byte queue reports eof when closed" { + let queue = ByteQueue::new() + queue.close() + let dst = FixedArray::make(1, b'\x00') + @debug.assert_eq(queue.read(dst, max_len=1), 0) +} + +///| +async test "byte queue writes data" { + let queue = ByteQueue::new() + queue.put_data("ab") + queue.put_data(b"cd") + let dst = FixedArray::make(8, b'\x00') + let n = queue.read(dst, max_len=8) + @debug.assert_eq(n, 4) + @debug.assert_eq(dst.unsafe_reinterpret_as_bytes()[:n].to_owned(), b"abcd") +} diff --git a/internal/io/moon.pkg b/internal/io/moon.pkg new file mode 100644 index 0000000..b83a1a9 --- /dev/null +++ b/internal/io/moon.pkg @@ -0,0 +1,9 @@ +import { + "moonbitlang/async", + "moonbitlang/async/io" @async/io, +} + +import { + "moonbitlang/async", + "moonbitlang/core/debug", +} for "test" diff --git a/internal/io/pkg.generated.mbti b/internal/io/pkg.generated.mbti new file mode 100644 index 0000000..69fd40e --- /dev/null +++ b/internal/io/pkg.generated.mbti @@ -0,0 +1,19 @@ +// Generated using `moon info`, DON'T EDIT IT +package "moonbit-community/tty/internal/io" + +// Values + +// Errors + +// Types and methods +type ByteQueue +pub fn ByteQueue::close(Self) -> Unit +pub fn ByteQueue::new() -> Self +pub fn ByteQueue::put_byte(Self, Byte) -> Unit +pub fn ByteQueue::put_data(Self, &@moonbitlang/async/io.Data) -> Unit +pub impl @moonbitlang/async/io.Reader for ByteQueue + +// Type aliases + +// Traits + diff --git a/internal/win32/key.mbt b/internal/win32/key.mbt new file mode 100644 index 0000000..11450fc --- /dev/null +++ b/internal/win32/key.mbt @@ -0,0 +1,348 @@ +///| +pub fn KeyRecord::is_down(self : KeyRecord) -> Bool { + self.key_down != 0 +} + +///| +pub fn KeyRecord::repeat_count(self : KeyRecord) -> Int { + if self.repeat_count > 0 { + self.repeat_count + } else { + 1 + } +} + +///| +pub fn KeyRecord::text_uses_decoder(self : KeyRecord) -> Bool { + self.key_down != 0 && !self.has_direct_key_metadata() +} + +///| +fn KeyRecord::has_direct_key_metadata(self : KeyRecord) -> Bool { + (self.has_keyboard_modifier() && !self.is_alt_gr_text()) || self.is_keypad() +} + +///| +fn KeyRecord::has_keyboard_modifier(self : KeyRecord) -> Bool { + has_state( + self.control_key_state, + ShiftPressed | + LeftAltPressed | + RightAltPressed | + LeftCtrlPressed | + RightCtrlPressed, + ) +} + +///| +fn KeyRecord::is_alt_gr_text(self : KeyRecord) -> Bool { + let alt_gr_state = RightAltPressed | LeftCtrlPressed + self.key_down != 0 && + self.unicode_char > 0x1f && + (self.control_key_state & alt_gr_state) == alt_gr_state && + !has_state(self.control_key_state, LeftAltPressed | RightCtrlPressed) +} + +///| +pub fn KeyRecord::text( + self : KeyRecord, + pending_surrogate : Ref[Int], +) -> String? { + if self.key_down == 0 { + return None + } + let charcode = self.unicode_char + if charcode == 0 { + pending_surrogate.val = 0 + return None + } + if is_high_surrogate(charcode) { + pending_surrogate.val = charcode + return None + } + if is_low_surrogate(charcode) { + let high = pending_surrogate.val + pending_surrogate.val = 0 + guard high != 0 else { return None } + let char = decode_surrogate_pair(high, charcode) + return Some([char]) + } + pending_surrogate.val = 0 + Some([codepoint_to_char(charcode)]) +} + +///| +pub fn KeyRecord::input( + self : KeyRecord, + pending_surrogate : Ref[Int], +) -> @public_input.InputEvent? { + guard self.key_event(pending_surrogate) is Some(event) else { return None } + Some(Key(event)) +} + +///| +fn KeyRecord::key_kind(self : KeyRecord) -> @public_input.KeyEventKind { + if self.key_down == 0 { + Release + } else if self.repeat_count > 1 { + Repeat + } else { + Press + } +} + +///| +fn KeyRecord::modifiers(self : KeyRecord) -> @public_input.KeyModifiers { + modifiers_from_control_key_state(self.control_key_state) +} + +///| +fn KeyRecord::key_state(self : KeyRecord) -> @public_input.KeyEventState { + @public_input.KeyEventState::new( + keypad=self.is_keypad(), + caps_lock=has_state(self.control_key_state, CapsLockOn), + num_lock=has_state(self.control_key_state, NumLockOn), + ) +} + +///| +fn KeyRecord::key_event( + self : KeyRecord, + pending_surrogate : Ref[Int], +) -> @public_input.KeyEvent? { + let kind = self.key_kind() + let modifiers = self.modifiers() + let state = self.key_state() + match self.key_code_from_virtual_key() { + Some(code) => { + pending_surrogate.val = 0 + return Some(@public_input.KeyEvent::new(code, modifiers~, kind~, state~)) + } + None => () + } + let charcode = self.unicode_char + if charcode == 0 { + pending_surrogate.val = 0 + return control_code_from_virtual_key(self.virtual_key_code).map(code => { + @public_input.KeyEvent::new(code, modifiers~, kind~, state~) + }) + } + if charcode >= 0x00 && charcode <= 0x1f { + pending_surrogate.val = 0 + return control_code_from_char(charcode).map(code => { + @public_input.KeyEvent::new(code, modifiers~, kind~, state~) + }) + } + if is_high_surrogate(charcode) { + pending_surrogate.val = charcode + return None + } + if is_low_surrogate(charcode) { + let high = pending_surrogate.val + pending_surrogate.val = 0 + guard high != 0 else { return None } + let char = decode_surrogate_pair(high, charcode) + return Some(self.char_key_event(char, modifiers, kind, state)) + } + pending_surrogate.val = 0 + let char = codepoint_to_char(charcode) + Some(self.char_key_event(char, modifiers, kind, state)) +} + +///| +fn KeyRecord::key_code_from_virtual_key( + self : KeyRecord, +) -> @public_input.KeyCode? { + if self.is_keypad_enter() { + Some(KeypadEnter) + } else { + key_code_from_virtual_key(self.virtual_key_code) + } +} + +///| +fn KeyRecord::is_keypad(self : KeyRecord) -> Bool { + virtual_key_is_keypad(self.virtual_key_code) || + self.is_keypad_enter() || + self.is_keypad_navigation() +} + +///| +fn KeyRecord::is_keypad_enter(self : KeyRecord) -> Bool { + self.virtual_key_code == 0x0d && + has_state(self.control_key_state, EnhancedKey) +} + +///| +fn KeyRecord::is_keypad_navigation(self : KeyRecord) -> Bool { + virtual_key_is_navigation(self.virtual_key_code) && + !has_state(self.control_key_state, EnhancedKey) +} + +///| +fn KeyRecord::char_key_event( + self : KeyRecord, + char : Char, + modifiers : @public_input.KeyModifiers, + kind : @public_input.KeyEventKind, + state : @public_input.KeyEventState, +) -> @public_input.KeyEvent { + match text_for_key(self, char) { + Some(text) => + @public_input.KeyEvent::new(Char(char), modifiers~, kind~, state~, text~) + None => @public_input.KeyEvent::new(Char(char), modifiers~, kind~, state~) + } +} + +///| +fn text_for_key(record : KeyRecord, char : Char) -> String? { + if record.key_down == 0 { + None + } else { + Some([char]) + } +} + +///| +fn key_code_from_virtual_key(vk : Int) -> @public_input.KeyCode? { + if vk >= 0x70 && vk <= 0x87 { + return function_key(vk - 0x6f) + } + match vk { + 0x08 => Some(Backspace) + 0x09 => Some(Tab) + 0x0d => Some(Enter) + 0x13 => Some(Pause) + 0x1b => Some(Escape) + 0x21 => Some(PageUp) + 0x22 => Some(PageDown) + 0x23 => Some(End) + 0x24 => Some(Home) + 0x25 => Some(Left) + 0x26 => Some(Up) + 0x27 => Some(Right) + 0x28 => Some(Down) + 0x2c => Some(PrintScreen) + 0x2d => Some(Insert) + 0x2e => Some(Delete) + 0x5d => Some(Menu) + 0x6a => Some(KeypadMultiply) + 0x6b => Some(KeypadPlus) + 0x6c => Some(KeypadSeparator) + 0x6d => Some(KeypadMinus) + 0x6e => Some(KeypadDecimal) + 0x6f => Some(KeypadDivide) + 0x90 => Some(NumLock) + 0x91 => Some(ScrollLock) + _ => if vk >= 0x60 && vk <= 0x69 { keypad_digit(vk - 0x60) } else { None } + } +} + +///| +fn control_code_from_char(charcode : Int) -> @public_input.KeyCode? { + match charcode { + 0x00 => Some(Char(' ')) + 0x01..=0x1a => Some(Char(codepoint_to_char(charcode - 1 + 'a'.to_int()))) + 0x1b => Some(Escape) + 0x1c..=0x1f => Some(Char(codepoint_to_char(charcode - 0x1c + '4'.to_int()))) + _ => None + } +} + +///| +fn control_code_from_virtual_key(vk : Int) -> @public_input.KeyCode? { + if vk == 0x20 { + Some(Char(' ')) + } else if vk >= 0x41 && vk <= 0x5a { + Some(Char(codepoint_to_char(vk - 0x41 + 'a'.to_int()))) + } else { + None + } +} + +///| +fn function_key(number : Int) -> @public_input.KeyCode? { + match number { + 1 => Some(F1) + 2 => Some(F2) + 3 => Some(F3) + 4 => Some(F4) + 5 => Some(F5) + 6 => Some(F6) + 7 => Some(F7) + 8 => Some(F8) + 9 => Some(F9) + 10 => Some(F10) + 11 => Some(F11) + 12 => Some(F12) + 13 => Some(F13) + 14 => Some(F14) + 15 => Some(F15) + 16 => Some(F16) + 17 => Some(F17) + 18 => Some(F18) + 19 => Some(F19) + 20 => Some(F20) + 21 => Some(F21) + 22 => Some(F22) + 23 => Some(F23) + 24 => Some(F24) + _ => None + } +} + +///| +fn keypad_digit(number : Int) -> @public_input.KeyCode? { + match number { + 0 => Some(Keypad0) + 1 => Some(Keypad1) + 2 => Some(Keypad2) + 3 => Some(Keypad3) + 4 => Some(Keypad4) + 5 => Some(Keypad5) + 6 => Some(Keypad6) + 7 => Some(Keypad7) + 8 => Some(Keypad8) + 9 => Some(Keypad9) + _ => None + } +} + +///| +fn virtual_key_is_keypad(vk : Int) -> Bool { + vk >= 0x60 && vk <= 0x6f +} + +///| +fn virtual_key_is_navigation(vk : Int) -> Bool { + match vk { + 0x21 | 0x22 | 0x23 | 0x24 | 0x25 | 0x26 | 0x27 | 0x28 | 0x2d | 0x2e => true + _ => false + } +} + +///| +fn has_state(state : Int, mask : Int) -> Bool { + (state & mask) != 0 +} + +///| +fn is_high_surrogate(code : Int) -> Bool { + code >= 0xd800 && code <= 0xdbff +} + +///| +fn is_low_surrogate(code : Int) -> Bool { + code >= 0xdc00 && code <= 0xdfff +} + +///| +fn decode_surrogate_pair(high : Int, low : Int) -> Char { + let code = 0x10000 + (high - 0xd800) * 0x400 + (low - 0xdc00) + codepoint_to_char(code) +} + +///| +fn codepoint_to_char(code : Int) -> Char { + code.to_char().unwrap_or('\u{FFFD}') +} diff --git a/internal/win32/moon.pkg b/internal/win32/moon.pkg new file mode 100644 index 0000000..961ca3e --- /dev/null +++ b/internal/win32/moon.pkg @@ -0,0 +1,12 @@ +import { + "moonbit-community/tty/input" @public_input, +} + +import { + "moonbit-community/tty/input" @public_input, + "moonbitlang/core/debug", +} for "test" + +import { + "moonbitlang/core/debug", +} for "wbtest" diff --git a/internal/win32/mouse.mbt b/internal/win32/mouse.mbt new file mode 100644 index 0000000..33dc4e7 --- /dev/null +++ b/internal/win32/mouse.mbt @@ -0,0 +1,145 @@ +///| +pub fn MouseButtonState::input_event( + self : MouseButtonState, + record : MouseRecord, +) -> @public_input.InputEvent? { + guard self.event_kind(record) is Some(kind) else { return None } + Some( + Mouse( + @public_input.MouseEvent::new( + kind, + record.y + 1, + record.x + 1, + modifiers=record.modifiers(), + ), + ), + ) +} + +///| +fn MouseButtonState::event_kind( + self : MouseButtonState, + record : MouseRecord, +) -> @public_input.MouseEventKind? { + let buttons = record.button_state & MouseButtonMask + match record.event_flags { + 0 => { + let previous = self.buttons + self.buttons = buttons + if newly_pressed_mouse_button(previous, buttons) is Some(button) { + Some(Press(button)) + } else if newly_released_mouse_button(previous, buttons) is Some(button) { + Some(Release(button)) + } else { + None + } + } + MouseMoved => { + self.buttons = buttons + if mouse_button_from_state(buttons) is Some(button) { + Some(Drag(button)) + } else { + Some(Move) + } + } + MouseDoubleClick => { + self.buttons = buttons + mouse_button_from_state(buttons).map(button => Press(button)) + } + MouseWheeled => { + self.buttons = buttons + let delta = signed_high_word(record.button_state) + if delta > 0 { + Some(Scroll(Up)) + } else if delta < 0 { + Some(Scroll(Down)) + } else { + None + } + } + MouseHorizontalWheeled => { + self.buttons = buttons + let delta = signed_high_word(record.button_state) + if delta > 0 { + Some(Scroll(Right)) + } else if delta < 0 { + Some(Scroll(Left)) + } else { + None + } + } + _ => { + self.buttons = buttons + None + } + } +} + +///| +fn MouseRecord::modifiers(self : MouseRecord) -> @public_input.KeyModifiers { + modifiers_from_control_key_state(self.control_key_state) +} + +///| +fn newly_pressed_mouse_button( + previous : Int, + buttons : Int, +) -> @public_input.MouseButton? { + if (buttons & MouseLeftButton) != 0 && (previous & MouseLeftButton) == 0 { + Some(Left) + } else if (buttons & MouseMiddleButton) != 0 && + (previous & MouseMiddleButton) == 0 { + Some(Middle) + } else if (buttons & MouseRightButton) != 0 && + (previous & MouseRightButton) == 0 { + Some(Right) + } else { + None + } +} + +///| +fn newly_released_mouse_button( + previous : Int, + buttons : Int, +) -> @public_input.MouseButton? { + if (previous & MouseLeftButton) != 0 && (buttons & MouseLeftButton) == 0 { + Some(Left) + } else if (previous & MouseMiddleButton) != 0 && + (buttons & MouseMiddleButton) == 0 { + Some(Middle) + } else if (previous & MouseRightButton) != 0 && + (buttons & MouseRightButton) == 0 { + Some(Right) + } else { + None + } +} + +///| +fn mouse_button_from_state(state : Int) -> @public_input.MouseButton? { + if (state & MouseLeftButton) != 0 { + Some(Left) + } else if (state & MouseMiddleButton) != 0 { + Some(Middle) + } else if (state & MouseRightButton) != 0 { + Some(Right) + } else { + None + } +} + +///| +fn signed_high_word(value : Int) -> Int { + let low_word = value & 0xffff + (value - low_word) / 0x10000 +} + +///| +fn modifiers_from_control_key_state(state : Int) -> @public_input.KeyModifiers { + @public_input.KeyModifiers::new( + shift=has_state(state, ShiftPressed), + alt=has_state(state, LeftAltPressed | RightAltPressed), + ctrl=has_state(state, LeftCtrlPressed | RightCtrlPressed), + ) +} diff --git a/internal/win32/pkg.generated.mbti b/internal/win32/pkg.generated.mbti new file mode 100644 index 0000000..5d0f5e9 --- /dev/null +++ b/internal/win32/pkg.generated.mbti @@ -0,0 +1,98 @@ +// Generated using `moon info`, DON'T EDIT IT +package "moonbit-community/tty/internal/win32" + +import { + "moonbit-community/tty/input", + "moonbitlang/core/ref", +} + +// Values +pub const CapsLockOn : Int = 0x0080 + +pub const EnhancedKey : Int = 0x0100 + +pub const LeftAltPressed : Int = 0x0002 + +pub const LeftCtrlPressed : Int = 0x0008 + +pub const MouseDoubleClick : Int = 0x0002 + +pub const MouseHorizontalWheeled : Int = 0x0008 + +pub const MouseLeftButton : Int = 0x0001 + +pub const MouseMiddleButton : Int = 0x0004 + +pub const MouseMoved : Int = 0x0001 + +pub const MouseRightButton : Int = 0x0002 + +pub const MouseWheeled : Int = 0x0004 + +pub const NumLockOn : Int = 0x0020 + +pub const RecordFocus : Int = 0x0010 + +pub const RecordKey : Int = 0x0001 + +pub const RecordMouse : Int = 0x0002 + +pub const RecordWindowBufferSize : Int = 0x0004 + +pub const RightAltPressed : Int = 0x0001 + +pub const RightCtrlPressed : Int = 0x0004 + +pub const ShiftPressed : Int = 0x0010 + +// Errors + +// Types and methods +pub struct Coord { + x : Int + y : Int +} + +pub(all) enum InputRecord { + Key(KeyRecord) + Mouse(MouseRecord) + WindowBufferSize(Coord) + Focus(Bool) + Unsupported(Int) +} +pub fn InputRecord::parse(Bytes) -> Self + +pub struct KeyRecord { + key_down : Int + repeat_count : Int + virtual_key_code : Int + virtual_scan_code : Int + unicode_char : Int + control_key_state : Int +} +pub fn KeyRecord::input(Self, @ref.Ref[Int]) -> @input.InputEvent? +pub fn KeyRecord::is_down(Self) -> Bool +pub fn KeyRecord::new(key_down? : Int, repeat_count? : Int, virtual_key_code? : Int, virtual_scan_code? : Int, unicode_char? : Int, control_key_state? : Int) -> Self +pub fn KeyRecord::repeat_count(Self) -> Int +pub fn KeyRecord::text(Self, @ref.Ref[Int]) -> String? +pub fn KeyRecord::text_uses_decoder(Self) -> Bool + +pub struct MouseButtonState { + mut buttons : Int +} +pub fn MouseButtonState::input_event(Self, MouseRecord) -> @input.InputEvent? +pub fn MouseButtonState::new() -> Self + +pub struct MouseRecord { + x : Int + y : Int + button_state : Int + control_key_state : Int + event_flags : Int +} +pub fn MouseRecord::new(x? : Int, y? : Int, button_state? : Int, control_key_state? : Int, event_flags? : Int) -> Self + +// Type aliases + +// Traits + diff --git a/internal/win32/record.mbt b/internal/win32/record.mbt new file mode 100644 index 0000000..92e01dd --- /dev/null +++ b/internal/win32/record.mbt @@ -0,0 +1,245 @@ +///| +pub const RecordKey : Int = 0x0001 + +///| +pub const RecordMouse : Int = 0x0002 + +///| +pub const RecordWindowBufferSize : Int = 0x0004 + +///| +pub const RecordFocus : Int = 0x0010 + +///| +pub const RightAltPressed : Int = 0x0001 + +///| +pub const LeftAltPressed : Int = 0x0002 + +///| +pub const RightCtrlPressed : Int = 0x0004 + +///| +pub const LeftCtrlPressed : Int = 0x0008 + +///| +pub const ShiftPressed : Int = 0x0010 + +///| +pub const NumLockOn : Int = 0x0020 + +///| +pub const CapsLockOn : Int = 0x0080 + +///| +pub const EnhancedKey : Int = 0x0100 + +///| +pub const MouseMoved : Int = 0x0001 + +///| +pub const MouseDoubleClick : Int = 0x0002 + +///| +pub const MouseWheeled : Int = 0x0004 + +///| +pub const MouseHorizontalWheeled : Int = 0x0008 + +///| +pub const MouseLeftButton : Int = 0x0001 + +///| +pub const MouseRightButton : Int = 0x0002 + +///| +pub const MouseMiddleButton : Int = 0x0004 + +///| +const MouseButtonMask : Int = 0x0007 + +///| +pub(all) enum InputRecord { + Key(KeyRecord) + Mouse(MouseRecord) + WindowBufferSize(Coord) + Focus(Bool) + Unsupported(Int) +} + +///| +pub struct Coord { + x : Int + y : Int +} + +///| +pub struct KeyRecord { + key_down : Int + repeat_count : Int + virtual_key_code : Int + virtual_scan_code : Int + unicode_char : Int + control_key_state : Int +} + +///| +pub struct MouseRecord { + x : Int + y : Int + button_state : Int + control_key_state : Int + event_flags : Int +} + +///| +pub struct MouseButtonState { + mut buttons : Int +} + +///| +pub fn KeyRecord::new( + key_down? : Int = 1, + repeat_count? : Int = 1, + virtual_key_code? : Int = 0, + virtual_scan_code? : Int = 0, + unicode_char? : Int = 0, + control_key_state? : Int = 0, +) -> KeyRecord { + { + key_down, + repeat_count, + virtual_key_code, + virtual_scan_code, + unicode_char, + control_key_state, + } +} + +///| +pub fn MouseRecord::new( + x? : Int = 0, + y? : Int = 0, + button_state? : Int = 0, + control_key_state? : Int = 0, + event_flags? : Int = 0, +) -> MouseRecord { + { x, y, button_state, control_key_state, event_flags } +} + +///| +pub fn MouseButtonState::new() -> MouseButtonState { + { buttons: 0 } +} + +///| +pub fn InputRecord::parse(record : Bytes) -> InputRecord { + let bytes = record[:] + guard u16_at(bytes, 0) is Some(event_type) else { return Unsupported(0) } + match event_type { + RecordKey => parse_key_record(bytes) + RecordMouse => parse_mouse_record(bytes) + RecordWindowBufferSize => parse_window_buffer_size_record(bytes) + RecordFocus => parse_focus_record(bytes) + _ => Unsupported(event_type) + } +} + +///| +fn parse_key_record(bytes : BytesView) -> InputRecord { + guard bool_at(bytes, 4) is Some(key_down) else { + return Unsupported(RecordKey) + } + guard u16_at(bytes, 8) is Some(repeat_count) else { + return Unsupported(RecordKey) + } + guard u16_at(bytes, 10) is Some(virtual_key_code) else { + return Unsupported(RecordKey) + } + guard u16_at(bytes, 12) is Some(virtual_scan_code) else { + return Unsupported(RecordKey) + } + guard u16_at(bytes, 14) is Some(unicode_char) else { + return Unsupported(RecordKey) + } + guard dword_at(bytes, 16) is Some(control_key_state) else { + return Unsupported(RecordKey) + } + Key( + KeyRecord::new( + key_down=if key_down { 1 } else { 0 }, + repeat_count~, + virtual_key_code~, + virtual_scan_code~, + unicode_char~, + control_key_state~, + ), + ) +} + +///| +fn parse_mouse_record(bytes : BytesView) -> InputRecord { + guard i16_at(bytes, 4) is Some(x) else { return Unsupported(RecordMouse) } + guard i16_at(bytes, 6) is Some(y) else { return Unsupported(RecordMouse) } + guard dword_at(bytes, 8) is Some(button_state) else { + return Unsupported(RecordMouse) + } + guard dword_at(bytes, 12) is Some(control_key_state) else { + return Unsupported(RecordMouse) + } + guard dword_at(bytes, 16) is Some(event_flags) else { + return Unsupported(RecordMouse) + } + Mouse( + MouseRecord::new(x~, y~, button_state~, control_key_state~, event_flags~), + ) +} + +///| +fn parse_window_buffer_size_record(bytes : BytesView) -> InputRecord { + guard i16_at(bytes, 4) is Some(x) else { + return Unsupported(RecordWindowBufferSize) + } + guard i16_at(bytes, 6) is Some(y) else { + return Unsupported(RecordWindowBufferSize) + } + WindowBufferSize({ x, y }) +} + +///| +fn parse_focus_record(bytes : BytesView) -> InputRecord { + guard bool_at(bytes, 4) is Some(focus_set) else { + return Unsupported(RecordFocus) + } + Focus(focus_set) +} + +///| +fn bool_at(bytes : BytesView, offset : Int) -> Bool? { + dword_at(bytes, offset).map(value => value != 0) +} + +///| +fn i16_at(bytes : BytesView, offset : Int) -> Int? { + guard u16_at(bytes, offset) is Some(value) else { return None } + if value >= 0x8000 { + Some(value - 0x10000) + } else { + Some(value) + } +} + +///| +fn u16_at(bytes : BytesView, offset : Int) -> Int? { + if offset < 0 || offset + 1 >= bytes.length() { + return None + } + Some(bytes[offset].to_int() + bytes[offset + 1].to_int() * 0x100) +} + +///| +fn dword_at(bytes : BytesView, offset : Int) -> Int? { + guard u16_at(bytes, offset) is Some(low) else { return None } + guard i16_at(bytes, offset + 2) is Some(high) else { return None } + Some(low + high * 0x10000) +} diff --git a/internal/win32/record_test.mbt b/internal/win32/record_test.mbt new file mode 100644 index 0000000..20b1a47 --- /dev/null +++ b/internal/win32/record_test.mbt @@ -0,0 +1,110 @@ +///| +fn test_raw_record(event_type : Int) -> FixedArray[Byte] { + let buf = FixedArray::make(20, b'\x00') + test_set_u16_le(buf, 0, event_type) + buf +} + +///| +fn test_input_record(buf : FixedArray[Byte]) -> InputRecord { + InputRecord::parse(buf.unsafe_reinterpret_as_bytes()) +} + +///| +fn test_set_u16_le(buf : FixedArray[Byte], offset : Int, value : Int) -> Unit { + buf[offset] = (value & 0xff).to_byte() + buf[offset + 1] = ((value / 0x100) & 0xff).to_byte() +} + +///| +fn test_set_u32_le(buf : FixedArray[Byte], offset : Int, value : Int) -> Unit { + buf[offset] = (value & 0xff).to_byte() + buf[offset + 1] = ((value / 0x100) & 0xff).to_byte() + buf[offset + 2] = ((value / 0x10000) & 0xff).to_byte() + buf[offset + 3] = ((value / 0x1000000) & 0xff).to_byte() +} + +///| +test "win32 raw input record parses focus records" { + let buf = test_raw_record(RecordFocus) + test_set_u32_le(buf, 4, 1) + match test_input_record(buf) { + Focus(true) => () + _ => fail("expected focus record") + } +} + +///| +test "win32 raw input record parses window buffer size records" { + let buf = test_raw_record(RecordWindowBufferSize) + test_set_u16_le(buf, 4, 80) + test_set_u16_le(buf, 6, 24) + match test_input_record(buf) { + WindowBufferSize(_) => () + _ => fail("expected window buffer size record") + } +} + +///| +test "win32 raw input record parses unsupported records" { + match InputRecord::parse(b"\x01") { + Unsupported(0) => () + _ => fail("expected unsupported short record") + } + let buf = test_raw_record(0x1234) + match test_input_record(buf) { + Unsupported(0x1234) => () + _ => fail("expected unsupported record type") + } +} + +///| +test "win32 raw input record parses key records to input events" { + let buf = test_raw_record(RecordKey) + test_set_u32_le(buf, 4, 1) + test_set_u16_le(buf, 8, 1) + test_set_u16_le(buf, 10, 0x41) + test_set_u16_le(buf, 12, 0x1e) + test_set_u16_le(buf, 14, 'A'.to_int()) + test_set_u32_le(buf, 16, ShiftPressed) + guard test_input_record(buf) is Key(record) else { + fail("expected key record") + } + @debug.assert_eq( + record.input(Ref(0)), + Some( + Key( + @public_input.KeyEvent::new( + Char('A'), + modifiers=@public_input.KeyModifiers::new(shift=true), + text="A", + ), + ), + ), + ) +} + +///| +test "win32 raw input record parses mouse records to input events" { + let buf = test_raw_record(RecordMouse) + test_set_u16_le(buf, 4, 30) + test_set_u16_le(buf, 6, 2) + test_set_u32_le(buf, 12, ShiftPressed) + test_set_u32_le(buf, 16, MouseMoved) + guard test_input_record(buf) is Mouse(record) else { + fail("expected mouse record") + } + @debug.assert_eq( + MouseButtonState::new().input_event(record), + Some( + Mouse( + @public_input.MouseEvent::new( + Move, + 3, + 31, + modifiers=@public_input.KeyModifiers::new(shift=true), + ), + ), + ), + ) +} diff --git a/internal/win32/record_wbtest.mbt b/internal/win32/record_wbtest.mbt new file mode 100644 index 0000000..b9e34d9 --- /dev/null +++ b/internal/win32/record_wbtest.mbt @@ -0,0 +1,63 @@ +///| +fn test_raw_record(event_type : Int) -> FixedArray[Byte] { + let buf = FixedArray::make(20, b'\x00') + test_set_u16_le(buf, 0, event_type) + buf +} + +///| +fn test_input_record(buf : FixedArray[Byte]) -> InputRecord { + InputRecord::parse(buf.unsafe_reinterpret_as_bytes()) +} + +///| +fn test_set_u16_le(buf : FixedArray[Byte], offset : Int, value : Int) -> Unit { + buf[offset] = (value & 0xff).to_byte() + buf[offset + 1] = ((value / 0x100) & 0xff).to_byte() +} + +///| +fn test_set_u32_le(buf : FixedArray[Byte], offset : Int, value : Int) -> Unit { + buf[offset] = (value & 0xff).to_byte() + buf[offset + 1] = ((value / 0x100) & 0xff).to_byte() + buf[offset + 2] = ((value / 0x10000) & 0xff).to_byte() + buf[offset + 3] = ((value / 0x1000000) & 0xff).to_byte() +} + +///| +test "win32 raw input record parses key records" { + let buf = test_raw_record(RecordKey) + test_set_u32_le(buf, 4, 1) + test_set_u16_le(buf, 8, 2) + test_set_u16_le(buf, 10, 0x41) + test_set_u16_le(buf, 12, 0x1e) + test_set_u16_le(buf, 14, 'A'.to_int()) + test_set_u32_le(buf, 16, ShiftPressed) + guard test_input_record(buf) is Key(record) else { + fail("expected key record") + } + @debug.assert_eq(record.key_down, 1) + @debug.assert_eq(record.repeat_count, 2) + @debug.assert_eq(record.virtual_key_code, 0x41) + @debug.assert_eq(record.virtual_scan_code, 0x1e) + @debug.assert_eq(record.unicode_char, 'A'.to_int()) + @debug.assert_eq(record.control_key_state, ShiftPressed) +} + +///| +test "win32 raw input record parses mouse records" { + let buf = test_raw_record(RecordMouse) + test_set_u16_le(buf, 4, 30) + test_set_u16_le(buf, 6, 2) + test_set_u32_le(buf, 8, MouseLeftButton) + test_set_u32_le(buf, 12, ShiftPressed) + test_set_u32_le(buf, 16, MouseMoved) + guard test_input_record(buf) is Mouse(record) else { + fail("expected mouse record") + } + @debug.assert_eq(record.x, 30) + @debug.assert_eq(record.y, 2) + @debug.assert_eq(record.button_state, MouseLeftButton) + @debug.assert_eq(record.control_key_state, ShiftPressed) + @debug.assert_eq(record.event_flags, MouseMoved) +} diff --git a/moon.pkg b/moon.pkg index ed4587c..acdc000 100644 --- a/moon.pkg +++ b/moon.pkg @@ -9,7 +9,9 @@ import { "moonbitlang/async/io" @async/io, "moonbit-community/tty/color", "moonbit-community/tty/input" @public_input, + "moonbit-community/tty/internal/io" @internal_io, "moonbit-community/tty/internal/input", + "moonbit-community/tty/internal/win32", "moonbit-community/tty/internal/vt", } diff --git a/non_windows_imports.mbt b/non_windows_imports.mbt new file mode 100644 index 0000000..8c76f9e --- /dev/null +++ b/non_windows_imports.mbt @@ -0,0 +1,6 @@ +///| +#cfg(not(platform="windows")) +let _windows_internal_import_anchor : Unit = { + ignore(@internal_io.ByteQueue::new) + ignore(@win32.InputRecord::parse) +} diff --git a/tty.mbt b/tty.mbt index b52ad57..cec6449 100644 --- a/tty.mbt +++ b/tty.mbt @@ -24,7 +24,7 @@ struct Tty { input : &Reader output : &Writer reader : @input.EventReader - win32_console_input : Win32ConsoleInputSource? + input_backend : WindowsInput pending_input : Array[@public_input.InputEvent] } @@ -43,12 +43,12 @@ struct Tty { pub fn[I : Reader, O : Writer] Tty::new(input : I, output : O) -> Tty { let input = input as &Reader let output = output as &Writer - let win32_console_input = win32_console_input_source(input.fd(), output.fd()) + let input_backend = WindowsInput::new(input.fd()) { input, output, - reader: @input.EventReader::new(input as &@async/io.Reader), - win32_console_input, + reader: input_backend.event_reader(input as &@async/io.Reader), + input_backend, pending_input: [], } } @@ -79,10 +79,7 @@ pub fn Tty::stdio() -> Tty { /// Closing stdio-backed handles is a no-op. #cfg(platform="windows") pub fn Tty::close(self : Self) -> Unit { - match self.win32_console_input { - Some(source) => source.close() - None => () - } + self.input_backend.close() self.input.close() self.output.close() } diff --git a/win32_input.c b/win32_input.c index aeae044..4653c51 100644 --- a/win32_input.c +++ b/win32_input.c @@ -1,5 +1,6 @@ #include #include +#include #ifdef _WIN32 #include @@ -7,6 +8,16 @@ #include #endif +MOONBIT_FFI_EXPORT +int32_t +moonbit_tty_get_sizeof_input_record(void) { +#ifdef _WIN32 + return (int32_t)sizeof(INPUT_RECORD); +#else + return 0; +#endif +} + MOONBIT_FFI_EXPORT int32_t moonbit_tty_read_console_input_record( @@ -15,17 +26,9 @@ moonbit_tty_read_console_input_record( #else intptr_t fd, #endif - int32_t *event_type, - int32_t *key_down, - int32_t *repeat_count, - int32_t *virtual_key_code, - int32_t *unicode_char, - int32_t *control_key_state, - int32_t *focus_set + void *output_record ) { - if (event_type == NULL || key_down == NULL || repeat_count == NULL || - virtual_key_code == NULL || unicode_char == NULL || - control_key_state == NULL || focus_set == NULL) { + if (output_record == NULL) { #ifdef _WIN32 SetLastError(ERROR_INVALID_PARAMETER); #else @@ -34,15 +37,9 @@ moonbit_tty_read_console_input_record( return -1; } - *event_type = 0; - *key_down = 0; - *repeat_count = 0; - *virtual_key_code = 0; - *unicode_char = 0; - *control_key_state = 0; - *focus_set = 0; - #ifdef _WIN32 + memset(output_record, 0, sizeof(INPUT_RECORD)); + INPUT_RECORD record; DWORD count = 0; if (!PeekConsoleInputW(fd, &record, 1, &count)) { @@ -58,24 +55,11 @@ moonbit_tty_read_console_input_record( return 0; } - *event_type = (int32_t)record.EventType; - switch (record.EventType) { - case KEY_EVENT: - *key_down = record.Event.KeyEvent.bKeyDown ? 1 : 0; - *repeat_count = (int32_t)record.Event.KeyEvent.wRepeatCount; - *virtual_key_code = (int32_t)record.Event.KeyEvent.wVirtualKeyCode; - *unicode_char = (int32_t)record.Event.KeyEvent.uChar.UnicodeChar; - *control_key_state = (int32_t)record.Event.KeyEvent.dwControlKeyState; - break; - case FOCUS_EVENT: - *focus_set = record.Event.FocusEvent.bSetFocus ? 1 : 0; - break; - default: - break; - } + memcpy(output_record, &record, sizeof(INPUT_RECORD)); return 1; #else (void)fd; + (void)output_record; errno = ENOSYS; return -1; #endif diff --git a/win32_input.mbt b/win32_input.mbt index af2730f..74f2d1f 100644 --- a/win32_input.mbt +++ b/win32_input.mbt @@ -1,111 +1,61 @@ ///| #cfg(platform="windows") -const Win32RecordKey : Int = 0x0001 - -///| -#cfg(platform="windows") -const Win32RecordWindowBufferSize : Int = 0x0004 - -///| -#cfg(platform="windows") -const Win32RecordFocus : Int = 0x0010 - -///| -#cfg(platform="windows") -const Win32RightAltPressed : Int = 0x0001 - -///| -#cfg(platform="windows") -const Win32LeftAltPressed : Int = 0x0002 - -///| -#cfg(platform="windows") -const Win32RightCtrlPressed : Int = 0x0004 - -///| -#cfg(platform="windows") -const Win32LeftCtrlPressed : Int = 0x0008 - -///| -#cfg(platform="windows") -const Win32ShiftPressed : Int = 0x0010 - -///| -#cfg(platform="windows") -const Win32NumLockOn : Int = 0x0020 - -///| -#cfg(platform="windows") -const Win32CapsLockOn : Int = 0x0080 - -///| -#cfg(platform="windows") -const Win32EnhancedKey : Int = 0x0100 - -///| -#cfg(platform="windows") -priv struct Win32InputRecord { - event_type : Int - key_down : Int - repeat_count : Int - virtual_key_code : Int - unicode_char : Int - control_key_state : Int - focus_set : Int +priv enum WindowsInput { + ByteStream + Console( + input_fd~ : @async/types.Fd, + byte_queue~ : @internal_io.ByteQueue, + events~ : @async.Queue[Event], + pending_surrogate~ : Ref[Int], + mouse_button_state~ : @win32.MouseButtonState + ) } ///| #cfg(platform="windows") -priv struct Win32ConsoleInputSource { - input_fd : @async/types.Fd - output_fd : @async/types.Fd - byte_read : @async/io.PipeRead - byte_write : @async/io.PipeWrite - reader : @input.EventReader - events : @async.Queue[Event] - pending_surrogate : Ref[Int] +fn WindowsInput::new(input_fd : @async/types.Fd) -> WindowsInput { + if tty_isatty(input_fd) > 0 { + WindowsInput::console(input_fd) + } else { + ByteStream + } } ///| #cfg(platform="windows") -fn Win32ConsoleInputSource::new( - input_fd : @async/types.Fd, - output_fd : @async/types.Fd, -) -> Win32ConsoleInputSource { - let (byte_read, byte_write) = @async/io.pipe() - { - input_fd, - output_fd, - byte_read, - byte_write, - reader: @input.EventReader::new(byte_read as &@async/io.Reader), - events: Queue(kind=Unbounded), - pending_surrogate: Ref(0), - } +fn WindowsInput::console(input_fd : @async/types.Fd) -> WindowsInput { + let byte_queue = @internal_io.ByteQueue::new() + Console( + input_fd~, + byte_queue~, + events=Queue(kind=Unbounded), + pending_surrogate=Ref(0), + mouse_button_state=@win32.MouseButtonState::new(), + ) } ///| #cfg(platform="windows") -fn Win32ConsoleInputSource::close(self : Win32ConsoleInputSource) -> Unit { - ignore(self.input_fd) - ignore(self.output_fd) - ignore(self.reader) - ignore(self.pending_surrogate.val) - self.byte_read.close() - self.byte_write.close() - self.events.close(clear=true) +fn WindowsInput::event_reader( + self : WindowsInput, + fallback : &@async/io.Reader, +) -> @input.EventReader { + match self { + ByteStream => @input.EventReader::new(fallback) + Console(byte_queue~, ..) => + @input.EventReader::new(byte_queue as &@async/io.Reader) + } } ///| #cfg(platform="windows") -fn win32_console_input_source( - input_fd : @async/types.Fd, - output_fd : @async/types.Fd, -) -> Win32ConsoleInputSource? { - if tty_isatty(input_fd) > 0 { - Some(Win32ConsoleInputSource::new(input_fd, output_fd)) - } else { - None +fn WindowsInput::close(self : WindowsInput) -> Unit { + match self { + ByteStream => () + Console(byte_queue~, events~, ..) => { + byte_queue.close() + events.close(clear=true) + } } } @@ -115,9 +65,9 @@ async fn Tty::read_win32_console_event( self : Self, esc_timeout_ms : Int, ) -> Event { - match self.win32_console_input { - Some(source) => source.read_event(esc_timeout_ms) - None => self.read_decoded_input_event(esc_timeout_ms) + match self.input_backend { + Console(..) => self.read_console_event(esc_timeout_ms) + ByteStream => self.read_decoded_input_event(esc_timeout_ms) } } @@ -127,13 +77,13 @@ async fn Tty::read_internal_event_from_sources( self : Self, esc_timeout_ms : Int, ) -> @input.Event { - match self.win32_console_input { - Some(source) => + match self.input_backend { + Console(..) => @async.with_task_group() <| group => { - group.spawn_bg(no_wait=true) <| () => { source.read_records() } - source.read_internal_event(esc_timeout_ms) + group.spawn_bg(no_wait=true) <| () => { self.read_records() } + self.reader.read_event(esc_timeout_ms~) } - None => self.reader.read_event(esc_timeout_ms~) + ByteStream => self.reader.read_event(esc_timeout_ms~) } } @@ -148,120 +98,109 @@ async fn Tty::read_internal_event_from_sources( ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::read_event( - self : Win32ConsoleInputSource, - esc_timeout_ms : Int, -) -> Event { - match self.try_get_event() { - Some(event) => return event - None => () - } +async fn Tty::read_console_event(self : Self, esc_timeout_ms : Int) -> Event { @async.with_task_group() <| group => { group.spawn_bg(no_wait=true) <| () => { self.read_records() } - group.spawn_bg(no_wait=true) <| () => { - group.return_immediately(self.read_decoded_input_event(esc_timeout_ms)) - } - self.events.get() + self.read_console_input_event(esc_timeout_ms) } } ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::read_decoded_input_event( - self : Win32ConsoleInputSource, +async fn Tty::read_console_input_event( + self : Self, esc_timeout_ms : Int, ) -> Event { - for ;; { - match self.read_internal_event(esc_timeout_ms) { - Input(event) => return Input(event) - CursorPosition(..) => () - KeyboardEnhancementFlags(_) => () - PrimaryDeviceAttributes(_) => () - DynamicColor(..) => () - } + match self.input_backend { + Console(events~, ..) => + @async.with_task_group() <| group => { + group.spawn_bg(no_wait=true) <| () => { + group.return_immediately( + self.read_decoded_input_event(esc_timeout_ms), + ) + } + events.get() + } + ByteStream => self.read_decoded_input_event(esc_timeout_ms) } } ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::read_internal_event( - self : Win32ConsoleInputSource, - esc_timeout_ms : Int, -) -> @input.Event { - self.reader.read_event(esc_timeout_ms~) +async fn Tty::read_records(self : Self) -> Unit { + guard self.input_backend is Console(input_fd~, ..) else { return } + for ;; { + if !self.drain_records(input_fd) { + @async.sleep(10) + } + } } ///| #cfg(platform="windows") -fn Win32ConsoleInputSource::try_get_event( - self : Win32ConsoleInputSource, -) -> Event? { - try self.events.try_get() catch { - _ => None - } noraise { - event => event +fn Tty::drain_records(self : Self, input_fd : @async/types.Fd) -> Bool raise { + let mut read_any = false + for ;; { + if win32_read_console_input_record(input_fd) is Some(record) { + read_any = true + self.read_record(record) + } else { + return read_any + } } } ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::read_records( - self : Win32ConsoleInputSource, -) -> Unit { - for ;; { - match win32_read_console_input_record(self.input_fd) { - None => @async.sleep(10) - Some(record) => self.read_record(record) - } - } +fn queue_console_event(events : @async.Queue[Event], event : Event) -> Unit { + guard (try! events.try_put(event)) } ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::read_record( - self : Win32ConsoleInputSource, - record : Win32InputRecord, -) -> Unit { - match record.event_type { - Win32RecordKey => self.read_key_record(record) - Win32RecordWindowBufferSize => { - self.pending_surrogate.val = 0 - match self.window_size() { - Some(size) => self.events.put(Resize(size)) - None => () +fn Tty::read_record(self : Self, record : @win32.InputRecord) -> Unit { + guard self.input_backend is Console(events~, pending_surrogate~, ..) else { + return + } + match record { + Key(record) => self.read_key_record(record) + Mouse(record) => self.read_mouse_record(record) + WindowBufferSize(_) => { + pending_surrogate.val = 0 + try self.window_size() catch { + _ => () + } noraise { + size => queue_console_event(events, Resize(size)) } } - Win32RecordFocus => { - self.pending_surrogate.val = 0 - if record.focus_set != 0 { - self.events.put(Input(FocusIn)) + Focus(focus_set) => { + pending_surrogate.val = 0 + if focus_set { + queue_console_event(events, Input(FocusIn)) } else { - self.events.put(Input(FocusOut)) + queue_console_event(events, Input(FocusOut)) } } - _ => { - self.pending_surrogate.val = 0 - () - } + Unsupported(_) => pending_surrogate.val = 0 } } ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::read_key_record( - self : Win32ConsoleInputSource, - record : Win32InputRecord, -) -> Unit { - if record.key_down == 0 { +fn Tty::read_key_record(self : Self, record : @win32.KeyRecord) -> Unit { + guard self.input_backend is Console(byte_queue~, pending_surrogate~, ..) else { return } - if !record.win32_key_text_uses_decoder() { + if !record.is_down() { + return + } + if !record.text_uses_decoder() { return self.queue_key_input(record) } - match record.win32_key_text(self.pending_surrogate) { + match record.text(pending_surrogate) { Some(text) => - for _ in 0.. self.queue_key_input(record) } @@ -269,14 +208,14 @@ async fn Win32ConsoleInputSource::read_key_record( ///| #cfg(platform="windows") -async fn Win32ConsoleInputSource::queue_key_input( - self : Win32ConsoleInputSource, - record : Win32InputRecord, -) -> Unit { - match record.win32_key_input(self.pending_surrogate) { +fn Tty::queue_key_input(self : Self, record : @win32.KeyRecord) -> Unit { + guard self.input_backend is Console(events~, pending_surrogate~, ..) else { + return + } + match record.input(pending_surrogate) { Some(input) => - for _ in 0.. () } @@ -284,464 +223,47 @@ async fn Win32ConsoleInputSource::queue_key_input( ///| #cfg(platform="windows") -fn Win32ConsoleInputSource::window_size( - self : Win32ConsoleInputSource, -) -> WindowSize? { - let rows = Ref(0) - let cols = Ref(0) - if tty_get_window_size(self.output_fd, rows, cols) < 0 { - None - } else { - Some({ rows: rows.val, cols: cols.val }) - } -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_repeat_count(self : Win32InputRecord) -> Int { - if self.repeat_count > 0 { - self.repeat_count - } else { - 1 - } -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_text_uses_decoder( - self : Win32InputRecord, -) -> Bool { - self.key_down != 0 && !self.win32_has_direct_key_metadata() -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_has_direct_key_metadata( - self : Win32InputRecord, -) -> Bool { - (self.win32_has_keyboard_modifier() && !self.win32_is_alt_gr_text()) || - self.win32_is_keypad() -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_has_keyboard_modifier( - self : Win32InputRecord, -) -> Bool { - win32_has_state( - self.control_key_state, - Win32ShiftPressed | - Win32LeftAltPressed | - Win32RightAltPressed | - Win32LeftCtrlPressed | - Win32RightCtrlPressed, - ) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_is_alt_gr_text(self : Win32InputRecord) -> Bool { - let alt_gr_state = Win32RightAltPressed | Win32LeftCtrlPressed - self.key_down != 0 && - self.unicode_char > 0x1f && - (self.control_key_state & alt_gr_state) == alt_gr_state && - !win32_has_state( - self.control_key_state, - Win32LeftAltPressed | Win32RightCtrlPressed, - ) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_text( - self : Win32InputRecord, - pending_surrogate : Ref[Int], -) -> String? { - if self.key_down == 0 { - return None - } - let charcode = self.unicode_char - if charcode == 0 { - pending_surrogate.val = 0 - return None - } - if win32_is_high_surrogate(charcode) { - pending_surrogate.val = charcode - return None - } - if win32_is_low_surrogate(charcode) { - let high = pending_surrogate.val - pending_surrogate.val = 0 - guard high != 0 else { return None } - let char = win32_decode_surrogate_pair(high, charcode) - return Some([char]) +fn Tty::read_mouse_record(self : Self, record : @win32.MouseRecord) -> Unit { + guard self.input_backend + is Console(events~, pending_surrogate~, mouse_button_state~, ..) else { + return } pending_surrogate.val = 0 - Some([charcode.unsafe_to_char()]) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_input( - self : Win32InputRecord, - pending_surrogate : Ref[Int], -) -> @public_input.InputEvent? { - guard self.win32_key_event(pending_surrogate) is Some(event) else { - return None - } - Some(Key(event)) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_kind( - self : Win32InputRecord, -) -> @public_input.KeyEventKind { - if self.key_down == 0 { - Release - } else if self.repeat_count > 1 { - Repeat - } else { - Press - } -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_modifiers( - self : Win32InputRecord, -) -> @public_input.KeyModifiers { - @public_input.KeyModifiers::new( - shift=win32_has_state(self.control_key_state, Win32ShiftPressed), - alt=win32_has_state( - self.control_key_state, - Win32LeftAltPressed | Win32RightAltPressed, - ), - ctrl=win32_has_state( - self.control_key_state, - Win32LeftCtrlPressed | Win32RightCtrlPressed, - ), - ) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_state( - self : Win32InputRecord, -) -> @public_input.KeyEventState { - @public_input.KeyEventState::new( - keypad=self.win32_is_keypad(), - caps_lock=win32_has_state(self.control_key_state, Win32CapsLockOn), - num_lock=win32_has_state(self.control_key_state, Win32NumLockOn), - ) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_event( - self : Win32InputRecord, - pending_surrogate : Ref[Int], -) -> @public_input.KeyEvent? { - let kind = self.win32_key_kind() - let modifiers = self.win32_modifiers() - let state = self.win32_key_state() - match self.win32_key_code_from_virtual_key() { - Some(code) => { - pending_surrogate.val = 0 - return Some(@public_input.KeyEvent::new(code, modifiers~, kind~, state~)) - } + match mouse_button_state.input_event(record) { + Some(input) => queue_console_event(events, Input(input)) None => () } - let charcode = self.unicode_char - if charcode == 0 { - pending_surrogate.val = 0 - return win32_control_code_from_virtual_key(self.virtual_key_code).map(code => { - @public_input.KeyEvent::new(code, modifiers~, kind~, state~) - }) - } - if charcode >= 0x00 && charcode <= 0x1f { - pending_surrogate.val = 0 - return win32_control_code_from_char(charcode).map(code => { - @public_input.KeyEvent::new(code, modifiers~, kind~, state~) - }) - } - if win32_is_high_surrogate(charcode) { - pending_surrogate.val = charcode - return None - } - if win32_is_low_surrogate(charcode) { - let high = pending_surrogate.val - pending_surrogate.val = 0 - guard high != 0 else { return None } - let char = win32_decode_surrogate_pair(high, charcode) - return Some(self.win32_char_key_event(char, modifiers, kind, state)) - } - pending_surrogate.val = 0 - let char = charcode.unsafe_to_char() - Some(self.win32_char_key_event(char, modifiers, kind, state)) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_key_code_from_virtual_key( - self : Win32InputRecord, -) -> @public_input.KeyCode? { - if self.win32_is_keypad_enter() { - Some(KeypadEnter) - } else { - win32_key_code_from_virtual_key(self.virtual_key_code) - } -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_is_keypad(self : Win32InputRecord) -> Bool { - win32_virtual_key_is_keypad(self.virtual_key_code) || - self.win32_is_keypad_enter() || - self.win32_is_keypad_navigation() -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_is_keypad_enter(self : Win32InputRecord) -> Bool { - self.virtual_key_code == 0x0d && - win32_has_state(self.control_key_state, Win32EnhancedKey) } ///| #cfg(platform="windows") -fn Win32InputRecord::win32_is_keypad_navigation( - self : Win32InputRecord, -) -> Bool { - win32_virtual_key_is_navigation(self.virtual_key_code) && - !win32_has_state(self.control_key_state, Win32EnhancedKey) -} - -///| -#cfg(platform="windows") -fn Win32InputRecord::win32_char_key_event( - self : Win32InputRecord, - char : Char, - modifiers : @public_input.KeyModifiers, - kind : @public_input.KeyEventKind, - state : @public_input.KeyEventState, -) -> @public_input.KeyEvent { - match win32_text_for_key(self, char) { - Some(text) => - @public_input.KeyEvent::new(Char(char), modifiers~, kind~, state~, text~) - None => @public_input.KeyEvent::new(Char(char), modifiers~, kind~, state~) - } -} +extern "c" fn win32_sizeof_input_record() -> Int = "moonbit_tty_get_sizeof_input_record" ///| #cfg(platform="windows") -fn win32_text_for_key(record : Win32InputRecord, char : Char) -> String? { - if record.key_down == 0 { - None - } else { - Some([char]) - } -} +let sizeof_win32_input_record : Int = win32_sizeof_input_record() ///| -#cfg(platform="windows") -fn win32_key_code_from_virtual_key(vk : Int) -> @public_input.KeyCode? { - if vk >= 0x70 && vk <= 0x87 { - return win32_function_key(vk - 0x6f) - } - match vk { - 0x08 => Some(Backspace) - 0x09 => Some(Tab) - 0x0d => Some(Enter) - 0x13 => Some(Pause) - 0x1b => Some(Escape) - 0x21 => Some(PageUp) - 0x22 => Some(PageDown) - 0x23 => Some(End) - 0x24 => Some(Home) - 0x25 => Some(Left) - 0x26 => Some(Up) - 0x27 => Some(Right) - 0x28 => Some(Down) - 0x2c => Some(PrintScreen) - 0x2d => Some(Insert) - 0x2e => Some(Delete) - 0x5d => Some(Menu) - 0x6a => Some(KeypadMultiply) - 0x6b => Some(KeypadPlus) - 0x6c => Some(KeypadSeparator) - 0x6d => Some(KeypadMinus) - 0x6e => Some(KeypadDecimal) - 0x6f => Some(KeypadDivide) - 0x90 => Some(NumLock) - 0x91 => Some(ScrollLock) - _ => - if vk >= 0x60 && vk <= 0x69 { - win32_keypad_digit(vk - 0x60) - } else { - None - } - } -} - -///| -#cfg(platform="windows") -fn win32_control_code_from_char(charcode : Int) -> @public_input.KeyCode? { - match charcode { - 0x00 => Some(Char(' ')) - 0x01..=0x1a => Some(Char((charcode - 1 + 'a'.to_int()).unsafe_to_char())) - 0x1b => Some(Escape) - 0x1c..=0x1f => Some(Char((charcode - 0x1c + '4'.to_int()).unsafe_to_char())) - _ => None - } -} - -///| -#cfg(platform="windows") -fn win32_control_code_from_virtual_key(vk : Int) -> @public_input.KeyCode? { - if vk == 0x20 { - Some(Char(' ')) - } else if vk >= 0x41 && vk <= 0x5a { - Some(Char((vk - 0x41 + 'a'.to_int()).unsafe_to_char())) - } else { - None - } -} - -///| -#cfg(platform="windows") -fn win32_function_key(number : Int) -> @public_input.KeyCode? { - match number { - 1 => Some(F1) - 2 => Some(F2) - 3 => Some(F3) - 4 => Some(F4) - 5 => Some(F5) - 6 => Some(F6) - 7 => Some(F7) - 8 => Some(F8) - 9 => Some(F9) - 10 => Some(F10) - 11 => Some(F11) - 12 => Some(F12) - 13 => Some(F13) - 14 => Some(F14) - 15 => Some(F15) - 16 => Some(F16) - 17 => Some(F17) - 18 => Some(F18) - 19 => Some(F19) - 20 => Some(F20) - 21 => Some(F21) - 22 => Some(F22) - 23 => Some(F23) - 24 => Some(F24) - _ => None - } -} - -///| -#cfg(platform="windows") -fn win32_keypad_digit(number : Int) -> @public_input.KeyCode? { - match number { - 0 => Some(Keypad0) - 1 => Some(Keypad1) - 2 => Some(Keypad2) - 3 => Some(Keypad3) - 4 => Some(Keypad4) - 5 => Some(Keypad5) - 6 => Some(Keypad6) - 7 => Some(Keypad7) - 8 => Some(Keypad8) - 9 => Some(Keypad9) - _ => None - } -} - -///| -#cfg(platform="windows") -fn win32_virtual_key_is_keypad(vk : Int) -> Bool { - vk >= 0x60 && vk <= 0x6f -} - -///| -#cfg(platform="windows") -fn win32_virtual_key_is_navigation(vk : Int) -> Bool { - match vk { - 0x21 | 0x22 | 0x23 | 0x24 | 0x25 | 0x26 | 0x27 | 0x28 | 0x2d | 0x2e => true - _ => false - } -} - -///| -#cfg(platform="windows") -fn win32_has_state(state : Int, mask : Int) -> Bool { - (state & mask) != 0 -} - -///| -#cfg(platform="windows") -fn win32_is_high_surrogate(code : Int) -> Bool { - code >= 0xd800 && code <= 0xdbff -} - -///| -#cfg(platform="windows") -fn win32_is_low_surrogate(code : Int) -> Bool { - code >= 0xdc00 && code <= 0xdfff -} - -///| -#cfg(platform="windows") -fn win32_decode_surrogate_pair(high : Int, low : Int) -> Char { - let code = 0x10000 + (high - 0xd800) * 0x400 + (low - 0xdc00) - code.unsafe_to_char() -} - -///| -#borrow(event_type, key_down, repeat_count, virtual_key_code, unicode_char, control_key_state, focus_set) +#borrow(record) #cfg(platform="windows") extern "c" fn win32_read_console_input_record_ffi( fd : @async/types.Fd, - event_type : Ref[Int], - key_down : Ref[Int], - repeat_count : Ref[Int], - virtual_key_code : Ref[Int], - unicode_char : Ref[Int], - control_key_state : Ref[Int], - focus_set : Ref[Int], + record : Bytes, ) -> Int = "moonbit_tty_read_console_input_record" ///| #cfg(platform="windows") fn win32_read_console_input_record( fd : @async/types.Fd, -) -> Win32InputRecord? raise { - let event_type = Ref(0) - let key_down = Ref(0) - let repeat_count = Ref(0) - let virtual_key_code = Ref(0) - let unicode_char = Ref(0) - let control_key_state = Ref(0) - let focus_set = Ref(0) - let rc = win32_read_console_input_record_ffi( - fd, event_type, key_down, repeat_count, virtual_key_code, unicode_char, control_key_state, - focus_set, - ) +) -> @win32.InputRecord? raise { + let record = Bytes::make(sizeof_win32_input_record, 0) + let rc = win32_read_console_input_record_ffi(fd, record) if rc < 0 { @os_error.check_errno("ReadConsoleInputW") } if rc == 0 { None } else { - Some({ - event_type: event_type.val, - key_down: key_down.val, - repeat_count: repeat_count.val, - virtual_key_code: virtual_key_code.val, - unicode_char: unicode_char.val, - control_key_state: control_key_state.val, - focus_set: focus_set.val, - }) + Some(@win32.InputRecord::parse(record)) } } diff --git a/win32_input_wbtest.mbt b/win32_input_wbtest.mbt index 42a5c94..c846d8b 100644 --- a/win32_input_wbtest.mbt +++ b/win32_input_wbtest.mbt @@ -5,16 +5,15 @@ fn win32_test_key_record( virtual_key_code? : Int = 0, control_key_state? : Int = 0, key_down? : Int = 1, -) -> Win32InputRecord { - { - event_type: Win32RecordKey, - key_down, - repeat_count: 1, - virtual_key_code, - unicode_char: char.to_int(), - control_key_state, - focus_set: 0, - } +) -> @win32.KeyRecord { + @win32.KeyRecord::new( + key_down~, + repeat_count=1, + virtual_key_code~, + virtual_scan_code=0, + unicode_char=char.to_int(), + control_key_state~, + ) } ///| @@ -24,15 +23,61 @@ fn win32_test_key_record_charcode( virtual_key_code? : Int = 0, control_key_state? : Int = 0, key_down? : Int = 1, -) -> Win32InputRecord { +) -> @win32.KeyRecord { + @win32.KeyRecord::new( + key_down~, + repeat_count=1, + virtual_key_code~, + virtual_scan_code=0, + unicode_char=charcode, + control_key_state~, + ) +} + +///| +#cfg(platform="windows") +fn win32_test_mouse_record( + x? : Int = 0, + y? : Int = 0, + button_state? : Int = 0, + control_key_state? : Int = 0, + event_flags? : Int = 0, +) -> @win32.MouseRecord { + @win32.MouseRecord::new( + x~, + y~, + button_state~, + control_key_state~, + event_flags~, + ) +} + +///| +#cfg(platform="windows") +fn[I : Reader, O : Writer] win32_test_console_tty(input : I, output : O) -> Tty { + let input = input as &Reader + let output = output as &Writer + let input_backend = WindowsInput::console(input.fd()) { - event_type: Win32RecordKey, - key_down, - repeat_count: 1, - virtual_key_code, - unicode_char: charcode, - control_key_state, - focus_set: 0, + input, + output, + reader: input_backend.event_reader(input as &@async/io.Reader), + input_backend, + pending_input: [], + } +} + +///| +#cfg(platform="windows") +fn win32_test_try_get_event(tty : Tty) -> Event? { + match tty.input_backend { + Console(events~, ..) => + try events.try_get() catch { + _ => None + } noraise { + event => event + } + ByteStream => None } } @@ -40,12 +85,10 @@ fn win32_test_key_record_charcode( #cfg(platform="windows") async test "win32 console source ignores key-up records" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record(win32_test_key_record('a', key_down=0)) - match source.try_get_event() { + match win32_test_try_get_event(source) { None => () Some(_) => fail("expected key-up record to be ignored") } @@ -55,20 +98,14 @@ async test "win32 console source ignores key-up records" { #cfg(platform="windows") async test "win32 console source decodes bracketed paste key records" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() - @async.with_task_group() <| group => { - group.spawn_bg() <| () => { - for char in "\u{1B}[200~hello\u{1B}[201~" { - source.read_key_record(win32_test_key_record(char)) - } - } - match source.read_decoded_input_event(50) { - Input(Paste(text)) => @debug.assert_eq(text, "hello") - _ => fail("expected bracketed paste event") - } + for char in "\u{1B}[200~hello\u{1B}[201~" { + source.read_key_record(win32_test_key_record(char)) + } + match source.read_decoded_input_event(50) { + Input(Paste(text)) => @debug.assert_eq(text, "hello") + _ => fail("expected bracketed paste event") } } @@ -76,36 +113,111 @@ async test "win32 console source decodes bracketed paste key records" { #cfg(platform="windows") async test "win32 console source preserves native events during internal reads" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() - @async.with_task_group() <| group => { - group.spawn_bg() <| () => { - source.read_record({ - event_type: Win32RecordFocus, - key_down: 0, - repeat_count: 0, - virtual_key_code: 0, - unicode_char: 0, - control_key_state: 0, - focus_set: 1, - }) - for char in "\u{1B}[7;9R" { - source.read_key_record(win32_test_key_record(char)) - } - } - match source.read_internal_event(50) { - CursorPosition(row~, col~) => { - @debug.assert_eq(row, 7) - @debug.assert_eq(col, 9) - } - _ => fail("expected cursor position event") - } - match source.try_get_event() { - Some(Input(FocusIn)) => () - _ => fail("expected native focus event to remain queued") + source.read_record(Focus(true)) + for char in "\u{1B}[7;9R" { + source.read_key_record(win32_test_key_record(char)) + } + match source.reader.read_event(esc_timeout_ms=50) { + CursorPosition(row~, col~) => { + @debug.assert_eq(row, 7) + @debug.assert_eq(col, 9) } + _ => fail("expected cursor position event") + } + match win32_test_try_get_event(source) { + Some(Input(FocusIn)) => () + _ => fail("expected native focus event to remain queued") + } +} + +///| +#cfg(platform="windows") +async test "win32 console source keeps queued bytes after native event" { + let (dummy_input, dummy_output) = @async/pipe.pipe() + let source = win32_test_console_tty(dummy_input, dummy_output) + defer source.close() + source.read_record(Focus(true)) + for char in "\u{1B}[<0;20;4M" { + source.read_key_record(win32_test_key_record(char)) + } + match win32_test_try_get_event(source) { + Some(Input(FocusIn)) => () + _ => fail("expected native focus event to remain queued") + } + match source.read_decoded_input_event(50) { + Input(Mouse(event)) => + @debug.assert_eq(event, @public_input.MouseEvent::new(Press(Left), 4, 20)) + _ => fail("expected complete queued SGR mouse event") + } +} + +///| +#cfg(platform="windows") +async test "win32 console source maps native mouse records" { + let (dummy_input, dummy_output) = @async/pipe.pipe() + let source = win32_test_console_tty(dummy_input, dummy_output) + defer source.close() + source.read_mouse_record( + win32_test_mouse_record(x=30, y=2, event_flags=@win32.MouseMoved), + ) + match win32_test_try_get_event(source) { + Some(Input(Mouse(event))) => + @debug.assert_eq(event, @public_input.MouseEvent::new(Move, 3, 31)) + _ => fail("expected mouse move event") + } + source.read_mouse_record( + win32_test_mouse_record(x=30, y=2, button_state=@win32.MouseLeftButton), + ) + match win32_test_try_get_event(source) { + Some(Input(Mouse(event))) => + @debug.assert_eq(event, @public_input.MouseEvent::new(Press(Left), 3, 31)) + _ => fail("expected left mouse press event") + } + source.read_mouse_record( + win32_test_mouse_record( + x=31, + y=2, + button_state=@win32.MouseLeftButton, + control_key_state=@win32.ShiftPressed, + event_flags=@win32.MouseMoved, + ), + ) + match win32_test_try_get_event(source) { + Some(Input(Mouse(event))) => + @debug.assert_eq( + event, + @public_input.MouseEvent::new( + Drag(Left), + 3, + 32, + modifiers=@public_input.KeyModifiers::new(shift=true), + ), + ) + _ => fail("expected left mouse drag event") + } + source.read_mouse_record(win32_test_mouse_record(x=31, y=2)) + match win32_test_try_get_event(source) { + Some(Input(Mouse(event))) => + @debug.assert_eq( + event, + @public_input.MouseEvent::new(Release(Left), 3, 32), + ) + _ => fail("expected left mouse release event") + } + source.read_mouse_record( + win32_test_mouse_record( + x=31, + y=2, + button_state=120 * 0x10000, + event_flags=@win32.MouseWheeled, + ), + ) + match win32_test_try_get_event(source) { + Some(Input(Mouse(event))) => + @debug.assert_eq(event, @public_input.MouseEvent::new(Scroll(Up), 3, 32)) + _ => fail("expected mouse wheel up event") } } @@ -113,14 +225,12 @@ async test "win32 console source preserves native events during internal reads" #cfg(platform="windows") async test "win32 console source preserves printable key modifiers" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record( - win32_test_key_record('a', control_key_state=Win32LeftAltPressed), + win32_test_key_record('a', control_key_state=@win32.LeftAltPressed), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -135,10 +245,10 @@ async test "win32 console source preserves printable key modifiers" { source.read_key_record( win32_test_key_record( 'X', - control_key_state=Win32ShiftPressed | Win32RightAltPressed, + control_key_state=@win32.ShiftPressed | @win32.RightAltPressed, ), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -156,18 +266,16 @@ async test "win32 console source preserves printable key modifiers" { #cfg(platform="windows") async test "win32 console source preserves ctrl left bracket" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record( win32_test_key_record( '\u{1B}', virtual_key_code=0xdb, - control_key_state=Win32LeftCtrlPressed, + control_key_state=@win32.LeftCtrlPressed, ), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -184,18 +292,16 @@ async test "win32 console source preserves ctrl left bracket" { #cfg(platform="windows") async test "win32 console source preserves ctrl space" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record( win32_test_key_record_charcode( 0, virtual_key_code=0x20, - control_key_state=Win32LeftCtrlPressed, + control_key_state=@win32.LeftCtrlPressed, ), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -212,27 +318,18 @@ async test "win32 console source preserves ctrl space" { #cfg(platform="windows") async test "win32 console source preserves AltGr text input" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() - @async.with_task_group() <| group => { - group.spawn_bg() <| () => { - source.read_key_record( - win32_test_key_record( - '@', - control_key_state=Win32RightAltPressed | Win32LeftCtrlPressed, - ), - ) - } - match source.read_decoded_input_event(50) { - Input(Key(event)) => - @debug.assert_eq( - event, - @public_input.KeyEvent::new(Char('@'), text="@"), - ) - _ => fail("expected decoded AltGr text key event") - } + source.read_key_record( + win32_test_key_record( + '@', + control_key_state=@win32.RightAltPressed | @win32.LeftCtrlPressed, + ), + ) + match source.read_decoded_input_event(50) { + Input(Key(event)) => + @debug.assert_eq(event, @public_input.KeyEvent::new(Char('@'), text="@")) + _ => fail("expected decoded AltGr text key event") } } @@ -240,18 +337,16 @@ async test "win32 console source preserves AltGr text input" { #cfg(platform="windows") async test "win32 console source preserves keypad key metadata" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record( win32_test_key_record( '1', virtual_key_code=0x61, - control_key_state=Win32NumLockOn, + control_key_state=@win32.NumLockOn, ), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -263,7 +358,7 @@ async test "win32 console source preserves keypad key metadata" { _ => fail("expected keypad 1 key event") } source.read_key_record(win32_test_key_record('*', virtual_key_code=0x6a)) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -280,18 +375,16 @@ async test "win32 console source preserves keypad key metadata" { #cfg(platform="windows") async test "win32 console source preserves keypad enter metadata" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record( win32_test_key_record( '\r', virtual_key_code=0x0d, - control_key_state=Win32EnhancedKey, + control_key_state=@win32.EnhancedKey, ), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -308,14 +401,12 @@ async test "win32 console source preserves keypad enter metadata" { #cfg(platform="windows") async test "win32 console source preserves keypad navigation metadata" { let (dummy_input, dummy_output) = @async/pipe.pipe() - defer dummy_input.close() - defer dummy_output.close() - let source = Win32ConsoleInputSource::new(dummy_input.fd(), dummy_output.fd()) + let source = win32_test_console_tty(dummy_input, dummy_output) defer source.close() source.read_key_record( win32_test_key_record_charcode(0, virtual_key_code=0x23), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq( event, @@ -330,10 +421,10 @@ async test "win32 console source preserves keypad navigation metadata" { win32_test_key_record_charcode( 0, virtual_key_code=0x23, - control_key_state=Win32EnhancedKey, + control_key_state=@win32.EnhancedKey, ), ) - match source.try_get_event() { + match win32_test_try_get_event(source) { Some(Input(Key(event))) => @debug.assert_eq(event, @public_input.KeyEvent::new(End)) _ => fail("expected enhanced End key event")