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