From 3e76e20de640a44488e8473cc9931a607204d7ed Mon Sep 17 00:00:00 2001 From: GGOBP Date: Mon, 7 Sep 2026 15:39:49 +0900 Subject: [PATCH] refactor(define): minimize custom terminal FFI Move the widget definition TUI terminal boundary into the internal glendix/internal/define/terminal_control module. Delegate size queries to the term_size package and successful process termination to plinth, while retaining only the TTY probe, raw-mode control, stdin lifecycle, and non-blocking key polling as documented custom FFI. Preserve the 80x24 fallback, raw-mode error messages, key decoding, queued input, and one-shot timeout behavior. Add contract coverage for every retained key sequence, raw-mode success and error paths, polling, and terminal-size tuple conversion without exposing a new public package API. Refs #18 --- gleam.toml | 3 +- manifest.toml | 2 + src/glendix/define.gleam | 41 ++---- .../internal/define/terminal_control.gleam | 128 +++++++++++++++++ .../define/terminal_control_ffi.mjs} | 27 ++-- terminal-ffi-spike.md | 85 +++++++++++ .../define/terminal_control_test.gleam | 132 ++++++++++++++++++ .../define/terminal_control_test_ffi.mjs | 102 ++++++++++++++ 8 files changed, 476 insertions(+), 44 deletions(-) create mode 100644 src/glendix/internal/define/terminal_control.gleam rename src/glendix/{define_ffi.mjs => internal/define/terminal_control_ffi.mjs} (77%) create mode 100644 terminal-ffi-spike.md create mode 100644 test/glendix/internal/define/terminal_control_test.gleam create mode 100644 test/glendix/internal/define/terminal_control_test_ffi.mjs diff --git a/gleam.toml b/gleam.toml index e9493a9..f694afa 100644 --- a/gleam.toml +++ b/gleam.toml @@ -21,9 +21,10 @@ lustre = ">= 5.7.1 and < 6.0.0" mendraw = ">= 2.0.0 and < 3.0.0" plinth = ">= 0.11.0 and < 1.0.0" simplifile = ">= 2.6.0 and < 3.0.0" -xmlm = ">= 1.0.1 and < 2.0.0" +term_size = ">= 1.0.1 and < 2.0.0" tom = ">= 2.1.0 and < 3.0.0" gossamer = ">= 10.0.0 and < 11.0.0" +xmlm = ">= 1.0.1 and < 2.0.0" [dev_dependencies] gleeunit = ">= 1.11.0 and < 2.0.0" diff --git a/manifest.toml b/manifest.toml index 7ca770e..451d876 100644 --- a/manifest.toml +++ b/manifest.toml @@ -29,6 +29,7 @@ packages = [ { name = "redraw", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_javascript", "gleam_stdlib"], otp_app = "redraw", source = "hex", outer_checksum = "B8CEEB74E8846CE10B8360B924DAD22441B61D947F9449854164F0686C4B8661" }, { name = "redraw_dom", version = "19.2.2", build_tools = ["gleam"], requirements = ["gleam_fetch", "gleam_stdlib", "redraw"], otp_app = "redraw_dom", source = "hex", outer_checksum = "80278296AD6E3D4457D6FF6A14FEA3E90696284BEA297BDE10E430FC4CE726B8" }, { name = "simplifile", version = "2.7.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "A2727627B063E87351934C7F7F008F2D1FDB16F6DE0B8C79F9E46459CFC9C164" }, + { name = "term_size", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "term_size", source = "hex", outer_checksum = "D00BD2BC8FB3EBB7E6AE076F3F1FF2AC9D5ED1805F004D0896C784D06C6645F1" }, { name = "tom", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "DCF04CB7AB35D58CFC598C66EA2E1816D160759802C89B2BA6238780D59BC256" }, { name = "xmlm", version = "1.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "xmlm", source = "hex", outer_checksum = "F23155B6F0B22CB8E09DAD41BB6B1CD036725FD0A80EE25B1D1B14138683A81D" }, ] @@ -46,5 +47,6 @@ plinth = { version = ">= 0.11.0 and < 1.0.0" } redraw = { version = ">= 19.2.2 and < 20.0.0" } redraw_dom = { version = ">= 19.2.2 and < 20.0.0" } simplifile = { version = ">= 2.6.0 and < 3.0.0" } +term_size = { version = ">= 1.0.1 and < 2.0.0" } tom = { version = ">= 2.1.0 and < 3.0.0" } xmlm = { version = ">= 1.0.1 and < 2.0.0" } diff --git a/src/glendix/define.gleam b/src/glendix/define.gleam index 8b71884..0cf2941 100644 --- a/src/glendix/define.gleam +++ b/src/glendix/define.gleam @@ -16,6 +16,8 @@ import glendix/define/document import glendix/define/file_boundary import glendix/define/model import glendix/define/ui +import glendix/internal/define/terminal_control +import plinth/node/process /// Runs this module's command-line entrypoint. pub fn main() -> Nil { @@ -53,7 +55,7 @@ pub fn main() -> Nil { edit_group_idx: 0, edit_item_idx: 0, ) - case is_tty() { + case terminal_control.is_tty() { True -> { case enter_tui() { Error(error) -> { @@ -80,7 +82,7 @@ pub fn main() -> Nil { ), ) } - exit_process() + process.exit(code: 0) promise.resolve(Nil) } Nil @@ -189,8 +191,6 @@ type TerminalControlError { RawModeCouldNotBeDisabled(reason: String) } -type RawTerminalModeError - fn parse_key(raw: #(Int, String)) -> KeyInput { case raw.0 { 1 -> KeyUp @@ -224,9 +224,9 @@ fn buf_delete(buffer: String, pos: Int) -> String { fn enter_tui() -> Result(Nil, TerminalControlError) { use _ <- result.try( - set_terminal_raw_mode(True) + terminal_control.set_raw_mode(terminal_control.Enabled) |> result.map_error(fn(error) { - RawModeCouldNotBeEnabled(raw_terminal_mode_error_message(error)) + RawModeCouldNotBeEnabled(terminal_control.raw_mode_error_message(error)) }), ) stdout.execute([command.EnterAlternateScreen, command.HideCursor]) @@ -235,14 +235,14 @@ fn enter_tui() -> Result(Nil, TerminalControlError) { fn exit_tui() -> Result(Nil, TerminalControlError) { stdout.execute([command.ShowCursor, command.LeaveAlternateScreen]) - set_terminal_raw_mode(False) + terminal_control.set_raw_mode(terminal_control.Disabled) |> result.map_error(fn(error) { - RawModeCouldNotBeDisabled(raw_terminal_mode_error_message(error)) + RawModeCouldNotBeDisabled(terminal_control.raw_mode_error_message(error)) }) } fn render(state: DefineState) -> Nil { - let #(_, term_rows) = terminal_size() + let #(_, term_rows) = terminal_control.size() let screen = case state.view_mode { TreeView -> ui.render_tree_screen( @@ -344,7 +344,7 @@ fn render(state: DefineState) -> Nil { fn tui_loop(state: DefineState) -> promise.Promise(DefineState) { render(state) - use raw <- promise.await(poll_key_raw(0)) + use raw <- promise.await(terminal_control.poll_key_raw(0)) let key = parse_key(raw) case key { KeyNone -> tui_loop(state) @@ -475,7 +475,7 @@ fn move_cursor(state: DefineState, delta: Int) -> DefineState { 0 -> state _ -> { let new_cursor = int.clamp(state.cursor + delta, 0, max - 1) - let #(_, term_rows) = terminal_size() + let #(_, term_rows) = terminal_control.size() let visible = case term_rows > 6 { True -> term_rows - 6 False -> 10 @@ -2072,22 +2072,3 @@ fn file_error_message(error: file_boundary.FileError) -> String { "Unable to write " <> path <> ": " <> reason } } - -// -- FFI -- -@external(javascript, "./define_ffi.mjs", "is_tty") -fn is_tty() -> Bool - -@external(javascript, "./define_ffi.mjs", "exit_process") -fn exit_process() -> Nil - -@external(javascript, "./define_ffi.mjs", "terminal_size") -fn terminal_size() -> #(Int, Int) - -@external(javascript, "./define_ffi.mjs", "poll_key_raw") -fn poll_key_raw(timeout_ms: Int) -> promise.Promise(#(Int, String)) - -@external(javascript, "./define_ffi.mjs", "set_terminal_raw_mode") -fn set_terminal_raw_mode(enabled: Bool) -> Result(Nil, RawTerminalModeError) - -@external(javascript, "./define_ffi.mjs", "terminal_mode_error_message") -fn raw_terminal_mode_error_message(error: RawTerminalModeError) -> String diff --git a/src/glendix/internal/define/terminal_control.gleam b/src/glendix/internal/define/terminal_control.gleam new file mode 100644 index 0000000..8355ede --- /dev/null +++ b/src/glendix/internal/define/terminal_control.gleam @@ -0,0 +1,128 @@ +//// Terminal capability and raw-input boundary for the widget definition TUI. +//// +//// Issue #18 spike outcome: the terminal size query delegates to the +//// `term_size` Hex package, which reads the size on every supported runtime. +//// Raw-mode toggling, the stdin lifecycle, and non-blocking one-shot key +//// polling have no reliable cross-runtime ecosystem equivalent, so they remain +//// custom FFI in `terminal_control_ffi.mjs`. Each retained external documents +//// why it stays custom. See `terminal-ffi-spike.md` for the full evaluation. +//// + +import gleam/javascript/promise +import term_size + +/// Opaque handle for a raw-mode failure reported by the terminal runtime. +/// +/// The value is produced by the FFI and carries the underlying runtime error so +/// callers can surface an exact reason instead of a generic sentinel. +pub type RawModeError + +/// Selects whether terminal raw mode is turned on or off. +/// +/// A dedicated type keeps the public boundary free of a positional `Bool` whose +/// meaning is easy to invert at a call site. +pub type RawMode { + /// Raw mode is on, so individual keypresses arrive without line buffering. + Enabled + /// Raw mode is off, so the terminal returns to cooked line input. + Disabled +} + +/// Reports whether standard input is an interactive TTY. +/// +/// Retained as custom FFI: no evaluated package exposes the runtime +/// `process.stdin.isTTY` probe the TUI needs before entering raw mode. +pub fn is_tty() -> Bool { + is_tty_ffi() +} + +/// Returns the terminal size as `#(columns, rows)`. +/// +/// Delegates to `term_size` and falls back to the conventional 80x24 default +/// when either dimension is unavailable, preserving the previous FFI contract. +pub fn size() -> #(Int, Int) { + size_from(term_size.get()) +} + +/// Resolves the package's `#(rows, columns)` result into the TUI contract. +/// +/// The 80x24 fallback applies when the runtime cannot determine the size. +/// Non-positive dimensions also fall back independently, matching the previous +/// JavaScript `columns || 80` and `rows || 24` behavior. +pub fn size_from(using measurement: Result(#(Int, Int), Nil)) -> #(Int, Int) { + case measurement { + Ok(#(rows, columns)) -> #( + positive_or_fallback(value: columns, fallback: 80), + positive_or_fallback(value: rows, fallback: 24), + ) + Error(Nil) -> #(80, 24) + } +} + +/// Enables or disables terminal raw mode. +/// +/// Retained as custom FFI: toggling raw mode and resuming stdin are runtime +/// terminal-control effects no evaluated package provides safely. Returns a +/// `Result` carrying a `RawModeError` so the caller can report the exact runtime +/// reason, including the "stdin does not support raw mode" case. +pub fn set_raw_mode(to mode: RawMode) -> Result(Nil, RawModeError) { + set_terminal_raw_mode(raw_mode_is_enabled(mode)) +} + +/// Describes a raw-mode failure in human-readable form. +pub fn raw_mode_error_message(for error: RawModeError) -> String { + terminal_mode_error_message(error) +} + +/// Polls for a single key press, waiting up to `timeout_milliseconds`. +/// +/// Retained as custom FFI: the stdin lifecycle, non-blocking one-shot polling, +/// and UTF-8 aware key decoding have no ecosystem equivalent that preserves the +/// required behavior. The raw `#(code, text)` encoding is preserved so the TUI +/// key model stays unchanged, matching the issue #18 non-goals. +pub fn poll_key_raw( + within timeout_milliseconds: Int, +) -> promise.Promise(#(Int, String)) { + poll_key_raw_ffi(timeout_milliseconds) +} + +/// Decodes one raw stdin chunk into the TUI's existing key-code contract. +/// +/// Kept on the internal boundary so contract tests can cover every retained +/// key sequence without exposing a new package API. +pub fn decode_key(raw input: String) -> #(Int, String) { + decode_key_ffi(input) +} + +/// Applies the previous truthy-number fallback without JavaScript coercion. +fn positive_or_fallback(value value: Int, fallback fallback: Int) -> Int { + case value > 0 { + True -> value + False -> fallback + } +} + +/// Translates the raw-mode selection into the boolean the runtime FFI expects. +fn raw_mode_is_enabled(mode: RawMode) -> Bool { + case mode { + Enabled -> True + Disabled -> False + } +} + +// -- FFI -- + +@external(javascript, "./terminal_control_ffi.mjs", "is_tty") +fn is_tty_ffi() -> Bool + +@external(javascript, "./terminal_control_ffi.mjs", "set_terminal_raw_mode") +fn set_terminal_raw_mode(enabled: Bool) -> Result(Nil, RawModeError) + +@external(javascript, "./terminal_control_ffi.mjs", "terminal_mode_error_message") +fn terminal_mode_error_message(error: RawModeError) -> String + +@external(javascript, "./terminal_control_ffi.mjs", "poll_key_raw") +fn poll_key_raw_ffi(timeout_ms: Int) -> promise.Promise(#(Int, String)) + +@external(javascript, "./terminal_control_ffi.mjs", "decode_key") +fn decode_key_ffi(input: String) -> #(Int, String) diff --git a/src/glendix/define_ffi.mjs b/src/glendix/internal/define/terminal_control_ffi.mjs similarity index 77% rename from src/glendix/define_ffi.mjs rename to src/glendix/internal/define/terminal_control_ffi.mjs index 22036b6..c5c1a87 100644 --- a/src/glendix/define_ffi.mjs +++ b/src/glendix/internal/define/terminal_control_ffi.mjs @@ -1,16 +1,17 @@ -// FFI adapter for the Mendix widget property TUI editor. -import { Ok, Error as GleamError } from "../gleam.mjs"; +// Retained custom terminal FFI for the Mendix widget definition TUI editor. +// +// Issue #18 spike outcome: terminal size now delegates to the `term_size` +// package (see terminal_control.gleam). The functions below have no reliable +// cross-runtime ecosystem equivalent and are intentionally kept as custom FFI: +// - is_tty: runtime capability probe. +// - set_terminal_raw_mode / terminal_mode_error_message: raw-mode toggling +// that must resume stdin and report an exact failure reason. +// - poll_key_raw plus the stdin lifecycle and key decoding helpers: +// non-blocking one-shot key polling with UTF-8 aware decoding. +import { Ok, Error as GleamError } from "../../../gleam.mjs"; export function is_tty() { return !!process.stdin.isTTY; } -export function exit_process() { - process.exit(0); -} -export function terminal_size() { - const cols = process.stdout.columns || 80; - const rows = process.stdout.rows || 24; - return [cols, rows]; -} export function set_terminal_raw_mode(enabled) { try { if (typeof process.stdin.setRawMode !== "function") { @@ -42,8 +43,7 @@ function ensureStdin() { process.stdin.resume(); } function onStdinData(data) { - const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8"); - const key = parseKeyBuf(buf); + const key = decode_key(data); if (keyResolver) { if (keyTimer) { clearTimeout(keyTimer); keyTimer = null; } const r = keyResolver; @@ -53,7 +53,8 @@ function onStdinData(data) { keyQueue.push(key); } } -function parseKeyBuf(buf) { +export function decode_key(data) { + const buf = Buffer.isBuffer(data) ? data : Buffer.from(String(data), "utf8"); if (buf.length === 0) return [0, ""]; const b = buf[0]; if (b === 0x1b) { diff --git a/terminal-ffi-spike.md b/terminal-ffi-spike.md new file mode 100644 index 0000000..ed5f6bb --- /dev/null +++ b/terminal-ffi-spike.md @@ -0,0 +1,85 @@ +# Terminal FFI reduction spike (issue #18) + +## Goal + +The widget definition TUI owned all terminal-capability and raw-input FFI in a +single `define_ffi.mjs` adapter. This spike evaluates ecosystem packages that +could replace that custom FFI and minimizes the residue to only what has no +reliable cross-runtime equivalent. + +## Evaluation + +| FFI function | Ecosystem replacement | Decision | +| --- | --- | --- | +| `terminal_size` | `term_size` (`term_size.get`) | Replaced | +| `is_tty` | none | Retained custom FFI | +| `exit_process` | `plinth/node/process.exit` | Replaced | +| `set_terminal_raw_mode` | none | Retained custom FFI | +| `terminal_mode_error_message` | none | Retained custom FFI | +| `poll_key_raw` (+ stdin lifecycle and key decoding) | none | Retained custom FFI | + +### Replaced functions + +#### `terminal_size` -> `term_size` + +`term_size` (v1.x) exposes `term_size.get()`, which reads the terminal size on +every supported runtime and returns a `Result(#(rows, columns), Nil)`, so the +boundary can keep the previous 80x24 fallback without custom FFI. The `etch` +terminal package was also considered but only emits ANSI control strings; it +never queries the runtime size, so it cannot replace this function. + +#### `exit_process` -> `plinth/node/process.exit` + +`plinth` is already a Glendix dependency and exposes Node's typed +`process.exit(code:)` operation, so the custom zero-argument FFI export is +unnecessary. The TUI now calls `process.exit(code: 0)` directly and preserves +the previous successful exit status. + +The Gleam wrapper converts the package's `#(rows, columns)` result into the +previous `#(columns, rows)` contract and preserves the `columns || 80` / +`rows || 24` fallback, including the non-positive case: + +```gleam +import term_size + +pub fn size() -> #(Int, Int) { + size_from(term_size.get()) +} +``` + +### Retained custom FFI + +No evaluated package safely provides the remaining behavior: + +- `is_tty` is a runtime capability probe not exposed by the evaluated + packages. +- `set_terminal_raw_mode` must toggle raw mode, resume stdin, and report an + exact failure reason (including "stdin does not support raw mode") through a + `Result`. +- `poll_key_raw` provides non-blocking one-shot key polling with a buffered + stdin lifecycle and UTF-8 aware decoding of arrow/navigation keys, Enter, + Backspace, Ctrl+C, and Tab. Adopting a package that cannot preserve this + non-blocking one-shot behavior is an explicit non-goal. + +These stay in +`src/glendix/internal/define/terminal_control_ffi.mjs`, and each retained +external is documented as intentionally custom in `terminal_control.gleam`. + +## Outcome + +- Terminal capability and raw input now live in a dedicated + internal `glendix/internal/define/terminal_control` boundary module instead + of the general `define` module; `define_ffi.mjs` is removed without adding a + new public package API. +- Terminal size delegates to `term_size`; the retained raw-input and lifecycle + FFI is documented as intentional residue, and process exit delegates to + `plinth`. +- The size fallback, the raw-mode error path, and key decoding are unchanged. +- The TUI event loop and key model are unchanged, matching the non-goals. +- `glendix -> mendraw` keeps its currently declared dependency source form. + +## Verification + +- `./scripts/verify.sh inner glendix` +- `./scripts/verify.sh shared glendix` when public signatures change +- `./scripts/verify.sh final` before a release or family-wide claim diff --git a/test/glendix/internal/define/terminal_control_test.gleam b/test/glendix/internal/define/terminal_control_test.gleam new file mode 100644 index 0000000..9da417d --- /dev/null +++ b/test/glendix/internal/define/terminal_control_test.gleam @@ -0,0 +1,132 @@ +//// Exercises the terminal control boundary: the delegated size fallback and +//// the retained raw-input FFI surface. +//// + +import gleam/javascript/promise +import gleam/list +import gleeunit/should +import glendix/internal/define/terminal_control + +/// Verifies `term_size` row/column ordering is converted to the TUI contract. +pub fn size_from_available_measurement_swaps_to_columns_rows_test() -> Nil { + terminal_control.size_from(using: Ok(#(42, 132))) + |> should.equal(#(132, 42)) +} + +/// Verifies zero and negative dimensions use their independent fallbacks. +pub fn size_from_nonpositive_measurement_returns_fallbacks_test() -> Nil { + terminal_control.size_from(using: Ok(#(0, -5))) + |> should.equal(#(80, 24)) +} + +/// Verifies one invalid dimension does not replace the other valid dimension. +pub fn size_from_partially_invalid_measurement_falls_back_independently_test() -> Nil { + terminal_control.size_from(using: Ok(#(32, 0))) + |> should.equal(#(80, 32)) +} + +/// Verifies an unavailable terminal size falls back to 80 columns by 24 rows. +pub fn size_from_error_returns_80_by_24_fallback_test() -> Nil { + terminal_control.size_from(using: Error(Nil)) + |> should.equal(#(80, 24)) +} + +/// Verifies the size query always reports positive columns and rows, so the +/// 80x24 fallback keeps the TUI layout valid when the runtime has no size. +pub fn size_always_returns_positive_dimensions_test() -> Nil { + let #(columns, rows) = terminal_control.size() + { columns > 0 } + |> should.be_true + { rows > 0 } + |> should.be_true +} + +/// Verifies the TTY probe reports a boolean capability without raising. +pub fn is_tty_reports_boolean_capability_test() -> Nil { + let interactive = terminal_control.is_tty() + { interactive == True || interactive == False } + |> should.be_true +} + +/// Verifies every retained navigation, control, character, and UTF-8 sequence. +pub fn decode_key_preserves_existing_key_semantics_test() -> Nil { + [ + #("", #(0, "")), + #("\u{1b}[A", #(1, "")), + #("\u{1b}[B", #(2, "")), + #("\u{1b}[C", #(3, "")), + #("\u{1b}[D", #(4, "")), + #("\r", #(5, "")), + #("\n", #(5, "")), + #("\u{1b}", #(6, "")), + #("\u{1b}[Z", #(6, "")), + #("\u{7f}", #(7, "")), + #("\u{8}", #(7, "")), + #("\u{3}", #(8, "")), + #("a", #(9, "a")), + #("é", #(9, "é")), + #("한", #(9, "한")), + #("😀", #(9, "😀")), + #("\u{1b}[H", #(10, "")), + #("\u{1b}[F", #(11, "")), + #("\u{1b}[5~", #(12, "")), + #("\u{1b}[6~", #(13, "")), + #("\t", #(14, "")), + ] + |> list.each(fn(example) { + terminal_control.decode_key(example.0) + |> should.equal(example.1) + }) +} + +/// Verifies unsupported stdin reports the required raw-mode error message. +pub fn set_raw_mode_without_support_preserves_error_message_test() -> Nil { + case set_raw_mode_without_support() { + Ok(Nil) -> False |> should.be_true + Error(error) -> + terminal_control.raw_mode_error_message(error) + |> should.equal("stdin does not support raw mode") + } +} + +/// Verifies thrown runtime errors preserve their exact reason. +pub fn set_raw_mode_exception_preserves_error_message_test() -> Nil { + case set_raw_mode_with_exception() { + Ok(Nil) -> False |> should.be_true + Error(error) -> + terminal_control.raw_mode_error_message(error) + |> should.equal("raw mode exploded") + } +} + +/// Verifies enabling raw mode resumes stdin while disabling it does not. +pub fn set_raw_mode_preserves_enable_disable_lifecycle_test() -> Nil { + raw_mode_lifecycle() + |> should.equal(#(True, True, True, False)) +} + +/// Verifies pending input, queued input, and timeout each resolve exactly once. +pub fn poll_key_raw_preserves_one_shot_queue_and_timeout_test() -> promise.Promise( + Nil, +) { + use results <- promise.await(poll_key_sequence()) + results + |> should.equal(#(#(1, ""), #(9, "q"), #(0, ""))) + promise.resolve(Nil) +} + +// -- FFI -- + +@external(javascript, "./terminal_control_test_ffi.mjs", "set_raw_mode_without_support") +fn set_raw_mode_without_support() -> Result(Nil, terminal_control.RawModeError) + +@external(javascript, "./terminal_control_test_ffi.mjs", "set_raw_mode_with_exception") +fn set_raw_mode_with_exception() -> Result(Nil, terminal_control.RawModeError) + +@external(javascript, "./terminal_control_test_ffi.mjs", "raw_mode_lifecycle") +fn raw_mode_lifecycle() -> #(Bool, Bool, Bool, Bool) + +@external(javascript, "./terminal_control_test_ffi.mjs", "poll_key_sequence") +fn poll_key_sequence() -> promise.Promise( + #(#(Int, String), #(Int, String), #(Int, String)), +) diff --git a/test/glendix/internal/define/terminal_control_test_ffi.mjs b/test/glendix/internal/define/terminal_control_test_ffi.mjs new file mode 100644 index 0000000..6c4d36c --- /dev/null +++ b/test/glendix/internal/define/terminal_control_test_ffi.mjs @@ -0,0 +1,102 @@ +import { + poll_key_raw, + set_terminal_raw_mode, +} from "./terminal_control_ffi.mjs"; + +function with_stdin_methods(setRawMode, resume, action) { + const setRawModeDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "setRawMode", + ); + const resumeDescriptor = Object.getOwnPropertyDescriptor( + process.stdin, + "resume", + ); + Object.defineProperty(process.stdin, "setRawMode", { + configurable: true, + value: setRawMode, + writable: true, + }); + Object.defineProperty(process.stdin, "resume", { + configurable: true, + value: resume, + writable: true, + }); + try { + return action(); + } finally { + if (setRawModeDescriptor) { + Object.defineProperty(process.stdin, "setRawMode", setRawModeDescriptor); + } else { + delete process.stdin.setRawMode; + } + if (resumeDescriptor) { + Object.defineProperty(process.stdin, "resume", resumeDescriptor); + } else { + delete process.stdin.resume; + } + } +} + +export function set_raw_mode_without_support() { + return with_stdin_methods( + undefined, + () => undefined, + () => set_terminal_raw_mode(true), + ); +} + +export function set_raw_mode_with_exception() { + return with_stdin_methods( + () => { + throw new Error("raw mode exploded"); + }, + () => undefined, + () => set_terminal_raw_mode(true), + ); +} + +export function raw_mode_lifecycle() { + let enabledArgument = false; + let enabledResumeCalled = false; + const enabledResult = with_stdin_methods( + (enabled) => { + enabledArgument = enabled; + }, + () => { + enabledResumeCalled = true; + }, + () => set_terminal_raw_mode(true), + ); + + let disabledArgument = true; + let disabledResumeCalled = false; + const disabledResult = with_stdin_methods( + (enabled) => { + disabledArgument = enabled; + }, + () => { + disabledResumeCalled = true; + }, + () => set_terminal_raw_mode(false), + ); + + return [ + enabledResult.constructor.name === "Ok" && enabledArgument, + enabledResumeCalled, + disabledResult.constructor.name === "Ok" && !disabledArgument, + disabledResumeCalled, + ]; +} + +export async function poll_key_sequence() { + const pending = poll_key_raw(1000); + process.stdin.emit("data", Buffer.from("\u001b[A", "utf8")); + const pendingResult = await pending; + + process.stdin.emit("data", Buffer.from("q", "utf8")); + const queuedResult = await poll_key_raw(1000); + const timeoutResult = await poll_key_raw(1); + + return [pendingResult, queuedResult, timeoutResult]; +}