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
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal query functions (read-only access to terminal state).
|
|
5
|
+
|
|
6
|
+
use magnus::value::ReprValue;
|
|
7
|
+
use magnus::{Error, Module, Value};
|
|
8
|
+
|
|
9
|
+
use super::TerminalWrapper;
|
|
10
|
+
|
|
11
|
+
pub fn get_buffer_content() -> Result<String, Error> {
|
|
12
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
13
|
+
|
|
14
|
+
// This function builds a string from all cells - we need direct terminal access
|
|
15
|
+
crate::terminal::with_terminal_mut(|wrapper| {
|
|
16
|
+
if let TerminalWrapper::Test(terminal) = wrapper {
|
|
17
|
+
let buffer = terminal.backend().buffer();
|
|
18
|
+
let area = buffer.area;
|
|
19
|
+
let mut result = String::new();
|
|
20
|
+
for y in 0..area.height {
|
|
21
|
+
for x in 0..area.width {
|
|
22
|
+
let cell = buffer.cell((x, y)).unwrap();
|
|
23
|
+
result.push_str(cell.symbol());
|
|
24
|
+
}
|
|
25
|
+
result.push('\n');
|
|
26
|
+
}
|
|
27
|
+
Ok(result)
|
|
28
|
+
} else {
|
|
29
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
30
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
31
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
32
|
+
Err(Error::new(
|
|
33
|
+
error_class,
|
|
34
|
+
"Terminal is not initialized as TestBackend",
|
|
35
|
+
))
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
.unwrap_or_else(|| {
|
|
39
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
40
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
41
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
42
|
+
Err(Error::new(error_class, "Terminal is not initialized"))
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
pub fn get_terminal_area() -> Result<magnus::RHash, Error> {
|
|
47
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
48
|
+
|
|
49
|
+
crate::terminal::with_query(|q| {
|
|
50
|
+
let area = q.viewport_area();
|
|
51
|
+
let hash = ruby.hash_new();
|
|
52
|
+
hash.aset("x", area.x).unwrap();
|
|
53
|
+
hash.aset("y", area.y).unwrap();
|
|
54
|
+
hash.aset("width", area.width).unwrap();
|
|
55
|
+
hash.aset("height", area.height).unwrap();
|
|
56
|
+
Ok(hash)
|
|
57
|
+
})
|
|
58
|
+
.unwrap_or_else(|| {
|
|
59
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
60
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
61
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
62
|
+
Err(Error::new(error_class, "Terminal is not initialized"))
|
|
63
|
+
})
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/// Returns the full terminal backend size (not the viewport)
|
|
67
|
+
pub fn get_terminal_size() -> Result<magnus::RHash, Error> {
|
|
68
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
69
|
+
|
|
70
|
+
crate::terminal::with_query(|q| {
|
|
71
|
+
let size = q.size();
|
|
72
|
+
let hash = ruby.hash_new();
|
|
73
|
+
hash.aset("x", 0).unwrap();
|
|
74
|
+
hash.aset("y", 0).unwrap();
|
|
75
|
+
hash.aset("width", size.width).unwrap();
|
|
76
|
+
hash.aset("height", size.height).unwrap();
|
|
77
|
+
Ok(hash)
|
|
78
|
+
})
|
|
79
|
+
.unwrap_or_else(|| {
|
|
80
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
81
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
82
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
83
|
+
Err(Error::new(error_class, "Terminal is not initialized"))
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
pub fn get_viewport_type() -> Result<String, Error> {
|
|
88
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
89
|
+
|
|
90
|
+
crate::terminal::with_query(|q| {
|
|
91
|
+
let viewport = q.viewport_area();
|
|
92
|
+
let size = q.size();
|
|
93
|
+
if viewport.height < size.height {
|
|
94
|
+
Ok("inline".to_string())
|
|
95
|
+
} else {
|
|
96
|
+
Ok("fullscreen".to_string())
|
|
97
|
+
}
|
|
98
|
+
})
|
|
99
|
+
.unwrap_or_else(|| {
|
|
100
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
101
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
102
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
103
|
+
Err(Error::new(error_class, "Terminal not initialized"))
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
|
|
108
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
109
|
+
|
|
110
|
+
crate::terminal::with_query(|q| Ok(q.cursor_position())).unwrap_or_else(|| {
|
|
111
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
112
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
113
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
114
|
+
Err(Error::new(
|
|
115
|
+
error_class,
|
|
116
|
+
"Terminal is not initialized as TestBackend",
|
|
117
|
+
))
|
|
118
|
+
})
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
pub fn get_cell_at(x: u16, y: u16) -> Result<magnus::RHash, Error> {
|
|
122
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
123
|
+
|
|
124
|
+
crate::terminal::with_query(|q| {
|
|
125
|
+
if let Some(cell) = q.cell_at(x, y) {
|
|
126
|
+
let hash = ruby.hash_new();
|
|
127
|
+
hash.aset("char", cell.symbol()).unwrap();
|
|
128
|
+
hash.aset("fg", color_to_value(cell.fg)).unwrap();
|
|
129
|
+
hash.aset("bg", color_to_value(cell.bg)).unwrap();
|
|
130
|
+
hash.aset("underline_color", color_to_value(cell.underline_color))
|
|
131
|
+
.unwrap();
|
|
132
|
+
hash.aset("modifiers", modifiers_to_value(cell.modifier))
|
|
133
|
+
.unwrap();
|
|
134
|
+
Ok(hash)
|
|
135
|
+
} else {
|
|
136
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
137
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
138
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
139
|
+
Err(Error::new(
|
|
140
|
+
error_class,
|
|
141
|
+
format!("Coordinates ({x}, {y}) out of bounds"),
|
|
142
|
+
))
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
.unwrap_or_else(|| {
|
|
146
|
+
let module = ruby.define_module("RatatuiRuby").unwrap();
|
|
147
|
+
let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
|
|
148
|
+
let error_class = error_base.const_get("Terminal").unwrap();
|
|
149
|
+
Err(Error::new(
|
|
150
|
+
error_class,
|
|
151
|
+
"Terminal is not initialized as TestBackend",
|
|
152
|
+
))
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
fn color_to_value(color: ratatui::style::Color) -> Value {
|
|
157
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
158
|
+
match color {
|
|
159
|
+
ratatui::style::Color::Reset => ruby.qnil().as_value(),
|
|
160
|
+
ratatui::style::Color::Black => ruby.to_symbol("black").as_value(),
|
|
161
|
+
ratatui::style::Color::Red => ruby.to_symbol("red").as_value(),
|
|
162
|
+
ratatui::style::Color::Green => ruby.to_symbol("green").as_value(),
|
|
163
|
+
ratatui::style::Color::Yellow => ruby.to_symbol("yellow").as_value(),
|
|
164
|
+
ratatui::style::Color::Blue => ruby.to_symbol("blue").as_value(),
|
|
165
|
+
ratatui::style::Color::Magenta => ruby.to_symbol("magenta").as_value(),
|
|
166
|
+
ratatui::style::Color::Cyan => ruby.to_symbol("cyan").as_value(),
|
|
167
|
+
ratatui::style::Color::Gray => ruby.to_symbol("gray").as_value(),
|
|
168
|
+
ratatui::style::Color::DarkGray => ruby.to_symbol("dark_gray").as_value(),
|
|
169
|
+
ratatui::style::Color::LightRed => ruby.to_symbol("light_red").as_value(),
|
|
170
|
+
ratatui::style::Color::LightGreen => ruby.to_symbol("light_green").as_value(),
|
|
171
|
+
ratatui::style::Color::LightYellow => ruby.to_symbol("light_yellow").as_value(),
|
|
172
|
+
ratatui::style::Color::LightBlue => ruby.to_symbol("light_blue").as_value(),
|
|
173
|
+
ratatui::style::Color::LightMagenta => ruby.to_symbol("light_magenta").as_value(),
|
|
174
|
+
ratatui::style::Color::LightCyan => ruby.to_symbol("light_cyan").as_value(),
|
|
175
|
+
ratatui::style::Color::White => ruby.to_symbol("white").as_value(),
|
|
176
|
+
ratatui::style::Color::Rgb(r, g, b) => ruby
|
|
177
|
+
.str_new(&(format!("#{r:02x}{g:02x}{b:02x}")))
|
|
178
|
+
.as_value(),
|
|
179
|
+
ratatui::style::Color::Indexed(i) => ruby.to_symbol(format!("indexed_{i}")).as_value(),
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fn modifiers_to_value(modifier: ratatui::style::Modifier) -> Value {
|
|
184
|
+
let ruby = magnus::Ruby::get().unwrap();
|
|
185
|
+
let ary = ruby.ary_new();
|
|
186
|
+
|
|
187
|
+
if modifier.contains(ratatui::style::Modifier::BOLD) {
|
|
188
|
+
let _ = ary.push(ruby.to_symbol("bold"));
|
|
189
|
+
}
|
|
190
|
+
if modifier.contains(ratatui::style::Modifier::ITALIC) {
|
|
191
|
+
let _ = ary.push(ruby.to_symbol("italic"));
|
|
192
|
+
}
|
|
193
|
+
if modifier.contains(ratatui::style::Modifier::DIM) {
|
|
194
|
+
let _ = ary.push(ruby.to_symbol("dim"));
|
|
195
|
+
}
|
|
196
|
+
if modifier.contains(ratatui::style::Modifier::UNDERLINED) {
|
|
197
|
+
let _ = ary.push(ruby.to_symbol("underlined"));
|
|
198
|
+
}
|
|
199
|
+
if modifier.contains(ratatui::style::Modifier::REVERSED) {
|
|
200
|
+
let _ = ary.push(ruby.to_symbol("reversed"));
|
|
201
|
+
}
|
|
202
|
+
if modifier.contains(ratatui::style::Modifier::HIDDEN) {
|
|
203
|
+
let _ = ary.push(ruby.to_symbol("hidden"));
|
|
204
|
+
}
|
|
205
|
+
if modifier.contains(ratatui::style::Modifier::CROSSED_OUT) {
|
|
206
|
+
let _ = ary.push(ruby.to_symbol("crossed_out"));
|
|
207
|
+
}
|
|
208
|
+
if modifier.contains(ratatui::style::Modifier::SLOW_BLINK) {
|
|
209
|
+
let _ = ary.push(ruby.to_symbol("slow_blink"));
|
|
210
|
+
}
|
|
211
|
+
if modifier.contains(ratatui::style::Modifier::RAPID_BLINK) {
|
|
212
|
+
let _ = ary.push(ruby.to_symbol("rapid_blink"));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
ary.as_value()
|
|
216
|
+
}
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal query trait and implementations.
|
|
5
|
+
//!
|
|
6
|
+
//! This module provides a compiler-enforced interface for querying terminal state.
|
|
7
|
+
//! All queries go through `TerminalQuery` trait which has two implementations:
|
|
8
|
+
//! - `LiveTerminal`: Used outside draw, queries actual terminal
|
|
9
|
+
//! - `DrawSnapshot`: Used during draw, queries pre-captured snapshot
|
|
10
|
+
|
|
11
|
+
use ratatui::buffer::Cell;
|
|
12
|
+
use ratatui::layout::Rect;
|
|
13
|
+
|
|
14
|
+
/// All terminal queries MUST go through this trait.
|
|
15
|
+
/// Compiler enforces both implementations when adding new queries.
|
|
16
|
+
pub trait TerminalQuery {
|
|
17
|
+
fn size(&self) -> Rect;
|
|
18
|
+
fn viewport_area(&self) -> Rect;
|
|
19
|
+
fn is_test_mode(&self) -> bool;
|
|
20
|
+
fn cursor_position(&self) -> Option<(u16, u16)>;
|
|
21
|
+
fn cell_at(&self, x: u16, y: u16) -> Option<Cell>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/// Snapshot of terminal state captured before draw.
|
|
25
|
+
/// Answers queries during draw without accessing terminal.
|
|
26
|
+
#[derive(Clone)]
|
|
27
|
+
pub struct DrawSnapshot {
|
|
28
|
+
pub size: Rect,
|
|
29
|
+
pub viewport_area: Rect,
|
|
30
|
+
pub is_test_mode: bool,
|
|
31
|
+
pub cursor_position: Option<(u16, u16)>,
|
|
32
|
+
pub buffer: Option<ratatui::buffer::Buffer>,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
impl DrawSnapshot {
|
|
36
|
+
/// Capture snapshot from a `TerminalWrapper`.
|
|
37
|
+
pub fn capture(wrapper: &mut super::wrapper::TerminalWrapper) -> Self {
|
|
38
|
+
match wrapper {
|
|
39
|
+
super::wrapper::TerminalWrapper::Crossterm(t) => {
|
|
40
|
+
let size = t.size().unwrap_or_default();
|
|
41
|
+
let viewport = t.get_frame().area();
|
|
42
|
+
Self {
|
|
43
|
+
size: Rect::new(0, 0, size.width, size.height),
|
|
44
|
+
viewport_area: viewport,
|
|
45
|
+
is_test_mode: false,
|
|
46
|
+
cursor_position: None,
|
|
47
|
+
buffer: None,
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
super::wrapper::TerminalWrapper::Test(t) => {
|
|
51
|
+
let size = t.size().unwrap_or_default();
|
|
52
|
+
let viewport = t.get_frame().area();
|
|
53
|
+
let cursor = t.get_cursor_position().ok().map(Into::into);
|
|
54
|
+
let buffer = t.backend().buffer().clone();
|
|
55
|
+
Self {
|
|
56
|
+
size: Rect::new(0, 0, size.width, size.height),
|
|
57
|
+
viewport_area: viewport,
|
|
58
|
+
is_test_mode: true,
|
|
59
|
+
cursor_position: cursor,
|
|
60
|
+
buffer: Some(buffer),
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
impl TerminalQuery for DrawSnapshot {
|
|
68
|
+
fn size(&self) -> Rect {
|
|
69
|
+
self.size
|
|
70
|
+
}
|
|
71
|
+
fn viewport_area(&self) -> Rect {
|
|
72
|
+
self.viewport_area
|
|
73
|
+
}
|
|
74
|
+
fn is_test_mode(&self) -> bool {
|
|
75
|
+
self.is_test_mode
|
|
76
|
+
}
|
|
77
|
+
fn cursor_position(&self) -> Option<(u16, u16)> {
|
|
78
|
+
self.cursor_position
|
|
79
|
+
}
|
|
80
|
+
fn cell_at(&self, x: u16, y: u16) -> Option<Cell> {
|
|
81
|
+
self.buffer.as_ref()?.cell((x, y)).cloned()
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/// Live queries against actual terminal (outside draw).
|
|
86
|
+
/// Uses interior mutability since ratatui's API requires &mut for some queries.
|
|
87
|
+
pub struct LiveTerminal<'a>(std::cell::RefCell<&'a mut super::wrapper::TerminalWrapper>);
|
|
88
|
+
|
|
89
|
+
impl<'a> LiveTerminal<'a> {
|
|
90
|
+
pub fn new(wrapper: &'a mut super::wrapper::TerminalWrapper) -> Self {
|
|
91
|
+
Self(std::cell::RefCell::new(wrapper))
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
impl TerminalQuery for LiveTerminal<'_> {
|
|
96
|
+
fn size(&self) -> Rect {
|
|
97
|
+
match *self.0.borrow() {
|
|
98
|
+
super::wrapper::TerminalWrapper::Crossterm(ref t) => {
|
|
99
|
+
let s = t.size().unwrap_or_default();
|
|
100
|
+
Rect::new(0, 0, s.width, s.height)
|
|
101
|
+
}
|
|
102
|
+
super::wrapper::TerminalWrapper::Test(ref t) => {
|
|
103
|
+
let s = t.size().unwrap_or_default();
|
|
104
|
+
Rect::new(0, 0, s.width, s.height)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
fn viewport_area(&self) -> Rect {
|
|
110
|
+
match *self.0.borrow_mut() {
|
|
111
|
+
super::wrapper::TerminalWrapper::Crossterm(ref mut t) => t.get_frame().area(),
|
|
112
|
+
super::wrapper::TerminalWrapper::Test(ref mut t) => t.get_frame().area(),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
fn is_test_mode(&self) -> bool {
|
|
116
|
+
matches!(*self.0.borrow(), super::wrapper::TerminalWrapper::Test(_))
|
|
117
|
+
}
|
|
118
|
+
fn cursor_position(&self) -> Option<(u16, u16)> {
|
|
119
|
+
match *self.0.borrow_mut() {
|
|
120
|
+
super::wrapper::TerminalWrapper::Test(ref mut t) => {
|
|
121
|
+
t.get_cursor_position().ok().map(Into::into)
|
|
122
|
+
}
|
|
123
|
+
super::wrapper::TerminalWrapper::Crossterm(_) => None,
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
fn cell_at(&self, x: u16, y: u16) -> Option<Cell> {
|
|
127
|
+
match &*self.0.borrow() {
|
|
128
|
+
super::wrapper::TerminalWrapper::Test(t) => t.backend().buffer().cell((x, y)).cloned(),
|
|
129
|
+
super::wrapper::TerminalWrapper::Crossterm(_) => None,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#[cfg(test)]
|
|
135
|
+
mod tests {
|
|
136
|
+
use super::*;
|
|
137
|
+
|
|
138
|
+
// Test: TerminalQuery trait can be implemented by a simple struct
|
|
139
|
+
struct MockQuery {
|
|
140
|
+
size: Rect,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
impl TerminalQuery for MockQuery {
|
|
144
|
+
fn size(&self) -> Rect {
|
|
145
|
+
self.size
|
|
146
|
+
}
|
|
147
|
+
fn viewport_area(&self) -> Rect {
|
|
148
|
+
Rect::default()
|
|
149
|
+
}
|
|
150
|
+
fn is_test_mode(&self) -> bool {
|
|
151
|
+
false
|
|
152
|
+
}
|
|
153
|
+
fn cursor_position(&self) -> Option<(u16, u16)> {
|
|
154
|
+
None
|
|
155
|
+
}
|
|
156
|
+
fn cell_at(&self, _x: u16, _y: u16) -> Option<Cell> {
|
|
157
|
+
None
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
#[test]
|
|
162
|
+
fn test_trait_can_be_implemented() {
|
|
163
|
+
let query = MockQuery {
|
|
164
|
+
size: Rect::new(0, 0, 80, 24),
|
|
165
|
+
};
|
|
166
|
+
assert_eq!(query.size(), Rect::new(0, 0, 80, 24));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
#[test]
|
|
170
|
+
fn test_draw_snapshot_returns_size() {
|
|
171
|
+
let snapshot = super::DrawSnapshot {
|
|
172
|
+
size: Rect::new(0, 0, 120, 40),
|
|
173
|
+
viewport_area: Rect::new(0, 0, 120, 40),
|
|
174
|
+
is_test_mode: false,
|
|
175
|
+
cursor_position: None,
|
|
176
|
+
buffer: None,
|
|
177
|
+
};
|
|
178
|
+
assert_eq!(snapshot.size(), Rect::new(0, 0, 120, 40));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
#[test]
|
|
182
|
+
fn test_draw_snapshot_returns_viewport_area() {
|
|
183
|
+
let snapshot = super::DrawSnapshot {
|
|
184
|
+
size: Rect::new(0, 0, 120, 40),
|
|
185
|
+
viewport_area: Rect::new(5, 10, 80, 20), // Different from size!
|
|
186
|
+
is_test_mode: false,
|
|
187
|
+
cursor_position: None,
|
|
188
|
+
buffer: None,
|
|
189
|
+
};
|
|
190
|
+
// This should return the stored viewport_area, not Rect::default()
|
|
191
|
+
assert_eq!(snapshot.viewport_area(), Rect::new(5, 10, 80, 20));
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#[test]
|
|
195
|
+
fn test_draw_snapshot_returns_is_test_mode() {
|
|
196
|
+
let snapshot = super::DrawSnapshot {
|
|
197
|
+
size: Rect::default(),
|
|
198
|
+
viewport_area: Rect::default(),
|
|
199
|
+
is_test_mode: true, // TRUE!
|
|
200
|
+
cursor_position: None,
|
|
201
|
+
buffer: None,
|
|
202
|
+
};
|
|
203
|
+
assert!(snapshot.is_test_mode());
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
#[test]
|
|
207
|
+
fn test_draw_snapshot_returns_cursor_position() {
|
|
208
|
+
let snapshot = super::DrawSnapshot {
|
|
209
|
+
size: Rect::default(),
|
|
210
|
+
viewport_area: Rect::default(),
|
|
211
|
+
is_test_mode: false,
|
|
212
|
+
cursor_position: Some((15, 20)), // Not None!
|
|
213
|
+
buffer: None,
|
|
214
|
+
};
|
|
215
|
+
assert_eq!(snapshot.cursor_position(), Some((15, 20)));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[test]
|
|
219
|
+
fn test_draw_snapshot_returns_cell_at() {
|
|
220
|
+
use ratatui::buffer::Buffer;
|
|
221
|
+
|
|
222
|
+
// Create a buffer with a known cell
|
|
223
|
+
let mut buffer = Buffer::empty(Rect::new(0, 0, 10, 10));
|
|
224
|
+
buffer[(5, 5)].set_char('X');
|
|
225
|
+
|
|
226
|
+
let snapshot = super::DrawSnapshot {
|
|
227
|
+
size: Rect::new(0, 0, 10, 10),
|
|
228
|
+
viewport_area: Rect::new(0, 0, 10, 10),
|
|
229
|
+
is_test_mode: true,
|
|
230
|
+
cursor_position: None,
|
|
231
|
+
buffer: Some(buffer),
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
let cell = snapshot.cell_at(5, 5);
|
|
235
|
+
assert!(cell.is_some());
|
|
236
|
+
assert_eq!(cell.unwrap().symbol(), "X");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
#[test]
|
|
240
|
+
fn test_capture_from_terminal_wrapper() {
|
|
241
|
+
use crate::terminal::TerminalWrapper;
|
|
242
|
+
use ratatui::{backend::TestBackend, Terminal};
|
|
243
|
+
|
|
244
|
+
let backend = TestBackend::new(80, 24);
|
|
245
|
+
let terminal = Terminal::new(backend).unwrap();
|
|
246
|
+
let mut wrapper = TerminalWrapper::Test(terminal);
|
|
247
|
+
|
|
248
|
+
let snapshot = super::DrawSnapshot::capture(&mut wrapper);
|
|
249
|
+
|
|
250
|
+
assert_eq!(snapshot.size(), Rect::new(0, 0, 80, 24));
|
|
251
|
+
assert!(snapshot.is_test_mode());
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
#[test]
|
|
255
|
+
fn test_live_terminal_exists() {
|
|
256
|
+
use crate::terminal::TerminalWrapper;
|
|
257
|
+
use ratatui::{backend::TestBackend, Terminal};
|
|
258
|
+
|
|
259
|
+
let backend = TestBackend::new(80, 24);
|
|
260
|
+
let terminal = Terminal::new(backend).unwrap();
|
|
261
|
+
let mut wrapper = TerminalWrapper::Test(terminal);
|
|
262
|
+
|
|
263
|
+
// LiveTerminal should exist and wrap a TerminalWrapper reference
|
|
264
|
+
let _live = super::LiveTerminal::new(&mut wrapper);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
#[test]
|
|
268
|
+
fn test_live_terminal_returns_size() {
|
|
269
|
+
use crate::terminal::TerminalWrapper;
|
|
270
|
+
use ratatui::{backend::TestBackend, Terminal};
|
|
271
|
+
|
|
272
|
+
let backend = TestBackend::new(100, 50);
|
|
273
|
+
let terminal = Terminal::new(backend).unwrap();
|
|
274
|
+
let mut wrapper = TerminalWrapper::Test(terminal);
|
|
275
|
+
|
|
276
|
+
let live = super::LiveTerminal::new(&mut wrapper);
|
|
277
|
+
// LiveTerminal must implement TerminalQuery
|
|
278
|
+
let size = live.size();
|
|
279
|
+
assert_eq!(size.width, 100);
|
|
280
|
+
assert_eq!(size.height, 50);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
#[test]
|
|
284
|
+
fn test_live_terminal_returns_is_test_mode() {
|
|
285
|
+
use crate::terminal::TerminalWrapper;
|
|
286
|
+
use ratatui::{backend::TestBackend, Terminal};
|
|
287
|
+
|
|
288
|
+
let backend = TestBackend::new(80, 24);
|
|
289
|
+
let terminal = Terminal::new(backend).unwrap();
|
|
290
|
+
let mut wrapper = TerminalWrapper::Test(terminal);
|
|
291
|
+
|
|
292
|
+
let live = super::LiveTerminal::new(&mut wrapper);
|
|
293
|
+
// TestBackend should return true for is_test_mode
|
|
294
|
+
assert!(live.is_test_mode());
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
#[test]
|
|
298
|
+
fn test_live_terminal_returns_viewport_area() {
|
|
299
|
+
use crate::terminal::TerminalWrapper;
|
|
300
|
+
use ratatui::{backend::TestBackend, Terminal};
|
|
301
|
+
|
|
302
|
+
let backend = TestBackend::new(120, 40);
|
|
303
|
+
let terminal = Terminal::new(backend).unwrap();
|
|
304
|
+
let mut wrapper = TerminalWrapper::Test(terminal);
|
|
305
|
+
|
|
306
|
+
let live = super::LiveTerminal::new(&mut wrapper);
|
|
307
|
+
let viewport = live.viewport_area();
|
|
308
|
+
// Viewport should match the terminal size for fullscreen mode
|
|
309
|
+
assert_eq!(viewport.width, 120);
|
|
310
|
+
assert_eq!(viewport.height, 40);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#[test]
|
|
314
|
+
fn test_live_terminal_viewport_area_differs_from_size_for_inline() {
|
|
315
|
+
use crate::terminal::TerminalWrapper;
|
|
316
|
+
use ratatui::{backend::TestBackend, Terminal, Viewport};
|
|
317
|
+
|
|
318
|
+
// Create terminal with inline viewport of height 5 in a 40-line terminal
|
|
319
|
+
let backend = TestBackend::new(80, 40);
|
|
320
|
+
let options = ratatui::TerminalOptions {
|
|
321
|
+
viewport: Viewport::Inline(5),
|
|
322
|
+
};
|
|
323
|
+
let terminal = Terminal::with_options(backend, options).unwrap();
|
|
324
|
+
let mut wrapper = TerminalWrapper::Test(terminal);
|
|
325
|
+
|
|
326
|
+
let live = super::LiveTerminal::new(&mut wrapper);
|
|
327
|
+
let size = live.size();
|
|
328
|
+
let viewport = live.viewport_area();
|
|
329
|
+
|
|
330
|
+
// Terminal size is 80x40
|
|
331
|
+
assert_eq!(size.width, 80);
|
|
332
|
+
assert_eq!(size.height, 40);
|
|
333
|
+
|
|
334
|
+
// But viewport is only 5 lines high (inline mode)
|
|
335
|
+
assert_eq!(viewport.width, 80);
|
|
336
|
+
assert_eq!(viewport.height, 5);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Private terminal storage with safe accessor functions.
|
|
5
|
+
//!
|
|
6
|
+
//! This module provides thread-local storage for the terminal and draw snapshot,
|
|
7
|
+
//! with functions that route queries correctly:
|
|
8
|
+
//! - During draw: queries go to snapshot (no lock needed)
|
|
9
|
+
//! - Outside draw: queries go to live terminal
|
|
10
|
+
|
|
11
|
+
use super::query::{DrawSnapshot, LiveTerminal, TerminalQuery};
|
|
12
|
+
use super::wrapper::TerminalWrapper;
|
|
13
|
+
use std::cell::RefCell;
|
|
14
|
+
|
|
15
|
+
thread_local! {
|
|
16
|
+
static TERMINAL: RefCell<Option<TerminalWrapper>> = const { RefCell::new(None) };
|
|
17
|
+
static DRAW_SNAPSHOT: RefCell<Option<DrawSnapshot>> = const { RefCell::new(None) };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Check if we're currently inside a draw operation.
|
|
21
|
+
pub fn is_in_draw_mode() -> bool {
|
|
22
|
+
DRAW_SNAPSHOT.with(|cell| cell.borrow().is_some())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// Execute a query. Routes to snapshot during draw, live terminal outside.
|
|
26
|
+
pub fn with_query<R, F>(f: F) -> Option<R>
|
|
27
|
+
where
|
|
28
|
+
F: Fn(&dyn TerminalQuery) -> R,
|
|
29
|
+
{
|
|
30
|
+
// During draw: use snapshot
|
|
31
|
+
DRAW_SNAPSHOT
|
|
32
|
+
.with(|cell| cell.borrow().as_ref().map(|snapshot| f(snapshot)))
|
|
33
|
+
.or_else(|| {
|
|
34
|
+
// Outside draw: use live terminal from thread-local storage
|
|
35
|
+
TERMINAL.with(|cell| {
|
|
36
|
+
cell.borrow_mut().as_mut().map(|t| {
|
|
37
|
+
let live = LiveTerminal::new(t);
|
|
38
|
+
f(&live)
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// Mutable access to terminal. Only works OUTSIDE draw.
|
|
45
|
+
pub fn with_terminal_mut<R, F>(f: F) -> Option<R>
|
|
46
|
+
where
|
|
47
|
+
F: FnOnce(&mut TerminalWrapper) -> R,
|
|
48
|
+
{
|
|
49
|
+
// During draw: reject
|
|
50
|
+
if is_in_draw_mode() {
|
|
51
|
+
return None;
|
|
52
|
+
}
|
|
53
|
+
TERMINAL.with(|cell| cell.borrow_mut().as_mut().map(f))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/// Execute draw with snapshot for queries.
|
|
57
|
+
pub fn lend_for_draw<R, F>(f: F) -> Option<R>
|
|
58
|
+
where
|
|
59
|
+
F: FnOnce(&mut TerminalWrapper) -> R,
|
|
60
|
+
{
|
|
61
|
+
// Take terminal out of storage
|
|
62
|
+
let mut terminal = TERMINAL.with(|cell| cell.borrow_mut().take())?;
|
|
63
|
+
|
|
64
|
+
// Capture snapshot BEFORE draw (while we have &mut)
|
|
65
|
+
let snapshot = DrawSnapshot::capture(&mut terminal);
|
|
66
|
+
DRAW_SNAPSHOT.with(|cell| *cell.borrow_mut() = Some(snapshot));
|
|
67
|
+
|
|
68
|
+
let result = f(&mut terminal);
|
|
69
|
+
|
|
70
|
+
// Clear snapshot, restore terminal
|
|
71
|
+
DRAW_SNAPSHOT.with(|cell| *cell.borrow_mut() = None);
|
|
72
|
+
TERMINAL.with(|cell| *cell.borrow_mut() = Some(terminal));
|
|
73
|
+
|
|
74
|
+
Some(result)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// Set the terminal (used during initialization).
|
|
78
|
+
pub fn set_terminal(wrapper: TerminalWrapper) {
|
|
79
|
+
TERMINAL.with(|cell| {
|
|
80
|
+
*cell.borrow_mut() = Some(wrapper);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/// Take the terminal out of storage (used during cleanup).
|
|
85
|
+
pub fn take_terminal() -> Option<TerminalWrapper> {
|
|
86
|
+
TERMINAL.with(|cell| cell.borrow_mut().take())
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/// Check if terminal is initialized.
|
|
90
|
+
pub fn is_initialized() -> bool {
|
|
91
|
+
TERMINAL.with(|cell| cell.borrow().is_some())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[cfg(test)]
|
|
95
|
+
mod tests {
|
|
96
|
+
use crate::terminal::query::DrawSnapshot;
|
|
97
|
+
use ratatui::layout::Rect;
|
|
98
|
+
|
|
99
|
+
#[test]
|
|
100
|
+
fn test_is_in_draw_mode_false_by_default() {
|
|
101
|
+
assert!(!super::is_in_draw_mode());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
#[test]
|
|
105
|
+
fn test_with_query_returns_none_when_not_initialized() {
|
|
106
|
+
let result = super::with_query(|q| q.size());
|
|
107
|
+
assert!(result.is_none());
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
|
|
2
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
3
|
+
|
|
4
|
+
//! Terminal wrapper enum for different backend types.
|
|
5
|
+
|
|
6
|
+
use ratatui::{
|
|
7
|
+
backend::{CrosstermBackend, TestBackend},
|
|
8
|
+
Terminal,
|
|
9
|
+
};
|
|
10
|
+
use std::io;
|
|
11
|
+
|
|
12
|
+
/// Unified terminal type supporting different backends.
|
|
13
|
+
pub enum TerminalWrapper {
|
|
14
|
+
Crossterm(Terminal<CrosstermBackend<io::Stdout>>),
|
|
15
|
+
Test(Terminal<TestBackend>),
|
|
16
|
+
}
|
data/lib/ratatui_ruby/version.rb
CHANGED