Skip to content

Commit 6bbc0b7

Browse files
committed
feat(moonetui): add the native terminal driver.
Signed-off-by: Leo Cheng (heke1228) <chengkelfan@qq.com>
1 parent 45b57fa commit 6bbc0b7

6 files changed

Lines changed: 397 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,9 @@ Cell buffer, differential ANSI renderer, incremental key parser, layout, widgets
2929
| `@app` | The widget tree, the focus ring, and the frame loop: poll, parse, dispatch, lay out, compose, diff, write | done |
3030
| `@driver/node` | Node's terminal: raw mode, a queue the data handler fills, and a frame pump | done |
3131
| `@driver/web` | A terminal drawn on a canvas: the grid, the escape sequences it understands, key encoding, and in-band resize | done |
32+
| `@driver/native` | A real terminal through the C library: termios on Unix, virtual terminal mode on Windows | done |
3233

33-
What is left for 0.1.0: the native terminal driver (termios and the Windows console), which is written here but verified in CI, since this machine cannot link it.
34+
All of 0.1.0 is here. What is deliberately not: the CSS-like style system, scrolling containers, and the thirty-odd widgets beyond the eight below — see AGENTS.md for where the line is.
3435

3536
## Design
3637

@@ -44,7 +45,7 @@ Only four operations ever touch a platform: write, read what is available, set r
4445
moon add moonbitstack/moonetui
4546
```
4647

47-
Two runnable examples: `moon run --target js examples/hello` for a terminal, and `examples/browser/index.html` for the same application drawn on a canvas.
48+
Three runnable examples of the same application: `moon run --target native examples/tui` on a terminal, `moon run --target js examples/hello` under Node, and `examples/browser/index.html` on a canvas in a browser.
4849

4950
```moonbit
5051
let screen = @geom.Size::new(80, 24)

driver/native/moon.pkg

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// A real terminal, through the C library.
2+
//
3+
// Windows and Unix look the same from here: the console is put into virtual
4+
// terminal mode, which makes it speak the same escape sequences a Unix terminal
5+
// does, so one parser and one renderer serve both.
6+
import {
7+
"moonbitlang/core/encoding/utf8",
8+
"moonbitstack/moonetui/driver",
9+
"moonbitstack/moonetui/geom",
10+
}
11+
12+
supported_targets = "native"
13+
14+
options(
15+
"native-stub": [ "tty.c" ],
16+
)

driver/native/native.mbt

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright 2026 Leo Cheng
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
///|
5+
/// The terminal this process was started from.
6+
pub struct NativeTty {
7+
buffer : FixedArray[Byte]
8+
scratch : FixedArray[Int]
9+
mut started : Bool
10+
mut raw : Bool
11+
}
12+
13+
///|
14+
/// A driver over the standard streams.
15+
///
16+
/// The read buffer is allocated once: a terminal hands over a few bytes at a
17+
/// time, thousands of times a second, and allocating for each of them would be
18+
/// the only garbage this library produces per frame.
19+
pub fn NativeTty::new(buffer_size? : Int = 4096) -> NativeTty {
20+
{
21+
buffer: FixedArray::make(
22+
if buffer_size < 64 {
23+
64
24+
} else {
25+
buffer_size
26+
},
27+
b'\x00',
28+
),
29+
scratch: FixedArray::make(2, 0),
30+
started: false,
31+
raw: false,
32+
}
33+
}
34+
35+
///|
36+
pub impl @driver.Driver for NativeTty with fn start(self) {
37+
// Raw mode fails when the program is not attached to a terminal — piped into
38+
// a file, run from a test harness. That is not an error: the application
39+
// still draws, and the output is exactly what a terminal would have shown.
40+
self.raw = tty_raw(true) == 0
41+
self.started = true
42+
}
43+
44+
///|
45+
pub impl @driver.Driver for NativeTty with fn stop(self) {
46+
self.started = false
47+
if self.raw && tty_raw(false) != 0 {
48+
raise @driver.Io("cannot restore the terminal")
49+
}
50+
self.raw = false
51+
}
52+
53+
///|
54+
pub impl @driver.Driver for NativeTty with fn write(self, text) {
55+
if !self.started {
56+
raise @driver.Io("write before start")
57+
}
58+
let bytes = @utf8.encode(text[:])
59+
if bytes.length() == 0 {
60+
return
61+
}
62+
if tty_write(bytes, bytes.length()) < 0 {
63+
raise @driver.Io("cannot write to the terminal")
64+
}
65+
}
66+
67+
///|
68+
pub impl @driver.Driver for NativeTty with fn flush(_self) {
69+
// Writes go straight to the file descriptor, so there is nothing held back.
70+
}
71+
72+
///|
73+
pub impl @driver.Driver for NativeTty with fn size(self) {
74+
if tty_size(self.scratch) != 0 {
75+
// A terminal that will not say how big it is — a pipe, a CI log — still has
76+
// to be drawn on, and the classic default is what everything else assumes.
77+
return @geom.Size::new(80, 24)
78+
}
79+
@geom.Size::new(self.scratch[0], self.scratch[1])
80+
}
81+
82+
///|
83+
pub impl @driver.Driver for NativeTty with fn poll(self, timeout) {
84+
let got = tty_read(self.buffer, self.buffer.length(), timeout)
85+
if got < 0 {
86+
raise @driver.Io("cannot read from the terminal")
87+
}
88+
if got == 0 {
89+
return b""
90+
}
91+
Bytes::from_array(self.buffer[0:got])
92+
}
93+
94+
///|
95+
pub impl @driver.Driver for NativeTty with fn now(_self) {
96+
tty_now()
97+
}
98+
99+
///|
100+
/// Whether the terminal is in raw mode. False means the program is not attached
101+
/// to one, which a caller may want to report rather than discover later.
102+
pub fn NativeTty::is_raw(self : NativeTty) -> Bool {
103+
self.raw
104+
}
105+
106+
///|
107+
/// Run an application to completion on the terminal.
108+
///
109+
/// A native program can block, so the frame loop is a loop. The browser and
110+
/// Node drivers cannot, which is why the loop lives here rather than in `app`.
111+
pub fn run(step : () -> Bool raise) -> Unit raise {
112+
while step() {
113+
114+
}
115+
}
116+
117+
///|
118+
extern "C" fn tty_raw(on : Bool) -> Int = "moonetui_tty_raw"
119+
120+
///|
121+
#borrow(text)
122+
extern "C" fn tty_write(text : Bytes, len : Int) -> Int = "moonetui_tty_write"
123+
124+
///|
125+
#borrow(buf)
126+
extern "C" fn tty_read(buf : FixedArray[Byte], len : Int, timeout : Int) -> Int = "moonetui_tty_read"
127+
128+
///|
129+
#borrow(out)
130+
extern "C" fn tty_size(out : FixedArray[Int]) -> Int = "moonetui_tty_size"
131+
132+
///|
133+
extern "C" fn tty_now() -> Int = "moonetui_tty_now"

