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,11 +5,11 @@ use crate::errors::type_error_with_context;
5
5
  use crate::rendering::render_node;
6
6
  use magnus::{prelude::*, Error, Symbol, Value};
7
7
  use ratatui::{
8
+ buffer::Buffer,
8
9
  layout::{Constraint, Direction, Flex, Layout, Rect},
9
- Frame,
10
10
  };
11
11
 
12
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
12
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
13
13
  let ruby = magnus::Ruby::get().unwrap();
14
14
  let direction_sym: Symbol = node.funcall("direction", ())?;
15
15
  let children_val: Value = node.funcall("children", ())?;
@@ -72,7 +72,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
72
72
  let index = isize::try_from(i)
73
73
  .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
74
74
  let child: Value = children_array.entry(index)?;
75
- if let Err(e) = render_node(frame, chunks[i], child) {
75
+ if let Err(e) = render_node(buffer, chunks[i], child) {
76
76
  eprintln!("Error rendering child {i}: {e:?}");
77
77
  }
78
78
  }
@@ -5,9 +5,10 @@ use crate::style::{parse_block, parse_style};
5
5
  use crate::text::parse_span;
6
6
  use bumpalo::Bump;
7
7
  use magnus::{prelude::*, Error, Value};
8
- use ratatui::{layout::Rect, widgets::LineGauge, Frame};
8
+ use ratatui::buffer::Buffer;
9
+ use ratatui::{layout::Rect, widgets::LineGauge, widgets::Widget};
9
10
 
10
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
11
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
11
12
  let bump = Bump::new();
12
13
  let ratio: f64 = node.funcall("ratio", ())?;
13
14
  let label_val: Value = node.funcall("label", ())?;
@@ -52,7 +53,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
52
53
  gauge = gauge.block(parse_block(block_val, &bump)?);
53
54
  }
54
55
 
55
- frame.render_widget(gauge, area);
56
+ gauge.render(area, buffer);
56
57
  Ok(())
57
58
  }
58
59
 
@@ -7,14 +7,14 @@ use crate::text::{parse_line, parse_span};
7
7
  use crate::widgets::list_state::RubyListState;
8
8
  use bumpalo::Bump;
9
9
  use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
10
+ use ratatui::buffer::Buffer;
10
11
  use ratatui::{
11
12
  layout::Rect,
12
13
  text::Line,
13
- widgets::{HighlightSpacing, List, ListItem, ListState},
14
- Frame,
14
+ widgets::{HighlightSpacing, List, ListItem, ListState, StatefulWidget},
15
15
  };
16
16
 
17
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
17
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
18
18
  let bump = Bump::new();
19
19
  let ruby = magnus::Ruby::get().unwrap();
20
20
  let items_val: Value = node.funcall("items", ())?;
@@ -107,7 +107,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
107
107
  list = list.block(parse_block(block_val, &bump)?);
108
108
  }
109
109
 
110
- frame.render_stateful_widget(list, area, &mut state);
110
+ StatefulWidget::render(list, area, buffer, &mut state);
111
111
  Ok(())
112
112
  }
113
113
 
@@ -116,7 +116,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
116
116
  /// This function ignores `selected_index` and `offset` from the widget.
117
117
  /// The State object is the single source of truth for selection and scroll position.
118
118
  pub fn render_stateful(
119
- frame: &mut Frame,
119
+ buffer: &mut Buffer,
120
120
  area: Rect,
121
121
  node: Value,
122
122
  state_wrapper: Value,
@@ -210,7 +210,7 @@ pub fn render_stateful(
210
210
  // Borrow the inner ListState, render, and release the borrow immediately
211
211
  {
212
212
  let mut inner_state = state.borrow_mut();
213
- frame.render_stateful_widget(list, area, &mut inner_state);
213
+ StatefulWidget::render(list, area, buffer, &mut inner_state);
214
214
  }
215
215
  // Borrow is now released
216
216
 
@@ -299,7 +299,6 @@ mod tests {
299
299
  state.select(Some(1));
300
300
 
301
301
  let mut buf = Buffer::empty(Rect::new(0, 0, 10, 2));
302
- use ratatui::widgets::StatefulWidget;
303
302
  StatefulWidget::render(list, Rect::new(0, 0, 10, 2), &mut buf, &mut state);
304
303
 
305
304
  let content = buf.content().iter().map(|c| c.symbol()).collect::<String>();
@@ -328,7 +327,6 @@ mod tests {
328
327
  state.select(Some(0));
329
328
 
330
329
  let mut buf1 = Buffer::empty(Rect::new(0, 0, 10, 2));
331
- use ratatui::widgets::StatefulWidget;
332
330
  StatefulWidget::render(
333
331
  list_without_repeat,
334
332
  Rect::new(0, 0, 10, 2),
@@ -370,7 +368,6 @@ mod tests {
370
368
  state.select(Some(1));
371
369
 
372
370
  let mut buf = Buffer::empty(Rect::new(0, 0, 15, 4));
373
- use ratatui::widgets::StatefulWidget;
374
371
  StatefulWidget::render(list, Rect::new(0, 0, 15, 4), &mut buf, &mut state);
375
372
 
376
373
  let content = buf.content().iter().map(|c| c.symbol()).collect::<String>();
@@ -4,9 +4,9 @@
4
4
  use crate::errors::type_error_with_context;
5
5
  use crate::rendering::render_node;
6
6
  use magnus::{prelude::*, Error, Value};
7
- use ratatui::{layout::Rect, Frame};
7
+ use ratatui::{buffer::Buffer, layout::Rect};
8
8
 
9
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
9
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
10
10
  let ruby = magnus::Ruby::get().unwrap();
11
11
  let layers_val: Value = node.funcall("layers", ())?;
12
12
  let layers_array = magnus::RArray::from_value(layers_val)
@@ -16,7 +16,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
16
16
  let index = isize::try_from(i)
17
17
  .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
18
18
  let layer: Value = layers_array.entry(index)?;
19
- if let Err(e) = render_node(frame, area, layer) {
19
+ if let Err(e) = render_node(buffer, area, layer) {
20
20
  eprintln!("Error rendering overlay layer {i}: {e:?}");
21
21
  }
22
22
  }
@@ -4,15 +4,15 @@
4
4
  use crate::style::{parse_block, parse_style};
5
5
  use bumpalo::Bump;
6
6
  use magnus::{prelude::*, Error, Symbol, Value};
7
+ use ratatui::buffer::Buffer;
7
8
  use ratatui::{
8
9
  layout::{HorizontalAlignment, Rect},
9
- widgets::{Paragraph, Wrap},
10
- Frame,
10
+ widgets::{Paragraph, Widget, Wrap},
11
11
  };
12
12
 
13
13
  use crate::text::parse_text;
14
14
 
15
- fn create_paragraph(node: Value, bump: &Bump) -> Result<Paragraph<'_>, Error> {
15
+ pub fn create_paragraph(node: Value, bump: &Bump) -> Result<Paragraph<'_>, Error> {
16
16
  let text_val: Value = node.funcall("text", ())?;
17
17
  let style_val: Value = node.funcall("style", ())?;
18
18
  let block_val: Value = node.funcall("block", ())?;
@@ -50,10 +50,10 @@ fn create_paragraph(node: Value, bump: &Bump) -> Result<Paragraph<'_>, Error> {
50
50
  Ok(paragraph)
51
51
  }
52
52
 
53
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
53
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
54
54
  let bump = Bump::new();
55
55
  let paragraph = create_paragraph(node, &bump)?;
56
- frame.render_widget(paragraph, area);
56
+ Widget::render(paragraph, area, buffer);
57
57
  Ok(())
58
58
  }
59
59
 
@@ -78,7 +78,6 @@ mod tests {
78
78
  fn test_paragraph_rendering() {
79
79
  let p = Paragraph::new("test content").alignment(HorizontalAlignment::Center);
80
80
  let mut buf = Buffer::empty(Rect::new(0, 0, 20, 1));
81
- use ratatui::widgets::Widget;
82
81
  p.render(Rect::new(0, 0, 20, 1), &mut buf);
83
82
  let content = buf.content().iter().map(|c| c.symbol()).collect::<String>();
84
83
  assert!(content.contains("test content"));
@@ -4,16 +4,16 @@
4
4
 
5
5
  use magnus::Value;
6
6
  use ratatui::{
7
+ buffer::Buffer,
7
8
  layout::Rect,
8
- widgets::{RatatuiLogo, RatatuiLogoSize},
9
- Frame,
9
+ widgets::{RatatuiLogo, RatatuiLogoSize, Widget},
10
10
  };
11
11
 
12
- pub fn render(frame: &mut Frame, area: Rect, _node: Value) {
12
+ pub fn render(buffer: &mut Buffer, area: Rect, _node: Value) {
13
13
  // RatatuiLogo does not support custom styling (it has fixed colors).
14
14
  // It requires a size argument.
15
15
  let widget = RatatuiLogo::new(RatatuiLogoSize::Small);
16
- frame.render_widget(widget, area);
16
+ widget.render(area, buffer);
17
17
  }
18
18
 
19
19
  #[cfg(test)]
@@ -5,9 +5,13 @@
5
5
  use crate::style::parse_block;
6
6
  use bumpalo::Bump;
7
7
  use magnus::{prelude::*, Error, Value};
8
- use ratatui::{layout::Rect, widgets::RatatuiMascot, Frame};
8
+ use ratatui::{
9
+ buffer::Buffer,
10
+ layout::Rect,
11
+ widgets::{RatatuiMascot, Widget},
12
+ };
9
13
 
10
- pub fn render_ratatui_mascot(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
14
+ pub fn render_ratatui_mascot(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
11
15
  let block_val: Value = node.funcall("block", ())?;
12
16
 
13
17
  let mut inner_area = area;
@@ -16,11 +20,11 @@ pub fn render_ratatui_mascot(frame: &mut Frame, area: Rect, node: Value) -> Resu
16
20
  let bump = Bump::new();
17
21
  let block = parse_block(block_val, &bump)?;
18
22
  inner_area = block.inner(area);
19
- frame.render_widget(block, area);
23
+ block.render(area, buffer);
20
24
  }
21
25
 
22
26
  let widget = RatatuiMascot::new();
23
- frame.render_widget(widget, inner_area);
27
+ widget.render(inner_area, buffer);
24
28
  Ok(())
25
29
  }
26
30
 
@@ -6,12 +6,12 @@ use crate::widgets::scrollbar_state::RubyScrollbarState;
6
6
  use bumpalo::Bump;
7
7
  use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
8
8
  use ratatui::{
9
+ buffer::Buffer,
9
10
  layout::Rect,
10
- widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
11
- Frame,
11
+ widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget},
12
12
  };
13
13
 
14
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
14
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
15
15
  let content_length: usize = node.funcall("content_length", ())?;
16
16
  let position: usize = node.funcall("position", ())?;
17
17
  let orientation_sym: Symbol = node.funcall("orientation", ())?;
@@ -79,13 +79,13 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
79
79
  }
80
80
 
81
81
  if block_val.is_nil() {
82
- frame.render_stateful_widget(scrollbar, area, &mut state);
82
+ StatefulWidget::render(scrollbar, area, buffer, &mut state);
83
83
  } else {
84
84
  let bump = Bump::new();
85
85
  let block = parse_block(block_val, &bump)?;
86
86
  let inner_area = block.inner(area);
87
- frame.render_widget(block, area);
88
- frame.render_stateful_widget(scrollbar, inner_area, &mut state);
87
+ block.render(area, buffer);
88
+ StatefulWidget::render(scrollbar, inner_area, buffer, &mut state);
89
89
  }
90
90
  Ok(())
91
91
  }
@@ -95,7 +95,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
95
95
  /// The State object is the single source of truth for position and `content_length`.
96
96
  /// Widget properties (`position`, `content_length`) are ignored.
97
97
  pub fn render_stateful(
98
- frame: &mut Frame,
98
+ buffer: &mut Buffer,
99
99
  area: Rect,
100
100
  node: Value,
101
101
  state_wrapper: Value,
@@ -168,13 +168,13 @@ pub fn render_stateful(
168
168
  {
169
169
  let mut inner_state = state.borrow_mut();
170
170
  if block_val.is_nil() {
171
- frame.render_stateful_widget(scrollbar, area, &mut inner_state);
171
+ StatefulWidget::render(scrollbar, area, buffer, &mut inner_state);
172
172
  } else {
173
173
  let bump = Bump::new();
174
174
  let block = parse_block(block_val, &bump)?;
175
175
  let inner_area = block.inner(area);
176
- frame.render_widget(block, area);
177
- frame.render_stateful_widget(scrollbar, inner_area, &mut inner_state);
176
+ block.render(area, buffer);
177
+ StatefulWidget::render(scrollbar, inner_area, buffer, &mut inner_state);
178
178
  }
179
179
  }
180
180
 
@@ -4,9 +4,10 @@
4
4
  use crate::style::{parse_bar_set, parse_block, parse_style};
5
5
  use bumpalo::Bump;
6
6
  use magnus::{prelude::*, Error, RString, Value};
7
- use ratatui::{layout::Rect, widgets::RenderDirection, widgets::Sparkline, Frame};
7
+ use ratatui::buffer::Buffer;
8
+ use ratatui::{layout::Rect, widgets::RenderDirection, widgets::Sparkline, widgets::Widget};
8
9
 
9
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
10
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
10
11
  let bump = Bump::new();
11
12
  let ruby = magnus::Ruby::get().unwrap();
12
13
  let data_val: magnus::RArray = node.funcall("data", ())?;
@@ -72,7 +73,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
72
73
  sparkline = sparkline.bar_set(parse_bar_set(bar_set_val, &bump)?);
73
74
  }
74
75
 
75
- frame.render_widget(sparkline, area);
76
+ sparkline.render(area, buffer);
76
77
  Ok(())
77
78
  }
78
79
 
@@ -8,12 +8,12 @@ use crate::widgets::table_state::RubyTableState;
8
8
  use bumpalo::Bump;
9
9
  use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
10
10
  use ratatui::{
11
+ buffer::Buffer,
11
12
  layout::{Constraint, Flex, Rect},
12
- widgets::{Cell, HighlightSpacing, Row, Table, TableState},
13
- Frame,
13
+ widgets::{Cell, HighlightSpacing, Row, StatefulWidget, Table, TableState},
14
14
  };
15
15
 
16
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
16
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
17
17
  let bump = Bump::new();
18
18
  let ruby = magnus::Ruby::get().unwrap();
19
19
  let header_val: Value = node.funcall("header", ())?;
@@ -123,7 +123,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
123
123
  *state.offset_mut() = offset;
124
124
  }
125
125
 
126
- frame.render_stateful_widget(table, area, &mut state);
126
+ StatefulWidget::render(table, area, buffer, &mut state);
127
127
  Ok(())
128
128
  }
129
129
 
@@ -132,7 +132,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
132
132
  /// This function ignores `selected_row`, `selected_column`, and `offset` from the widget.
133
133
  /// The State object is the single source of truth for selection and scroll position.
134
134
  pub fn render_stateful(
135
- frame: &mut Frame,
135
+ buffer: &mut Buffer,
136
136
  area: Rect,
137
137
  node: Value,
138
138
  state_wrapper: Value,
@@ -230,7 +230,7 @@ pub fn render_stateful(
230
230
  // Borrow the inner TableState, render, and release the borrow immediately
231
231
  {
232
232
  let mut inner_state = state.borrow_mut();
233
- frame.render_stateful_widget(table, area, &mut inner_state);
233
+ StatefulWidget::render(table, area, buffer, &mut inner_state);
234
234
  }
235
235
 
236
236
  Ok(())
@@ -6,12 +6,13 @@ use crate::style::parse_block;
6
6
  use crate::text::{parse_line, parse_span};
7
7
  use bumpalo::Bump;
8
8
  use magnus::{prelude::*, Error, Value};
9
- use ratatui::{layout::Rect, text::Line, widgets::Tabs, Frame};
9
+ use ratatui::buffer::Buffer;
10
+ use ratatui::{layout::Rect, text::Line, widgets::Tabs, widgets::Widget};
10
11
 
11
- pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
12
+ pub fn render(buffer: &mut Buffer, area: Rect, node: Value) -> Result<(), Error> {
12
13
  let bump = Bump::new();
13
14
  let tabs = create_tabs(node, &bump)?;
14
- frame.render_widget(tabs, area);
15
+ tabs.render(area, buffer);
15
16
  Ok(())
16
17
  }
17
18
 
@@ -0,0 +1,173 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "tmpdir"
9
+ require "rexml/document"
10
+
11
+ module RatatuiRuby
12
+ module Labs
13
+ # A11Y lab: exports widget tree as XML.
14
+ #
15
+ # Writes an XML representation of the widget tree to a temporary file
16
+ # every frame when enabled.
17
+ module A11y
18
+ # Path to the XML output file in the system temp directory.
19
+ OUTPUT_PATH = File.join(Dir.tmpdir, "ratatui_ruby_a11y.xml").freeze
20
+
21
+ class << self
22
+ # Dumps the widget tree to XML (single widget for tree mode).
23
+ def dump_widget_tree(widget, _area = nil)
24
+ doc = REXML::Document.new
25
+ doc.add(REXML::XMLDecl.new("1.0", "UTF-8"))
26
+ doc.add(build_element(widget))
27
+ write_document(doc)
28
+ end
29
+
30
+ # Returns startup message for users to see before TUI launches.
31
+ #
32
+ # Since stdout is captured during TUI rendering, users need to know
33
+ # where the XML file will be written before the app starts.
34
+ def startup_message
35
+ <<~MSG
36
+ A11Y Lab enabled! Widget tree will be written to:
37
+ #{OUTPUT_PATH}
38
+
39
+ Press Enter to launch the TUI...
40
+ MSG
41
+ end
42
+
43
+ # Dumps multiple widgets captured from Frame API mode.
44
+ def dump_widgets(widgets_with_areas)
45
+ Labs.warn_once!("Labs::A11y (RR_LABS=A11Y)")
46
+
47
+ # Reset counter each frame for stable IDs
48
+ @widget_id_counter = 0
49
+
50
+ doc = REXML::Document.new
51
+ doc.add(REXML::XMLDecl.new("1.0", "UTF-8"))
52
+
53
+ frame = REXML::Element.new("RatatuiFrame")
54
+ widgets_with_areas.each do |widget, area|
55
+ frame.add(build_element_with_area(widget, area))
56
+ end
57
+ doc.add(frame)
58
+
59
+ write_document(doc)
60
+ end
61
+
62
+ private def write_document(doc)
63
+ output = +""
64
+ formatter = REXML::Formatters::Pretty.new(2)
65
+ formatter.compact = true
66
+ formatter.write(doc, output)
67
+ File.write(OUTPUT_PATH, output)
68
+ end
69
+
70
+ private def build_element_with_area(widget, area)
71
+ class_name = widget.class.name&.split("::")&.last || "Unknown"
72
+ element = REXML::Element.new(class_name)
73
+
74
+ # Generate unique id for this widget
75
+ @widget_id_counter ||= 0
76
+ @widget_id_counter += 1
77
+ widget_id = "w#{@widget_id_counter}"
78
+ element.add_attribute("id", widget_id)
79
+
80
+ # Add area attributes
81
+ element.add_attribute("x", area.x.to_s)
82
+ element.add_attribute("y", area.y.to_s)
83
+ element.add_attribute("width", area.width.to_s)
84
+ element.add_attribute("height", area.height.to_s)
85
+
86
+ add_members(element, widget, parent_id: widget_id)
87
+ element
88
+ end
89
+
90
+ private def build_element(node)
91
+ class_name = node.class.name&.split("::")&.last || "Unknown"
92
+ element = REXML::Element.new(class_name)
93
+
94
+ if node.respond_to?(:to_h) && node.respond_to?(:members)
95
+ add_members(element, node)
96
+ else
97
+ element.text = node.to_s
98
+ end
99
+
100
+ element
101
+ end
102
+
103
+ private def add_members(element, node, parent_id: nil)
104
+ return unless node.respond_to?(:to_h) && node.respond_to?(:members)
105
+
106
+ node.to_h.each do |key, value|
107
+ # Skip nil and empty values entirely (no noise in output)
108
+ next if value.nil?
109
+ next if value.respond_to?(:empty?) && value.empty?
110
+
111
+ # Skip objects where all members are nil/empty (like default Style)
112
+ if value.respond_to?(:to_h) && value.respond_to?(:members)
113
+ attrs = value.to_h.compact
114
+ next if attrs.empty? || attrs.values.all? { |v| v.respond_to?(:empty?) && v.empty? }
115
+ end
116
+
117
+ # Scalar values → XML attributes
118
+ # Exception: 'text' and 'content' accept Text (multi-line capable)
119
+ # Complex values → XML child elements
120
+ multiline_keys = %w[text content]
121
+ if scalar?(value) && !multiline_keys.include?(key.to_s)
122
+ element.add_attribute(key.to_s, value.to_s)
123
+ else
124
+ # Special handling for 'block' wrapper - add ARIA role and id/for
125
+ is_block_wrapper = key.to_s == "block" && parent_id
126
+ child = build_child_element(key, value, is_wrapper: is_block_wrapper, parent_id:)
127
+ element.add(child) if child
128
+ end
129
+ end
130
+ end
131
+
132
+ private def scalar?(value)
133
+ case value
134
+ when String, Symbol, Numeric, TrueClass, FalseClass
135
+ true
136
+ else
137
+ false
138
+ end
139
+ end
140
+
141
+ private def build_child_element(key, value, is_wrapper: false, parent_id: nil)
142
+ element = REXML::Element.new(key.to_s)
143
+
144
+ # Add ARIA role and id/for association for block wrappers
145
+ if is_wrapper && parent_id
146
+ element.add_attribute("role", "group")
147
+ element.add_attribute("for", parent_id)
148
+ end
149
+
150
+ case value
151
+ when Array
152
+ value.each { |item| element.add(build_element(item)) }
153
+ when Hash
154
+ # Plain Hash: serialize keys as attributes
155
+ value.each do |k, v|
156
+ next if v.nil?
157
+ next if v.respond_to?(:empty?) && v.empty?
158
+ element.add_attribute(k.to_s, v.to_s)
159
+ end
160
+ else
161
+ if value.respond_to?(:to_h) && value.respond_to?(:members)
162
+ add_members(element, value)
163
+ else
164
+ element.text = value.to_s
165
+ end
166
+ end
167
+
168
+ element
169
+ end
170
+ end
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ class Frame
10
+ # A11Y Lab Integration
11
+ #
12
+ # When the A11Y lab is enabled, we capture widgets as they are rendered
13
+ # and write the tree to XML when flush_a11y_capture is called.
14
+ module A11yCapture
15
+ # Intercepts render_widget to capture widgets for A11Y export.
16
+ # @param widget [widget] The widget being rendered
17
+ # @param area [Layout::Rect] The area to render into
18
+ def render_widget(widget, area)
19
+ if Labs.enabled?(:a11y)
20
+ widgets = (@a11y_widgets ||= []) #: Array[[(_CustomWidget | widget), Layout::Rect]]
21
+ widgets << [widget, area]
22
+ end
23
+ super
24
+ end
25
+
26
+ # Intercepts render_stateful_widget to capture widgets for A11Y export.
27
+ # @param widget [widget] The widget being rendered
28
+ # @param area [Layout::Rect] The area to render into
29
+ # @param state [Object] The widget state
30
+ def render_stateful_widget(widget, area, state)
31
+ if Labs.enabled?(:a11y)
32
+ widgets = (@a11y_widgets ||= []) #: Array[[(_CustomWidget | widget), Layout::Rect]]
33
+ widgets << [widget, area]
34
+ end
35
+ super
36
+ end
37
+
38
+ # Called at end of draw block to flush captured widgets
39
+ def flush_a11y_capture
40
+ widgets = @a11y_widgets
41
+ return unless Labs.enabled?(:a11y) && widgets&.any?
42
+
43
+ Labs::A11y.dump_widgets(widgets)
44
+ @a11y_widgets = nil
45
+ end
46
+ end
47
+
48
+ prepend A11yCapture
49
+ end
50
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ # Experimental lab features.
10
+ module Labs
11
+ @enabled_lab = nil #: Symbol?
12
+
13
+ class << self
14
+ # Returns whether the specified lab is enabled.
15
+ def enabled?(lab)
16
+ @enabled_lab == lab.to_sym.downcase
17
+ end
18
+
19
+ # Enables a lab programmatically.
20
+ def enable!(lab)
21
+ @enabled_lab = lab.to_sym.downcase
22
+ end
23
+
24
+ # Resets all labs (for testing only).
25
+ def reset!
26
+ @enabled_lab = nil
27
+ @warned = false
28
+ end
29
+
30
+ # Emits experimental warning once per session.
31
+ def warn_once!(feature_name)
32
+ return if @warned
33
+
34
+ RatatuiRuby.warn_experimental_feature(feature_name)
35
+ @warned = true
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ # Auto-enable from environment variable
42
+ if (lab = ENV["RR_LABS"])
43
+ RatatuiRuby::Labs.enable!(lab)
44
+ end
45
+
46
+ require_relative "labs/a11y"
47
+ require_relative "labs/frame_a11y_capture"
@@ -50,6 +50,32 @@ module RatatuiRuby
50
50
  def initialize(x: 0, y: 0)
51
51
  super(x: Integer(x), y: Integer(y))
52
52
  end
53
+
54
+ # Enables array destructuring for convenient coordinate extraction.
55
+ #
56
+ # Returns:: Array of [x, y] coordinates.
57
+ #
58
+ # === Example
59
+ #
60
+ #--
61
+ # SPDX-SnippetBegin
62
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
63
+ # SPDX-License-Identifier: MIT-0
64
+ #++
65
+ # pos = Position.new(x: 10, y: 5)
66
+ # x, y = pos # Uses deconstruct
67
+ # puts "Column: #{x}, Row: #{y}"
68
+ #--
69
+ # SPDX-SnippetEnd
70
+ #++
71
+ def deconstruct
72
+ [x, y]
73
+ end
74
+
75
+ # Alias for implicit array conversion.
76
+ #
77
+ # Enables `x, y = position` syntax by making Position respond to +to_ary+.
78
+ alias to_ary deconstruct
53
79
  end
54
80
  end
55
81
  end