ratatui_ruby 0.10.1 → 0.10.2

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.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +1 -1
  3. data/.builds/ruby-3.3.yml +1 -1
  4. data/.builds/ruby-3.4.yml +1 -1
  5. data/.builds/ruby-4.0.0.yml +1 -1
  6. data/CHANGELOG.md +24 -0
  7. data/doc/concepts/application_architecture.md +2 -2
  8. data/doc/concepts/application_testing.md +1 -1
  9. data/doc/concepts/custom_widgets.md +2 -2
  10. data/doc/contributors/todo/align/api_completeness_audit-finished.md +375 -0
  11. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +206 -0
  12. data/doc/contributors/todo/align/terminal.md +647 -0
  13. data/doc/getting_started/quickstart.md +41 -41
  14. data/doc/images/app_cli_rich_moments.gif +0 -0
  15. data/examples/app_cli_rich_moments/README.md +81 -0
  16. data/examples/app_cli_rich_moments/app.rb +189 -0
  17. data/ext/ratatui_ruby/Cargo.lock +1 -1
  18. data/ext/ratatui_ruby/Cargo.toml +1 -1
  19. data/ext/ratatui_ruby/src/frame.rs +17 -4
  20. data/ext/ratatui_ruby/src/lib.rs +17 -3
  21. data/ext/ratatui_ruby/src/lib.rs.bak +286 -0
  22. data/ext/ratatui_ruby/src/rendering.rs +38 -25
  23. data/ext/ratatui_ruby/src/rendering.rs.bak +152 -0
  24. data/ext/ratatui_ruby/src/terminal.rs +245 -33
  25. data/ext/ratatui_ruby/src/terminal.rs.bak +381 -0
  26. data/ext/ratatui_ruby/src/terminal.rs.orig +409 -0
  27. data/ext/ratatui_ruby/src/widgets/barchart.rs +4 -3
  28. data/ext/ratatui_ruby/src/widgets/block.rs +4 -4
  29. data/ext/ratatui_ruby/src/widgets/calendar.rs +4 -3
  30. data/ext/ratatui_ruby/src/widgets/canvas.rs +7 -4
  31. data/ext/ratatui_ruby/src/widgets/center.rs +3 -3
  32. data/ext/ratatui_ruby/src/widgets/chart.rs +4 -4
  33. data/ext/ratatui_ruby/src/widgets/clear.rs +6 -6
  34. data/ext/ratatui_ruby/src/widgets/cursor.rs +10 -7
  35. data/ext/ratatui_ruby/src/widgets/gauge.rs +4 -3
  36. data/ext/ratatui_ruby/src/widgets/layout.rs +3 -3
  37. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +4 -3
  38. data/ext/ratatui_ruby/src/widgets/list.rs +6 -9
  39. data/ext/ratatui_ruby/src/widgets/overlay.rs +3 -3
  40. data/ext/ratatui_ruby/src/widgets/paragraph.rs +5 -6
  41. data/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +4 -4
  42. data/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +8 -4
  43. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +10 -10
  44. data/ext/ratatui_ruby/src/widgets/sparkline.rs +4 -3
  45. data/ext/ratatui_ruby/src/widgets/table.rs +6 -6
  46. data/ext/ratatui_ruby/src/widgets/tabs.rs +4 -3
  47. data/lib/ratatui_ruby/labs/a11y.rb +173 -0
  48. data/lib/ratatui_ruby/labs/frame_a11y_capture.rb +50 -0
  49. data/lib/ratatui_ruby/labs.rb +47 -0
  50. data/lib/ratatui_ruby/layout/position.rb +26 -0
  51. data/lib/ratatui_ruby/terminal/viewport.rb +80 -0
  52. data/lib/ratatui_ruby/terminal_lifecycle.rb +164 -6
  53. data/lib/ratatui_ruby/terminal_lifecycle.rb.bak +197 -0
  54. data/lib/ratatui_ruby/test_helper/terminal.rb +8 -1
  55. data/lib/ratatui_ruby/tui/core.rb +16 -0
  56. data/lib/ratatui_ruby/version.rb +1 -1
  57. data/lib/ratatui_ruby.rb +82 -3
  58. data/migrate_to_buffer.rb +145 -0
  59. data/sig/examples/app_cli_rich_moments/app.rbs +12 -0
  60. data/sig/ratatui_ruby/labs.rbs +87 -0
  61. data/sig/ratatui_ruby/ratatui_ruby.rbs +12 -4
  62. data/sig/ratatui_ruby/terminal/viewport.rbs +19 -0
  63. data/sig/ratatui_ruby/terminal_lifecycle.rbs +13 -5
  64. data/sig/ratatui_ruby/tui/core.rbs +3 -0
  65. metadata +21 -2
  66. /data/doc/contributors/{future_work.md → todo/future_work.md} +0 -0