driver/native/tty.c

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
// Copyright 2026 Leo Cheng
2+
// SPDX-License-Identifier: Apache-2.0
3+
//
4+
// The five things a terminal UI cannot do without the operating system: switch
5+
// raw mode, write, read what has arrived, ask for the size, and read a clock.
6+
// Everything else in moonetui is pure MoonBit.
7+
8+
#include <stdint.h>
9+
#include <string.h>
10+
#include <moonbit.h>
11+
12+
#ifdef _WIN32
13+
14+
#include <windows.h>
15+
16+
static DWORD saved_in_mode = 0;
17+
static DWORD saved_out_mode = 0;
18+
static int saved = 0;
19+
20+
MOONBIT_FFI_EXPORT
21+
int32_t moonetui_tty_raw(int32_t on) {
22+
HANDLE in = GetStdHandle(STD_INPUT_HANDLE);
23+
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
24+
if (in == INVALID_HANDLE_VALUE || out == INVALID_HANDLE_VALUE) {
25+
return -1;
26+
}
27+
if (on) {
28+
if (!saved) {
29+
if (!GetConsoleMode(in, &saved_in_mode) ||
30+
!GetConsoleMode(out, &saved_out_mode)) {
31+
return -1;
32+
}
33+
saved = 1;
34+
}
35+
// Virtual terminal input makes the console send the same escape sequences a
36+
// Unix terminal does, which is what lets one parser serve both.
37+
DWORD in_mode = saved_in_mode;
38+
in_mode &= ~(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT);
39+
in_mode |= ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_WINDOW_INPUT;
40+
DWORD out_mode = saved_out_mode | ENABLE_VIRTUAL_TERMINAL_PROCESSING |
41+
DISABLE_NEWLINE_AUTO_RETURN;
42+
if (!SetConsoleMode(in, in_mode) || !SetConsoleMode(out, out_mode)) {
43+
return -1;
44+
}
45+
return 0;
46+
}
47+
if (saved) {
48+
SetConsoleMode(in, saved_in_mode);
49+
SetConsoleMode(out, saved_out_mode);
50+
}
51+
return 0;
52+
}
53+
54+
MOONBIT_FFI_EXPORT
55+
int32_t moonetui_tty_write(const uint8_t *text, int32_t len) {
56+
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
57+
DWORD written = 0;
58+
int32_t at = 0;
59+
while (at < len) {
60+
if (!WriteFile(out, text + at, (DWORD)(len - at), &written, NULL)) {
61+
return -1;
62+
}
63+
at += (int32_t)written;
64+
}
65+
return at;
66+
}
67+
68+
MOONBIT_FFI_EXPORT
69+
int32_t moonetui_tty_read(uint8_t *buf, int32_t len, int32_t timeout_ms) {
70+
HANDLE in = GetStdHandle(STD_INPUT_HANDLE);
71+
DWORD ready = WaitForSingleObject(in, (DWORD)(timeout_ms < 0 ? 0 : timeout_ms));
72+
if (ready != WAIT_OBJECT_0) {
73+
return 0;
74+
}
75+
DWORD got = 0;
76+
if (!ReadFile(in, buf, (DWORD)len, &got, NULL)) {
77+
return -1;
78+
}
79+
return (int32_t)got;
80+
}
81+
82+
MOONBIT_FFI_EXPORT
83+
int32_t moonetui_tty_size(int32_t *out) {
84+
CONSOLE_SCREEN_BUFFER_INFO info;
85+
HANDLE handle = GetStdHandle(STD_OUTPUT_HANDLE);
86+
if (!GetConsoleScreenBufferInfo(handle, &info)) {
87+
return -1;
88+
}
89+
out[0] = info.srWindow.Right - info.srWindow.Left + 1;
90+
out[1] = info.srWindow.Bottom - info.srWindow.Top + 1;
91+
return 0;
92+
}
93+
94+
MOONBIT_FFI_EXPORT
95+
int32_t moonetui_tty_now(void) {
96+
return (int32_t)(GetTickCount64() & 0x7fffffff);
97+
}
98+
99+
#else
100+
101+
#include <errno.h>
102+
#include <poll.h>
103+
#include <sys/ioctl.h>
104+
#include <termios.h>
105+
#include <time.h>
106+
#include <unistd.h>
107+
108+
static struct termios saved_termios;
109+
static int saved = 0;
110+
111+
MOONBIT_FFI_EXPORT
112+
int32_t moonetui_tty_raw(int32_t on) {
113+
if (on) {
114+
if (!saved) {
115+
if (tcgetattr(STDIN_FILENO, &saved_termios) != 0) {
116+
return -1;
117+
}
118+
saved = 1;
119+
}
120+
struct termios raw = saved_termios;
121+
// Characters arrive as they are typed, without echo, and without the
122+
// terminal turning any of them into signals or flow control.
123+
raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
124+
raw.c_iflag &= ~(IXON | IXOFF | ICRNL | INLCR | IGNCR | BRKINT | ISTRIP);
125+
raw.c_oflag &= ~(OPOST);
126+
raw.c_cc[VMIN] = 0;
127+
raw.c_cc[VTIME] = 0;
128+
return tcsetattr(STDIN_FILENO, TCSANOW, &raw) == 0 ? 0 : -1;
129+
}
130+
if (saved) {
131+
return tcsetattr(STDIN_FILENO, TCSANOW, &saved_termios) == 0 ? 0 : -1;
132+
}
133+
return 0;
134+
}
135+
136+
MOONBIT_FFI_EXPORT
137+
int32_t moonetui_tty_write(const uint8_t *text, int32_t len) {
138+
int32_t at = 0;
139+
while (at < len) {
140+
ssize_t wrote = write(STDOUT_FILENO, text + at, (size_t)(len - at));
141+
if (wrote < 0) {
142+
if (errno == EINTR) {
143+
continue;
144+
}
145+
return -1;
146+
}
147+
at += (int32_t)wrote;
148+
}
149+
return at;
150+
}
151+
152+
MOONBIT_FFI_EXPORT
153+
int32_t moonetui_tty_read(uint8_t *buf, int32_t len, int32_t timeout_ms) {
154+
struct pollfd waiting;
155+
waiting.fd = STDIN_FILENO;
156+
waiting.events = POLLIN;
157+
waiting.revents = 0;
158+
int ready = poll(&waiting, 1, timeout_ms < 0 ? 0 : timeout_ms);
159+
if (ready <= 0) {
160+
return 0;
161+
}
162+
ssize_t got = read(STDIN_FILENO, buf, (size_t)len);
163+
if (got < 0) {
164+
return errno == EAGAIN || errno == EINTR ? 0 : -1;
165+
}
166+
return (int32_t)got;
167+
}
168+
169+
MOONBIT_FFI_EXPORT
170+
int32_t moonetui_tty_size(int32_t *out) {
171+
struct winsize size;
172+
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &size) != 0) {
173+
return -1;
174+
}
175+
out[0] = size.ws_col;
176+
out[1] = size.ws_row;
177+
return 0;
178+
}
179+
180+
MOONBIT_FFI_EXPORT
181+
int32_t moonetui_tty_now(void) {
182+
struct timespec now;
183+
clock_gettime(CLOCK_MONOTONIC, &now);
184+
int64_t ms = (int64_t)now.tv_sec * 1000 + now.tv_nsec / 1000000;
185+
return (int32_t)(ms & 0x7fffffff);
186+
}
187+
188+
#endif

0 commit comments

Comments
 (0)