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
@@ -0,0 +1,82 @@
1
+ // SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! Color conversion functions exposed to Ruby.
5
+ //!
6
+ //! These functions wrap Ratatui's color conversion methods that require the `palette` feature.
7
+
8
+ use magnus::{Error, Ruby};
9
+ use ratatui::palette::{Hsl, Hsluv};
10
+ use ratatui::style::Color;
11
+
12
+ /// Formats an RGB color as a hex string.
13
+ fn rgb_to_hex(red: u8, green: u8, blue: u8) -> String {
14
+ format!("#{red:02x}{green:02x}{blue:02x}")
15
+ }
16
+
17
+ /// Convert HSL values to a hex color string.
18
+ ///
19
+ /// # Arguments
20
+ /// * `hue` - Hue in degrees (0-360, wraps)
21
+ /// * `saturation` - Saturation as percentage (0-100)
22
+ /// * `lightness` - Lightness as percentage (0-100)
23
+ ///
24
+ /// # Returns
25
+ /// A hex color string like "#rrggbb"
26
+ pub fn from_hsl(_ruby: &Ruby, hue: f32, saturation: f32, lightness: f32) -> String {
27
+ // Normalize: h wraps, s and l are percentages (0-100) that need to be 0-1
28
+ let hsl = Hsl::new(hue, saturation / 100.0, lightness / 100.0);
29
+ let color = Color::from_hsl(hsl);
30
+
31
+ match color {
32
+ Color::Rgb(red, green, blue) => rgb_to_hex(red, green, blue),
33
+ _ => "#000000".to_string(),
34
+ }
35
+ }
36
+
37
+ /// Convert `HSLuv` values to a hex color string.
38
+ ///
39
+ /// `HSLuv` is a perceptually uniform color space where colors at the same
40
+ /// lightness appear equally bright regardless of hue.
41
+ ///
42
+ /// # Arguments
43
+ /// * `hue` - Hue in degrees (-180 to 360, wraps)
44
+ /// * `saturation` - Saturation as percentage (0-100)
45
+ /// * `lightness` - Lightness as percentage (0-100)
46
+ ///
47
+ /// # Returns
48
+ /// A hex color string like "#rrggbb"
49
+ pub fn from_hsluv(_ruby: &Ruby, hue: f32, saturation: f32, lightness: f32) -> String {
50
+ // Hsluv expects h in degrees, s and l as percentages (0-100)
51
+ let hsluv = Hsluv::new(hue, saturation, lightness);
52
+ let color = Color::from_hsluv(hsluv);
53
+
54
+ match color {
55
+ Color::Rgb(red, green, blue) => rgb_to_hex(red, green, blue),
56
+ _ => "#000000".to_string(),
57
+ }
58
+ }
59
+
60
+ /// Convert a u32 to a hex color string.
61
+ ///
62
+ /// # Arguments
63
+ /// * `val` - A u32 in the format 0x00RRGGBB
64
+ ///
65
+ /// # Returns
66
+ /// A hex color string like "#rrggbb"
67
+ pub fn from_u32(_ruby: &Ruby, val: u32) -> String {
68
+ let color = Color::from_u32(val);
69
+
70
+ match color {
71
+ Color::Rgb(red, green, blue) => rgb_to_hex(red, green, blue),
72
+ _ => "#000000".to_string(),
73
+ }
74
+ }
75
+
76
+ /// Register color module functions in the `RatatuiRuby` module.
77
+ pub fn register(_ruby: &Ruby, module: magnus::RModule) -> Result<(), Error> {
78
+ module.define_module_function("_color_from_hsl", magnus::function!(from_hsl, 3))?;
79
+ module.define_module_function("_color_from_hsluv", magnus::function!(from_hsluv, 3))?;
80
+ module.define_module_function("_color_from_u32", magnus::function!(from_u32, 1))?;
81
+ Ok(())
82
+ }
@@ -0,0 +1,28 @@
1
+ // SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use magnus::{prelude::*, Error, Value};
5
+
6
+ /// Creates a `TypeError` with context showing the actual value received.
7
+ ///
8
+ /// Calls `inspect` on the Ruby value and includes it in the error message.
9
+ /// Long inspect strings (>200 chars) are truncated.
10
+ ///
11
+ /// # Example error message
12
+ /// ```text
13
+ /// expected array for rows, got {:title=>"Processes", :header=>["Name", ...]}
14
+ /// ```
15
+ pub fn type_error_with_context(ruby: &magnus::Ruby, expected: &str, got: Value) -> Error {
16
+ let inspect: String = got
17
+ .funcall("inspect", ())
18
+ .unwrap_or_else(|_| "<uninspectable>".to_string());
19
+ let truncated = if inspect.len() > 200 {
20
+ format!("{}...", &inspect[..200])
21
+ } else {
22
+ inspect
23
+ };
24
+ Error::new(
25
+ ruby.exception_type_error(),
26
+ format!("{expected}, got {truncated}"),
27
+ )
28
+ }
@@ -30,25 +30,26 @@ pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(),
30
30
 
31
31
  /// Parses a `snake_case` string to `MediaKeyCode`.
32
32
  ///
33
- /// Accepts both the new `media_`-prefixed codes (canonical) and the legacy
34
- /// unprefixed codes for backward compatibility with existing tests.
33
+ /// Parses a `snake_case` string to `MediaKeyCode`.
34
+ ///
35
+ /// Only accepts the `media_`-prefixed codes (canonical). Legacy unprefixed codes are no longer supported.
35
36
  fn parse_media_key(s: &str) -> Option<ratatui::crossterm::event::MediaKeyCode> {
36
37
  use ratatui::crossterm::event::MediaKeyCode;
37
38
  match s {
38
39
  // New canonical codes (media_ prefix)
39
- "media_play" | "play" => Some(MediaKeyCode::Play),
40
+ "media_play" => Some(MediaKeyCode::Play),
40
41
  "media_pause" => Some(MediaKeyCode::Pause),
41
- "media_play_pause" | "play_pause" => Some(MediaKeyCode::PlayPause),
42
- "media_reverse" | "reverse" => Some(MediaKeyCode::Reverse),
43
- "media_stop" | "stop" => Some(MediaKeyCode::Stop),
44
- "media_fast_forward" | "fast_forward" => Some(MediaKeyCode::FastForward),
45
- "media_rewind" | "rewind" => Some(MediaKeyCode::Rewind),
46
- "media_track_next" | "track_next" => Some(MediaKeyCode::TrackNext),
47
- "media_track_previous" | "track_previous" => Some(MediaKeyCode::TrackPrevious),
48
- "media_record" | "record" => Some(MediaKeyCode::Record),
49
- "media_lower_volume" | "lower_volume" => Some(MediaKeyCode::LowerVolume),
50
- "media_raise_volume" | "raise_volume" => Some(MediaKeyCode::RaiseVolume),
51
- "media_mute_volume" | "mute_volume" => Some(MediaKeyCode::MuteVolume),
42
+ "media_play_pause" => Some(MediaKeyCode::PlayPause),
43
+ "media_reverse" => Some(MediaKeyCode::Reverse),
44
+ "media_stop" => Some(MediaKeyCode::Stop),
45
+ "media_fast_forward" => Some(MediaKeyCode::FastForward),
46
+ "media_rewind" => Some(MediaKeyCode::Rewind),
47
+ "media_track_next" => Some(MediaKeyCode::TrackNext),
48
+ "media_track_previous" => Some(MediaKeyCode::TrackPrevious),
49
+ "media_record" => Some(MediaKeyCode::Record),
50
+ "media_lower_volume" => Some(MediaKeyCode::LowerVolume),
51
+ "media_raise_volume" => Some(MediaKeyCode::RaiseVolume),
52
+ "media_mute_volume" => Some(MediaKeyCode::MuteVolume),
52
53
  _ => None,
53
54
  }
54
55
  }
