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