nandi/jolt-nativepublic Fork 0
6a3304ddddcc7d3e9486b470fea5933a1f81f8e8
Commits
Clone
git clone https://git.rickub.com/nandi/jolt-native.git
git clone ssh://git@rickub.com/nandi/jolt-native.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

lib.rs · 509 lines · 15.9 KBRust Blame HistoryRaw
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago1//! A terminal backend for glimmer, behind a C ABI — `libjolttui.so`.
2//!
3//! [`glimmer-tui`](https://github.com/jolt-lang/glimmer-tui) is the design this
4//! follows: the same tags, the same props, the same keyboard, and the same rule
5//! that painting goes through a grid so a test needs no terminal. What is
6//! different is where the widget layer lives. There it is jolt over ncurses;
7//! here it is Rust behind the same retained-tree ABI `libvidya` already
8//! exports, so one glimmer backend on the jolt side can drive a GPU window or a
9//! terminal by naming a different shared object.
10//!
11//! That split is the point. A reconciler needs widgets to patch, and a terminal
12//! has none — so the tree lives down here, and FFI traffic tracks *edits*
13//! rather than frames: a static screen costs no crossings per frame, and only
14//! what the reconciler actually changed is sent.
15//!
16//! Rules inherited from this workspace's ABI:
17//!
18//! * one session per process;
19//! * every call stays on the thread that opened it — the session lives in
20//! thread-local storage, so a call from another thread is inert rather than
21//! unsound;
22//! * only integers, doubles, and UTF-8 byte strings cross, and a returned
23//! string is borrowed until the next one of its family;
24//! * nothing calls back. Interactions queue, and the caller polls.
25//!
26//! Handlers never cross the boundary: a node reports that it was clicked, and
27//! the caller looks up whose `:on-click` that was.
28
29// Without the terminal feature the flush path is gone, and with it the only
30// caller of a handful of screen and session accessors. They are the ABI's
31// vocabulary, not dead code, so a headless build does not warn about them.
32#![cfg_attr(not(feature = "terminal"), allow(dead_code))]
33
Give the terminal's entry a caret that means what it says 361b4dc nandi 9d ago34mod entry;
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago35mod graphics;
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago36mod keys;
37mod layout;
38mod paint;
39mod screen;
40#[cfg(feature = "terminal")]
41mod term;
42mod tree;
43mod ui;
44
45#[cfg(test)]
46mod tests;
47
48use std::cell::RefCell;
49use std::ffi::{c_char, c_double, c_int};
50
51use jolt_abi::{borrowed, empty_str, guard, Scratch};
52use tree::Value;
53use ui::Ui;
54
55/// The reads: a prop, a tag, a line of the screen. One scratch, so a caller
56/// holding a tag pointer across a prop read gets the documented lifetime and
57/// not a surprise.
58static READS: Scratch = Scratch::new();
59/// Dumps are their own family: a dump is usually being printed beside the
60/// props it mentions.
61static DUMPS: Scratch = Scratch::new();
62/// The event name and the event text are read one after the other by every
63/// caller there will ever be, so they cannot share a scratch.
64static NAMES: Scratch = Scratch::new();
65static EVENTS: Scratch = Scratch::new();
66
67struct Session {
68 ui: Ui,
69 #[cfg(feature = "terminal")]
70 term: Option<term::Term>,
71}
72
73thread_local! {
74 /// The process's session, owned by the thread that opened it.
75 static SESSION: RefCell<Option<Session>> = const { RefCell::new(None) };
76}
77
78fn with<R: Copy>(fallback: R, f: impl FnOnce(&mut Session) -> R) -> R {
79 guard(fallback, || {
80 SESSION.with_borrow_mut(|slot| match slot.as_mut() {
81 Some(session) => f(session),
82 None => fallback,
83 })
84 })
85}
86
87/// Most of this ABI is a call on the tree with a session around it.
88fn with_ui<R: Copy>(fallback: R, f: impl FnOnce(&mut Ui) -> R) -> R {
89 with(fallback, |session| f(&mut session.ui))
90}
91
92// ── the session ─────────────────────────────────────────────────────────────
93
94/// Take the terminal. `mouse` non-zero turns on mouse reporting. 1 on success,
95/// 0 if a session is already open or the terminal refused raw mode.
96#[no_mangle]
97pub extern "C" fn tui_open(mouse: c_int) -> c_int {
98 guard(0, || {
99 SESSION.with_borrow_mut(|slot| {
100 if slot.is_some() {
101 log::error!("jolt-tui: a session is already open");
102 return 0;
103 }
104 #[cfg(feature = "terminal")]
105 {
106 match term::Term::open(mouse != 0) {
107 Ok(term) => {
108 let (w, h) = term.size();
109 *slot = Some(Session {
110 ui: Ui::new(w, h),
111 term: Some(term),
112 });
113 1
114 }
115 Err(e) => {
116 log::error!("jolt-tui: could not take the terminal: {e}");
117 0
118 }
119 }
120 }
121 #[cfg(not(feature = "terminal"))]
122 {
123 let _ = mouse;
124 log::error!("jolt-tui: built without the terminal feature");
125 0
126 }
127 })
128 })
129}
130
131/// Open a session with no terminal at all, at a fixed size.
132///
133/// The whole widget layer works here — layout, painting, focus, keys fed with
134/// `tui_feed_key` — and `tui_screen_line` reads the result back. This is what a
135/// test suite and CI use, and it is the same code path a real session paints
136/// through, not a second implementation of it.
137#[no_mangle]
138pub extern "C" fn tui_headless(width: c_int, height: c_int) -> c_int {
139 guard(0, || {
140 SESSION.with_borrow_mut(|slot| {
141 if slot.is_some() {
142 return 0;
143 }
144 *slot = Some(Session {
145 ui: Ui::new(
146 width.clamp(1, u16::MAX as c_int) as u16,
147 height.clamp(1, u16::MAX as c_int) as u16,
148 ),
149 #[cfg(feature = "terminal")]
150 term: None,
151 });
152 1
153 })
154 })
155}
156
157/// Give the terminal back and drop the tree. Safe to call twice.
158#[no_mangle]
159pub extern "C" fn tui_close() {
160 guard((), || {
161 SESSION.with_borrow_mut(|slot| {
162 #[cfg(feature = "terminal")]
163 if let Some(session) = slot.as_mut() {
164 if let Some(term) = session.term.as_mut() {
165 term.close();
166 }
167 }
168 *slot = None;
169 })
170 })
171}
172
173#[no_mangle]
174pub extern "C" fn tui_should_close() -> c_int {
175 with_ui(1, |ui| ui.should_close() as c_int)
176}
177
178#[no_mangle]
179pub extern "C" fn tui_quit() {
180 with_ui((), |ui| ui.quit())
181}
182
183/// Wait up to `timeout_ms` for input, then handle everything that arrived.
184/// Answers how many things it handled, so a caller can skip a repaint when
185/// nothing happened. Inert in a headless session, which is fed by hand.
186#[no_mangle]
187pub extern "C" fn tui_tick(timeout_ms: c_int) -> c_int {
188 with(0, |session| {
189 #[cfg(feature = "terminal")]
190 {
191 let Some(term) = session.term.as_mut() else {
192 return 0;
193 };
194 let inputs = term.poll(timeout_ms.max(0) as u64);
195 let mut handled = 0;
196 for input in inputs {
197 handled += 1;
198 match input {
199 term::Input::Key(name) => {
200 session.ui.key(&name);
201 }
202 term::Input::Click(x, y) => {
203 session.ui.click(x, y);
204 }
205 term::Input::Wheel(x, y, by) => {
206 session.ui.wheel(x, y, by);
207 }
208 term::Input::Resize(w, h) => session.ui.resize(w, h),
209 }
210 }
211 handled
212 }
213 #[cfg(not(feature = "terminal"))]
214 {
215 let _ = (session, timeout_ms);
216 0
217 }
218 })
219}
220
221/// Lay the tree out, paint it, and send what changed. A headless session paints
222/// and stops there.
223#[no_mangle]
224pub extern "C" fn tui_frame() {
225 with((), |session| {
226 session.ui.frame();
227 #[cfg(feature = "terminal")]
228 if let Some(term) = session.term.as_mut() {
229 let cursor = session.ui.cursor();
Draw a picture in a terminal, over the Kitty graphics protocol c2d912f nandi 16d ago230 let images = session.ui.images().to_vec();
231 if let Err(e) = term.flush(&session.ui.screen, cursor, &images) {
Paint the same tree into a terminal, for the machines with no window a7f6202 nandi 17d ago232 log::error!("jolt-tui: could not write a frame: {e}");
233 }
234 }
235 })
236}
237
238#[no_mangle]
239pub extern "C" fn tui_screen_width() -> c_int {
240 with_ui(0, |ui| ui.screen.width() as c_int)
241}
242
243#[no_mangle]
244pub extern "C" fn tui_screen_height() -> c_int {
245 with_ui(0, |ui| ui.screen.height() as c_int)
246}
247
248/// One painted row as text, trailing blanks trimmed — what a test asserts on,
249/// and what a bug report pastes. Borrowed until the next read.
250#[no_mangle]
251pub extern "C" fn tui_screen_line(y: c_int) -> *const c_char {
252 with_ui(empty_str(), |ui| {
253 if y < 0 {
254 return empty_str();
255 }
256 READS.lend(ui.screen.line(y as u16))
257 })
258}
259
260// ── input by hand ───────────────────────────────────────────────────────────
261
262/// Feed one key by name — `"ctrl+u"`, `"page-down"`, `"a"` — as if the terminal
263/// had sent it. Answers 1 when the backend acted on it and 0 when it went out
264/// as a `key` event instead.
265///
266/// # Safety
267/// `name` is null or a NUL-terminated UTF-8 string.
268#[no_mangle]
269pub unsafe extern "C" fn tui_feed_key(name: *const c_char) -> c_int {
270 let name = borrowed(name);
271 with_ui(0, |ui| ui.key(&name) as c_int)
272}
273
274#[no_mangle]
275pub extern "C" fn tui_feed_click(x: c_int, y: c_int) -> c_int {
276 with_ui(0, |ui| {
277 if x < 0 || y < 0 {
278 return 0;
279 }
280 ui.click(x as u16, y as u16) as c_int
281 })
282}
283
284#[no_mangle]
285pub extern "C" fn tui_feed_wheel(x: c_int, y: c_int, by: c_int) -> c_int {
286 with_ui(0, |ui| {
287 if x < 0 || y < 0 {
288 return 0;
289 }
290 ui.wheel(x as u16, y as u16, by) as c_int
291 })
292}
293
294/// The focused node, 0 for none.
295#[no_mangle]
296pub extern "C" fn tui_focus() -> c_int {
297 with_ui(0, |ui| ui.focus() as c_int)
298}
299
300// ── the tree ────────────────────────────────────────────────────────────────
301
302#[no_mangle]
303pub extern "C" fn tui_tree_root() -> c_int {
304 with_ui(0, |ui| ui.tree.root() as c_int)
305}
306
307/// # Safety
308/// `tag` is null or a NUL-terminated UTF-8 string.
309#[no_mangle]
310pub unsafe extern "C" fn tui_node_new(tag: *const c_char) -> c_int {
311 let tag = borrowed(tag);
312 with_ui(0, |ui| ui.tree.new_node(&tag) as c_int)
313}
314
315#[no_mangle]
316pub extern "C" fn tui_node_free(node: c_int) {
317 with_ui((), |ui| ui.tree.free_node(node.max(0) as u32))
318}
319
320#[no_mangle]
321pub extern "C" fn tui_node_exists(node: c_int) -> c_int {
322 with_ui(0, |ui| ui.tree.exists(node.max(0) as u32) as c_int)
323}
324
325/// # Safety
326/// `key` and `value` are null or NUL-terminated UTF-8 strings.
327#[no_mangle]
328pub unsafe extern "C" fn tui_node_set_str(node: c_int, key: *const c_char, value: *const c_char) {
329 let (key, value) = (borrowed(key), borrowed(value));
330 with_ui((), |ui| {
331 ui.tree.set(node.max(0) as u32, &key, Value::Str(value))
332 })
333}
334
335/// # Safety
336/// `key` is null or a NUL-terminated UTF-8 string.
337#[no_mangle]
338pub unsafe extern "C" fn tui_node_set_num(node: c_int, key: *const c_char, value: c_double) {
339 let key = borrowed(key);
340 with_ui((), |ui| {
341 ui.tree.set(node.max(0) as u32, &key, Value::Num(value))
342 })
343}
344
345/// # Safety
346/// `key` is null or a NUL-terminated UTF-8 string.
347#[no_mangle]
348pub unsafe extern "C" fn tui_node_set_bool(node: c_int, key: *const c_char, value: c_int) {
349 let key = borrowed(key);
350 with_ui((), |ui| {
351 ui.tree
352 .set(node.max(0) as u32, &key, Value::Bool(value != 0))
353 })
354}
355
356#[no_mangle]
357pub extern "C" fn tui_node_clear_props(node: c_int) {
358 with_ui((), |ui| ui.tree.clear_props(node.max(0) as u32))
359}
360
361/// # Safety
362/// `key` is null or a NUL-terminated UTF-8 string.
363#[no_mangle]
364pub unsafe extern "C" fn tui_node_get_str(node: c_int, key: *const c_char) -> *const c_char {
365 let key = borrowed(key);
366 with_ui(empty_str(), |ui| {
367 match ui.tree.get(node.max(0) as u32, &key) {
368 Some(Value::Str(text)) => READS.lend(text.clone()),
369 _ => empty_str(),
370 }
371 })
372}
373
374/// # Safety
375/// `key` is null or a NUL-terminated UTF-8 string.
376#[no_mangle]
377pub unsafe extern "C" fn tui_node_get_num(node: c_int, key: *const c_char) -> c_double {
378 let key = borrowed(key);
379 with_ui(0.0, |ui| match ui.tree.get(node.max(0) as u32, &key) {
380 Some(Value::Num(n)) => *n,
381 Some(Value::Bool(b)) => *b as i32 as f64,
382 _ => 0.0,
383 })
384}
385
386/// # Safety
387/// `key` is null or a NUL-terminated UTF-8 string.
388#[no_mangle]
389pub unsafe extern "C" fn tui_node_get_bool(node: c_int, key: *const c_char) -> c_int {
390 let key = borrowed(key);
391 with_ui(0, |ui| match ui.tree.get(node.max(0) as u32, &key) {
392 Some(Value::Bool(b)) => *b as c_int,
393 Some(Value::Num(n)) => (*n != 0.0) as c_int,
394 _ => 0,
395 })
396}
397
398#[no_mangle]
399pub extern "C" fn tui_node_tag(node: c_int) -> *const c_char {
400 with_ui(empty_str(), |ui| {
401 READS.lend(ui.tree.tag_name(node.max(0) as u32).to_owned())
402 })
403}
404
405/// The node this one hangs off, 0 when it is unparented or is the window.
406#[no_mangle]
407pub extern "C" fn tui_node_parent(node: c_int) -> c_int {
408 with_ui(0, |ui| ui.tree.parent(node.max(0) as u32) as c_int)
409}
410
411#[no_mangle]
412pub extern "C" fn tui_node_child_count(node: c_int) -> c_int {
413 with_ui(0, |ui| ui.tree.child_count(node.max(0) as u32) as c_int)
414}
415
416#[no_mangle]
417pub extern "C" fn tui_node_child_at(node: c_int, index: c_int) -> c_int {
418 with_ui(0, |ui| {
419 if index < 0 {
420 return 0;
421 }
422 ui.tree.child_at(node.max(0) as u32, index as usize) as c_int
423 })
424}
425
426#[no_mangle]
427pub extern "C" fn tui_node_append(parent: c_int, child: c_int) -> c_int {
428 with_ui(0, |ui| {
429 ui.tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int
430 })
431}
432
433#[no_mangle]
434pub extern "C" fn tui_node_remove(parent: c_int, child: c_int) {
435 with_ui((), |ui| {
436 ui.tree.remove(parent.max(0) as u32, child.max(0) as u32)
437 })
438}
439
440#[no_mangle]
441pub extern "C" fn tui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int {
442 with_ui(0, |ui| {
443 ui.tree.insert_after(
444 parent.max(0) as u32,
445 child.max(0) as u32,
446 sibling.max(0) as u32,
447 ) as c_int
448 })
449}
450
451#[no_mangle]
452pub extern "C" fn tui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int {
453 with_ui(0, |ui| {
454 ui.tree.replace(
455 parent.max(0) as u32,
456 old_child.max(0) as u32,
457 new_child.max(0) as u32,
458 ) as c_int
459 })
460}
461
462/// The subtree at `node` as pretty-printed hiccup; `node` 0 means the root, so
463/// `tui_tree_dump(0)` is the whole window.
464#[no_mangle]
465pub extern "C" fn tui_tree_dump(node: c_int) -> *const c_char {
466 with_ui(empty_str(), |ui| {
467 let id = if node <= 0 {
468 ui.tree.root()
469 } else {
470 node as u32
471 };
472 DUMPS.lend(ui.tree.dump(id))
473 })
474}
475
476// ── events ──────────────────────────────────────────────────────────────────
477
478#[no_mangle]
479pub extern "C" fn tui_tree_poll_event() -> c_int {
480 with_ui(0, |ui| ui.tree.poll() as c_int)
481}
482
483#[no_mangle]
484pub extern "C" fn tui_tree_event_node() -> c_int {
485 with_ui(0, |ui| {
486 ui.tree.current().map_or(0, |event| event.node as c_int)
487 })
488}
489
490#[no_mangle]
491pub extern "C" fn tui_tree_event_name() -> *const c_char {
492 with_ui(empty_str(), |ui| match ui.tree.current() {
493 Some(event) => NAMES.lend(event.name),
494 None => empty_str(),
495 })
496}
497
498#[no_mangle]
499pub extern "C" fn tui_tree_event_text() -> *const c_char {
500 with_ui(empty_str(), |ui| match ui.tree.current() {
501 Some(event) => EVENTS.lend(event.text.clone()),
502 None => empty_str(),
503 })
504}
505
506#[no_mangle]
507pub extern "C" fn tui_tree_event_num() -> c_double {
508 with_ui(0.0, |ui| ui.tree.current().map_or(0.0, |event| event.num))
509}