Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion gleam.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions manifest.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand All @@ -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" }
41 changes: 11 additions & 30 deletions src/glendix/define.gleam
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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) -> {
Expand All @@ -80,7 +82,7 @@ pub fn main() -> Nil {
),
)
}
exit_process()
process.exit(code: 0)
promise.resolve(Nil)
}
Nil
Expand Down Expand Up @@ -189,8 +191,6 @@ type TerminalControlError {
RawModeCouldNotBeDisabled(reason: String)
}

type RawTerminalModeError

fn parse_key(raw: #(Int, String)) -> KeyInput {
case raw.0 {
1 -> KeyUp
Expand Down Expand Up @@ -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])
Expand All @@ -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(
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
128 changes: 128 additions & 0 deletions src/glendix/internal/define/terminal_control.gleam
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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") {
Expand Down Expand Up @@ -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;
Expand All @@ -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) {
Expand Down
85 changes: 85 additions & 0 deletions terminal-ffi-spike.md
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading