ratatui_ruby 0.9.1 → 0.10.0

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 (267) 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/AGENTS.md +2 -1
  7. data/CHANGELOG.md +98 -0
  8. data/REUSE.toml +5 -0
  9. data/Rakefile +1 -1
  10. data/Steepfile +49 -0
  11. data/doc/concepts/debugging.md +401 -0
  12. data/doc/getting_started/quickstart.md +8 -3
  13. data/doc/images/app_all_events.png +0 -0
  14. data/doc/images/app_color_picker.png +0 -0
  15. data/doc/images/app_debugging_showcase.gif +0 -0
  16. data/doc/images/app_debugging_showcase.png +0 -0
  17. data/doc/images/app_login_form.png +0 -0
  18. data/doc/images/app_stateful_interaction.png +0 -0
  19. data/doc/images/verify_quickstart_dsl.png +0 -0
  20. data/doc/images/verify_quickstart_layout.png +0 -0
  21. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  22. data/doc/images/verify_readme_usage.png +0 -0
  23. data/doc/images/widget_barchart.png +0 -0
  24. data/doc/images/widget_block.png +0 -0
  25. data/doc/images/widget_box.png +0 -0
  26. data/doc/images/widget_calendar.png +0 -0
  27. data/doc/images/widget_canvas.png +0 -0
  28. data/doc/images/widget_cell.png +0 -0
  29. data/doc/images/widget_center.png +0 -0
  30. data/doc/images/widget_chart.png +0 -0
  31. data/doc/images/widget_gauge.png +0 -0
  32. data/doc/images/widget_layout_split.png +0 -0
  33. data/doc/images/widget_line_gauge.png +0 -0
  34. data/doc/images/widget_list.png +0 -0
  35. data/doc/images/widget_map.png +0 -0
  36. data/doc/images/widget_overlay.png +0 -0
  37. data/doc/images/widget_popup.png +0 -0
  38. data/doc/images/widget_ratatui_logo.png +0 -0
  39. data/doc/images/widget_ratatui_mascot.png +0 -0
  40. data/doc/images/widget_rect.png +0 -0
  41. data/doc/images/widget_render.png +0 -0
  42. data/doc/images/widget_rich_text.png +0 -0
  43. data/doc/images/widget_scroll_text.png +0 -0
  44. data/doc/images/widget_scrollbar.png +0 -0
  45. data/doc/images/widget_sparkline.png +0 -0
  46. data/doc/images/widget_style_colors.png +0 -0
  47. data/doc/images/widget_table.png +0 -0
  48. data/doc/images/widget_tabs.png +0 -0
  49. data/doc/images/widget_text_width.png +0 -0
  50. data/doc/troubleshooting/async.md +4 -0
  51. data/examples/app_debugging_showcase/README.md +119 -0
  52. data/examples/app_debugging_showcase/app.rb +318 -0
  53. data/examples/widget_canvas/app.rb +19 -14
  54. data/examples/widget_gauge/app.rb +18 -3
  55. data/examples/widget_layout_split/app.rb +10 -4
  56. data/examples/widget_list/app.rb +22 -6
  57. data/examples/widget_rect/app.rb +7 -6
  58. data/examples/widget_rich_text/app.rb +62 -37
  59. data/examples/widget_style_colors/app.rb +26 -47
  60. data/examples/widget_table/app.rb +28 -5
  61. data/examples/widget_text_width/app.rb +6 -4
  62. data/ext/ratatui_ruby/Cargo.lock +48 -1
  63. data/ext/ratatui_ruby/Cargo.toml +6 -2
  64. data/ext/ratatui_ruby/src/color.rs +82 -0
  65. data/ext/ratatui_ruby/src/errors.rs +28 -0
  66. data/ext/ratatui_ruby/src/events.rs +15 -14
  67. data/ext/ratatui_ruby/src/lib.rs +56 -0
  68. data/ext/ratatui_ruby/src/rendering.rs +3 -1
  69. data/ext/ratatui_ruby/src/style.rs +48 -21
  70. data/ext/ratatui_ruby/src/terminal.rs +40 -9
  71. data/ext/ratatui_ruby/src/text.rs +21 -9
  72. data/ext/ratatui_ruby/src/widgets/chart.rs +2 -1
  73. data/ext/ratatui_ruby/src/widgets/layout.rs +90 -2
  74. data/ext/ratatui_ruby/src/widgets/list.rs +6 -5
  75. data/ext/ratatui_ruby/src/widgets/overlay.rs +2 -1
  76. data/ext/ratatui_ruby/src/widgets/table.rs +7 -6
  77. data/ext/ratatui_ruby/src/widgets/table_state.rs +55 -0
  78. data/ext/ratatui_ruby/src/widgets/tabs.rs +3 -2
  79. data/lib/ratatui_ruby/buffer/cell.rb +25 -15
  80. data/lib/ratatui_ruby/buffer.rb +134 -2
  81. data/lib/ratatui_ruby/cell.rb +13 -5
  82. data/lib/ratatui_ruby/debug.rb +215 -0
  83. data/lib/ratatui_ruby/event/key.rb +3 -2
  84. data/lib/ratatui_ruby/event.rb +1 -1
  85. data/lib/ratatui_ruby/layout/constraint.rb +49 -0
  86. data/lib/ratatui_ruby/layout/layout.rb +119 -13
  87. data/lib/ratatui_ruby/layout/position.rb +55 -0
  88. data/lib/ratatui_ruby/layout/rect.rb +188 -0
  89. data/lib/ratatui_ruby/layout/size.rb +55 -0
  90. data/lib/ratatui_ruby/layout.rb +4 -0
  91. data/lib/ratatui_ruby/style/color.rb +149 -0
  92. data/lib/ratatui_ruby/style/style.rb +51 -4
  93. data/lib/ratatui_ruby/style.rb +2 -0
  94. data/lib/ratatui_ruby/symbols.rb +435 -0
  95. data/lib/ratatui_ruby/synthetic_events.rb +1 -1
  96. data/lib/ratatui_ruby/table_state.rb +51 -0
  97. data/lib/ratatui_ruby/terminal_lifecycle.rb +2 -1
  98. data/lib/ratatui_ruby/test_helper/event_injection.rb +6 -1
  99. data/lib/ratatui_ruby/test_helper.rb +9 -0
  100. data/lib/ratatui_ruby/text/line.rb +245 -0
  101. data/lib/ratatui_ruby/text/span.rb +158 -0
  102. data/lib/ratatui_ruby/text.rb +99 -0
  103. data/lib/ratatui_ruby/tui/canvas_factories.rb +103 -0
  104. data/lib/ratatui_ruby/tui/core.rb +13 -2
  105. data/lib/ratatui_ruby/tui/layout_factories.rb +50 -3
  106. data/lib/ratatui_ruby/tui/state_factories.rb +42 -0
  107. data/lib/ratatui_ruby/tui/text_factories.rb +40 -0
  108. data/lib/ratatui_ruby/tui/widget_factories.rb +135 -60
  109. data/lib/ratatui_ruby/tui.rb +22 -1
  110. data/lib/ratatui_ruby/version.rb +1 -1
  111. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +2 -0
  112. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +2 -0
  113. data/lib/ratatui_ruby/widgets/bar_chart.rb +30 -20
  114. data/lib/ratatui_ruby/widgets/block.rb +14 -6
  115. data/lib/ratatui_ruby/widgets/calendar.rb +2 -0
  116. data/lib/ratatui_ruby/widgets/canvas.rb +56 -0
  117. data/lib/ratatui_ruby/widgets/cell.rb +2 -0
  118. data/lib/ratatui_ruby/widgets/center.rb +2 -0
  119. data/lib/ratatui_ruby/widgets/chart.rb +6 -0
  120. data/lib/ratatui_ruby/widgets/clear.rb +2 -0
  121. data/lib/ratatui_ruby/widgets/coerceable_widget.rb +77 -0
  122. data/lib/ratatui_ruby/widgets/cursor.rb +2 -0
  123. data/lib/ratatui_ruby/widgets/gauge.rb +61 -3
  124. data/lib/ratatui_ruby/widgets/line_gauge.rb +66 -4
  125. data/lib/ratatui_ruby/widgets/list.rb +87 -3
  126. data/lib/ratatui_ruby/widgets/list_item.rb +2 -0
  127. data/lib/ratatui_ruby/widgets/overlay.rb +2 -0
  128. data/lib/ratatui_ruby/widgets/paragraph.rb +4 -0
  129. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +2 -0
  130. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +2 -0
  131. data/lib/ratatui_ruby/widgets/row.rb +45 -0
  132. data/lib/ratatui_ruby/widgets/scrollbar.rb +2 -0
  133. data/lib/ratatui_ruby/widgets/shape/label.rb +2 -0
  134. data/lib/ratatui_ruby/widgets/sparkline.rb +21 -13
  135. data/lib/ratatui_ruby/widgets/table.rb +13 -3
  136. data/lib/ratatui_ruby/widgets/tabs.rb +6 -4
  137. data/lib/ratatui_ruby/widgets.rb +1 -0
  138. data/lib/ratatui_ruby.rb +40 -9
  139. data/sig/examples/app_all_events/model/app_model.rbs +23 -0
  140. data/sig/examples/app_all_events/model/event_entry.rbs +15 -8
  141. data/sig/examples/app_all_events/model/timestamp.rbs +1 -1
  142. data/sig/examples/app_all_events/view.rbs +1 -1
  143. data/sig/examples/app_stateful_interaction/app.rbs +5 -5
  144. data/sig/examples/widget_block_demo/app.rbs +6 -6
  145. data/sig/manifest.yaml +5 -0
  146. data/sig/patches/data.rbs +26 -0
  147. data/sig/patches/debugger__.rbs +8 -0
  148. data/sig/ratatui_ruby/buffer/cell.rbs +46 -0
  149. data/sig/ratatui_ruby/buffer.rbs +18 -0
  150. data/sig/ratatui_ruby/cell.rbs +44 -0
  151. data/sig/ratatui_ruby/clear.rbs +18 -0
  152. data/sig/ratatui_ruby/constraint.rbs +26 -0
  153. data/sig/ratatui_ruby/debug.rbs +45 -0
  154. data/sig/ratatui_ruby/draw.rbs +30 -0
  155. data/sig/ratatui_ruby/event.rbs +68 -8
  156. data/sig/ratatui_ruby/frame.rbs +4 -4
  157. data/sig/ratatui_ruby/interfaces.rbs +25 -0
  158. data/sig/ratatui_ruby/layout/constraint.rbs +39 -0
  159. data/sig/ratatui_ruby/layout/layout.rbs +45 -0
  160. data/sig/ratatui_ruby/layout/position.rbs +18 -0
  161. data/sig/ratatui_ruby/layout/rect.rbs +64 -0
  162. data/sig/ratatui_ruby/layout/size.rbs +18 -0
  163. data/sig/ratatui_ruby/output_guard.rbs +23 -0
  164. data/sig/ratatui_ruby/ratatui_ruby.rbs +83 -4
  165. data/sig/ratatui_ruby/rect.rbs +17 -0
  166. data/sig/ratatui_ruby/style/color.rbs +22 -0
  167. data/sig/ratatui_ruby/style/style.rbs +29 -0
  168. data/sig/ratatui_ruby/symbols.rbs +141 -0
  169. data/sig/ratatui_ruby/synthetic_events.rbs +21 -0
  170. data/sig/ratatui_ruby/table_state.rbs +6 -0
  171. data/sig/ratatui_ruby/terminal_lifecycle.rbs +31 -0
  172. data/sig/ratatui_ruby/test_helper/event_injection.rbs +2 -2
  173. data/sig/ratatui_ruby/test_helper/snapshot.rbs +22 -3
  174. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +8 -1
  175. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +7 -3
  176. data/sig/ratatui_ruby/text/line.rbs +27 -0
  177. data/sig/ratatui_ruby/text/span.rbs +23 -0
  178. data/sig/ratatui_ruby/text.rbs +12 -0
  179. data/sig/ratatui_ruby/tui/buffer_factories.rbs +1 -1
  180. data/sig/ratatui_ruby/tui/canvas_factories.rbs +23 -5
  181. data/sig/ratatui_ruby/tui/core.rbs +2 -2
  182. data/sig/ratatui_ruby/tui/layout_factories.rbs +16 -2
  183. data/sig/ratatui_ruby/tui/state_factories.rbs +8 -3
  184. data/sig/ratatui_ruby/tui/style_factories.rbs +3 -1
  185. data/sig/ratatui_ruby/tui/text_factories.rbs +7 -4
  186. data/sig/ratatui_ruby/tui/widget_factories.rbs +123 -30
  187. data/sig/ratatui_ruby/widgets/bar_chart.rbs +95 -0
  188. data/sig/ratatui_ruby/widgets/block.rbs +51 -0
  189. data/sig/ratatui_ruby/widgets/calendar.rbs +45 -0
  190. data/sig/ratatui_ruby/widgets/canvas.rbs +95 -0
  191. data/sig/ratatui_ruby/widgets/chart.rbs +91 -0
  192. data/sig/ratatui_ruby/widgets/coerceable_widget.rbs +26 -0
  193. data/sig/ratatui_ruby/widgets/gauge.rbs +44 -0
  194. data/sig/ratatui_ruby/widgets/line_gauge.rbs +48 -0
  195. data/sig/ratatui_ruby/widgets/list.rbs +63 -0
  196. data/sig/ratatui_ruby/widgets/misc.rbs +158 -0
  197. data/sig/ratatui_ruby/widgets/paragraph.rbs +45 -0
  198. data/sig/ratatui_ruby/widgets/row.rbs +43 -0
  199. data/sig/ratatui_ruby/widgets/scrollbar.rbs +53 -0
  200. data/sig/ratatui_ruby/widgets/shape/label.rbs +37 -0
  201. data/sig/ratatui_ruby/widgets/sparkline.rbs +45 -0
  202. data/sig/ratatui_ruby/widgets/table.rbs +78 -0
  203. data/sig/ratatui_ruby/widgets/tabs.rbs +44 -0
  204. data/sig/ratatui_ruby/{schema/list_item.rbs → widgets.rbs} +4 -4
  205. data/tasks/steep.rake +11 -0
  206. metadata +80 -63
  207. data/doc/contributors/v1.0.0_blockers.md +0 -870
  208. data/doc/troubleshooting/debugging.md +0 -101
  209. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +0 -47
  210. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +0 -25
  211. data/lib/ratatui_ruby/schema/bar_chart.rb +0 -287
  212. data/lib/ratatui_ruby/schema/block.rb +0 -198
  213. data/lib/ratatui_ruby/schema/calendar.rb +0 -84
  214. data/lib/ratatui_ruby/schema/canvas.rb +0 -239
  215. data/lib/ratatui_ruby/schema/center.rb +0 -67
  216. data/lib/ratatui_ruby/schema/chart.rb +0 -159
  217. data/lib/ratatui_ruby/schema/clear.rb +0 -62
  218. data/lib/ratatui_ruby/schema/constraint.rb +0 -151
  219. data/lib/ratatui_ruby/schema/cursor.rb +0 -50
  220. data/lib/ratatui_ruby/schema/gauge.rb +0 -72
  221. data/lib/ratatui_ruby/schema/layout.rb +0 -122
  222. data/lib/ratatui_ruby/schema/line_gauge.rb +0 -80
  223. data/lib/ratatui_ruby/schema/list.rb +0 -135
  224. data/lib/ratatui_ruby/schema/list_item.rb +0 -51
  225. data/lib/ratatui_ruby/schema/overlay.rb +0 -51
  226. data/lib/ratatui_ruby/schema/paragraph.rb +0 -107
  227. data/lib/ratatui_ruby/schema/ratatui_logo.rb +0 -31
  228. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +0 -36
  229. data/lib/ratatui_ruby/schema/rect.rb +0 -174
  230. data/lib/ratatui_ruby/schema/row.rb +0 -76
  231. data/lib/ratatui_ruby/schema/scrollbar.rb +0 -143
  232. data/lib/ratatui_ruby/schema/shape/label.rb +0 -76
  233. data/lib/ratatui_ruby/schema/sparkline.rb +0 -142
  234. data/lib/ratatui_ruby/schema/style.rb +0 -97
  235. data/lib/ratatui_ruby/schema/table.rb +0 -141
  236. data/lib/ratatui_ruby/schema/tabs.rb +0 -85
  237. data/lib/ratatui_ruby/schema/text.rb +0 -217
  238. data/sig/examples/app_all_events/model/events.rbs +0 -15
  239. data/sig/examples/app_all_events/view_state.rbs +0 -21
  240. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +0 -22
  241. data/sig/ratatui_ruby/schema/bar_chart/bar_group.rbs +0 -19
  242. data/sig/ratatui_ruby/schema/bar_chart.rbs +0 -38
  243. data/sig/ratatui_ruby/schema/block.rbs +0 -18
  244. data/sig/ratatui_ruby/schema/calendar.rbs +0 -23
  245. data/sig/ratatui_ruby/schema/canvas.rbs +0 -81
  246. data/sig/ratatui_ruby/schema/center.rbs +0 -17
  247. data/sig/ratatui_ruby/schema/chart.rbs +0 -39
  248. data/sig/ratatui_ruby/schema/constraint.rbs +0 -30
  249. data/sig/ratatui_ruby/schema/cursor.rbs +0 -16
  250. data/sig/ratatui_ruby/schema/draw.rbs +0 -33
  251. data/sig/ratatui_ruby/schema/gauge.rbs +0 -23
  252. data/sig/ratatui_ruby/schema/layout.rbs +0 -27
  253. data/sig/ratatui_ruby/schema/line_gauge.rbs +0 -24
  254. data/sig/ratatui_ruby/schema/list.rbs +0 -28
  255. data/sig/ratatui_ruby/schema/overlay.rbs +0 -15
  256. data/sig/ratatui_ruby/schema/paragraph.rbs +0 -20
  257. data/sig/ratatui_ruby/schema/ratatui_logo.rbs +0 -14
  258. data/sig/ratatui_ruby/schema/ratatui_mascot.rbs +0 -17
  259. data/sig/ratatui_ruby/schema/rect.rbs +0 -48
  260. data/sig/ratatui_ruby/schema/row.rbs +0 -28
  261. data/sig/ratatui_ruby/schema/scrollbar.rbs +0 -42
  262. data/sig/ratatui_ruby/schema/sparkline.rbs +0 -22
  263. data/sig/ratatui_ruby/schema/style.rbs +0 -19
  264. data/sig/ratatui_ruby/schema/table.rbs +0 -32
  265. data/sig/ratatui_ruby/schema/tabs.rbs +0 -21
  266. data/sig/ratatui_ruby/schema/text.rbs +0 -31
  267. /data/lib/ratatui_ruby/{schema/draw.rb → draw.rb} +0 -0
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
+ use crate::errors::type_error_with_context;
4
5
  use crate::style::{parse_block, parse_style};
