ratatui_ruby 0.9.1 → 0.10.1

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 (268) 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 +113 -0
  8. data/README.md +17 -0
  9. data/REUSE.toml +5 -0
  10. data/Rakefile +1 -1
  11. data/Steepfile +49 -0
  12. data/doc/concepts/debugging.md +401 -0
  13. data/doc/getting_started/quickstart.md +8 -3
  14. data/doc/images/app_all_events.png +0 -0
  15. data/doc/images/app_color_picker.png +0 -0
  16. data/doc/images/app_debugging_showcase.gif +0 -0
  17. data/doc/images/app_debugging_showcase.png +0 -0
  18. data/doc/images/app_login_form.png +0 -0
  19. data/doc/images/app_stateful_interaction.png +0 -0
  20. data/doc/images/verify_quickstart_dsl.png +0 -0
  21. data/doc/images/verify_quickstart_layout.png +0 -0
  22. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  23. data/doc/images/verify_readme_usage.png +0 -0
  24. data/doc/images/widget_barchart.png +0 -0
  25. data/doc/images/widget_block.png +0 -0
  26. data/doc/images/widget_box.png +0 -0
  27. data/doc/images/widget_calendar.png +0 -0
  28. data/doc/images/widget_canvas.png +0 -0
  29. data/doc/images/widget_cell.png +0 -0
  30. data/doc/images/widget_center.png +0 -0
  31. data/doc/images/widget_chart.png +0 -0
  32. data/doc/images/widget_gauge.png +0 -0
  33. data/doc/images/widget_layout_split.png +0 -0
  34. data/doc/images/widget_line_gauge.png +0 -0
  35. data/doc/images/widget_list.png +0 -0
  36. data/doc/images/widget_map.png +0 -0
  37. data/doc/images/widget_overlay.png +0 -0
  38. data/doc/images/widget_popup.png +0 -0
  39. data/doc/images/widget_ratatui_logo.png +0 -0
  40. data/doc/images/widget_ratatui_mascot.png +0 -0
  41. data/doc/images/widget_rect.png +0 -0
  42. data/doc/images/widget_render.png +0 -0
  43. data/doc/images/widget_rich_text.png +0 -0
  44. data/doc/images/widget_scroll_text.png +0 -0
  45. data/doc/images/widget_scrollbar.png +0 -0
  46. data/doc/images/widget_sparkline.png +0 -0
  47. data/doc/images/widget_style_colors.png +0 -0
  48. data/doc/images/widget_table.png +0 -0
  49. data/doc/images/widget_tabs.png +0 -0
  50. data/doc/images/widget_text_width.png +0 -0
  51. data/doc/troubleshooting/async.md +4 -0
  52. data/examples/app_debugging_showcase/README.md +119 -0
  53. data/examples/app_debugging_showcase/app.rb +318 -0
  54. data/examples/widget_canvas/app.rb +19 -14
  55. data/examples/widget_gauge/app.rb +18 -3
  56. data/examples/widget_layout_split/app.rb +10 -4
  57. data/examples/widget_list/app.rb +22 -6
  58. data/examples/widget_rect/app.rb +7 -6
  59. data/examples/widget_rich_text/app.rb +62 -37
  60. data/examples/widget_style_colors/app.rb +26 -47
  61. data/examples/widget_table/app.rb +28 -5
  62. data/examples/widget_text_width/app.rb +6 -4
  63. data/ext/ratatui_ruby/Cargo.lock +48 -1
  64. data/ext/ratatui_ruby/Cargo.toml +6 -2
  65. data/ext/ratatui_ruby/src/color.rs +82 -0
  66. data/ext/ratatui_ruby/src/errors.rs +28 -0
  67. data/ext/ratatui_ruby/src/events.rs +15 -14
  68. data/ext/ratatui_ruby/src/lib.rs +56 -0
  69. data/ext/ratatui_ruby/src/rendering.rs +3 -1
  70. data/ext/ratatui_ruby/src/style.rs +48 -21
  71. data/ext/ratatui_ruby/src/terminal.rs +40 -9
  72. data/ext/ratatui_ruby/src/text.rs +21 -9
  73. data/ext/ratatui_ruby/src/widgets/chart.rs +2 -1
  74. data/ext/ratatui_ruby/src/widgets/layout.rs +90 -2
  75. data/ext/ratatui_ruby/src/widgets/list.rs +6 -5
  76. data/ext/ratatui_ruby/src/widgets/overlay.rs +2 -1
  77. data/ext/ratatui_ruby/src/widgets/table.rs +7 -6
  78. data/ext/ratatui_ruby/src/widgets/table_state.rs +55 -0
  79. data/ext/ratatui_ruby/src/widgets/tabs.rs +3 -2
  80. data/lib/ratatui_ruby/buffer/cell.rb +25 -15
  81. data/lib/ratatui_ruby/buffer.rb +134 -2
  82. data/lib/ratatui_ruby/cell.rb +13 -5
  83. data/lib/ratatui_ruby/debug.rb +215 -0
  84. data/lib/ratatui_ruby/event/key.rb +3 -2
  85. data/lib/ratatui_ruby/event.rb +1 -1
  86. data/lib/ratatui_ruby/layout/constraint.rb +49 -0
  87. data/lib/ratatui_ruby/layout/layout.rb +119 -13
  88. data/lib/ratatui_ruby/layout/position.rb +55 -0
  89. data/lib/ratatui_ruby/layout/rect.rb +188 -0
  90. data/lib/ratatui_ruby/layout/size.rb +55 -0
  91. data/lib/ratatui_ruby/layout.rb +4 -0
  92. data/lib/ratatui_ruby/style/color.rb +149 -0
  93. data/lib/ratatui_ruby/style/style.rb +51 -4
  94. data/lib/ratatui_ruby/style.rb +2 -0
  95. data/lib/ratatui_ruby/symbols.rb +435 -0
  96. data/lib/ratatui_ruby/synthetic_events.rb +1 -1
  97. data/lib/ratatui_ruby/table_state.rb +51 -0
  98. data/lib/ratatui_ruby/terminal_lifecycle.rb +2 -1
  99. data/lib/ratatui_ruby/test_helper/event_injection.rb +6 -1
  100. data/lib/ratatui_ruby/test_helper.rb +9 -0
  101. data/lib/ratatui_ruby/text/line.rb +245 -0
  102. data/lib/ratatui_ruby/text/span.rb +158 -0
  103. data/lib/ratatui_ruby/text.rb +99 -0
  104. data/lib/ratatui_ruby/tui/canvas_factories.rb +103 -0
  105. data/lib/ratatui_ruby/tui/core.rb +13 -2
  106. data/lib/ratatui_ruby/tui/layout_factories.rb +50 -3
  107. data/lib/ratatui_ruby/tui/state_factories.rb +42 -0
  108. data/lib/ratatui_ruby/tui/text_factories.rb +40 -0
  109. data/lib/ratatui_ruby/tui/widget_factories.rb +135 -60
  110. data/lib/ratatui_ruby/tui.rb +22 -1
  111. data/lib/ratatui_ruby/version.rb +1 -1
  112. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +2 -0
  113. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +2 -0
  114. data/lib/ratatui_ruby/widgets/bar_chart.rb +30 -20
  115. data/lib/ratatui_ruby/widgets/block.rb +14 -6
  116. data/lib/ratatui_ruby/widgets/calendar.rb +2 -0
  117. data/lib/ratatui_ruby/widgets/canvas.rb +56 -0
  118. data/lib/ratatui_ruby/widgets/cell.rb +2 -0
  119. data/lib/ratatui_ruby/widgets/center.rb +2 -0
  120. data/lib/ratatui_ruby/widgets/chart.rb +6 -0
  121. data/lib/ratatui_ruby/widgets/clear.rb +2 -0
  122. data/lib/ratatui_ruby/widgets/coerceable_widget.rb +77 -0
  123. data/lib/ratatui_ruby/widgets/cursor.rb +2 -0
  124. data/lib/ratatui_ruby/widgets/gauge.rb +61 -3
  125. data/lib/ratatui_ruby/widgets/line_gauge.rb +66 -4
  126. data/lib/ratatui_ruby/widgets/list.rb +87 -3
  127. data/lib/ratatui_ruby/widgets/list_item.rb +2 -0
  128. data/lib/ratatui_ruby/widgets/overlay.rb +2 -0
  129. data/lib/ratatui_ruby/widgets/paragraph.rb +4 -0
  130. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +2 -0
  131. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +2 -0
  132. data/lib/ratatui_ruby/widgets/row.rb +45 -0
  133. data/lib/ratatui_ruby/widgets/scrollbar.rb +2 -0
  134. data/lib/ratatui_ruby/widgets/shape/label.rb +2 -0
  135. data/lib/ratatui_ruby/widgets/sparkline.rb +21 -13
  136. data/lib/ratatui_ruby/widgets/table.rb +13 -3
  137. data/lib/ratatui_ruby/widgets/tabs.rb +6 -4
  138. data/lib/ratatui_ruby/widgets.rb +1 -0
  139. data/lib/ratatui_ruby.rb +42 -11
  140. data/sig/examples/app_all_events/model/app_model.rbs +23 -0
  141. data/sig/examples/app_all_events/model/event_entry.rbs +15 -8
  142. data/sig/examples/app_all_events/model/timestamp.rbs +1 -1
  143. data/sig/examples/app_all_events/view.rbs +1 -1
  144. data/sig/examples/app_stateful_interaction/app.rbs +5 -5
  145. data/sig/examples/widget_block_demo/app.rbs +6 -6
  146. data/sig/manifest.yaml +5 -0
  147. data/sig/patches/data.rbs +26 -0
  148. data/sig/patches/debugger__.rbs +8 -0
  149. data/sig/ratatui_ruby/buffer/cell.rbs +46 -0
  150. data/sig/ratatui_ruby/buffer.rbs +18 -0
  151. data/sig/ratatui_ruby/cell.rbs +44 -0
  152. data/sig/ratatui_ruby/clear.rbs +18 -0
  153. data/sig/ratatui_ruby/constraint.rbs +26 -0
  154. data/sig/ratatui_ruby/debug.rbs +45 -0
  155. data/sig/ratatui_ruby/draw.rbs +30 -0
  156. data/sig/ratatui_ruby/event.rbs +68 -8
  157. data/sig/ratatui_ruby/frame.rbs +4 -4
  158. data/sig/ratatui_ruby/interfaces.rbs +25 -0
  159. data/sig/ratatui_ruby/layout/constraint.rbs +39 -0
  160. data/sig/ratatui_ruby/layout/layout.rbs +45 -0
  161. data/sig/ratatui_ruby/layout/position.rbs +18 -0
  162. data/sig/ratatui_ruby/layout/rect.rbs +64 -0
  163. data/sig/ratatui_ruby/layout/size.rbs +18 -0
  164. data/sig/ratatui_ruby/output_guard.rbs +23 -0
  165. data/sig/ratatui_ruby/ratatui_ruby.rbs +84 -5
  166. data/sig/ratatui_ruby/rect.rbs +17 -0
  167. data/sig/ratatui_ruby/style/color.rbs +22 -0
  168. data/sig/ratatui_ruby/style/style.rbs +29 -0
  169. data/sig/ratatui_ruby/symbols.rbs +141 -0
  170. data/sig/ratatui_ruby/synthetic_events.rbs +21 -0
  171. data/sig/ratatui_ruby/table_state.rbs +6 -0
  172. data/sig/ratatui_ruby/terminal_lifecycle.rbs +31 -0
  173. data/sig/ratatui_ruby/test_helper/event_injection.rbs +2 -2
  174. data/sig/ratatui_ruby/test_helper/snapshot.rbs +22 -3
  175. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +8 -1
  176. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +7 -3
  177. data/sig/ratatui_ruby/text/line.rbs +27 -0
  178. data/sig/ratatui_ruby/text/span.rbs +23 -0
  179. data/sig/ratatui_ruby/text.rbs +12 -0
  180. data/sig/ratatui_ruby/tui/buffer_factories.rbs +1 -1
  181. data/sig/ratatui_ruby/tui/canvas_factories.rbs +23 -5
  182. data/sig/ratatui_ruby/tui/core.rbs +2 -2
  183. data/sig/ratatui_ruby/tui/layout_factories.rbs +16 -2
  184. data/sig/ratatui_ruby/tui/state_factories.rbs +8 -3
  185. data/sig/ratatui_ruby/tui/style_factories.rbs +3 -1
  186. data/sig/ratatui_ruby/tui/text_factories.rbs +7 -4
  187. data/sig/ratatui_ruby/tui/widget_factories.rbs +123 -30
  188. data/sig/ratatui_ruby/widgets/bar_chart.rbs +95 -0
  189. data/sig/ratatui_ruby/widgets/block.rbs +51 -0
  190. data/sig/ratatui_ruby/widgets/calendar.rbs +45 -0
  191. data/sig/ratatui_ruby/widgets/canvas.rbs +95 -0
  192. data/sig/ratatui_ruby/widgets/chart.rbs +91 -0
  193. data/sig/ratatui_ruby/widgets/coerceable_widget.rbs +26 -0
  194. data/sig/ratatui_ruby/widgets/gauge.rbs +44 -0
  195. data/sig/ratatui_ruby/widgets/line_gauge.rbs +48 -0
  196. data/sig/ratatui_ruby/widgets/list.rbs +63 -0
  197. data/sig/ratatui_ruby/widgets/misc.rbs +158 -0
  198. data/sig/ratatui_ruby/widgets/paragraph.rbs +45 -0
  199. data/sig/ratatui_ruby/widgets/row.rbs +43 -0
  200. data/sig/ratatui_ruby/widgets/scrollbar.rbs +53 -0
  201. data/sig/ratatui_ruby/widgets/shape/label.rbs +37 -0
  202. data/sig/ratatui_ruby/widgets/sparkline.rbs +45 -0
  203. data/sig/ratatui_ruby/widgets/table.rbs +78 -0
  204. data/sig/ratatui_ruby/widgets/tabs.rbs +44 -0
  205. data/sig/ratatui_ruby/{schema/list_item.rbs → widgets.rbs} +4 -4
  206. data/tasks/steep.rake +11 -0
  207. metadata +80 -63
  208. data/doc/contributors/v1.0.0_blockers.md +0 -870
  209. data/doc/troubleshooting/debugging.md +0 -101
  210. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +0 -47
  211. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +0 -25
  212. data/lib/ratatui_ruby/schema/bar_chart.rb +0 -287
  213. data/lib/ratatui_ruby/schema/block.rb +0 -198
  214. data/lib/ratatui_ruby/schema/calendar.rb +0 -84
  215. data/lib/ratatui_ruby/schema/canvas.rb +0 -239
  216. data/lib/ratatui_ruby/schema/center.rb +0 -67
  217. data/lib/ratatui_ruby/schema/chart.rb +0 -159
  218. data/lib/ratatui_ruby/schema/clear.rb +0 -62
  219. data/lib/ratatui_ruby/schema/constraint.rb +0 -151
  220. data/lib/ratatui_ruby/schema/cursor.rb +0 -50
  221. data/lib/ratatui_ruby/schema/gauge.rb +0 -72
  222. data/lib/ratatui_ruby/schema/layout.rb +0 -122
  223. data/lib/ratatui_ruby/schema/line_gauge.rb +0 -80
  224. data/lib/ratatui_ruby/schema/list.rb +0 -135
  225. data/lib/ratatui_ruby/schema/list_item.rb +0 -51
  226. data/lib/ratatui_ruby/schema/overlay.rb +0 -51
  227. data/lib/ratatui_ruby/schema/paragraph.rb +0 -107
  228. data/lib/ratatui_ruby/schema/ratatui_logo.rb +0 -31
  229. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +0 -36
  230. data/lib/ratatui_ruby/schema/rect.rb +0 -174
  231. data/lib/ratatui_ruby/schema/row.rb +0 -76
  232. data/lib/ratatui_ruby/schema/scrollbar.rb +0 -143
  233. data/lib/ratatui_ruby/schema/shape/label.rb +0 -76
  234. data/lib/ratatui_ruby/schema/sparkline.rb +0 -142
  235. data/lib/ratatui_ruby/schema/style.rb +0 -97
  236. data/lib/ratatui_ruby/schema/table.rb +0 -141
  237. data/lib/ratatui_ruby/schema/tabs.rb +0 -85
  238. data/lib/ratatui_ruby/schema/text.rb +0 -217
  239. data/sig/examples/app_all_events/model/events.rbs +0 -15
  240. data/sig/examples/app_all_events/view_state.rbs +0 -21
  241. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +0 -22
  242. data/sig/ratatui_ruby/schema/bar_chart/bar_group.rbs +0 -19
  243. data/sig/ratatui_ruby/schema/bar_chart.rbs +0 -38
  244. data/sig/ratatui_ruby/schema/block.rbs +0 -18
  245. data/sig/ratatui_ruby/schema/calendar.rbs +0 -23
  246. data/sig/ratatui_ruby/schema/canvas.rbs +0 -81
  247. data/sig/ratatui_ruby/schema/center.rbs +0 -17
  248. data/sig/ratatui_ruby/schema/chart.rbs +0 -39
  249. data/sig/ratatui_ruby/schema/constraint.rbs +0 -30
  250. data/sig/ratatui_ruby/schema/cursor.rbs +0 -16
  251. data/sig/ratatui_ruby/schema/draw.rbs +0 -33
  252. data/sig/ratatui_ruby/schema/gauge.rbs +0 -23
  253. data/sig/ratatui_ruby/schema/layout.rbs +0 -27
  254. data/sig/ratatui_ruby/schema/line_gauge.rbs +0 -24
  255. data/sig/ratatui_ruby/schema/list.rbs +0 -28
  256. data/sig/ratatui_ruby/schema/overlay.rbs +0 -15
  257. data/sig/ratatui_ruby/schema/paragraph.rbs +0 -20
  258. data/sig/ratatui_ruby/schema/ratatui_logo.rbs +0 -14
  259. data/sig/ratatui_ruby/schema/ratatui_mascot.rbs +0 -17
  260. data/sig/ratatui_ruby/schema/rect.rbs +0 -48
  261. data/sig/ratatui_ruby/schema/row.rbs +0 -28
  262. data/sig/ratatui_ruby/schema/scrollbar.rbs +0 -42
  263. data/sig/ratatui_ruby/schema/sparkline.rbs +0 -22
  264. data/sig/ratatui_ruby/schema/style.rbs +0 -19
  265. data/sig/ratatui_ruby/schema/table.rbs +0 -32
  266. data/sig/ratatui_ruby/schema/tabs.rbs +0 -21
  267. data/sig/ratatui_ruby/schema/text.rbs +0 -31
  268. /data/lib/ratatui_ruby/{schema/draw.rb → draw.rb} +0 -0