@@ -10,6 +10,8 @@
10
10
  #![allow(clippy::missing_panics_doc)]
11
11
  #![allow(clippy::module_name_repetitions)]
12
12
 
13
+ mod color;
14
+ mod errors;
13
15
  mod events;
14
16
  mod frame;
15
17
  mod rendering;
@@ -115,6 +117,43 @@ fn draw(args: &[Value]) -> Result<(), Error> {
115
117
  Ok(())
116
118
  }
117
119
 
120
+ /// Storage for the last panic info, to be retrieved and printed after terminal restore.
121
+ static LAST_PANIC: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
122
+
123
+ /// Enables Rust backtraces and installs a custom panic hook.
124
+ ///
125
+ /// The panic hook stores the backtrace info instead of printing immediately.
126
+ /// This allows Ruby to retrieve and print it after terminal restoration,
127
+ /// preventing output from being lost on the alternate screen.
128
+ fn enable_rust_backtrace(_ruby: &magnus::Ruby) {
129
+ std::env::set_var("RUST_BACKTRACE", "1");
130
+ std::panic::set_hook(Box::new(|info| {
131
+ let backtrace = std::backtrace::Backtrace::force_capture();
132
+ let message = format!("Rust panic: {info}\n{backtrace}");
133
+ if let Ok(mut guard) = LAST_PANIC.lock() {
134
+ *guard = Some(message);
135
+ }
136
+ }));
137
+ }
138
+
139
+ /// Returns the last panic info (if any) and clears it.
140
+ ///
141
+ /// Call this after terminal restoration to get deferred panic output.
142
+ fn get_last_panic(_ruby: &magnus::Ruby) -> Option<String> {
143
+ if let Ok(mut guard) = LAST_PANIC.lock() {
144
+ guard.take()
145
+ } else {
146
+ None
147
+ }
148
+ }
149
+
150
+ /// Intentionally panics to test backtrace output.
151
+ ///
152
+ /// Only use this for debugging/testing the backtrace feature.
153
+ fn test_panic(_ruby: &magnus::Ruby) {
154
+ panic!("Test panic triggered by RatatuiRuby._test_panic");
155
+ }
156
+
118
157
  #[magnus::init]
