tuby 0.0.1.pre

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 5064c98e34924df24734220ff31d11a71e23fb3c932be3a44b245ef2d60571f2
4
+ data.tar.gz: 0e95b5507d0446e1aea9efdee95298f992982439b70c747a5687951cb9781b7b
5
+ SHA512:
6
+ metadata.gz: fdde892756e75e3e3f02eaca088d0560489f164554117bad4cc515e27974edb6b5fccb6269026175d1fcd084e3fcb16e5dc7bf440f3c1764aba6999d0d97c38f
7
+ data.tar.gz: a50985362c41df5cceecce1860621cb323d8bd4f3c0df3441c879d72f759578989c24e8fc004be05bc4e698f55e19fdb61d89ce79fa4413ce2f4782b0acd71ac
data/README.md ADDED
@@ -0,0 +1,206 @@
1
+ # tuby
2
+
3
+ > Proof-of-concept Ruby bindings for the [ratatui](https://ratatui.rs) Rust TUI
4
+ > library, using the `ffi` gem over a hand-written C ABI (Approach A). Not
5
+ > published to rubygems.org — source-only install from git.
6
+
7
+ **Status:** POC complete. The demo passes all eight spec §9 acceptance items,
8
+ the test suite is green, the architecture held up under end-to-end execution.
9
+ See [`docs/superpowers/specs/2026-04-11-tuby-design.md`](docs/superpowers/specs/2026-04-11-tuby-design.md)
10
+ for the full design, decisions log, and scope. See
11
+ [`docs/superpowers/plans/2026-04-11-tuby-poc.md`](docs/superpowers/plans/2026-04-11-tuby-poc.md)
12
+ for the 18-task implementation plan that actually built it (+ the 10 plan bugs
13
+ caught and logged during execution).
14
+
15
+ ## Quickstart
16
+
17
+ ```sh
18
+ git clone <this-repo> tuby
19
+ cd tuby
20
+ bin/setup # bundle install && cargo build --release
21
+ bundle exec ruby demo/hello.rb
22
+ ```
23
+
24
+ Press `q` or `Ctrl-C` to quit. Press `?` for the help overlay. Press `Tab` to
25
+ cycle the outer border style. Resize the terminal and watch the layout reflow
26
+ (the "keys pressed" list cap auto-adjusts to the available height).
27
+
28
+ ## What's inside
29
+
30
+ - `lib/tuby/` — the Ruby gem
31
+ - Tier 1: `Tuby::Terminal` — 1:1 wrapper around the Rust FFI (lifecycle,
32
+ `begin_frame`/`end_frame`, `split`, `push_block`/`push_paragraph`/
33
+ `push_list`/`push_clear`, `poll_event`, `close`)
34
+ - Tier 2: `Tuby::Frame` + `Tuby.run` — ~80 lines of pure Ruby sugar on top
35
+ of Tier 1 (convenience shortcuts, centered rects, lifecycle + signal
36
+ handling + main-loop + `catch(:tuby_quit)` exit)
37
+ - `ext/tuby/` — the Rust `cdylib` crate (ratatui 0.30 + crossterm 0.29),
38
+ 17 `extern "C"` functions
39
+ - `demo/hello.rb` — feature-rich acceptance demo exercising every Tier 1/2
40
+ primitive in one screen
41
+ - `test/` — minitest suite (26 runs, 160 assertions): smoke, errors, rect/
42
+ event, terminal lifecycle, layout, events, thread safety
43
+ - `docs/superpowers/` — the design spec and implementation plan that shaped
44
+ every decision; the plan has ~10 in-place fixes documenting bugs caught at
45
+ execution time
46
+
47
+ ## Running tests
48
+
49
+ ```sh
50
+ bundle exec rake test
51
+ ```
52
+
53
+ All tests use a headless ratatui `TestBackend`, so they work in CI without a
54
+ TTY. Visual correctness of rendered output is validated by running the demo
55
+ and walking the eight-item checklist in spec §9.
56
+
57
+ ## Threading
58
+
59
+ - A single `Tuby::Terminal` is safe to share across Ruby threads; every FFI
60
+ entry point takes a Rust-side `Mutex<TerminalInner>` before touching state.
61
+ - `Tuby::Terminal#poll_event` releases the GVL during its timeout
62
+ (`blocking: true` on the FFI attach), so worker threads make progress while
63
+ you wait for input instead of freezing the entire VM.
64
+ - `Tuby::Terminal` is **not** shareable across Ractors.
65
+ - If a render call raises `Tuby::PanicError`, the Rust side has caught a panic
66
+ and stored its message in the thread-local last-error slot. The terminal is
67
+ effectively dead — rescue, `close`, and construct a new one.
68
+
69
+ ## POC retrospective
70
+
71
+ ### What was painful
72
+
73
+ **Plan bugs surfaced at execution time, not at write time.** The plan was
74
+ carefully written but ten things were wrong with it across the 18 tasks, and
75
+ each one only became visible when a subagent actually executed the code. See
76
+ `docs/superpowers/plans/2026-04-11-tuby-poc.md` for the in-place fixes and the
77
+ plan-fixes memory log for the sequential bug numbers. Examples: `ffi_lib` with
78
+ an absolute path doesn't auto-append `.so` (bug 2); `Buffer::get` was
79
+ deprecated in ratatui 0.30 between plan writing and execution (bug 8);
80
+ `FFI::Pointer#read_string` silently returns ASCII-8BIT, breaking UTF-8 regex
81
+ matching (bug 8 companion); crossterm normalizes Ctrl-C to `Char('c') +
82
+ CONTROL` (code 99), not raw ETX (code 3), so the plan's synthesis branch was
83
+ dead code (bug 9); Ruby's `ObjectSpace.define_finalizer` warns when the proc
84
+ closes over the finalized object, which is exactly what the plan's
85
+ boilerplate did (bug 10). None of these would have been caught by
86
+ paper-review.
87
+
88
+ **Encoding at the FFI boundary is a persistent tax.** Every byte-oriented
89
+ buffer that crosses the boundary has to be `.force_encoding(Encoding::UTF_8)`
90
+ on the Ruby side and UTF-8 validated on the Rust side. Both directions.
91
+ Missing a single one is invisible until a non-ASCII character shows up and
92
+ the whole chain stops working.
93
+
94
+ **ratatui widget semantics around "clearing cells."** Block, Paragraph, and
95
+ List widgets only paint cells they write to — they do NOT clear the interior
96
+ of their rect. Every floating overlay leaks whatever was underneath. This
97
+ wasn't mentioned anywhere in the plan and only showed up when the help
98
+ overlay in the demo was drawn on top of the split_h divider. The fix was
99
+ adding an explicit `push_clear` primitive backed by ratatui's `Clear` widget
100
+ — small change, but a whole new variant + FFI function + Ruby wrapper +
101
+ regression test landed after the plan was otherwise complete.
102
+
103
+ ### What was surprising
104
+
105
+ **The architecture held.** Every decision from the spec paid off during
106
+ execution, and nothing needed to be refactored mid-build:
107
+
108
+ - `Mutex<TerminalInner>` in Task 6 made thread safety in Task 13 a one-line
109
+ `blocking: true` flag change instead of a cross-cutting retrofit.
110
+ - The headless-first order (Task 6 ships `new_headless` before Task 14 ships
111
+ `new`) meant every render task could be tested in CI without ever needing
112
+ a TTY. 24 of 26 tests run headless; only the two thread-safety tests care
113
+ about wall-clock timing.
114
+ - The command-buffer pattern (Task 7 scaffolds, Tasks 9–11 add one variant
115
+ each, Task "17-ish" adds Clear late) kept each widget addition to a tiny,
116
+ isolated diff. Every `push_*` function is structurally identical.
117
+ - The `TerminalBackend` enum's `Crossterm` variant, left as a commented-out
118
+ placeholder in Task 6, meant Task 14 was just "fill in the blank" — no
119
+ invasive changes to the 12 sites that already `match`-ed on the enum.
120
+
121
+ **Ruby-FFI was less painful than expected.** `attach_function` with
122
+ `FFI.map_library_name` + absolute path is reliable on Linux. `FFI::Struct`
123
+ layouts match `#[repr(C)]` byte-for-byte when you use an explicit `_pad`
124
+ field for alignment. The `blocking: true` option Just Worked on the first
125
+ try for GVL release. The main foot-gun was the `read_string` encoding
126
+ default (see above).
127
+
128
+ **Subagent-driven execution caught plan bugs one at a time.** The
129
+ controller-subagent workflow forced each task to be self-contained: dispatch
130
+ an implementer, run tests, dispatch a reviewer for complex tasks, fix plan
131
+ bugs in-place, commit separately, continue. No bug survived more than one
132
+ task before being written up. The 10 plan bugs are all numbered and
133
+ documented in auto-memory, which means the next time someone writes a
134
+ Ruby+Rust gem plan, they can pre-apply the lessons.
135
+
136
+ **Test-assertion count ballooned from one test.** The suite has 26 runs
137
+ (atomic tests) but 160 assertions because `test_poll_event_releases_gvl`
138
+ alone contributes 100 `assert_same` calls in a loop and the concurrent-push
139
+ test contributes ~800 implicit assertions through `Thread#join` under a
140
+ timeout. The ratio is a signal that the thread-safety tests are exercising
141
+ the FFI layer heavily even though they look small in the test file.
142
+
143
+ ### What's the blocker if we want to take this beyond a POC
144
+
145
+ **No styling.** Every visual is monochrome. Adding colors, bold, italic,
146
+ reverse-video, etc. means marshalling ratatui's `Style` struct across FFI.
147
+ The struct has ~10 fields (`fg`, `bg`, `modifier` bitmask, underline color,
148
+ underline style, etc.). The current "flat parameters" FFI pattern
149
+ (`push_paragraph` already has 10 args) scales badly. Options: (a) `Style`
150
+ as an `FFI::Struct` mirror, consistent with how `TubyEvent` works —
151
+ probably the cleanest; (b) pre-register palette IDs and pass a u8 index
152
+ per widget — faster but less flexible; (c) styled text runs (tagged string
153
+ markup parsed on the Rust side) — most ergonomic for end users but
154
+ complicates the `text` parameter of every widget.
155
+
156
+ **Three widgets is not enough.** ratatui ships 15+ widgets: Gauge, Chart,
157
+ Table, Tabs, Canvas, Scrollbar, Sparkline, BarChart, LineGauge, Calendar,
158
+ …. Each is ~1 new `RenderCmd` variant + 1 new `tuby_push_*` FFI function
159
+ + 1 new Ruby wrapper + 1 round-trip test. Mechanical but scales linearly
160
+ — and adding stateful widgets (the `List::state` / `ListState::select`
161
+ machinery for selection + scroll position) requires the command buffer to
162
+ carry state, not just one-shot config, which is a bigger architectural
163
+ change.
164
+
165
+ **No mouse events.** `TubyEvent` reserves `kind=3` for mouse but no
166
+ `encode_mouse` exists. crossterm supports it fully; this is a one-evening
167
+ add. Until then, any mouse-driven UI is out.
168
+
169
+ **Source-only install is a ceiling on adoption.** Every user has to have a
170
+ Rust toolchain available at install time. `rake-compiler-dock` +
171
+ pre-built `.so` per platform would fix it, but that's a build-infra
172
+ project unto itself (Linux/macOS/Windows × x86_64/aarch64 = 6 artifacts,
173
+ plus a CI matrix and a release workflow).
174
+
175
+ **ratatui stability is not a given.** We already hit a 0.29→0.30 API drift
176
+ mid-execution (Buffer::get deprecation). ratatui has had breaking changes
177
+ every ~6 months in its 0.2x line. A production gem would need either a
178
+ strict version pin (and a bump cycle every release) or a compat shim layer.
179
+
180
+ ### Go / no-go
181
+
182
+ **Go, with caveats.** The POC proves the architecture is sound, the
183
+ ergonomics are acceptable, the performance is adequate (160 assertions in
184
+ <0.2s on headless, real-terminal demo feels instant), and the subagent
185
+ workflow can execute a plan this size end-to-end with high quality. The
186
+ ten plan bugs were all caught cheaply and fixed in place; none of them
187
+ indicated a structural problem.
188
+
189
+ **But** turning this into a v0.2 gem that people would actually use
190
+ requires three substantial efforts before anything else:
191
+
192
+ 1. **Styling** (FFI::Struct-based Style marshalling) — 1–2 sessions
193
+ 2. **At least 6 more widgets** with a stateful-widget story for
194
+ List/Table selection — 2–3 sessions
195
+ 3. **Release infra** (rake-compiler-dock, CI matrix, pre-built artifacts)
196
+ — 1–2 sessions
197
+
198
+ After those, the gem is viable. Before those, it's a demo. Whether to
199
+ commit the time depends on whether there's a real user with a real Ruby
200
+ TUI need — which is a niche audience today (most people reach for TUI
201
+ work in Go or Rust directly). The POC was worth building; the production
202
+ gem is conditional on finding a concrete user story.
203
+
204
+ ## License
205
+
206
+ MIT
@@ -0,0 +1,13 @@
1
+ [package]
2
+ name = "tuby_core"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+ publish = false
6
+
7
+ [lib]
8
+ name = "tuby_core"
9
+ crate-type = ["cdylib"]
10
+
11
+ [dependencies]
12
+ ratatui = "0.30"
13
+ crossterm = "0.29"
@@ -0,0 +1,77 @@
1
+ use std::cell::RefCell;
2
+ use std::ffi::{c_char, CString};
3
+ use std::os::raw::c_int;
4
+ use std::panic::{catch_unwind, AssertUnwindSafe};
5
+
6
+ thread_local! {
7
+ static LAST_ERROR: RefCell<Option<CString>> = const { RefCell::new(None) };
8
+ }
9
+
10
+ /// Store a message in the thread-local error slot. The next call to
11
+ /// `tuby_last_error()` on this thread will return a pointer to it.
12
+ pub fn set_last_error<S: Into<String>>(msg: S) {
13
+ let msg = msg.into();
14
+ let cstring = CString::new(msg).unwrap_or_else(|_| {
15
+ CString::new("tuby internal: error message contained NUL byte").unwrap()
16
+ });
17
+ LAST_ERROR.with(|slot| *slot.borrow_mut() = Some(cstring));
18
+ }
19
+
20
+ /// Wrapper used by every extern "C" function. Runs `f` inside `catch_unwind`
21
+ /// so panics become return code -2 instead of undefined behavior across the
22
+ /// C ABI boundary. Ordinary errors are returned as Err(String) from `f` and
23
+ /// become return code -1.
24
+ ///
25
+ /// Return codes:
26
+ /// 0 success
27
+ /// -1 ordinary error (`Err(msg)` from `f`); message in last_error
28
+ /// -2 panic caught; payload in last_error prefixed with "panic: "
29
+ ///
30
+ /// The closure is wrapped internally in `AssertUnwindSafe`, so callers do not
31
+ /// need to prove unwind-safety themselves. This is safe because of the
32
+ /// architectural stance in spec §5: after a caught panic, the terminal is
33
+ /// considered dead and the library is not expected to be reused — so the
34
+ /// "observer of broken invariants" concern that `UnwindSafe` exists to flag
35
+ /// is moot in practice.
36
+ #[must_use = "catch_ffi's return code must be forwarded to the C caller to signal success/failure"]
37
+ pub fn catch_ffi<F>(f: F) -> c_int
38
+ where
39
+ F: FnOnce() -> Result<(), String>,
40
+ {
41
+ match catch_unwind(AssertUnwindSafe(f)) {
42
+ Ok(Ok(())) => 0,
43
+ Ok(Err(msg)) => {
44
+ set_last_error(msg);
45
+ -1
46
+ }
47
+ Err(payload) => {
48
+ let msg = panic_payload_to_string(payload);
49
+ set_last_error(format!("panic: {msg}"));
50
+ -2
51
+ }
52
+ }
53
+ }
54
+
55
+ pub(crate) fn panic_payload_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
56
+ if let Some(s) = payload.downcast_ref::<&'static str>() {
57
+ (*s).to_string()
58
+ } else if let Some(s) = payload.downcast_ref::<String>() {
59
+ s.clone()
60
+ } else {
61
+ "unknown panic payload".to_string()
62
+ }
63
+ }
64
+
65
+ /// Return a pointer to the current thread's last error message, or a pointer
66
+ /// to an empty NUL-terminated string if none is set. The returned pointer is
67
+ /// valid until the next call on this thread that writes to the error slot.
68
+ #[no_mangle]
69
+ pub extern "C" fn tuby_last_error() -> *const c_char {
70
+ LAST_ERROR.with(|slot| match &*slot.borrow() {
71
+ Some(cstring) => cstring.as_ptr(),
72
+ None => {
73
+ static EMPTY: &[u8] = b"\0";
74
+ EMPTY.as_ptr() as *const c_char
75
+ }
76
+ })
77
+ }
@@ -0,0 +1,123 @@
1
+ use crate::errors::catch_ffi;
2
+ use crate::terminal::{TerminalBackend, TubyTerminal};
3
+ use crossterm::event::{self, Event as CEvent, KeyCode, KeyModifiers};
4
+ use std::os::raw::c_int;
5
+ use std::time::Duration;
6
+
7
+ /// POD struct that Ruby pre-allocates and Rust fills in.
8
+ /// Layout matches the Ruby-side FFI::Struct declaration in ffi_bindings.rb.
9
+ #[repr(C)]
10
+ pub struct TubyEvent {
11
+ pub kind: u8, // 0=none 1=key 2=resize 3=mouse(unused) 4=other
12
+ pub key_code: u8, // ASCII char OR special-key sentinel >= 128
13
+ pub key_mods: u8, // bitflags: 1=ctrl, 2=alt, 4=shift
14
+ pub _pad: u8,
15
+ pub resize_cols: u16,
16
+ pub resize_rows: u16,
17
+ }
18
+
19
+ pub const K_ENTER: u8 = 128;
20
+ pub const K_ESC: u8 = 129;
21
+ pub const K_BACKSPACE: u8 = 130;
22
+ pub const K_TAB: u8 = 131;
23
+ pub const K_LEFT: u8 = 132;
24
+ pub const K_RIGHT: u8 = 133;
25
+ pub const K_UP: u8 = 134;
26
+ pub const K_DOWN: u8 = 135;
27
+ pub const K_HOME: u8 = 136;
28
+ pub const K_END: u8 = 137;
29
+ pub const K_PAGEUP: u8 = 138;
30
+ pub const K_PAGEDOWN: u8 = 139;
31
+ pub const K_INSERT: u8 = 140;
32
+ pub const K_DELETE: u8 = 141;
33
+ pub const K_F1: u8 = 150;
34
+ pub const K_OTHER: u8 = 200;
35
+
36
+ #[no_mangle]
37
+ pub extern "C" fn tuby_poll_event(
38
+ term: *mut TubyTerminal,
39
+ timeout_ms: u32,
40
+ out: *mut TubyEvent,
41
+ ) -> c_int {
42
+ catch_ffi(|| {
43
+ let term = unsafe { term.as_mut().ok_or_else(|| "null terminal pointer".to_string())? };
44
+ if out.is_null() {
45
+ return Err("null out pointer".to_string());
46
+ }
47
+
48
+ // Headless backends have no stdin — always return "no event" cleanly.
49
+ {
50
+ let state = term.inner.lock().map_err(|_| "mutex poisoned".to_string())?;
51
+ if matches!(state.backend, TerminalBackend::Headless(_)) {
52
+ write_none(out);
53
+ return Ok(());
54
+ }
55
+ }
56
+
57
+ let ready = event::poll(Duration::from_millis(timeout_ms as u64))
58
+ .map_err(|e| format!("event::poll failed: {e}"))?;
59
+ if !ready {
60
+ write_none(out);
61
+ return Ok(());
62
+ }
63
+
64
+ let ev = event::read().map_err(|e| format!("event::read failed: {e}"))?;
65
+ unsafe {
66
+ match ev {
67
+ CEvent::Key(k) => {
68
+ let (code, flags) = encode_key(k.code, k.modifiers);
69
+ (*out).kind = 1;
70
+ (*out).key_code = code;
71
+ (*out).key_mods = flags;
72
+ }
73
+ CEvent::Resize(cols, rows) => {
74
+ (*out).kind = 2;
75
+ (*out).resize_cols = cols;
76
+ (*out).resize_rows = rows;
77
+ }
78
+ _ => {
79
+ (*out).kind = 4;
80
+ }
81
+ }
82
+ }
83
+ Ok(())
84
+ })
85
+ }
86
+
87
+ fn write_none(out: *mut TubyEvent) {
88
+ unsafe {
89
+ (*out).kind = 0;
90
+ (*out).key_code = 0;
91
+ (*out).key_mods = 0;
92
+ (*out)._pad = 0;
93
+ (*out).resize_cols = 0;
94
+ (*out).resize_rows = 0;
95
+ }
96
+ }
97
+
98
+ fn encode_key(code: KeyCode, mods: KeyModifiers) -> (u8, u8) {
99
+ let c = match code {
100
+ KeyCode::Char(ch) if (ch as u32) < 128 => ch as u8,
101
+ KeyCode::Enter => K_ENTER,
102
+ KeyCode::Esc => K_ESC,
103
+ KeyCode::Backspace => K_BACKSPACE,
104
+ KeyCode::Tab => K_TAB,
105
+ KeyCode::Left => K_LEFT,
106
+ KeyCode::Right => K_RIGHT,
107
+ KeyCode::Up => K_UP,
108
+ KeyCode::Down => K_DOWN,
109
+ KeyCode::Home => K_HOME,
110
+ KeyCode::End => K_END,
111
+ KeyCode::PageUp => K_PAGEUP,
112
+ KeyCode::PageDown => K_PAGEDOWN,
113
+ KeyCode::Insert => K_INSERT,
114
+ KeyCode::Delete => K_DELETE,
115
+ KeyCode::F(n) if (1..=12).contains(&n) => K_F1 + (n - 1),
116
+ _ => K_OTHER,
117
+ };
118
+ let mut flags = 0u8;
119
+ if mods.contains(KeyModifiers::CONTROL) { flags |= 1; }
120
+ if mods.contains(KeyModifiers::ALT) { flags |= 2; }
121
+ if mods.contains(KeyModifiers::SHIFT) { flags |= 4; }
122
+ (c, flags)
123
+ }
@@ -0,0 +1,49 @@
1
+ use std::ffi::{c_char, CString};
2
+ use std::os::raw::c_int;
3
+ use std::sync::OnceLock;
4
+
5
+ mod errors;
6
+ mod events;
7
+ mod render;
8
+ mod terminal;
9
+
10
+ use crate::errors::{catch_ffi, set_last_error};
11
+ pub use errors::tuby_last_error;
12
+ pub use events::tuby_poll_event;
13
+ pub use terminal::{
14
+ tuby_begin_frame, tuby_end_frame, tuby_headless_buffer_snapshot,
15
+ tuby_push_block, tuby_push_clear, tuby_push_list, tuby_push_paragraph,
16
+ tuby_split, tuby_string_free, tuby_terminal_free, tuby_terminal_new,
17
+ tuby_terminal_new_headless, tuby_terminal_size,
18
+ };
19
+
20
+ /// Returns a pointer to a NUL-terminated version string owned by the library.
21
+ /// The returned pointer is valid for the lifetime of the process — it is backed
22
+ /// by a `OnceLock<CString>`, so the `CString` is never dropped.
23
+ #[no_mangle]
24
+ pub extern "C" fn tuby_version() -> *const c_char {
25
+ static VERSION: OnceLock<CString> = OnceLock::new();
26
+ VERSION
27
+ .get_or_init(|| CString::new(env!("CARGO_PKG_VERSION")).unwrap())
28
+ .as_ptr()
29
+ }
30
+
31
+ /// Test-only helper. Populates the last-error slot with a synthetic message.
32
+ /// - kind = -1: ordinary error via catch_ffi Err path
33
+ /// - kind = -2: triggers a panic inside catch_ffi
34
+ /// Any other value is a no-op returning 0.
35
+ #[no_mangle]
36
+ pub extern "C" fn tuby_force_error(kind: c_int) -> c_int {
37
+ match kind {
38
+ -1 => {
39
+ // Manually stage the error without going through catch_ffi, because
40
+ // the test wants to observe the thread-local *after* the call.
41
+ set_last_error("forced error from test helper");
42
+ -1
43
+ }
44
+ -2 => catch_ffi(|| {
45
+ panic!("forced panic from test helper");
46
+ }),
47
+ _ => 0,
48
+ }
49
+ }
@@ -0,0 +1,80 @@
1
+ use ratatui::{
2
+ layout::Rect as RRect,
3
+ widgets::{Block, BorderType, Borders, Clear, List, ListItem, Paragraph},
4
+ Frame,
5
+ };
6
+
7
+ #[derive(Debug)]
8
+ pub enum RenderCmd {
9
+ Block {
10
+ rect: RRect,
11
+ border: u8,
12
+ title: Option<String>,
13
+ },
14
+ Paragraph {
15
+ rect: RRect,
16
+ text: String,
17
+ border: u8,
18
+ title: Option<String>,
19
+ },
20
+ List {
21
+ rect: RRect,
22
+ items: Vec<String>,
23
+ border: u8,
24
+ title: Option<String>,
25
+ },
26
+ /// Fill the given rect with blank cells. Use before drawing a floating
27
+ /// overlay so background content (border lines from underlying panels,
28
+ /// previous-frame ghosts) doesn't bleed through — ratatui's Paragraph/
29
+ /// Block widgets do NOT clear cells they don't explicitly write to.
30
+ Clear {
31
+ rect: RRect,
32
+ },
33
+ }
34
+
35
+ pub fn replay(cmds: &[RenderCmd], frame: &mut Frame) {
36
+ for cmd in cmds {
37
+ match cmd {
38
+ RenderCmd::Block { rect, border, title } => {
39
+ let b = build_block(*border, title.as_deref());
40
+ frame.render_widget(b, *rect);
41
+ }
42
+ RenderCmd::Paragraph { rect, text, border, title } => {
43
+ let mut p = Paragraph::new(text.as_str());
44
+ if *border != 0 || title.is_some() {
45
+ p = p.block(build_block(*border, title.as_deref()));
46
+ }
47
+ frame.render_widget(p, *rect);
48
+ }
49
+ RenderCmd::List { rect, items, border, title } => {
50
+ let list_items: Vec<ListItem> = items
51
+ .iter()
52
+ .map(|s| ListItem::new(s.as_str()))
53
+ .collect();
54
+ let mut list = List::new(list_items);
55
+ if *border != 0 || title.is_some() {
56
+ list = list.block(build_block(*border, title.as_deref()));
57
+ }
58
+ frame.render_widget(list, *rect);
59
+ }
60
+ RenderCmd::Clear { rect } => {
61
+ frame.render_widget(Clear, *rect);
62
+ }
63
+ }
64
+ }
65
+ }
66
+
67
+ fn build_block(border: u8, title: Option<&str>) -> Block<'_> {
68
+ let mut b = Block::default();
69
+ if border != 0 {
70
+ b = b.borders(Borders::ALL).border_type(match border {
71
+ 2 => BorderType::Rounded,
72
+ 3 => BorderType::Double,
73
+ _ => BorderType::Plain,
74
+ });
75
+ }
76
+ if let Some(t) = title {
77
+ b = b.title(t);
78
+ }
79
+ b
80
+ }