@@ -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
@@ -0,0 +1,215 @@
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
+ ##
10
+ # Debug mode control for RatatuiRuby.
11
+ #
12
+ # TUI applications are hard to debug. Rust panics show cryptic stack traces.
13
+ # Ruby exceptions lack Rust context.
14
+ #
15
+ # This module controls debug visibility. Enable Rust backtraces only, or
16
+ # enable full debug mode for both Rust and Ruby-side features.
17
+ #
18
+ # == Activation Methods
19
+ #
20
+ # Three ways to enable debug features:
21
+ #
22
+ # [<tt>RUST_BACKTRACE=1</tt>] Rust backtraces only (no Ruby-side debug).
23
+ # [<tt>RR_DEBUG=1</tt>] Full debug mode (backtraces + Ruby features).
24
+ # [<tt>include RatatuiRuby::TestHelper</tt>] Auto-enables debug mode.
25
+ #
26
+ # === Example
27
+ #
28
+ #--
29
+ # SPDX-SnippetBegin
30
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
31
+ # SPDX-License-Identifier: MIT-0
32
+ #++
33
+ # # Programmatic activation
34
+ # RatatuiRuby::Debug.enable!
35
+ #
36
+ # # Or use the convenience alias
37
+ # RatatuiRuby.debug_mode!
38
+ #
39
+ #--
40
+ # SPDX-SnippetEnd
41
+ #++
42
+ module Debug
43
+ @rust_backtrace_enabled = false
44
+ @debug_mode_enabled = false
45
+
46
+ class << self
47
+ ##
48
+ # Enables Rust backtraces only.
49
+ #
50
+ # Call this to get meaningful stack traces when Rust panics.
51
+ # Does not enable Ruby-side debug features.
52
+ #
53
+ # Safe to call multiple times; subsequent calls are no-ops.
54
+ def enable_rust_backtrace!
55
+ return if @rust_backtrace_enabled
56
+
57
+ @rust_backtrace_enabled = true
58
+ RatatuiRuby.__send__(:_enable_rust_backtrace)
59
+ end
60
+
61
+ ##
62
+ # Enables full debug mode.
63
+ #
64
+ # Activates Rust backtraces plus any Ruby-side debug features.
65
+ # Optionally enables remote debugging via the debug gem.
66
+ #
67
+ # Safe to call multiple times; subsequent calls are no-ops.
68
+ #
69
+ # [source] <tt>:env</tt> if called from RR_DEBUG env var,
70
+ #--
71
+ # SPDX-SnippetBegin
72
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
73
+ # SPDX-License-Identifier: MIT-0
74
+ #++
75
+ # <tt>:test</tt> from TestHelper (skips remote debugging),
76
+ # <tt>:programmatic</tt> otherwise.
77
+ #--
78
+ # SPDX-SnippetEnd
79
+ #++
80
+ def enable!(source: :programmatic)
81
+ return @socket_path if @debug_mode_enabled
82
+
83
+ @debug_mode_enabled = true
84
+ enable_rust_backtrace!
85
+
86
+ # Tests don't need remote debugging — it would cause hangs
87
+ return if source == :test
88
+
89
+ @remote_debugging_mode = (source == :env) ? :open : :open_nonstop
90
+ @socket_path = enable_remote_debugging!
91
+ end
92
+
93
+ # rubocop:disable Lint/Debugger -- intentional debug gem integration
94
+ private def enable_remote_debugging!
95
+ # Suppress the "Debugger can attach via..." message that corrupts TUI displays
96
+ # Only suppress for programmatic activation; RR_DEBUG=1 users need to see it
97
+ old_log_level = ENV["RUBY_DEBUG_LOG_LEVEL"]
98
+ ENV["RUBY_DEBUG_LOG_LEVEL"] = "ERROR" if @remote_debugging_mode == :open_nonstop
99
+
100
+ case @remote_debugging_mode
101
+ when :open
102
+ # Stop at load so user can read socket path before TUI enters raw mode
103
+ ENV["RUBY_DEBUG_STOP_AT_LOAD"] = "1"
104
+ require "debug/open"
105
+ when :open_nonstop
106
+ require "debug/open_nonstop"
107
+ end
108
+
109
+ # Restore log level after require (the require is what prints the message)
110
+ ENV["RUBY_DEBUG_LOG_LEVEL"] = old_log_level if @remote_debugging_mode == :open_nonstop
111
+
112
+ # Return the socket path so apps can display it
113
+ ::DEBUGGER__.create_unix_domain_socket_name
114
+ rescue NameError
115
+ # Windows uses TCP/IP, not Unix sockets — DEBUGGER__ might not have this method
116
+ nil
117
+ # rubocop:enable Lint/Debugger
118
+ rescue LoadError
119
+ return unless @remote_debugging_mode == :open
120
+
121
+ raise LoadError,
122
+ "RR_DEBUG=1 requires the 'debug' gem for remote debugging. " \
123
+ "Add `gem 'debug'` to your Gemfile or install it with `gem install debug`."
124
+ end
125
+
126
+ ##
127
+ # Returns whether full debug mode is enabled.
128
+ public def enabled?
129
+ @debug_mode_enabled
130
+ end
131
+
132
+ ##
133
+ # Returns whether Rust backtraces are enabled.
134
+ public def rust_backtrace_enabled?
135
+ @rust_backtrace_enabled
136
+ end
137
+
138
+ ##
139
+ # Returns the remote debugging mode for debug gem integration.
140
+ #
141
+ # TUI apps run in raw terminal mode, making interactive debugging
142
+ # impossible. The debug gem's remote debugging feature lets you
143
+ # attach from another terminal via UNIX socket.
144
+ #
145
+ # Returns one of:
146
+ # <tt>:open</tt> Stop at program start, wait for debugger attach.
147
+ # Activated when <tt>RR_DEBUG=1</tt> is set at startup.
148
+ # <tt>:open_nonstop</tt> Continue running, attach whenever ready.
149
+ # Activated when <tt>enable!</tt> is called programmatically.
150
+ # <tt>nil</tt> No remote debugging configured.
151
+ public def remote_debugging_mode
152
+ @remote_debugging_mode
153
+ end
154
+
155
+ ##
156
+ # Triggers a Rust panic for backtrace verification.
157
+ #
158
+ # Debugging TUI apps is hard. Rust errors lack context. You want to
159
+ # confirm <tt>RUST_BACKTRACE=1</tt> actually shows stack traces before
160
+ # hitting a real bug.
161
+ #
162
+ # This method deliberately panics. The panic hook catches it and prints
163
+ # the Rust backtrace to stderr. If you see stack frames, your setup works.
164
+ #
165
+ # <b>WARNING</b>: Crashes your process. Use only for debugging.
166
+ #
167
+ # === Example
168
+ #
169
+ #--
170
+ # SPDX-SnippetBegin
171
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
172
+ # SPDX-License-Identifier: MIT-0
173
+ #++
174
+ # RUST_BACKTRACE=1 ruby -e 'require "ratatui_ruby"; RatatuiRuby::Debug.test_panic!'
175
+ #--
176
+ # SPDX-SnippetEnd
177
+ #++
178
+ public def test_panic!
179
+ RatatuiRuby.__send__(:_test_panic)
180
+ end
181
+
182
+ ##
183
+ # Temporarily suppresses Ruby-side debug mode checks.
184
+ #
185
+ # Rust backtraces remain enabled if previously activated; only
186
+ # Ruby-side features (like unknown-key errors) are suppressed
187
+ # within the block.
188
+ #
189
+ # === Example
190
+ #
191
+ #--
192
+ # SPDX-SnippetBegin
193
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
194
+ # SPDX-License-Identifier: MIT-0
195
+ #++
196
+ # RatatuiRuby::Debug.suppress_debug_mode do
197
+ # tui.table({ unknown_key: 1 }) # Does not raise
198
+ # end
199
+ #--
200
+ # SPDX-SnippetEnd
201
+ #++
202
+ public def suppress_debug_mode
203
+ old_value = @debug_mode_enabled
204
+ @debug_mode_enabled = false
205
+ yield
206
+ ensure
207
+ @debug_mode_enabled = old_value
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ # Auto-enable based on environment variables
214
+ RatatuiRuby::Debug.enable_rust_backtrace! if ENV["RUST_BACKTRACE"]
215
+ RatatuiRuby::Debug.enable!(source: :env) if ENV["RR_DEBUG"]
@@ -330,9 +330,10 @@ module RatatuiRuby
330
330
  #--
331
331
  # SPDX-SnippetEnd
332
332
  #++
333
- def method_missing(name, *args, &block)
333
+ def method_missing(name, *args, **kwargs, &block)
334
334
  if name.to_s.end_with?("?")
335
- key_name = name.to_s[0...-1]
335
+ name_str = name.to_s
336
+ key_name = name_str.chop # Returns String, never nil for non-empty string
336
337
  key_sym = key_name.to_sym
337
338
 
338
339
  # Fast path: Exact match (e.g., media_pause? for media_pause)
@@ -118,7 +118,7 @@ module RatatuiRuby
118
118
 
119
119
  # Responds to dynamic predicate methods for key checks.
120
120
  # All non-Key events return false for any key predicate.
121
- def method_missing(name, *args, &block)
121
+ def method_missing(name, *args, **kwargs, &block)
122
122
  if name.to_s.end_with?("?")
123
123
  false
124
124
  else