ratatui_ruby 1.0.0.pre.beta.2 → 1.0.0.pre.beta.3
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 +4 -4
- data/.builds/ruby-3.2.yml +1 -1
- data/.builds/ruby-3.3.yml +1 -1
- data/.builds/ruby-3.4.yml +1 -1
- data/.builds/ruby-4.0.0.yml +1 -1
- data/CHANGELOG.md +20 -0
- data/ext/ratatui_ruby/Cargo.lock +1 -1
- data/ext/ratatui_ruby/Cargo.toml +1 -1
- data/ext/ratatui_ruby/src/events.rs +10 -14
- data/ext/ratatui_ruby/src/lib.rs +61 -50
- data/ext/ratatui_ruby/src/terminal/init.rs +141 -0
- data/ext/ratatui_ruby/src/terminal/mod.rs +33 -0
- data/ext/ratatui_ruby/src/terminal/mutations.rs +158 -0
- data/ext/ratatui_ruby/src/terminal/queries.rs +216 -0
- data/ext/ratatui_ruby/src/terminal/query.rs +338 -0
- data/ext/ratatui_ruby/src/terminal/storage.rs +109 -0
- data/ext/ratatui_ruby/src/terminal/wrapper.rs +16 -0
- data/lib/ratatui_ruby/version.rb +1 -1
- metadata +8 -6
- data/ext/ratatui_ruby/src/lib.rs.bak +0 -286
- data/ext/ratatui_ruby/src/rendering.rs.bak +0 -152
- data/ext/ratatui_ruby/src/terminal.rs +0 -491
- data/ext/ratatui_ruby/src/terminal.rs.bak +0 -381
- data/ext/ratatui_ruby/src/terminal.rs.orig +0 -409
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: db0cf2519be48abadebb47b0ff1473739692a8b0556add21c774420db188c392
|
|
4
|
+
data.tar.gz: 9454ead9e7cbe09a886b33f437833cd753ceaca565400554056c26eb6110e1ed
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c96411ef407f2d9e54535239af7121b3acdfed15cf72ec1d146166b8b0fbaa0320aadc0c5aaba6b2d47dcb9ecb59bf508a5ebe658327e05a12190029b010d823
|
|
7
|
+
data.tar.gz: d418563a2fa210c302c83ced44095af3a37453b1e129367ab532291eeb9574db077782228ba7f2cac3ea1cadba90dbdf1c7f2a8d0924615dd4f629ef0f849a6c
|
data/.builds/ruby-3.2.yml
CHANGED
data/.builds/ruby-3.3.yml
CHANGED
data/.builds/ruby-3.4.yml
CHANGED
data/.builds/ruby-4.0.0.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -18,6 +18,21 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
18
18
|
|
|
19
19
|
### Removed
|
|
20
20
|
|
|
21
|
+
## [1.0.0-beta.3] - 2026-01-24
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
### Fixed
|
|
28
|
+
|
|
29
|
+
- **Terminal Queries During Draw (Deadlock)**: Calling `viewport_area`, `terminal_size`, `get_cell_at`, `poll_event`, `get_cursor_position`, or `get_viewport_type` from inside a `draw()` block previously caused the application to freeze forever. The thread blocked on a Mutex. Ruby's `Timeout` could not interrupt it. The only recovery was `kill -9`. These reads now work via a snapshot captured before rendering.
|
|
30
|
+
- **Terminal Mutations During Draw (Deadlock)**: Calling `insert_before`, `set_cursor_position`, or `resize_terminal` from inside a `draw()` block previously caused the same silent freeze. These writes now raise `Error::Invariant` with a clear message.
|
|
31
|
+
- **Nested Draw Calls (Deadlock)**: Calling `draw()` from inside another `draw()` block previously froze the application. Nested draws now raise `Error::Invariant`.
|
|
32
|
+
- **Event Injection During Draw (Deadlock)**: Calling `inject_test_event` from inside a `draw()` block previously froze the application. Event injection now works correctly during draw.
|
|
33
|
+
|
|
34
|
+
### Removed
|
|
35
|
+
|
|
21
36
|
## [1.0.0-beta.2] - 2026-01-20
|
|
22
37
|
|
|
23
38
|
### Added
|
|
@@ -26,6 +41,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
|
|
|
26
41
|
|
|
27
42
|
### Fixed
|
|
28
43
|
|
|
44
|
+
- **Terminal Queries During Draw (Deadlock)**: Calling `viewport_area`, `terminal_size`, `get_cell_at`, `poll_event`, `get_cursor_position`, or `get_viewport_type` from inside a `draw()` block previously caused the application to freeze forever. The thread blocked on a Mutex. Ruby's `Timeout` could not interrupt it. The only recovery was `kill -9`. These reads now work via a snapshot captured before rendering.
|
|
45
|
+
- **Terminal Mutations During Draw (Deadlock)**: Calling `insert_before`, `set_cursor_position`, or `resize_terminal` from inside a `draw()` block previously caused the same silent freeze. These writes now raise `Error::Invariant` with a clear message.
|
|
46
|
+
- **Nested Draw Calls (Deadlock)**: Calling `draw()` from inside another `draw()` block previously froze the application. Nested draws now raise `Error::Invariant`.
|
|
47
|
+
- **Event Injection During Draw (Deadlock)**: Calling `inject_test_event` from inside a `draw()` block previously froze the application. Event injection now works correctly during draw.
|
|
29
48
|
- **TableState Row Navigation Methods**: Added missing row navigation methods (`select_next`, `select_previous`, `select_first`, `select_last`) that should have been included alongside the column navigation methods added in v0.10.0. These methods match `ListState`'s navigation API and are used in the `app_stateful_interaction` example.
|
|
30
49
|
|
|
31
50
|
### Removed
|
|
@@ -682,6 +701,7 @@ This release is functionally equivalent to v0.10.3. The version bump signals the
|
|
|
682
701
|
- **Testing Support**: Included `RatatuiRuby::TestHelper` and RSpec integration to make testing your TUI applications possible.
|
|
683
702
|
|
|
684
703
|
[Unreleased]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/HEAD
|
|
704
|
+
[1.0.0-beta.3]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v1.0.0-beta.3
|
|
685
705
|
[1.0.0-beta.2]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v1.0.0-beta.2
|
|
686
706
|
[1.0.0-beta.1]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v1.0.0-beta.1
|
|
687
707
|
[0.10.3]: https://git.sr.ht/~kerrick/ratatui_ruby/refs/v0.10.3
|
data/ext/ratatui_ruby/Cargo.lock
CHANGED
data/ext/ratatui_ruby/Cargo.toml
CHANGED
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
3
|
|
|
4
4
|
use magnus::{Error, IntoValue, TryConvert, Value};
|
|
5
|
-
use std::
|
|
5
|
+
use std::cell::RefCell;
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
thread_local! {
|
|
8
|
+
static EVENT_QUEUE: RefCell<Vec<ratatui::crossterm::event::Event>> = const { RefCell::new(Vec::new()) };
|
|
9
|
+
}
|
|
8
10
|
|
|
9
11
|
#[allow(clippy::needless_pass_by_value)]
|
|
10
12
|
pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(), Error> {
|
|
@@ -24,7 +26,7 @@ pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(),
|
|
|
24
26
|
}
|
|
25
27
|
};
|
|
26
28
|
|
|
27
|
-
EVENT_QUEUE.
|
|
29
|
+
EVENT_QUEUE.with(|q| q.borrow_mut().push(event));
|
|
28
30
|
Ok(())
|
|
29
31
|
}
|
|
30
32
|
|
|
@@ -271,30 +273,24 @@ fn parse_paste_event(
|
|
|
271
273
|
}
|
|
272
274
|
|
|
273
275
|
pub fn clear_events() {
|
|
274
|
-
EVENT_QUEUE.
|
|
276
|
+
EVENT_QUEUE.with(|q| q.borrow_mut().clear());
|
|
275
277
|
}
|
|
276
278
|
|
|
277
279
|
pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value, Error> {
|
|
278
|
-
let event = {
|
|
279
|
-
let mut queue =
|
|
280
|
+
let event = EVENT_QUEUE.with(|q| {
|
|
281
|
+
let mut queue = q.borrow_mut();
|
|
280
282
|
if queue.is_empty() {
|
|
281
283
|
None
|
|
282
284
|
} else {
|
|
283
285
|
Some(queue.remove(0))
|
|
284
286
|
}
|
|
285
|
-
};
|
|
287
|
+
});
|
|
286
288
|
|
|
287
289
|
if let Some(e) = event {
|
|
288
290
|
return handle_event(e);
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
let is_test_mode =
|
|
292
|
-
let term_lock = crate::terminal::TERMINAL.lock().unwrap();
|
|
293
|
-
matches!(
|
|
294
|
-
term_lock.as_ref(),
|
|
295
|
-
Some(crate::terminal::TerminalWrapper::Test(_))
|
|
296
|
-
)
|
|
297
|
-
};
|
|
293
|
+
let is_test_mode = crate::terminal::with_query(|q| q.is_test_mode()).unwrap_or(false);
|
|
298
294
|
|
|
299
295
|
if is_test_mode {
|
|
300
296
|
return Ok(ruby.qnil().into_value_with(ruby));
|
data/ext/ratatui_ruby/src/lib.rs
CHANGED
|
@@ -17,13 +17,13 @@ mod frame;
|
|
|
17
17
|
mod rendering;
|
|
18
18
|
mod string_width;
|
|
19
19
|
mod style;
|
|
20
|
-
mod terminal;
|
|
20
|
+
mod terminal; // New module with TerminalQuery trait
|
|
21
21
|
mod text;
|
|
22
22
|
mod widgets;
|
|
23
23
|
|
|
24
24
|
use frame::RubyFrame;
|
|
25
25
|
use magnus::{function, method, Error, Module, Object, Ruby, Value};
|
|
26
|
-
use terminal::{init_terminal, restore_terminal
|
|
26
|
+
use terminal::{init_terminal, restore_terminal};
|
|
27
27
|
|
|
28
28
|
/// Draw to the terminal.
|
|
29
29
|
///
|
|
@@ -63,63 +63,78 @@ fn draw(args: &[Value]) -> Result<(), Error> {
|
|
|
63
63
|
));
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
66
|
+
// Nested draw() calls are not allowed - would deadlock
|
|
67
|
+
if terminal::is_in_draw_mode() {
|
|
68
|
+
let module = ruby.define_module("RatatuiRuby")?;
|
|
69
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error")?;
|
|
70
|
+
let error_class = error_base.const_get("Invariant")?;
|
|
71
|
+
return Err(Error::new(
|
|
72
|
+
error_class,
|
|
73
|
+
"draw cannot be called during another draw (nested draws not allowed)",
|
|
74
|
+
));
|
|
75
|
+
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
77
|
+
// Use lend_for_draw which handles snapshot capture/cleanup automatically
|
|
78
|
+
let result = terminal::lend_for_draw(|wrapper| {
|
|
79
|
+
let ruby = magnus::Ruby::get().expect("Ruby must be initialized");
|
|
80
|
+
let mut render_error: Option<Error> = None;
|
|
81
|
+
|
|
82
|
+
// Helper closure to execute the draw callback logic for either terminal type
|
|
83
|
+
let mut draw_callback = |f: &mut ratatui::Frame<'_>| {
|
|
84
|
+
if block_given {
|
|
85
|
+
// New API: yield RubyFrame to block
|
|
86
|
+
let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
|
|
87
|
+
|
|
88
|
+
let ruby_frame = RubyFrame::new(f, active.clone());
|
|
89
|
+
if let Err(e) = ruby.yield_value::<_, Value>(ruby_frame) {
|
|
90
|
+
render_error = Some(e);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Invalidate frame immediately after block returns
|
|
94
|
+
active.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
95
|
+
} else if let Some(tree_value) = tree {
|
|
96
|
+
// Legacy API: render tree to full area
|
|
97
|
+
let area = f.area();
|
|
98
|
+
if let Err(e) = rendering::render_node(f.buffer_mut(), area, tree_value) {
|
|
99
|
+
render_error = Some(e);
|
|
100
|
+
}
|
|
79
101
|
}
|
|
102
|
+
};
|
|
80
103
|
|
|
81
|
-
// Invalidate frame immediately after block returns
|
|
82
|
-
// This prevents use-after-free if user stored the frame object
|
|
83
|
-
active.store(false, std::sync::atomic::Ordering::Relaxed);
|
|
84
|
-
} else if let Some(tree_value) = tree {
|
|
85
|
-
// Legacy API: render tree to full area
|
|
86
|
-
let area = f.area();
|
|
87
|
-
if let Err(e) = rendering::render_node(f.buffer_mut(), area, tree_value) {
|
|
88
|
-
render_error = Some(e);
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
if let Some(wrapper) = term_lock.as_mut() {
|
|
94
104
|
match wrapper {
|
|
95
105
|
terminal::TerminalWrapper::Crossterm(term) => {
|
|
96
|
-
let module = ruby.define_module("RatatuiRuby")
|
|
97
|
-
let error_base = module.const_get::<_, magnus::RClass>("Error")
|
|
98
|
-
let error_class = error_base.const_get("Terminal")
|
|
106
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
107
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
108
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
99
109
|
term.draw(&mut draw_callback)
|
|
100
110
|
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
101
111
|
}
|
|
102
112
|
terminal::TerminalWrapper::Test(term) => {
|
|
103
|
-
let module = ruby.define_module("RatatuiRuby")
|
|
104
|
-
let error_base = module.const_get::<_, magnus::RClass>("Error")
|
|
105
|
-
let error_class = error_base.const_get("Terminal")
|
|
113
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
114
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
115
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
106
116
|
term.draw(&mut draw_callback)
|
|
107
117
|
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
108
118
|
}
|
|
109
119
|
}
|
|
110
|
-
} else {
|
|
111
|
-
eprintln!("Terminal is None!");
|
|
112
|
-
}
|
|
113
120
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
121
|
+
if let Some(e) = render_error {
|
|
122
|
+
return Err(e);
|
|
123
|
+
}
|
|
117
124
|
|
|
118
|
-
|
|
125
|
+
Ok(())
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
result.unwrap_or_else(|| {
|
|
129
|
+
eprintln!("Terminal is None!");
|
|
130
|
+
Ok(())
|
|
131
|
+
})
|
|
119
132
|
}
|
|
120
133
|
|
|
121
|
-
|
|
122
|
-
|
|
134
|
+
// Storage for the last panic info, to be retrieved and printed after terminal restore.
|
|
135
|
+
thread_local! {
|
|
136
|
+
static LAST_PANIC: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
|
137
|
+
}
|
|
123
138
|
|
|
124
139
|
/// Enables Rust backtraces and installs a custom panic hook.
|
|
125
140
|
///
|
|
@@ -131,9 +146,9 @@ fn enable_rust_backtrace(_ruby: &magnus::Ruby) {
|
|
|
131
146
|
std::panic::set_hook(Box::new(|info| {
|
|
132
147
|
let backtrace = std::backtrace::Backtrace::force_capture();
|
|
133
148
|
let message = format!("Rust panic: {info}\n{backtrace}");
|
|
134
|
-
|
|
135
|
-
*
|
|
136
|
-
}
|
|
149
|
+
LAST_PANIC.with(|p| {
|
|
150
|
+
*p.borrow_mut() = Some(message);
|
|
151
|
+
});
|
|
137
152
|
}));
|
|
138
153
|
}
|
|
139
154
|
|
|
@@ -141,11 +156,7 @@ fn enable_rust_backtrace(_ruby: &magnus::Ruby) {
|
|
|
141
156
|
///
|
|
142
157
|
/// Call this after terminal restoration to get deferred panic output.
|
|
143
158
|
fn get_last_panic(_ruby: &magnus::Ruby) -> Option<String> {
|
|
144
|
-
|
|
145
|
-
guard.take()
|
|
146
|
-
} else {
|
|
147
|
-
None
|
|
148
|
-
}
|
|
159
|
+
LAST_PANIC.with(|p| p.borrow_mut().take())
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
/// Intentionally panics to test backtrace output.
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal initialization and restoration functions.
|
|
5
|
+
|
|
6
|
+
use magnus::{Error, Module};
|
|
7
|
+
use ratatui::{
|
|
8
|
+
backend::{CrosstermBackend, TestBackend},
|
|
9
|
+
Terminal, TerminalOptions, Viewport,
|
|
10
|
+
};
|
|
11
|
+
use std::io;
|
|
12
|
+
|
|
13
|
+
use super::TerminalWrapper;
|
|
14
|
+
|
|
15
|
+
// Track whether we're using fullscreen viewport (for restore_terminal)
|
|
16
|
+
thread_local! {
|
|
17
|
+
static IS_FULLSCREEN: std::cell::Cell<bool> = const { std::cell::Cell::new(false) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
#[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
|
|
21
|
+
pub fn init_terminal(
|
|
22
|
+
focus_events: bool,
|
|
23
|
+
bracketed_paste: bool,
|
|
24
|
+
viewport_type: String,
|
|
25
|
+
viewport_height: Option<u16>,
|
|
26
|
+
) -> Result<(), Error> {
|
|
27
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
28
|
+
|
|
29
|
+
// Check if already initialized
|
|
30
|
+
if super::is_initialized() {
|
|
31
|
+
return Ok(());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let module = ruby.define_module("RatatuiRuby")?;
|
|
35
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error")?;
|
|
36
|
+
let error_class = error_base.const_get("Terminal")?;
|
|
37
|
+
|
|
38
|
+
// Parse viewport type
|
|
39
|
+
let viewport = match viewport_type.as_ref() {
|
|
40
|
+
"inline" => {
|
|
41
|
+
let height = viewport_height.unwrap_or(8);
|
|
42
|
+
Viewport::Inline(height)
|
|
43
|
+
}
|
|
44
|
+
_ => Viewport::Fullscreen,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
ratatui::crossterm::terminal::enable_raw_mode()
|
|
48
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
49
|
+
let mut stdout = io::stdout();
|
|
50
|
+
|
|
51
|
+
// Only enter alternate screen for fullscreen viewports
|
|
52
|
+
if matches!(viewport, Viewport::Fullscreen) {
|
|
53
|
+
ratatui::crossterm::execute!(stdout, ratatui::crossterm::terminal::EnterAlternateScreen)
|
|
54
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableMouseCapture)
|
|
58
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
59
|
+
|
|
60
|
+
if focus_events {
|
|
61
|
+
ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableFocusChange)
|
|
62
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
63
|
+
}
|
|
64
|
+
if bracketed_paste {
|
|
65
|
+
ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableBracketedPaste)
|
|
66
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
let backend = CrosstermBackend::new(stdout);
|
|
70
|
+
|
|
71
|
+
// Store whether we're using fullscreen for restore_terminal (before moving viewport)
|
|
72
|
+
let is_fullscreen = matches!(viewport, Viewport::Fullscreen);
|
|
73
|
+
IS_FULLSCREEN.with(|f| f.set(is_fullscreen));
|
|
74
|
+
|
|
75
|
+
let options = TerminalOptions { viewport };
|
|
76
|
+
let terminal = Terminal::with_options(backend, options)
|
|
77
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
78
|
+
|
|
79
|
+
super::set_terminal(TerminalWrapper::Crossterm(terminal));
|
|
80
|
+
Ok(())
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
#[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
|
|
84
|
+
pub fn init_test_terminal(
|
|
85
|
+
width: u16,
|
|
86
|
+
height: u16,
|
|
87
|
+
viewport_type: String,
|
|
88
|
+
viewport_height: Option<u16>,
|
|
89
|
+
) -> Result<(), Error> {
|
|
90
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
91
|
+
let backend = TestBackend::new(width, height);
|
|
92
|
+
let module = ruby.define_module("RatatuiRuby")?;
|
|
93
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error")?;
|
|
94
|
+
let error_class = error_base.const_get("Terminal")?;
|
|
95
|
+
|
|
96
|
+
// Parse viewport type (same as init_terminal)
|
|
97
|
+
let viewport = match viewport_type.as_ref() {
|
|
98
|
+
"inline" => {
|
|
99
|
+
let vp_height = viewport_height.unwrap_or(height);
|
|
100
|
+
Viewport::Inline(vp_height)
|
|
101
|
+
}
|
|
102
|
+
_ => Viewport::Fullscreen,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
let options = TerminalOptions { viewport };
|
|
106
|
+
let terminal = Terminal::with_options(backend, options)
|
|
107
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
108
|
+
|
|
109
|
+
super::set_terminal(TerminalWrapper::Test(terminal));
|
|
110
|
+
Ok(())
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
pub fn restore_terminal() {
|
|
114
|
+
if let Some(wrapper) = super::take_terminal() {
|
|
115
|
+
match wrapper {
|
|
116
|
+
TerminalWrapper::Crossterm(mut t) => {
|
|
117
|
+
let _ = ratatui::crossterm::terminal::disable_raw_mode();
|
|
118
|
+
|
|
119
|
+
// Only leave alternate screen if we were in fullscreen mode
|
|
120
|
+
let is_fullscreen = IS_FULLSCREEN.with(std::cell::Cell::get);
|
|
121
|
+
if is_fullscreen {
|
|
122
|
+
let _ = ratatui::crossterm::execute!(
|
|
123
|
+
t.backend_mut(),
|
|
124
|
+
ratatui::crossterm::terminal::LeaveAlternateScreen,
|
|
125
|
+
ratatui::crossterm::event::DisableMouseCapture,
|
|
126
|
+
ratatui::crossterm::event::DisableFocusChange,
|
|
127
|
+
ratatui::crossterm::event::DisableBracketedPaste
|
|
128
|
+
);
|
|
129
|
+
} else {
|
|
130
|
+
let _ = ratatui::crossterm::execute!(
|
|
131
|
+
t.backend_mut(),
|
|
132
|
+
ratatui::crossterm::event::DisableMouseCapture,
|
|
133
|
+
ratatui::crossterm::event::DisableFocusChange,
|
|
134
|
+
ratatui::crossterm::event::DisableBracketedPaste
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
TerminalWrapper::Test(_) => {}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal management module.
|
|
5
|
+
//!
|
|
6
|
+
//! Provides thread-local terminal storage with safe accessors. Queries
|
|
7
|
+
//! during `draw()` callbacks return data from a pre-captured snapshot,
|
|
8
|
+
//! avoiding reentrancy issues with the terminal lock.
|
|
9
|
+
|
|
10
|
+
mod init;
|
|
11
|
+
mod mutations;
|
|
12
|
+
mod queries;
|
|
13
|
+
mod query;
|
|
14
|
+
mod storage;
|
|
15
|
+
mod wrapper;
|
|
16
|
+
|
|
17
|
+
pub use storage::{
|
|
18
|
+
is_in_draw_mode, is_initialized, lend_for_draw, set_terminal, take_terminal, with_query,
|
|
19
|
+
with_terminal_mut,
|
|
20
|
+
};
|
|
21
|
+
pub use wrapper::TerminalWrapper;
|
|
22
|
+
|
|
23
|
+
// Init/restore functions
|
|
24
|
+
pub use init::{init_terminal, init_test_terminal, restore_terminal};
|
|
25
|
+
|
|
26
|
+
// Query functions
|
|
27
|
+
pub use queries::{
|
|
28
|
+
get_buffer_content, get_cell_at, get_cursor_position, get_terminal_area, get_terminal_size,
|
|
29
|
+
get_viewport_type,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// Mutation functions
|
|
33
|
+
pub use mutations::{insert_before, resize_terminal, set_cursor_position};
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal mutation functions (write operations).
|
|
5
|
+
|
|
6
|
+
use magnus::{Error, Module};
|
|
7
|
+
|
|
8
|
+
use super::TerminalWrapper;
|
|
9
|
+
|
|
10
|
+
pub fn insert_before(height: u16, widget: magnus::Value) -> Result<(), Error> {
|
|
11
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
12
|
+
|
|
13
|
+
// with_terminal_mut returns None during draw mode, so we handle that case
|
|
14
|
+
crate::terminal::with_terminal_mut(|wrapper| {
|
|
15
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
16
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
17
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
18
|
+
|
|
19
|
+
match wrapper {
|
|
20
|
+
TerminalWrapper::Crossterm(term) => {
|
|
21
|
+
// Capture rendering error since closure can't return Result
|
|
22
|
+
let mut render_error: Option<String> = None;
|
|
23
|
+
|
|
24
|
+
let result = term.insert_before(height, |buf| {
|
|
25
|
+
let area = buf.area();
|
|
26
|
+
let area_copy = *area; // Copy rect before closure capture
|
|
27
|
+
|
|
28
|
+
// Render widget to buffer using centralized dispatch
|
|
29
|
+
let render_result =
|
|
30
|
+
crate::rendering::render_widget_to_buffer(buf, area_copy, widget);
|
|
31
|
+
|
|
32
|
+
if let Err(e) = render_result {
|
|
33
|
+
render_error = Some(e.to_string());
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Handle insert_before error
|
|
38
|
+
result.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
39
|
+
|
|
40
|
+
// Handle rendering error
|
|
41
|
+
if let Some(err_msg) = render_error {
|
|
42
|
+
return Err(Error::new(error_class, err_msg));
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
TerminalWrapper::Test(term) => {
|
|
46
|
+
// Capture rendering error since closure can't return Result
|
|
47
|
+
let mut render_error: Option<String> = None;
|
|
48
|
+
|
|
49
|
+
let result = term.insert_before(height, |buf| {
|
|
50
|
+
let area = buf.area();
|
|
51
|
+
let area_copy = *area; // Copy rect before closure capture
|
|
52
|
+
|
|
53
|
+
// Render widget to buffer using centralized dispatch
|
|
54
|
+
let render_result =
|
|
55
|
+
crate::rendering::render_widget_to_buffer(buf, area_copy, widget);
|
|
56
|
+
|
|
57
|
+
if let Err(e) = render_result {
|
|
58
|
+
render_error = Some(e.to_string());
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
// Handle insert_before error
|
|
63
|
+
result.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
64
|
+
|
|
65
|
+
// Handle rendering error
|
|
66
|
+
if let Some(err_msg) = render_error {
|
|
67
|
+
return Err(Error::new(error_class, err_msg));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
Ok(())
|
|
72
|
+
})
|
|
73
|
+
.unwrap_or_else(|| {
|
|
74
|
+
// During draw or terminal not initialized
|
|
75
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
76
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
77
|
+
if crate::terminal::is_in_draw_mode() {
|
|
78
|
+
let error_class = error_base.const_get("Invariant").unwrap();
|
|
79
|
+
Err(Error::new(
|
|
80
|
+
error_class,
|
|
81
|
+
"insert_before cannot be called during draw",
|
|
82
|
+
))
|
|
83
|
+
} else {
|
|
84
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
85
|
+
Err(Error::new(error_class, "Terminal not initialized"))
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
pub fn set_cursor_position(x: u16, y: u16) -> Result<(), Error> {
|
|
91
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
92
|
+
|
|
93
|
+
crate::terminal::with_terminal_mut(|wrapper| {
|
|
94
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
95
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
96
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
97
|
+
|
|
98
|
+
match wrapper {
|
|
99
|
+
TerminalWrapper::Crossterm(term) => {
|
|
100
|
+
term.set_cursor_position((x, y))
|
|
101
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
102
|
+
}
|
|
103
|
+
TerminalWrapper::Test(term) => {
|
|
104
|
+
term.set_cursor_position((x, y))
|
|
105
|
+
.map_err(|e| Error::new(error_class, e.to_string()))?;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
Ok(())
|
|
109
|
+
})
|
|
110
|
+
.unwrap_or_else(|| {
|
|
111
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
112
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
113
|
+
if crate::terminal::is_in_draw_mode() {
|
|
114
|
+
let error_class = error_base.const_get("Invariant").unwrap();
|
|
115
|
+
Err(Error::new(
|
|
116
|
+
error_class,
|
|
117
|
+
"set_cursor_position cannot be called during draw",
|
|
118
|
+
))
|
|
119
|
+
} else {
|
|
120
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
121
|
+
Err(Error::new(error_class, "Terminal is not initialized"))
|
|
122
|
+
}
|
|
123
|
+
})
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
pub fn resize_terminal(width: u16, height: u16) -> Result<(), Error> {
|
|
127
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
128
|
+
|
|
129
|
+
crate::terminal::with_terminal_mut(|wrapper| {
|
|
130
|
+
match wrapper {
|
|
131
|
+
TerminalWrapper::Crossterm(_) => {}
|
|
132
|
+
TerminalWrapper::Test(terminal) => {
|
|
133
|
+
terminal.backend_mut().resize(width, height);
|
|
134
|
+
if let Err(e) = terminal.resize(ratatui::layout::Rect::new(0, 0, width, height)) {
|
|
135
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
136
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
137
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
138
|
+
return Err(Error::new(error_class, e.to_string()));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
Ok(())
|
|
143
|
+
})
|
|
144
|
+
.unwrap_or_else(|| {
|
|
145
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
146
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
147
|
+
if crate::terminal::is_in_draw_mode() {
|
|
148
|
+
let error_class = error_base.const_get("Invariant").unwrap();
|
|
149
|
+
Err(Error::new(
|
|
150
|
+
error_class,
|
|
151
|
+
"resize_terminal cannot be called during draw",
|
|
152
|
+
))
|
|
153
|
+
} else {
|
|
154
|
+
// No terminal initialized is OK for resize - just no-op
|
|
155
|
+
Ok(())
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
}
|