5
6
  use crate::text::{parse_line, parse_span};
6
7
  use crate::widgets::list_state::RubyListState;
@@ -18,7 +19,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
18
19
  let ruby = magnus::Ruby::get().unwrap();
19
20
  let items_val: Value = node.funcall("items", ())?;
20
21
  let items_array = magnus::RArray::from_value(items_val)
21
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array"))?;
22
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for items", items_val))?;
22
23
  let selected_index_val: Value = node.funcall("selected_index", ())?;
23
24
  let style_val: Value = node.funcall("style", ())?;
24
25
  let highlight_style_val: Value = node.funcall("highlight_style", ())?;
@@ -129,7 +130,7 @@ pub fn render_stateful(
129
130
  // Build items
130
131
  let items_val: Value = node.funcall("items", ())?;
131
132
  let items_array = magnus::RArray::from_value(items_val)
132
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array"))?;
133
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for items", items_val))?;
133
134
 
134
135
  let mut items: Vec<ListItem> = Vec::new();
135
136
  for i in 0..items_array.len() {
@@ -270,10 +271,10 @@ fn parse_list_item(value: Value) -> Result<ListItem<'static>, Error> {
270
271
  return Ok(ListItem::new(Line::from(vec![span])));
271
272
  }
272
273
 
273
- // Fallback
274
- Err(Error::new(
275
- ruby.exception_type_error(),
274
+ Err(type_error_with_context(
275
+ &ruby,
276
276
  "expected String, Text::Span, Text::Line, or ListItem",
277
+ value,
277
278
  ))
278
279
  }
279
280
 
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
+ use crate::errors::type_error_with_context;
4
5
  use crate::rendering::render_node;
5
6
  use magnus::{prelude::*, Error, Value};
6
7
  use ratatui::{layout::Rect, Frame};
@@ -9,7 +10,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
9
10
  let ruby = magnus::Ruby::get().unwrap();
10
11
  let layers_val: Value = node.funcall("layers", ())?;
11
12
  let layers_array = magnus::RArray::from_value(layers_val)
12
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for layers"))?;
13
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for layers", layers_val))?;
13
14
 
14
15
  for i in 0..layers_array.len() {
15
16
  let index = isize::try_from(i)
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
+ use crate::errors::type_error_with_context;
4
5
  use crate::style::{parse_block, parse_style};
5
6
  use crate::text::{parse_line, parse_span, parse_text};
6
7
  use crate::widgets::table_state::RubyTableState;
@@ -19,10 +20,10 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
19
20
  let footer_val: Value = node.funcall("footer", ())?;
20
21
  let rows_value: Value = node.funcall("rows", ())?;
21
22
  let rows_array = magnus::RArray::from_value(rows_value)
22
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for rows"))?;
23
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for rows", rows_value))?;
23
24
  let widths_val: Value = node.funcall("widths", ())?;
24
25
  let widths_array = magnus::RArray::from_value(widths_val)
25
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for widths"))?;
26
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for widths", widths_val))?;
26
27
  let row_highlight_style_val: Value = node.funcall("row_highlight_style", ())?;
27
28
  let column_highlight_style_val: Value = node.funcall("column_highlight_style", ())?;
28
29
  let cell_highlight_style_val: Value = node.funcall("cell_highlight_style", ())?;
@@ -145,10 +146,10 @@ pub fn render_stateful(
145
146
  // Parse rows
146
147
  let rows_value: Value = node.funcall("rows", ())?;
147
148
  let rows_array = magnus::RArray::from_value(rows_value)
148
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for rows"))?;
149
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for rows", rows_value))?;
149
150
  let widths_val: Value = node.funcall("widths", ())?;
150
151
  let widths_array = magnus::RArray::from_value(widths_val)
151
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for widths"))?;
152
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for widths", widths_val))?;
152
153
 
153
154
  let mut rows = Vec::new();
154
155
  for i in 0..rows_array.len() {
@@ -251,7 +252,7 @@ fn parse_row(row_val: Value) -> Result<Row<'static>, Error> {
251
252
  let bottom_margin_val: Value = row_val.funcall("bottom_margin", ())?;
252
253
 
253
254
  let cells_array = magnus::RArray::from_value(cells_val).ok_or_else(|| {
254
- Error::new(ruby.exception_type_error(), "expected array for Row.cells")
255
+ type_error_with_context(&ruby, "expected array for Row.cells", cells_val)
255
256
  })?;
256
257
 
257
258
  let mut cells = Vec::new();
@@ -285,7 +286,7 @@ fn parse_row(row_val: Value) -> Result<Row<'static>, Error> {
285
286
 
286
287
  // Fallback: plain array of cells
287
288
  let row_array = magnus::RArray::from_value(row_val)
288
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for row"))?;
289
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for row", row_val))?;
289
290
 
290
291
  let mut cells = Vec::new();
291
292
  for i in 0..row_array.len() {
@@ -69,6 +69,40 @@ impl RubyTableState {
69
69
  self.inner.borrow_mut().scroll_up_by(amount);
70
70
  }
71
71
 
72
+ /// Returns the currently selected cell as (row, column) tuple.
73
+ /// Returns None if either row or column is not selected.
74
+ pub fn selected_cell(&self) -> Option<(usize, usize)> {
75
+ self.inner.borrow().selected_cell()
76
+ }
77
+
78
+ /// Selects the next column or the first one if no column is selected.
79
+ pub fn select_next_column(&self) {
80
+ self.inner.borrow_mut().select_next_column();
81
+ }
82
+
83
+ /// Selects the previous column or the last one if no column is selected.
84
+ pub fn select_previous_column(&self) {
85
+ self.inner.borrow_mut().select_previous_column();
86
+ }
87
+
88
+ /// Selects the first column.
89
+ pub fn select_first_column(&self) {
90
+ self.inner.borrow_mut().select_first_column();
91
+ }
92
+
93
+ /// Selects the last column.
94
+ pub fn select_last_column(&self) {
95
+ self.inner.borrow_mut().select_last_column();
96
+ }
97
+
98
+ /// Creates a new `RubyTableState` with a selected cell (row, column).
99
+ pub fn with_selected_cell(cell: Option<(usize, usize)>) -> Self {
100
+ let state = TableState::default().with_selected_cell(cell);
101
+ Self {
102
+ inner: RefCell::new(state),
103
+ }
104
+ }
105
+
72
106
  /// Borrows the inner `TableState` mutably for rendering.
73
107
  pub fn borrow_mut(&self) -> std::cell::RefMut<'_, TableState> {
74
108
  self.inner.borrow_mut()
@@ -79,6 +113,10 @@ impl RubyTableState {
79
113
  pub fn register(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
80
114
  let class = module.define_class("TableState", ruby.class_object())?;
81
115
  class.define_singleton_method("new", function!(RubyTableState::new, 1))?;
116
+ class.define_singleton_method(
117
+ "with_selected_cell",
118
+ function!(RubyTableState::with_selected_cell, 1),
119
+ )?;
82
120
  class.define_method("select", method!(RubyTableState::select, 1))?;
83
121
  class.define_method("selected", method!(RubyTableState::selected, 0))?;
84
122
  class.define_method("select_column", method!(RubyTableState::select_column, 1))?;
@@ -86,6 +124,23 @@ pub fn register(ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
86
124
  "selected_column",
87
125
  method!(RubyTableState::selected_column, 0),
88
126
  )?;
127
+ class.define_method("selected_cell", method!(RubyTableState::selected_cell, 0))?;
128
+ class.define_method(
129
+ "select_next_column",
130
+ method!(RubyTableState::select_next_column, 0),
131
+ )?;
132
+ class.define_method(
133
+ "select_previous_column",
134
+ method!(RubyTableState::select_previous_column, 0),
135
+ )?;
136
+ class.define_method(
137
+ "select_first_column",
138
+ method!(RubyTableState::select_first_column, 0),
139
+ )?;
140
+ class.define_method(
141
+ "select_last_column",
142
+ method!(RubyTableState::select_last_column, 0),
143
+ )?;
89
144
  class.define_method("offset", method!(RubyTableState::offset, 0))?;
90
145
  class.define_method("scroll_down_by", method!(RubyTableState::scroll_down_by, 1))?;
91
146
  class.define_method("scroll_up_by", method!(RubyTableState::scroll_up_by, 1))?;
@@ -1,6 +1,7 @@
1
1
  // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
+ use crate::errors::type_error_with_context;
4
5
  use crate::style::parse_block;
5
6
  use crate::text::{parse_line, parse_span};
6
7
  use bumpalo::Bump;
@@ -59,7 +60,7 @@ fn create_tabs(node: Value, bump: &Bump) -> Result<Tabs<'_>, Error> {
59
60
  let padding_right_val: Value = node.funcall("padding_right", ())?;
60
61
 
61
62
  let titles_array = magnus::RArray::from_value(titles_val)
62
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for titles"))?;
63
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for titles", titles_val))?;
63
64
 
64
65
  let mut titles = Vec::new();
65
66
  for i in 0..titles_array.len() {
@@ -117,7 +118,7 @@ pub fn width(node: Value) -> Result<usize, Error> {
117
118
  let padding_right: usize = node.funcall("padding_right", ())?;
118
119
 
119
120
  let titles_array = magnus::RArray::from_value(titles_val)
120
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array for titles"))?;
121
+ .ok_or_else(|| type_error_with_context(&ruby, "expected array for titles", titles_val))?;
121
122
 
122
123
  let mut total_width = padding_left + padding_right;
123
124
 
@@ -46,6 +46,11 @@ module RatatuiRuby
46
46
  # The background color of the cell (e.g., :black, nil).
47
47
  attr_reader :bg
48
48
 
49
+ # The underline color of the cell.
50
+ #
51
+ # Distinct from foreground color. Some terminals support colored underlines.
52
+ attr_reader :underline_color
53
+
49
54
  # The list of active modifiers (e.g., ["bold", "italic"]).
50
55
  attr_reader :modifiers
51
56
 
@@ -64,7 +69,7 @@ module RatatuiRuby
64
69
  # SPDX-SnippetEnd
65
70
  #++
66
71
  def self.empty
67
- new(symbol: " ", fg: nil, bg: nil, modifiers: [])
72
+ new(symbol: " ", fg: nil, bg: nil, underline_color: nil, modifiers: [])
68
73
  end
69
74
 
70
75
  # Returns a default cell (alias for empty).
@@ -102,7 +107,7 @@ module RatatuiRuby
102
107
  # SPDX-SnippetEnd
103
108
  #++
104
109
  def self.symbol(symbol)
105
- new(symbol:, fg: nil, bg: nil, modifiers: [])
110
+ new(symbol:, fg: nil, bg: nil, underline_color: nil, modifiers: [])
106
111
  end
107
112
 
108
113
  # Alias for Rubyists who prefer a shorter name.
@@ -115,58 +120,61 @@ module RatatuiRuby
115
120
  # [symbol] String (single character). Aliased as <tt>char:</tt>.
116
121
  # [fg] Symbol or String (nullable).
117
122
  # [bg] Symbol or String (nullable).
118
- # [modifiers] Array of Strings.
119
- def initialize(symbol: nil, char: nil, fg: nil, bg: nil, modifiers: [])
123
+ # [underline_color] Symbol or String (nullable).
124
+ # [modifiers] Array of Strings, Symbols, or any object responding to to_sym or to_s.
125
+ # Normalized to Symbols for consistent output.
126
+ def initialize(symbol: nil, char: nil, fg: nil, bg: nil, underline_color: nil, modifiers: [])
120
127
  @symbol = (symbol || char || " ").freeze
121
128
  @fg = fg&.freeze
122
129
  @bg = bg&.freeze
123
- @modifiers = modifiers.map(&:freeze).freeze
130
+ @underline_color = underline_color&.freeze
131
+ @modifiers = modifiers.map { |m| m.respond_to?(:to_sym) ? m.to_sym : m.to_s.to_sym }.freeze
124
132
  freeze
125
133
  end
126
134
 
127
135
  # Returns true if the cell has the bold modifier.
128
136
  def bold?
129
- modifiers.include?("bold")
137
+ modifiers.include?(:bold)
130
138
  end
131
139
 
132
140
  # Returns true if the cell has the dim modifier.
133
141
  def dim?
134
- modifiers.include?("dim")
142
+ modifiers.include?(:dim)
135
143
  end
136
144
 
137
145
  # Returns true if the cell has the italic modifier.
138
146
  def italic?
139
- modifiers.include?("italic")
147
+ modifiers.include?(:italic)
140
148
  end
141
149
 
142
150
  # Returns true if the cell has the underlined modifier.
143
151
  def underlined?
144
- modifiers.include?("underlined")
152
+ modifiers.include?(:underlined)
145
153
  end
146
154
 
147
155
  # Returns true if the cell has the slow_blink modifier.
148
156
  def slow_blink?
149
- modifiers.include?("slow_blink")
157
+ modifiers.include?(:slow_blink)
150
158
  end
151
159
 
152
160
  # Returns true if the cell has the rapid_blink modifier.
153
161
  def rapid_blink?
154
- modifiers.include?("rapid_blink")
162
+ modifiers.include?(:rapid_blink)
155
163
  end
156
164
 
157
165
  # Returns true if the cell has the reversed modifier.
158
166
  def reversed?
159
- modifiers.include?("reversed")
167
+ modifiers.include?(:reversed)
160
168
  end
161
169
 
162
170
  # Returns true if the cell has the hidden modifier.
163
171
  def hidden?
164
- modifiers.include?("hidden")
172
+ modifiers.include?(:hidden)
165
173
  end
166
174
 
167
175
  # Returns true if the cell has the crossed_out modifier.
168
176
  def crossed_out?
169
- modifiers.include?("crossed_out")
177
+ modifiers.include?(:crossed_out)
170
178
  end
171
179
 
172
180
  # Checks equality with another Cell.
@@ -175,6 +183,7 @@ module RatatuiRuby
175
183
  char == other.char &&
176
184
  fg == other.fg &&
177
185
  bg == other.bg &&
186
+ underline_color == other.underline_color &&
178
187
  modifiers == other.modifiers
179
188
  end
180
189
 
@@ -183,6 +192,7 @@ module RatatuiRuby
183
192
  parts = ["symbol=#{symbol.inspect}"]
184
193
  parts << "fg=#{fg.inspect}" if fg
185
194
  parts << "bg=#{bg.inspect}" if bg
195
+ parts << "underline_color=#{underline_color.inspect}" if underline_color
186
196
  parts << "modifiers=#{modifiers.inspect}" unless modifiers.empty?
187
197
  "#<#{self.class} #{parts.join(' ')}>"
188
198
  end
@@ -195,7 +205,7 @@ module RatatuiRuby
195
205
  # Support for pattern matching.
196
206
  # Supports both <tt>:symbol</tt> and <tt>:char</tt> keys.
197
207
  def deconstruct_keys(keys)
198
- { symbol:, char: symbol, fg:, bg:, modifiers: }
208
+ { symbol:, char: symbol, fg:, bg:, underline_color:, modifiers: }
199
209
  end
200
210
  end
201
211
  end
@@ -8,9 +8,141 @@
8
8
  module RatatuiRuby
9
9
  # Buffer primitives for terminal cell inspection.
10
10
  #
11
- # This module mirrors +ratatui::buffer+ and contains:
12
- # - {Cell} Single terminal cell (for inspection)
11
+ # Widgets render to an intermediate buffer, not directly to the terminal.
12
+ # Testing and debugging require access to buffer state.
13
+ #
14
+ # This module mirrors +ratatui::buffer+ and provides query methods
15
+ # for inspecting buffer contents, converting between coordinates and indices,
16
+ # and retrieving individual cells.
17
+ #
18
+ # Use it in tests to verify rendered output or in debugging to inspect state.
13
19
  module Buffer
20
+ class << self
21
+ # Converts a position to a linear buffer index.
22
+ #
23
+ # Buffers store cells in a flat array, row by row. Widget code
24
+ # works with (x, y) coordinates. Bridging these representations
25
+ # requires index translation.
26
+ #
27
+ # The index is calculated as <tt>y * width + x</tt>.
28
+ #
29
+ # === Example
30
+ #
31
+ #--
32
+ # SPDX-SnippetBegin
33
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
34
+ # SPDX-License-Identifier: MIT-0
35
+ #++
36
+ # # In a 10-wide buffer, position (3, 2) maps to index 23
37
+ # Buffer.index_of(3, 2) # => 23
38
+ #--
39
+ # SPDX-SnippetEnd
40
+ #++
41
+ #
42
+ # [x] Column (0-indexed from left).
43
+ # [y] Row (0-indexed from top).
44
+ #
45
+ # Returns the linear index (Integer).
46
+ def index_of(x, y)
47
+ area = RatatuiRuby._get_terminal_area
48
+ (y * area["width"]) + x
49
+ end
50
+
51
+ # Converts a linear buffer index to position coordinates.
52
+ #
53
+ # Inverse of +index_of+. When iterating over buffer content
54
+ # by index, use this to recover the original coordinates.
55
+ #
56
+ # === Example
57
+ #
58
+ #--
59
+ # SPDX-SnippetBegin
60
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
61
+ # SPDX-License-Identifier: MIT-0
62
+ #++
63
+ # # In a 10-wide buffer, index 23 maps to position (3, 2)
64
+ # Buffer.pos_of(23) # => [3, 2]
65
+ #--
66
+ # SPDX-SnippetEnd
67
+ #++
68
+ #
69
+ # [index] Linear buffer index (Integer).
70
+ #
71
+ # Returns <tt>[x, y]</tt> coordinates.
72
+ def pos_of(index)
73
+ area = RatatuiRuby._get_terminal_area
74
+ width = area["width"]
75
+ x = index % width
76
+ y = index / width
77
+ [x, y]
78
+ end
79
+
80
+ # Returns the Cell at the specified position.
81
+ #
82
+ # Tests assert on cell contents. This method provides direct
83
+ # access without iterating the entire buffer.
84
+ #
85
+ # Delegates to +RatatuiRuby.get_cell_at+.
86
+ #
87
+ # === Example
88
+ #
89
+ #--
90
+ # SPDX-SnippetBegin
91
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
92
+ # SPDX-License-Identifier: MIT-0
93
+ #++
94
+ # cell = Buffer.get(0, 0)
95
+ # assert_equal "H", cell.char
96
+ #--
97
+ # SPDX-SnippetEnd
98
+ #++
99
+ #
100
+ # [x] Column (0-indexed from left).
101
+ # [y] Row (0-indexed from top).
102
+ #
103
+ # Returns a Buffer::Cell containing the character and style at that position.
104
+ def get(x, y)
105
+ RatatuiRuby.get_cell_at(x, y)
106
+ end
107
+
108
+ # Returns all cells in the buffer as a flat array.
109
+ #
110
+ # Snapshot testing compares entire buffer states. Manually
111
+ # iterating coordinates is verbose and error-prone.
112
+ #
113
+ # This method returns every cell, ordered row by row
114
+ # (top to bottom, left to right).
115
+ #
116
+ # === Example
117
+ #
118
+ #--
119
+ # SPDX-SnippetBegin
120
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
121
+ # SPDX-License-Identifier: MIT-0
122
+ #++
123
+ # cells = Buffer.content
124
+ # cells.each { |cell| puts cell.char }
125
+ #--
126
+ # SPDX-SnippetEnd
127
+ #++
128
+ #
129
+ # Returns an Array of Buffer::Cell objects.
130
+ def content
131
+ area = RatatuiRuby._get_terminal_area
132
+ width = area["width"]
133
+ height = area["height"]
134
+ cells = [] #: Array[Buffer::Cell]
135
+ (0...height).each do |y|
136
+ (0...width).each do |x|
137
+ cells << RatatuiRuby.get_cell_at(x, y)
138
+ end
139
+ end
140
+ cells
141
+ end
142
+
143
+ # Ruby-idiomatic alias (TIMTOWTDI)
144
+ alias [] get
145
+ end
14
146
  end
15
147
  end
16
148
 
@@ -45,8 +45,12 @@ module RatatuiRuby
45
45
  # The background color of the cell (e.g., :black, nil).
46
46
  attr_reader :bg
47
47
 
48
+ # The underline color of the cell.
49
+ #
50
+ # Distinct from foreground color. Some terminals support colored underlines.
51
+ attr_reader :underline_color
52
+
48
53
  # The list of active modifiers (e.g., ["bold", "italic"]).
49
- attr_reader :modifiers
50
54
 
51
55
  # Returns an empty cell (space character, no styles).
52
56
  #
@@ -63,7 +67,7 @@ module RatatuiRuby
63
67
  # SPDX-SnippetEnd
64
68
  #++
65
69
  def self.empty
66
- new(symbol: " ", fg: nil, bg: nil, modifiers: [])
70
+ new(symbol: " ", fg: nil, bg: nil, underline_color: nil, modifiers: [])
67
71
  end
68
72
 
69
73
  # Returns a default cell (alias for empty).
@@ -101,7 +105,7 @@ module RatatuiRuby
101
105
  # SPDX-SnippetEnd
102
106
  #++
103
107
  def self.symbol(symbol)
104
- new(symbol:, fg: nil, bg: nil, modifiers: [])
108
+ new(symbol:, fg: nil, bg: nil, underline_color: nil, modifiers: [])
105
109
  end
106
110
 
107
111
  # Alias for Rubyists who prefer a shorter name.
@@ -114,11 +118,13 @@ module RatatuiRuby
114
118
  # [symbol] String (single character). Aliased as <tt>char:</tt>.
115
119
  # [fg] Symbol or String (nullable).
116
120
  # [bg] Symbol or String (nullable).
121
+ # [underline_color] Symbol or String (nullable).
117
122
  # [modifiers] Array of Strings.
118
- def initialize(symbol: nil, char: nil, fg: nil, bg: nil, modifiers: [])
123
+ def initialize(symbol: nil, char: nil, fg: nil, bg: nil, underline_color: nil, modifiers: [])
119
124
  @symbol = (symbol || char || " ").freeze
120
125
  @fg = fg&.freeze
121
126
  @bg = bg&.freeze
127
+ @underline_color = underline_color&.freeze
122
128
  @modifiers = modifiers.map(&:freeze).freeze
123
129
  freeze
124
130
  end
@@ -174,6 +180,7 @@ module RatatuiRuby
174
180
  char == other.char &&
175
181
  fg == other.fg &&
176
182
  bg == other.bg &&
183
+ underline_color == other.underline_color &&
177
184
  modifiers == other.modifiers
178
185
  end
179
186
 
@@ -182,6 +189,7 @@ module RatatuiRuby
182
189
  parts = ["symbol=#{symbol.inspect}"]
183
190
  parts << "fg=#{fg.inspect}" if fg
184
191
  parts << "bg=#{bg.inspect}" if bg
192
+ parts << "underline_color=#{underline_color.inspect}" if underline_color
185
193
  parts << "modifiers=#{modifiers.inspect}" unless modifiers.empty?
186
194
  "#<#{self.class} #{parts.join(' ')}>"
187
195
  end
@@ -194,7 +202,7 @@ module RatatuiRuby
194
202
  # Support for pattern matching.
195
203
  # Supports both <tt>:symbol</tt> and <tt>:char</tt> keys.
196
204
  def deconstruct_keys(keys)
197
- { symbol:, char: symbol, fg:, bg:, modifiers: }
205
+ { symbol:, char: symbol, fg:, bg:, underline_color:, modifiers: }
198
206
  end
199
207
  end
200
208
  end