@@ -5,7 +5,7 @@ use magnus::value::ReprValue;
5
5
  use magnus::{Error, Module};
6
6
  use ratatui::{
7
7
  backend::{CrosstermBackend, TestBackend},
8
- Terminal,
8
+ Terminal, TerminalOptions, Viewport,
9
9
  };
10
10
  use std::io;
11
11
  use std::sync::Mutex;
@@ -16,8 +16,16 @@ pub enum TerminalWrapper {
16
16
  }
17
17
 
18
18
  pub static TERMINAL: Mutex<Option<TerminalWrapper>> = Mutex::new(None);
19
+ // Track whether we're using fullscreen viewport (for restore_terminal)
20
+ static IS_FULLSCREEN: Mutex<bool> = Mutex::new(false);
19
21
 
20
- pub fn init_terminal(focus_events: bool, bracketed_paste: bool) -> Result<(), Error> {
22
+ #[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
23
+ pub fn init_terminal(
24
+ focus_events: bool,
25
+ bracketed_paste: bool,
26
+ viewport_type: String,
27
+ viewport_height: Option<u16>,
28
+ ) -> Result<(), Error> {
21
29
  let ruby = magnus::Ruby::get().unwrap();
22
30
  let mut term_lock = TERMINAL.lock().unwrap();
23
31
  if term_lock.is_none() {
@@ -25,15 +33,30 @@ pub fn init_terminal(focus_events: bool, bracketed_paste: bool) -> Result<(), Er
25
33
  let error_base = module.const_get::<_, magnus::RClass>("Error")?;
26
34
  let error_class = error_base.const_get("Terminal")?;
27
35
 
36
+ // Parse viewport type
37
+ let viewport = match viewport_type.as_ref() {
38
+ "inline" => {
39
+ let height = viewport_height.unwrap_or(8);
40
+ Viewport::Inline(height)
41
+ }
42
+ _ => Viewport::Fullscreen,
43
+ };
44
+
28
45
  ratatui::crossterm::terminal::enable_raw_mode()
29
46
  .map_err(|e| Error::new(error_class, e.to_string()))?;
30
47
  let mut stdout = io::stdout();
31
- ratatui::crossterm::execute!(
32
- stdout,
33
- ratatui::crossterm::terminal::EnterAlternateScreen,
34
- ratatui::crossterm::event::EnableMouseCapture
35
- )
36
- .map_err(|e| Error::new(error_class, e.to_string()))?;
48
+
49
+ // Only enter alternate screen for fullscreen viewports
50
+ if matches!(viewport, Viewport::Fullscreen) {
51
+ ratatui::crossterm::execute!(
52
+ stdout,
53
+ ratatui::crossterm::terminal::EnterAlternateScreen
54
+ )
55
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
56
+ }
57
+
58
+ ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableMouseCapture)
59
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
37
60
 
38
61
  if focus_events {
39
62
  ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableFocusChange)
@@ -45,21 +68,47 @@ pub fn init_terminal(focus_events: bool, bracketed_paste: bool) -> Result<(), Er
45
68
  }
46
69
 
47
70
  let backend = CrosstermBackend::new(stdout);
48
- let terminal =
49
- Terminal::new(backend).map_err(|e| Error::new(error_class, e.to_string()))?;
71
+
72
+ // Store whether we're using fullscreen for restore_terminal (before moving viewport)
73
+ let is_fullscreen = matches!(viewport, Viewport::Fullscreen);
74
+ *IS_FULLSCREEN.lock().unwrap() = is_fullscreen;
75
+
76
+ let options = TerminalOptions { viewport };
77
+ let terminal = Terminal::with_options(backend, options)
78
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
79
+
50
80
  *term_lock = Some(TerminalWrapper::Crossterm(terminal));
51
81
  }
