//! A terminal backend for glimmer, behind a C ABI — `libjolttui.so`. //! //! [`glimmer-tui`](https://github.com/jolt-lang/glimmer-tui) is the design this //! follows: the same tags, the same props, the same keyboard, and the same rule //! that painting goes through a grid so a test needs no terminal. What is //! different is where the widget layer lives. There it is jolt over ncurses; //! here it is Rust behind the same retained-tree ABI `libvidya` already //! exports, so one glimmer backend on the jolt side can drive a GPU window or a //! terminal by naming a different shared object. //! //! That split is the point. A reconciler needs widgets to patch, and a terminal //! has none — so the tree lives down here, and FFI traffic tracks *edits* //! rather than frames: a static screen costs no crossings per frame, and only //! what the reconciler actually changed is sent. //! //! Rules inherited from this workspace's ABI: //! //! * one session per process; //! * every call stays on the thread that opened it — the session lives in //! thread-local storage, so a call from another thread is inert rather than //! unsound; //! * only integers, doubles, and UTF-8 byte strings cross, and a returned //! string is borrowed until the next one of its family; //! * nothing calls back. Interactions queue, and the caller polls. //! //! Handlers never cross the boundary: a node reports that it was clicked, and //! the caller looks up whose `:on-click` that was. // Without the terminal feature the flush path is gone, and with it the only // caller of a handful of screen and session accessors. They are the ABI's // vocabulary, not dead code, so a headless build does not warn about them. #![cfg_attr(not(feature = "terminal"), allow(dead_code))] mod entry; mod graphics; mod keys; mod layout; mod paint; mod screen; #[cfg(feature = "terminal")] mod term; mod tree; mod ui; #[cfg(test)] mod tests; use std::cell::RefCell; use std::ffi::{c_char, c_double, c_int}; use jolt_abi::{borrowed, empty_str, guard, Scratch}; use tree::Value; use ui::Ui; /// The reads: a prop, a tag, a line of the screen. One scratch, so a caller /// holding a tag pointer across a prop read gets the documented lifetime and /// not a surprise. static READS: Scratch = Scratch::new(); /// Dumps are their own family: a dump is usually being printed beside the /// props it mentions. static DUMPS: Scratch = Scratch::new(); /// The event name and the event text are read one after the other by every /// caller there will ever be, so they cannot share a scratch. static NAMES: Scratch = Scratch::new(); static EVENTS: Scratch = Scratch::new(); struct Session { ui: Ui, #[cfg(feature = "terminal")] term: Option, } thread_local! { /// The process's session, owned by the thread that opened it. static SESSION: RefCell> = const { RefCell::new(None) }; } fn with(fallback: R, f: impl FnOnce(&mut Session) -> R) -> R { guard(fallback, || { SESSION.with_borrow_mut(|slot| match slot.as_mut() { Some(session) => f(session), None => fallback, }) }) } /// Most of this ABI is a call on the tree with a session around it. fn with_ui(fallback: R, f: impl FnOnce(&mut Ui) -> R) -> R { with(fallback, |session| f(&mut session.ui)) } // ── the session ───────────────────────────────────────────────────────────── /// Take the terminal. `mouse` non-zero turns on mouse reporting. 1 on success, /// 0 if a session is already open or the terminal refused raw mode. #[no_mangle] pub extern "C" fn tui_open(mouse: c_int) -> c_int { guard(0, || { SESSION.with_borrow_mut(|slot| { if slot.is_some() { log::error!("jolt-tui: a session is already open"); return 0; } #[cfg(feature = "terminal")] { match term::Term::open(mouse != 0) { Ok(term) => { let (w, h) = term.size(); *slot = Some(Session { ui: Ui::new(w, h), term: Some(term), }); 1 } Err(e) => { log::error!("jolt-tui: could not take the terminal: {e}"); 0 } } } #[cfg(not(feature = "terminal"))] { let _ = mouse; log::error!("jolt-tui: built without the terminal feature"); 0 } }) }) } /// Open a session with no terminal at all, at a fixed size. /// /// The whole widget layer works here — layout, painting, focus, keys fed with /// `tui_feed_key` — and `tui_screen_line` reads the result back. This is what a /// test suite and CI use, and it is the same code path a real session paints /// through, not a second implementation of it. #[no_mangle] pub extern "C" fn tui_headless(width: c_int, height: c_int) -> c_int { guard(0, || { SESSION.with_borrow_mut(|slot| { if slot.is_some() { return 0; } *slot = Some(Session { ui: Ui::new( width.clamp(1, u16::MAX as c_int) as u16, height.clamp(1, u16::MAX as c_int) as u16, ), #[cfg(feature = "terminal")] term: None, }); 1 }) }) } /// Give the terminal back and drop the tree. Safe to call twice. #[no_mangle] pub extern "C" fn tui_close() { guard((), || { SESSION.with_borrow_mut(|slot| { #[cfg(feature = "terminal")] if let Some(session) = slot.as_mut() { if let Some(term) = session.term.as_mut() { term.close(); } } *slot = None; }) }) } #[no_mangle] pub extern "C" fn tui_should_close() -> c_int { with_ui(1, |ui| ui.should_close() as c_int) } #[no_mangle] pub extern "C" fn tui_quit() { with_ui((), |ui| ui.quit()) } /// Wait up to `timeout_ms` for input, then handle everything that arrived. /// Answers how many things it handled, so a caller can skip a repaint when /// nothing happened. Inert in a headless session, which is fed by hand. #[no_mangle] pub extern "C" fn tui_tick(timeout_ms: c_int) -> c_int { with(0, |session| { #[cfg(feature = "terminal")] { let Some(term) = session.term.as_mut() else { return 0; }; let inputs = term.poll(timeout_ms.max(0) as u64); let mut handled = 0; for input in inputs { handled += 1; match input { term::Input::Key(name) => { session.ui.key(&name); } term::Input::Click(x, y) => { session.ui.click(x, y); } term::Input::Wheel(x, y, by) => { session.ui.wheel(x, y, by); } term::Input::Resize(w, h) => session.ui.resize(w, h), } } handled } #[cfg(not(feature = "terminal"))] { let _ = (session, timeout_ms); 0 } }) } /// Lay the tree out, paint it, and send what changed. A headless session paints /// and stops there. #[no_mangle] pub extern "C" fn tui_frame() { with((), |session| { session.ui.frame(); #[cfg(feature = "terminal")] if let Some(term) = session.term.as_mut() { let cursor = session.ui.cursor(); let images = session.ui.images().to_vec(); if let Err(e) = term.flush(&session.ui.screen, cursor, &images) { log::error!("jolt-tui: could not write a frame: {e}"); } } }) } #[no_mangle] pub extern "C" fn tui_screen_width() -> c_int { with_ui(0, |ui| ui.screen.width() as c_int) } #[no_mangle] pub extern "C" fn tui_screen_height() -> c_int { with_ui(0, |ui| ui.screen.height() as c_int) } /// One painted row as text, trailing blanks trimmed — what a test asserts on, /// and what a bug report pastes. Borrowed until the next read. #[no_mangle] pub extern "C" fn tui_screen_line(y: c_int) -> *const c_char { with_ui(empty_str(), |ui| { if y < 0 { return empty_str(); } READS.lend(ui.screen.line(y as u16)) }) } // ── input by hand ─────────────────────────────────────────────────────────── /// Feed one key by name — `"ctrl+u"`, `"page-down"`, `"a"` — as if the terminal /// had sent it. Answers 1 when the backend acted on it and 0 when it went out /// as a `key` event instead. /// /// # Safety /// `name` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_feed_key(name: *const c_char) -> c_int { let name = borrowed(name); with_ui(0, |ui| ui.key(&name) as c_int) } #[no_mangle] pub extern "C" fn tui_feed_click(x: c_int, y: c_int) -> c_int { with_ui(0, |ui| { if x < 0 || y < 0 { return 0; } ui.click(x as u16, y as u16) as c_int }) } #[no_mangle] pub extern "C" fn tui_feed_wheel(x: c_int, y: c_int, by: c_int) -> c_int { with_ui(0, |ui| { if x < 0 || y < 0 { return 0; } ui.wheel(x as u16, y as u16, by) as c_int }) } /// The focused node, 0 for none. #[no_mangle] pub extern "C" fn tui_focus() -> c_int { with_ui(0, |ui| ui.focus() as c_int) } // ── the tree ──────────────────────────────────────────────────────────────── #[no_mangle] pub extern "C" fn tui_tree_root() -> c_int { with_ui(0, |ui| ui.tree.root() as c_int) } /// # Safety /// `tag` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_node_new(tag: *const c_char) -> c_int { let tag = borrowed(tag); with_ui(0, |ui| ui.tree.new_node(&tag) as c_int) } #[no_mangle] pub extern "C" fn tui_node_free(node: c_int) { with_ui((), |ui| ui.tree.free_node(node.max(0) as u32)) } #[no_mangle] pub extern "C" fn tui_node_exists(node: c_int) -> c_int { with_ui(0, |ui| ui.tree.exists(node.max(0) as u32) as c_int) } /// # Safety /// `key` and `value` are null or NUL-terminated UTF-8 strings. #[no_mangle] pub unsafe extern "C" fn tui_node_set_str(node: c_int, key: *const c_char, value: *const c_char) { let (key, value) = (borrowed(key), borrowed(value)); with_ui((), |ui| { ui.tree.set(node.max(0) as u32, &key, Value::Str(value)) }) } /// # Safety /// `key` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_node_set_num(node: c_int, key: *const c_char, value: c_double) { let key = borrowed(key); with_ui((), |ui| { ui.tree.set(node.max(0) as u32, &key, Value::Num(value)) }) } /// # Safety /// `key` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_node_set_bool(node: c_int, key: *const c_char, value: c_int) { let key = borrowed(key); with_ui((), |ui| { ui.tree .set(node.max(0) as u32, &key, Value::Bool(value != 0)) }) } #[no_mangle] pub extern "C" fn tui_node_clear_props(node: c_int) { with_ui((), |ui| ui.tree.clear_props(node.max(0) as u32)) } /// # Safety /// `key` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_node_get_str(node: c_int, key: *const c_char) -> *const c_char { let key = borrowed(key); with_ui(empty_str(), |ui| { match ui.tree.get(node.max(0) as u32, &key) { Some(Value::Str(text)) => READS.lend(text.clone()), _ => empty_str(), } }) } /// # Safety /// `key` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_node_get_num(node: c_int, key: *const c_char) -> c_double { let key = borrowed(key); with_ui(0.0, |ui| match ui.tree.get(node.max(0) as u32, &key) { Some(Value::Num(n)) => *n, Some(Value::Bool(b)) => *b as i32 as f64, _ => 0.0, }) } /// # Safety /// `key` is null or a NUL-terminated UTF-8 string. #[no_mangle] pub unsafe extern "C" fn tui_node_get_bool(node: c_int, key: *const c_char) -> c_int { let key = borrowed(key); with_ui(0, |ui| match ui.tree.get(node.max(0) as u32, &key) { Some(Value::Bool(b)) => *b as c_int, Some(Value::Num(n)) => (*n != 0.0) as c_int, _ => 0, }) } #[no_mangle] pub extern "C" fn tui_node_tag(node: c_int) -> *const c_char { with_ui(empty_str(), |ui| { READS.lend(ui.tree.tag_name(node.max(0) as u32).to_owned()) }) } /// The node this one hangs off, 0 when it is unparented or is the window. #[no_mangle] pub extern "C" fn tui_node_parent(node: c_int) -> c_int { with_ui(0, |ui| ui.tree.parent(node.max(0) as u32) as c_int) } #[no_mangle] pub extern "C" fn tui_node_child_count(node: c_int) -> c_int { with_ui(0, |ui| ui.tree.child_count(node.max(0) as u32) as c_int) } #[no_mangle] pub extern "C" fn tui_node_child_at(node: c_int, index: c_int) -> c_int { with_ui(0, |ui| { if index < 0 { return 0; } ui.tree.child_at(node.max(0) as u32, index as usize) as c_int }) } #[no_mangle] pub extern "C" fn tui_node_append(parent: c_int, child: c_int) -> c_int { with_ui(0, |ui| { ui.tree.append(parent.max(0) as u32, child.max(0) as u32) as c_int }) } #[no_mangle] pub extern "C" fn tui_node_remove(parent: c_int, child: c_int) { with_ui((), |ui| { ui.tree.remove(parent.max(0) as u32, child.max(0) as u32) }) } #[no_mangle] pub extern "C" fn tui_node_insert_after(parent: c_int, child: c_int, sibling: c_int) -> c_int { with_ui(0, |ui| { ui.tree.insert_after( parent.max(0) as u32, child.max(0) as u32, sibling.max(0) as u32, ) as c_int }) } #[no_mangle] pub extern "C" fn tui_node_replace(parent: c_int, old_child: c_int, new_child: c_int) -> c_int { with_ui(0, |ui| { ui.tree.replace( parent.max(0) as u32, old_child.max(0) as u32, new_child.max(0) as u32, ) as c_int }) } /// The subtree at `node` as pretty-printed hiccup; `node` 0 means the root, so /// `tui_tree_dump(0)` is the whole window. #[no_mangle] pub extern "C" fn tui_tree_dump(node: c_int) -> *const c_char { with_ui(empty_str(), |ui| { let id = if node <= 0 { ui.tree.root() } else { node as u32 }; DUMPS.lend(ui.tree.dump(id)) }) } // ── events ────────────────────────────────────────────────────────────────── #[no_mangle] pub extern "C" fn tui_tree_poll_event() -> c_int { with_ui(0, |ui| ui.tree.poll() as c_int) } #[no_mangle] pub extern "C" fn tui_tree_event_node() -> c_int { with_ui(0, |ui| { ui.tree.current().map_or(0, |event| event.node as c_int) }) } #[no_mangle] pub extern "C" fn tui_tree_event_name() -> *const c_char { with_ui(empty_str(), |ui| match ui.tree.current() { Some(event) => NAMES.lend(event.name), None => empty_str(), }) } #[no_mangle] pub extern "C" fn tui_tree_event_text() -> *const c_char { with_ui(empty_str(), |ui| match ui.tree.current() { Some(event) => EVENTS.lend(event.text.clone()), None => empty_str(), }) } #[no_mangle] pub extern "C" fn tui_tree_event_num() -> c_double { with_ui(0.0, |ui| ui.tree.current().map_or(0.0, |event| event.num)) }