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 +7 -0
- data/README.md +206 -0
- data/ext/tuby/Cargo.toml +13 -0
- data/ext/tuby/src/errors.rs +77 -0
- data/ext/tuby/src/events.rs +123 -0
- data/ext/tuby/src/lib.rs +49 -0
- data/ext/tuby/src/render.rs +80 -0
- data/ext/tuby/src/terminal.rs +451 -0
- data/ext/tuby/target/release/build/thiserror-f8d7bb24131b0733/out/private.rs +5 -0
- data/lib/tuby/errors.rb +18 -0
- data/lib/tuby/event.rb +17 -0
- data/lib/tuby/ffi_bindings.rb +72 -0
- data/lib/tuby/frame.rb +92 -0
- data/lib/tuby/rect.rb +6 -0
- data/lib/tuby/terminal.rb +215 -0
- data/lib/tuby/version.rb +3 -0
- data/lib/tuby.rb +7 -0
- metadata +97 -0
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
|
data/ext/tuby/Cargo.toml
ADDED
|
@@ -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
|
+
}
|
data/ext/tuby/src/lib.rs
ADDED
|
@@ -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
|
+
}
|