119
158
  fn init() -> Result<(), Error> {
120
159
  let ruby = magnus::Ruby::get().unwrap();
@@ -123,6 +162,12 @@ fn init() -> Result<(), Error> {
123
162
  m.define_module_function("_init_terminal", function!(init_terminal, 2))?;
124
163
  m.define_module_function("_restore_terminal", function!(restore_terminal, 0))?;
125
164
  m.define_module_function("_draw", function!(draw, -1))?;
165
+ m.define_module_function(
166
+ "_enable_rust_backtrace",
167
+ function!(enable_rust_backtrace, 0),
168
+ )?;
169
+ m.define_module_function("_test_panic", function!(test_panic, 0))?;
170
+ m.define_module_function("_get_last_panic", function!(get_last_panic, 0))?;
126
171
 
127
172
  // Register Frame class
128
173
  let frame_class = m.define_class("Frame", ruby.class_object())?;
@@ -160,11 +205,19 @@ fn init() -> Result<(), Error> {
160
205
  )?;
161
206
  m.define_module_function("_get_cell_at", function!(terminal::get_cell_at, 2))?;
162
207
  m.define_module_function("resize_terminal", function!(terminal::resize_terminal, 2))?;
208
+ m.define_module_function(
209
+ "_get_terminal_area",
210
+ function!(terminal::get_terminal_area, 0),
211
+ )?;
163
212
 
164
213
  // Register Layout.split on the Layout::Layout class (inside the Layout module)
165
214
  let layout_mod = m.const_get::<_, magnus::RModule>("Layout")?;
166
215
  let layout_class = layout_mod.const_get::<_, magnus::RClass>("Layout")?;
167
216
  layout_class.define_singleton_method("_split", function!(widgets::layout::split_layout, 4))?;
217
+ layout_class.define_singleton_method(
218
+ "_split_with_spacers",
219
+ function!(widgets::layout::split_with_spacers_layout, 4),
220
+ )?;
168
221
 
169
222
  // Paragraph metrics
170
223
  m.define_module_function(
@@ -182,6 +235,9 @@ fn init() -> Result<(), Error> {
182
235
  // Text measurement
183
236
  m.define_module_function("_text_width", function!(string_width::text_width, 1))?;
184
237
 
238
+ // Color conversion
239
+ color::register(&ruby, m)?;
240
+
185
241
  Ok(())
186
242
  }
187
243
 
@@ -120,7 +120,9 @@ fn process_draw_command(buffer: &mut Buffer, cmd: Value) -> Result<(), Error> {
120
120
  for i in 0..mods_array.len() {
121
121
  let index = isize::try_from(i)
122
122
  .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
123
- let mod_str: String = mods_array.entry::<String>(index)?;
123
+ let mod_val: Value = mods_array.entry(index)?;
124
+ // Accept both symbols and strings (DWIM)
125
+ let mod_str: String = mod_val.funcall("to_s", ())?;
124
126
  if let Some(modifier) = parse_modifier_str(&mod_str) {
125
127
  style = style.add_modifier(modifier);
126
128
  }
@@ -2,6 +2,7 @@
2
2
  //
3
3
  // SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
+ use crate::errors::type_error_with_context;
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, Symbol, Value};
7
8
  use ratatui::{
@@ -65,22 +66,29 @@ pub fn parse_style(style_val: Value) -> Result<Style, Error> {
65
66
 
66
67
  let mut style = Style::default();
67
68
 
68
- let (fg, bg, modifiers_val) = if let Some(hash) = magnus::RHash::from_value(style_val) {
69
- (
70
- hash.lookup(ruby.to_symbol("fg"))
71
- .unwrap_or_else(|_| ruby.qnil().as_value()),
72
- hash.lookup(ruby.to_symbol("bg"))
73
- .unwrap_or_else(|_| ruby.qnil().as_value()),
74
- hash.lookup(ruby.to_symbol("modifiers"))
75
- .unwrap_or_else(|_| ruby.qnil().as_value()),
76
- )
77
- } else {
78
- (
79
- style_val.funcall("fg", ())?,
80
- style_val.funcall("bg", ())?,
81
- style_val.funcall("modifiers", ())?,
82
- )
83
- };
69
+ let (fg, bg, underline_color, modifiers_val, remove_modifiers_val) =
70
+ if let Some(hash) = magnus::RHash::from_value(style_val) {
71
+ (
72
+ hash.lookup(ruby.to_symbol("fg"))
73
+ .unwrap_or_else(|_| ruby.qnil().as_value()),
74
+ hash.lookup(ruby.to_symbol("bg"))
75
+ .unwrap_or_else(|_| ruby.qnil().as_value()),
76
+ hash.lookup(ruby.to_symbol("underline_color"))
77
+ .unwrap_or_else(|_| ruby.qnil().as_value()),
78
+ hash.lookup(ruby.to_symbol("modifiers"))
79
+ .unwrap_or_else(|_| ruby.qnil().as_value()),
80
+ hash.lookup(ruby.to_symbol("remove_modifiers"))
81
+ .unwrap_or_else(|_| ruby.qnil().as_value()),
82
+ )
83
+ } else {
84
+ (
85
+ style_val.funcall("fg", ())?,
86
+ style_val.funcall("bg", ())?,
87
+ style_val.funcall("underline_color", ())?,
88
+ style_val.funcall("modifiers", ())?,
89
+ style_val.funcall("remove_modifiers", ())?,
90
+ )
91
+ };
84
92
 
85
93
  if !fg.is_nil() {
86
94
  if let Ok(fg_str) = fg.funcall::<_, _, String>("to_s", ()) {
@@ -98,6 +106,14 @@ pub fn parse_style(style_val: Value) -> Result<Style, Error> {
98
106
  }
99
107
  }
100
108
 
109
+ if !underline_color.is_nil() {
110
+ if let Ok(uc_str) = underline_color.funcall::<_, _, String>("to_s", ()) {
111
+ if let Some(color) = parse_color(&uc_str) {
112
+ style = style.underline_color(color);
113
+ }
114
+ }
115
+ }
116
+
101
117
  if !modifiers_val.is_nil() {
102
118
  if let Some(modifiers_array) = magnus::RArray::from_value(modifiers_val) {
103
119
  for i in 0..modifiers_array.len() {
@@ -112,6 +128,20 @@ pub fn parse_style(style_val: Value) -> Result<Style, Error> {
112
128
  }
113
129
  }
114
130
 
131
+ if !remove_modifiers_val.is_nil() {
132
+ if let Some(remove_modifiers_array) = magnus::RArray::from_value(remove_modifiers_val) {
133
+ for i in 0..remove_modifiers_array.len() {
134
+ let index = isize::try_from(i)
135
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
136
+ if let Ok(sym) = remove_modifiers_array.entry::<Symbol>(index) {
137
+ if let Some(m) = parse_modifier_str(&sym.to_string()) {
138
+ style = style.remove_modifier(m);
139
+ }
140
+ }
141
+ }
142
+ }
143
+ }
144
+
115
145
  Ok(style)
116
146
  }
117
147
 
@@ -121,7 +151,7 @@ pub fn parse_border_set<'a>(
121
151
  ) -> Result<symbols::border::Set<'a>, Error> {
122
152
  let ruby = magnus::Ruby::get().unwrap();
123
153
  let hash = magnus::RHash::from_value(set_val)
124
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected hash for border_set"))?;
154
+ .ok_or_else(|| type_error_with_context(&ruby, "expected hash for border_set", set_val))?;
125
155
 
126
156
  let get_char = |key: &str| -> Result<Option<&'a str>, Error> {
127
157
  let mut val: Value = hash
@@ -188,10 +218,7 @@ pub fn parse_bar_set<'a>(set_val: Value, bump: &'a Bump) -> Result<symbols::bar:
188
218
  }
189
219
 
190
220
  let hash = magnus::RHash::from_value(set_val).ok_or_else(|| {
191
- Error::new(
192
- ruby.exception_type_error(),
193
- "expected symbol or hash for bar_set",
194
- )
221
+ type_error_with_context(&ruby, "expected symbol or hash for bar_set", set_val)
195
222
  })?;
196
223
 
197
224
  let get_char = |key: &str| -> Result<Option<&'a str>, Error> {
@@ -109,6 +109,36 @@ pub fn get_buffer_content() -> Result<String, Error> {
109
109
  }
110
110
  }
111
111
 
112
+ pub fn get_terminal_area() -> Result<magnus::RHash, Error> {
113
+ let ruby = magnus::Ruby::get().unwrap();
114
+ let term_lock = TERMINAL.lock().unwrap();
115
+ if let Some(wrapper) = term_lock.as_ref() {
116
+ let hash = ruby.hash_new();
117
+ match wrapper {
118
+ TerminalWrapper::Crossterm(term) => {
119
+ let size = term.size().unwrap_or_default();
120
+ hash.aset("x", 0u16)?;
121
+ hash.aset("y", 0u16)?;
122
+ hash.aset("width", size.width)?;
123
+ hash.aset("height", size.height)?;
124
+ }
125
+ TerminalWrapper::Test(term) => {
126
+ let area = term.backend().buffer().area;
127
+ hash.aset("x", area.x)?;
128
+ hash.aset("y", area.y)?;
129
+ hash.aset("width", area.width)?;
130
+ hash.aset("height", area.height)?;
131
+ }
132
+ }
133
+ Ok(hash)
134
+ } else {
135
+ let module = ruby.define_module("RatatuiRuby")?;
136
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
137
+ let error_class = error_base.const_get("Terminal")?;
138
+ Err(Error::new(error_class, "Terminal is not initialized"))
139
+ }
140
+ }
141
+
112
142
  pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
113
143
  let ruby = magnus::Ruby::get().unwrap();
114
144
  let mut term_lock = TERMINAL.lock().unwrap();
@@ -163,6 +193,7 @@ pub fn get_cell_at(x: u16, y: u16) -> Result<magnus::RHash, Error> {
163
193
  hash.aset("char", cell.symbol())?;
164
194
  hash.aset("fg", color_to_value(cell.fg))?;
165
195
  hash.aset("bg", color_to_value(cell.bg))?;
196
+ hash.aset("underline_color", color_to_value(cell.underline_color))?;
166
197
  hash.aset("modifiers", modifiers_to_value(cell.modifier))?;
167
198
  Ok(hash)
168
199
  } else {
@@ -217,31 +248,31 @@ fn modifiers_to_value(modifier: ratatui::style::Modifier) -> Value {
217
248
  let ary = ruby.ary_new();
218
249
 
219
250
  if modifier.contains(ratatui::style::Modifier::BOLD) {
220
- let _ = ary.push(ruby.str_new("bold"));
251
+ let _ = ary.push(ruby.to_symbol("bold"));
221
252
  }
222
253
  if modifier.contains(ratatui::style::Modifier::ITALIC) {
223
- let _ = ary.push(ruby.str_new("italic"));
254
+ let _ = ary.push(ruby.to_symbol("italic"));
224
255
  }
225
256
  if modifier.contains(ratatui::style::Modifier::DIM) {
226
- let _ = ary.push(ruby.str_new("dim"));
257
+ let _ = ary.push(ruby.to_symbol("dim"));
227
258
  }
228
259
  if modifier.contains(ratatui::style::Modifier::UNDERLINED) {
229
- let _ = ary.push(ruby.str_new("underlined"));
260
+ let _ = ary.push(ruby.to_symbol("underlined"));
230
261
  }
231
262
  if modifier.contains(ratatui::style::Modifier::REVERSED) {
232
- let _ = ary.push(ruby.str_new("reversed"));
263
+ let _ = ary.push(ruby.to_symbol("reversed"));
233
264
  }
234
265
  if modifier.contains(ratatui::style::Modifier::HIDDEN) {
235
- let _ = ary.push(ruby.str_new("hidden"));
266
+ let _ = ary.push(ruby.to_symbol("hidden"));
236
267
  }
237
268
  if modifier.contains(ratatui::style::Modifier::CROSSED_OUT) {
238
- let _ = ary.push(ruby.str_new("crossed_out"));
269
+ let _ = ary.push(ruby.to_symbol("crossed_out"));
239
270
  }
240
271
  if modifier.contains(ratatui::style::Modifier::SLOW_BLINK) {
241
- let _ = ary.push(ruby.str_new("slow_blink"));
272
+ let _ = ary.push(ruby.to_symbol("slow_blink"));
242
273
  }
243
274
  if modifier.contains(ratatui::style::Modifier::RAPID_BLINK) {
244
- let _ = ary.push(ruby.str_new("rapid_blink"));
275
+ let _ = ary.push(ruby.to_symbol("rapid_blink"));
245
276
  }
246
277
 
247
278
  ary.as_value()
@@ -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_style;
5
6
  use magnus::{prelude::*, Error, Value};
6
7
  use ratatui::text::{Line, Span};
@@ -82,9 +83,10 @@ pub fn parse_text(value: Value) -> Result<Vec<Line<'static>>, Error> {
82
83
  Ok(lines)
83
84
  }
84
85
  }
85
- Err(_) => Err(Error::new(
86
- ruby.exception_type_error(),
86
+ Err(_) => Err(type_error_with_context(
87
+ &ruby,
87
88
  "expected String, Text::Span, Text::Line, or Array of Text::Lines/Spans",
89
+ value,
88
90
  )),
89
91
  }
90
92
  }
@@ -98,9 +100,10 @@ pub fn parse_span(value: Value) -> Result<Span<'static>, Error> {
98
100
  let class_name: String = class_obj.funcall("name", ())?;
99
101
 
100
102
  if !class_name.contains("Span") {
101
- return Err(Error::new(
102
- ruby.exception_type_error(),
103
+ return Err(type_error_with_context(
104
+ &ruby,
103
105
  "expected a Text::Span object",
106
+ value,
104
107
  ));
105
108
  }
106
109
 
@@ -115,6 +118,7 @@ pub fn parse_span(value: Value) -> Result<Span<'static>, Error> {
115
118
  }
116
119
 
117
120
  /// Parses a Ruby `Text::Line` object into a ratatui Line.
121
+ /// Also accepts `Text::Span` objects and auto-coerces them to a Line.
118
122
  pub fn parse_line(value: Value) -> Result<Line<'static>, Error> {
119
123
  let ruby = magnus::Ruby::get().unwrap();
120
124
 
@@ -122,10 +126,17 @@ pub fn parse_line(value: Value) -> Result<Line<'static>, Error> {
122
126
  let class_obj: Value = value.funcall("class", ())?;
123
127
  let class_name: String = class_obj.funcall("name", ())?;
124
128
 
129
+ // Auto-coerce Span to Line: wrap a single Span in a Line
130
+ if class_name.contains("Span") {
131
+ let span = parse_span(value)?;
132
+ return Ok(Line::from(vec![span]));
133
+ }
134
+
125
135
  if !class_name.contains("Line") {
126
- return Err(Error::new(
127
- ruby.exception_type_error(),
128
- "expected a Text::Line object",
136
+ return Err(type_error_with_context(
137
+ &ruby,
138
+ "expected a Text::Line or Text::Span object",
139
+ value,
129
140
  ));
130
141
  }
131
142
 
@@ -141,9 +152,10 @@ pub fn parse_line(value: Value) -> Result<Line<'static>, Error> {
141
152
  }
142
153
 
143
154
  let spans_array = magnus::RArray::from_value(spans_val).ok_or_else(|| {
144
- Error::new(
145
- ruby.exception_type_error(),
155
+ type_error_with_context(
156
+ &ruby,
146
157
  "expected array of Spans in Text::Line.spans",
158
+ spans_val,
147
159
  )
148
160
  })?;
149
161
 
@@ -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;
6
7
  use bumpalo::Bump;
@@ -41,7 +42,7 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
41
42
  .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
42
43
  let point_array_val: Value = data_array.entry(index)?;
43
44
  let point_array = magnus::RArray::from_value(point_array_val).ok_or_else(|| {
44
- Error::new(ruby.exception_type_error(), "expected array for point")
45
+ type_error_with_context(&ruby, "expected array for point", point_array_val)
45
46
  })?;
46
47
  let x: f64 = point_array.entry(0)?;
47
48
  let y: f64 = point_array.entry(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::rendering::render_node;
5
6
  use magnus::{prelude::*, Error, Symbol, Value};
6
7
  use ratatui::{
@@ -12,8 +13,9 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
12
13
  let ruby = magnus::Ruby::get().unwrap();
13
14
  let direction_sym: Symbol = node.funcall("direction", ())?;
14
15
  let children_val: Value = node.funcall("children", ())?;
15
- let children_array = magnus::RArray::from_value(children_val)
16
- .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array"))?;
16
+ let children_array = magnus::RArray::from_value(children_val).ok_or_else(|| {
17
+ type_error_with_context(&ruby, "expected array for children", children_val)
18
+ })?;
17
19
 
18
20
  let constraints_val: Value = node.funcall("constraints", ())?;
19
21
  let constraints_array = magnus::RArray::from_value(constraints_val);
@@ -201,6 +203,92 @@ pub fn split_layout(
201
203
  Ok(result)
202
204
  }
203
205
 
206
+ /// Splits an area into multiple rectangles, returning both segments and spacers.
207
+ /// This is the Ratatui `split_with_spacers` equivalent.
208
+ ///
209
+ /// # Arguments
210
+ /// * `area` - A Ruby Hash or Rect with :x, :y, :width, :height keys
211
+ /// * `direction` - Symbol :vertical or :horizontal
212
+ /// * `constraints` - Array of Constraint objects
213
+ /// * `flex` - Symbol for flex mode
214
+ ///
215
+ /// # Returns
216
+ /// An array containing two arrays: [segments, spacers], each containing Ruby Hashes representing Rect objects
217
+ pub fn split_with_spacers_layout(
218
+ area: Value,
219
+ direction: Symbol,
220
+ constraints: magnus::RArray,
221
+ flex: Symbol,
222
+ ) -> Result<magnus::RArray, Error> {
223
+ let ruby = magnus::Ruby::get().unwrap();
224
+
225
+ // Parse area from Hash or Rect-like object
226
+ let x: u16 = area.funcall("x", ())?;
227
+ let y: u16 = area.funcall("y", ())?;
228
+ let width: u16 = area.funcall("width", ())?;
229
+ let height: u16 = area.funcall("height", ())?;
230
+ let rect = Rect::new(x, y, width, height);
231
+
232
+ // Parse direction
233
+ let dir = if direction.to_string() == "vertical" {
234
+ Direction::Vertical
235
+ } else {
236
+ Direction::Horizontal
237
+ };
238
+
239
+ // Parse flex
240
+ let flex_mode = match flex.to_string().as_str() {
241
+ "start" => Flex::Start,
242
+ "center" => Flex::Center,
243
+ "end" => Flex::End,
244
+ "space_between" => Flex::SpaceBetween,
245
+ "space_around" => Flex::SpaceAround,
246
+ "space_evenly" => Flex::SpaceEvenly,
247
+ _ => Flex::Legacy,
248
+ };
249
+
250
+ // Parse constraints
251
+ let mut ratatui_constraints = Vec::new();
252
+ for i in 0..constraints.len() {
253
+ let index = isize::try_from(i)
254
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
255
+ let constraint_obj: Value = constraints.entry(index)?;
256
+ if let Ok(constraint) = parse_constraint(constraint_obj) {
257
+ ratatui_constraints.push(constraint);
258
+ }
259
+ }
260
+
261
+ // Compute layout with spacers
262
+ let (segments, spacers) = Layout::default()
263
+ .direction(dir)
264
+ .flex(flex_mode)
265
+ .constraints(ratatui_constraints)
266
+ .split_with_spacers(rect);
267
+
268
+ // Helper to convert Rc<[Rect]> to Ruby array
269
+ let rects_to_ruby_array = |rects: &[Rect]| -> Result<magnus::RArray, Error> {
270
+ let arr = ruby.ary_new_capa(rects.len());
271
+ for chunk in rects {
272
+ let hash = ruby.hash_new();
273
+ hash.aset(ruby.sym_new("x"), chunk.x)?;
274
+ hash.aset(ruby.sym_new("y"), chunk.y)?;
275
+ hash.aset(ruby.sym_new("width"), chunk.width)?;
276
+ hash.aset(ruby.sym_new("height"), chunk.height)?;
277
+ arr.push(hash)?;
278
+ }
279
+ Ok(arr)
280
+ };
281
+
282
+ let segments_arr = rects_to_ruby_array(&segments)?;
283
+ let spacers_arr = rects_to_ruby_array(&spacers)?;
284
+
285
+ // Return [segments, spacers]
286
+ let result = ruby.ary_new_capa(2);
287
+ result.push(segments_arr)?;
288
+ result.push(spacers_arr)?;
289
+
290
+ Ok(result)
291
+ }
204
292
  #[cfg(test)]
205
293
  mod tests {
206
294
  use ratatui::layout::{Constraint, Direction, Flex, Layout, Rect};