52
82
  Ok(())
53
83
  }
54
84
 
55
- pub fn init_test_terminal(width: u16, height: u16) -> Result<(), Error> {
85
+ #[allow(clippy::needless_pass_by_value)] // Magnus FFI requires owned String, not &str
86
+ pub fn init_test_terminal(
87
+ width: u16,
88
+ height: u16,
89
+ viewport_type: String,
90
+ viewport_height: Option<u16>,
91
+ ) -> Result<(), Error> {
56
92
  let ruby = magnus::Ruby::get().unwrap();
57
93
  let mut term_lock = TERMINAL.lock().unwrap();
58
94
  let backend = TestBackend::new(width, height);
59
95
  let module = ruby.define_module("RatatuiRuby")?;
60
96
  let error_base = module.const_get::<_, magnus::RClass>("Error")?;
61
97
  let error_class = error_base.const_get("Terminal")?;
62
- let terminal = Terminal::new(backend).map_err(|e| Error::new(error_class, e.to_string()))?;
98
+
99
+ // Parse viewport type (same as init_terminal)
100
+ let viewport = match viewport_type.as_ref() {
101
+ "inline" => {
102
+ let vp_height = viewport_height.unwrap_or(height);
103
+ Viewport::Inline(vp_height)
104
+ }
105
+ _ => Viewport::Fullscreen,
106
+ };
107
+
108
+ let options = TerminalOptions { viewport };
109
+ let terminal = Terminal::with_options(backend, options)
110
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
111
+
63
112
  *term_lock = Some(TerminalWrapper::Test(terminal));
64
113
  Ok(())
65
114
  }
@@ -70,13 +119,25 @@ pub fn restore_terminal() {
70
119
  match wrapper {
71
120
  TerminalWrapper::Crossterm(mut t) => {
72
121
  let _ = ratatui::crossterm::terminal::disable_raw_mode();
73
- let _ = ratatui::crossterm::execute!(
74
- t.backend_mut(),
75
- ratatui::crossterm::terminal::LeaveAlternateScreen,
76
- ratatui::crossterm::event::DisableMouseCapture,
77
- ratatui::crossterm::event::DisableFocusChange,
78
- ratatui::crossterm::event::DisableBracketedPaste
79
- );
122
+
123
+ // Only leave alternate screen if we were in fullscreen mode
124
+ let is_fullscreen = *IS_FULLSCREEN.lock().unwrap();
125
+ if is_fullscreen {
126
+ let _ = ratatui::crossterm::execute!(
127
+ t.backend_mut(),
128
+ ratatui::crossterm::terminal::LeaveAlternateScreen,
129
+ ratatui::crossterm::event::DisableMouseCapture,
130
+ ratatui::crossterm::event::DisableFocusChange,
131
+ ratatui::crossterm::event::DisableBracketedPaste
132
+ );
133
+ } else {
134
+ let _ = ratatui::crossterm::execute!(
135
+ t.backend_mut(),
136
+ ratatui::crossterm::event::DisableMouseCapture,
137
+ ratatui::crossterm::event::DisableFocusChange,
138
+ ratatui::crossterm::event::DisableBracketedPaste
139
+ );
140
+ }
80
141
  }
81
142
  TerminalWrapper::Test(_) => {}
82
143
  }
@@ -109,27 +170,92 @@ pub fn get_buffer_content() -> Result<String, Error> {
109
170
  }
110
171
  }
111
172
 
112
- pub fn get_terminal_area() -> Result<magnus::RHash, Error> {
173
+ pub fn insert_before(height: u16, widget: magnus::Value) -> Result<(), Error> {
113
174
  let ruby = magnus::Ruby::get().unwrap();
114
- let term_lock = TERMINAL.lock().unwrap();
115
- if let Some(wrapper) = term_lock.as_ref() {
116
- let hash = ruby.hash_new();
175
+ let mut term_lock = TERMINAL.lock().unwrap();
176
+
177
+ if let Some(wrapper) = term_lock.as_mut() {
178
+ let module = ruby.define_module("RatatuiRuby")?;
179
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
180
+ let error_class = error_base.const_get("Terminal")?;
181
+
117
182
  match wrapper {
118
183
  TerminalWrapper::Crossterm(term) => {
119
- let size = term.size().unwrap_or_default();
120
- hash.aset("x", 0u16)?;
121
- hash.aset("y", 0u16)?;
122
- hash.aset("width", size.width)?;
123
- hash.aset("height", size.height)?;
184
+ // Capture rendering error since closure can't return Result
185
+ let mut render_error: Option<String> = None;
186
+
187
+ let result = term.insert_before(height, |buf| {
188
+ let area = buf.area();
189
+ let area_copy = *area; // Copy rect before closure capture
190
+
191
+ // Render widget to buffer using centralized dispatch
192
+ let render_result =
193
+ crate::rendering::render_widget_to_buffer(buf, area_copy, widget);
194
+
195
+ if let Err(e) = render_result {
196
+ render_error = Some(e.to_string());
197
+ }
198
+ });
199
+
200
+ // Handle insert_before error
201
+ result.map_err(|e| Error::new(error_class, e.to_string()))?;
202
+
203
+ // Handle rendering error
204
+ if let Some(err_msg) = render_error {
205
+ return Err(Error::new(error_class, err_msg));
206
+ }
124
207
  }
125
208
  TerminalWrapper::Test(term) => {
126
- let area = term.backend().buffer().area;
127
- hash.aset("x", area.x)?;
128
- hash.aset("y", area.y)?;
129
- hash.aset("width", area.width)?;
130
- hash.aset("height", area.height)?;
209
+ // Capture rendering error since closure can't return Result
210
+ let mut render_error: Option<String> = None;
211
+
212
+ let result = term.insert_before(height, |buf| {
213
+ let area = buf.area();
214
+ let area_copy = *area; // Copy rect before closure capture
215
+
216
+ // Render widget to buffer using centralized dispatch
217
+ let render_result =
218
+ crate::rendering::render_widget_to_buffer(buf, area_copy, widget);
219
+
220
+ if let Err(e) = render_result {
221
+ render_error = Some(e.to_string());
222
+ }
223
+ });
224
+
225
+ // Handle insert_before error
226
+ result.map_err(|e| Error::new(error_class, e.to_string()))?;
227
+
228
+ // Handle rendering error
229
+ if let Some(err_msg) = render_error {
230
+ return Err(Error::new(error_class, err_msg));
231
+ }
131
232
  }
132
233
  }
234
+ Ok(())
235
+ } else {
236
+ let module = ruby.define_module("RatatuiRuby")?;
237
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
238
+ let error_class = error_base.const_get("Terminal")?;
239
+ Err(Error::new(error_class, "Terminal not initialized"))
240
+ }
241
+ }
242
+
243
+ pub fn get_terminal_area() -> Result<magnus::RHash, Error> {
244
+ let ruby = magnus::Ruby::get().unwrap();
245
+ let mut term_lock = TERMINAL.lock().unwrap();
246
+
247
+ if let Some(wrapper) = term_lock.as_mut() {
248
+ // Get viewport area directly from the terminal
249
+ let area = match wrapper {
250
+ TerminalWrapper::Crossterm(term) => term.get_frame().area(),
251
+ TerminalWrapper::Test(term) => term.get_frame().area(),
252
+ };
253
+
254
+ let hash = ruby.hash_new();
255
+ hash.aset("x", area.x)?;
256
+ hash.aset("y", area.y)?;
257
+ hash.aset("width", area.width)?;
258
+ hash.aset("height", area.height)?;
133
259
  Ok(hash)
134
260
  } else {
135
261
  let module = ruby.define_module("RatatuiRuby")?;
@@ -139,6 +265,64 @@ pub fn get_terminal_area() -> Result<magnus::RHash, Error> {
139
265
  }
140
266
  }
141
267
 
268
+ /// Returns the full terminal backend size (not the viewport)
269
+ pub fn get_terminal_size() -> Result<magnus::RHash, Error> {
270
+ let ruby = magnus::Ruby::get().unwrap();
271
+ let term_lock = TERMINAL.lock().unwrap();
272
+
273
+ if let Some(wrapper) = term_lock.as_ref() {
274
+ let size = match wrapper {
275
+ TerminalWrapper::Crossterm(term) => term.size().map_err(|e| {
276
+ let module = ruby.define_module("RatatuiRuby").unwrap();
277
+ let error_base = module.const_get::<_, magnus::RClass>("Error").unwrap();
278
+ let error_class = error_base.const_get("Terminal").unwrap();
279
+ Error::new(error_class, e.to_string())
280
+ })?,
281
+ TerminalWrapper::Test(term) => term.size().unwrap_or_default(),
282
+ };
283
+
284
+ let hash = ruby.hash_new();
285
+ hash.aset("x", 0)?;
286
+ hash.aset("y", 0)?;
287
+ hash.aset("width", size.width)?;
288
+ hash.aset("height", size.height)?;
289
+ Ok(hash)
290
+ } else {
291
+ let module = ruby.define_module("RatatuiRuby")?;
292
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
293
+ let error_class = error_base.const_get("Terminal")?;
294
+ Err(Error::new(error_class, "Terminal is not initialized"))
295
+ }
296
+ }
297
+
298
+ pub fn get_viewport_type() -> Result<String, Error> {
299
+ let ruby = magnus::Ruby::get().unwrap();
300
+ let mut term_lock = TERMINAL.lock().unwrap();
301
+
302
+ if let Some(wrapper) = term_lock.as_mut() {
303
+ // Get viewport area directly from the terminal
304
+ let vp_area = match wrapper {
305
+ TerminalWrapper::Crossterm(term) => term.get_frame().area(),
306
+ TerminalWrapper::Test(term) => term.get_frame().area(),
307
+ };
308
+ let backend_size = match wrapper {
309
+ TerminalWrapper::Crossterm(term) => term.size().unwrap_or_default(),
310
+ TerminalWrapper::Test(term) => term.size().unwrap_or_default(),
311
+ };
312
+
313
+ if vp_area.height < backend_size.height {
314
+ Ok("inline".to_string())
315
+ } else {
316
+ Ok("fullscreen".to_string())
317
+ }
318
+ } else {
319
+ let module = ruby.define_module("RatatuiRuby")?;
320
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
321
+ let error_class = error_base.const_get("Terminal")?;
322
+ Err(Error::new(error_class, "Terminal not initialized"))
323
+ }
324
+ }
325
+
142
326
  pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
143
327
  let ruby = magnus::Ruby::get().unwrap();
144
328
  let mut term_lock = TERMINAL.lock().unwrap();
@@ -161,6 +345,34 @@ pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
161
345
  }
162
346
  }
163
347
 
348
+ pub fn set_cursor_position(x: u16, y: u16) -> Result<(), Error> {
349
+ let ruby = magnus::Ruby::get().unwrap();
350
+ let mut term_lock = TERMINAL.lock().unwrap();
351
+
352
+ if let Some(wrapper) = term_lock.as_mut() {
353
+ let module = ruby.define_module("RatatuiRuby")?;
354
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
355
+ let error_class = error_base.const_get("Terminal")?;
356
+
357
+ match wrapper {
358
+ TerminalWrapper::Crossterm(term) => {
359
+ term.set_cursor_position((x, y))
360
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
361
+ }
362
+ TerminalWrapper::Test(term) => {
363
+ term.set_cursor_position((x, y))
364
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
365
+ }
366
+ }
367
+ Ok(())
368
+ } else {
369
+ let module = ruby.define_module("RatatuiRuby")?;
370
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
371
+ let error_class = error_base.const_get("Terminal")?;
372
+ Err(Error::new(error_class, "Terminal is not initialized"))
373
+ }
374
+ }
375
+
164
376
  pub fn resize_terminal(width: u16, height: u16) -> Result<(), Error> {
165
377
  let ruby = magnus::Ruby::get().unwrap();
166
378
  let mut term_lock = TERMINAL.lock().unwrap();