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.
@@ -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
+ }
@@ -8,5 +8,5 @@
8
8
  module RatatuiRuby
9
9
  # The version of the ratatui_ruby gem.
10
10
  # See https://semver.org/spec/v2.0.0.html
11
- VERSION = "1.0.0-beta.2"
11
+ VERSION = "1.0.0-beta.3"
12
12
  end