ratatui_ruby 0.5.0 → 0.7.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 (311) 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 +10 -4
  7. data/CHANGELOG.md +79 -7
  8. data/README.md +37 -5
  9. data/REUSE.toml +2 -7
  10. data/doc/application_architecture.md +96 -22
  11. data/doc/application_testing.md +76 -30
  12. data/doc/contributors/architectural_overhaul/chat_conversations.md +4952 -0
  13. data/doc/contributors/architectural_overhaul/implementation_plan.md +60 -0
  14. data/doc/contributors/architectural_overhaul/task.md +37 -0
  15. data/doc/contributors/design/ruby_frontend.md +288 -56
  16. data/doc/contributors/design/rust_backend.md +349 -54
  17. data/doc/contributors/developing_examples.md +134 -49
  18. data/doc/contributors/index.md +7 -5
  19. data/doc/contributors/v1.0.0_blockers.md +1729 -0
  20. data/doc/event_handling.md +11 -3
  21. data/doc/images/app_all_events.png +0 -0
  22. data/doc/images/app_color_picker.png +0 -0
  23. data/doc/images/app_login_form.png +0 -0
  24. data/doc/images/app_stateful_interaction.png +0 -0
  25. data/doc/images/verify_quickstart_dsl.png +0 -0
  26. data/doc/images/verify_quickstart_layout.png +0 -0
  27. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  28. data/doc/images/verify_readme_usage.png +0 -0
  29. data/doc/images/widget_barchart_demo.png +0 -0
  30. data/doc/images/widget_block_demo.png +0 -0
  31. data/doc/images/widget_canvas_demo.png +0 -0
  32. data/doc/images/widget_cell_demo.png +0 -0
  33. data/doc/images/widget_center_demo.png +0 -0
  34. data/doc/images/widget_chart_demo.png +0 -0
  35. data/doc/images/widget_list_demo.png +0 -0
  36. data/doc/images/widget_overlay_demo.png +0 -0
  37. data/doc/images/widget_render.png +0 -0
  38. data/doc/images/widget_rich_text.png +0 -0
  39. data/doc/images/widget_scroll_text.png +0 -0
  40. data/doc/images/widget_sparkline_demo.png +0 -0
  41. data/doc/images/widget_table_demo.png +0 -0
  42. data/doc/images/widget_tabs_demo.png +0 -0
  43. data/doc/images/widget_text_width.png +0 -0
  44. data/doc/index.md +11 -6
  45. data/doc/interactive_design.md +2 -2
  46. data/doc/quickstart.md +127 -165
  47. data/doc/terminal_limitations.md +92 -0
  48. data/doc/v0.7.0_migration.md +236 -0
  49. data/doc/why.md +93 -0
  50. data/examples/app_all_events/README.md +47 -27
  51. data/examples/app_all_events/app.rb +38 -35
  52. data/examples/app_all_events/model/app_model.rb +157 -0
  53. data/examples/app_all_events/model/event_entry.rb +17 -0
  54. data/examples/app_all_events/model/msg.rb +37 -0
  55. data/examples/app_all_events/update.rb +73 -0
  56. data/examples/app_all_events/view/app_view.rb +9 -9
  57. data/examples/app_all_events/view/controls_view.rb +9 -7
  58. data/examples/app_all_events/view/counts_view.rb +13 -9
  59. data/examples/app_all_events/view/live_view.rb +9 -8
  60. data/examples/app_all_events/view/log_view.rb +11 -16
  61. data/examples/app_color_picker/README.md +84 -42
  62. data/examples/app_color_picker/app.rb +24 -62
  63. data/examples/app_color_picker/controls.rb +90 -0
  64. data/examples/app_color_picker/copy_dialog.rb +45 -49
  65. data/examples/app_color_picker/export_pane.rb +126 -0
  66. data/examples/app_color_picker/input.rb +99 -67
  67. data/examples/app_color_picker/main_container.rb +178 -0
  68. data/examples/app_color_picker/palette.rb +55 -26
  69. data/examples/app_login_form/README.md +49 -0
  70. data/examples/app_login_form/app.rb +2 -3
  71. data/examples/app_stateful_interaction/README.md +33 -0
  72. data/examples/app_stateful_interaction/app.rb +272 -0
  73. data/examples/timeout_demo.rb +43 -0
  74. data/examples/verify_quickstart_dsl/README.md +49 -0
  75. data/examples/verify_quickstart_dsl/app.rb +2 -0
  76. data/examples/verify_quickstart_layout/README.md +71 -0
  77. data/examples/verify_quickstart_layout/app.rb +2 -0
  78. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  79. data/examples/verify_quickstart_lifecycle/app.rb +10 -4
  80. data/examples/verify_readme_usage/README.md +43 -0
  81. data/examples/verify_readme_usage/app.rb +8 -2
  82. data/examples/widget_barchart_demo/README.md +50 -0
  83. data/examples/widget_barchart_demo/app.rb +5 -5
  84. data/examples/widget_block_demo/README.md +36 -0
  85. data/examples/widget_block_demo/app.rb +256 -0
  86. data/examples/widget_box_demo/README.md +45 -0
  87. data/examples/widget_calendar_demo/README.md +39 -0
  88. data/examples/widget_calendar_demo/app.rb +5 -1
  89. data/examples/widget_canvas_demo/README.md +27 -0
  90. data/examples/widget_canvas_demo/app.rb +123 -0
  91. data/examples/widget_cell_demo/README.md +36 -0
  92. data/examples/widget_cell_demo/app.rb +31 -24
  93. data/examples/widget_center_demo/README.md +29 -0
  94. data/examples/widget_center_demo/app.rb +116 -0
  95. data/examples/widget_chart_demo/README.md +41 -0
  96. data/examples/widget_chart_demo/app.rb +7 -2
  97. data/examples/widget_gauge_demo/README.md +41 -0
  98. data/examples/widget_layout_split/README.md +44 -0
  99. data/examples/widget_line_gauge_demo/README.md +41 -0
  100. data/examples/widget_list_demo/README.md +49 -0
  101. data/examples/widget_list_demo/app.rb +91 -107
  102. data/examples/widget_map_demo/README.md +39 -0
  103. data/examples/{app_map_demo → widget_map_demo}/app.rb +4 -4
  104. data/examples/widget_overlay_demo/README.md +36 -0
  105. data/examples/widget_overlay_demo/app.rb +248 -0
  106. data/examples/widget_popup_demo/README.md +36 -0
  107. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  108. data/examples/widget_ratatui_logo_demo/app.rb +1 -1
  109. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  110. data/examples/widget_rect/README.md +38 -0
  111. data/examples/widget_render/README.md +37 -0
  112. data/examples/widget_render/app.rb +3 -3
  113. data/examples/widget_rich_text/README.md +35 -0
  114. data/examples/widget_rich_text/app.rb +62 -33
  115. data/examples/widget_scroll_text/README.md +37 -0
  116. data/examples/widget_scroll_text/app.rb +0 -1
  117. data/examples/widget_scrollbar_demo/README.md +37 -0
  118. data/examples/widget_sparkline_demo/README.md +42 -0
  119. data/examples/widget_sparkline_demo/app.rb +4 -3
  120. data/examples/widget_style_colors/README.md +34 -0
  121. data/examples/widget_table_demo/README.md +48 -0
  122. data/examples/{app_table_select → widget_table_demo}/app.rb +65 -12
  123. data/examples/widget_tabs_demo/README.md +41 -0
  124. data/examples/widget_tabs_demo/app.rb +15 -1
  125. data/examples/widget_text_width/README.md +35 -0
  126. data/examples/widget_text_width/app.rb +113 -0
  127. data/exe/.gitkeep +0 -0
  128. data/ext/ratatui_ruby/Cargo.lock +11 -4
  129. data/ext/ratatui_ruby/Cargo.toml +2 -1
  130. data/ext/ratatui_ruby/src/events.rs +238 -26
  131. data/ext/ratatui_ruby/src/frame.rs +116 -3
  132. data/ext/ratatui_ruby/src/lib.rs +37 -6
  133. data/ext/ratatui_ruby/src/rendering.rs +22 -21
  134. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  135. data/ext/ratatui_ruby/src/terminal.rs +39 -15
  136. data/ext/ratatui_ruby/src/text.rs +13 -4
  137. data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
  138. data/ext/ratatui_ruby/src/widgets/canvas.rs +5 -5
  139. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  140. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  141. data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
  142. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  143. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  144. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
  145. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  146. data/ext/ratatui_ruby/src/widgets/table.rs +191 -34
  147. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  148. data/lib/ratatui_ruby/buffer/cell.rb +168 -0
  149. data/lib/ratatui_ruby/buffer.rb +15 -0
  150. data/lib/ratatui_ruby/cell.rb +4 -4
  151. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  152. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  153. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  154. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  155. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  156. data/lib/ratatui_ruby/event/key.rb +111 -51
  157. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  158. data/lib/ratatui_ruby/event/paste.rb +1 -1
  159. data/lib/ratatui_ruby/frame.rb +100 -4
  160. data/lib/ratatui_ruby/layout/constraint.rb +95 -0
  161. data/lib/ratatui_ruby/layout/layout.rb +106 -0
  162. data/lib/ratatui_ruby/layout/rect.rb +118 -0
  163. data/lib/ratatui_ruby/layout.rb +19 -0
  164. data/lib/ratatui_ruby/list_state.rb +88 -0
  165. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
  166. data/lib/ratatui_ruby/schema/cursor.rb +5 -0
  167. data/lib/ratatui_ruby/schema/gauge.rb +3 -1
  168. data/lib/ratatui_ruby/schema/layout.rb +1 -1
  169. data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
  170. data/lib/ratatui_ruby/schema/list.rb +25 -4
  171. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  172. data/lib/ratatui_ruby/schema/rect.rb +43 -0
  173. data/lib/ratatui_ruby/schema/row.rb +66 -0
  174. data/lib/ratatui_ruby/schema/style.rb +24 -4
  175. data/lib/ratatui_ruby/schema/table.rb +29 -11
  176. data/lib/ratatui_ruby/schema/text.rb +96 -3
  177. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  178. data/lib/ratatui_ruby/style/style.rb +81 -0
  179. data/lib/ratatui_ruby/style.rb +15 -0
  180. data/lib/ratatui_ruby/table_state.rb +90 -0
  181. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  182. data/lib/ratatui_ruby/test_helper/snapshot.rb +414 -0
  183. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  184. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  185. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  186. data/lib/ratatui_ruby/test_helper.rb +65 -358
  187. data/lib/ratatui_ruby/tui/buffer_factories.rb +20 -0
  188. data/lib/ratatui_ruby/tui/canvas_factories.rb +44 -0
  189. data/lib/ratatui_ruby/tui/core.rb +38 -0
  190. data/lib/ratatui_ruby/tui/layout_factories.rb +74 -0
  191. data/lib/ratatui_ruby/tui/state_factories.rb +33 -0
  192. data/lib/ratatui_ruby/tui/style_factories.rb +20 -0
  193. data/lib/ratatui_ruby/tui/text_factories.rb +44 -0
  194. data/lib/ratatui_ruby/tui/widget_factories.rb +195 -0
  195. data/lib/ratatui_ruby/tui.rb +75 -0
  196. data/lib/ratatui_ruby/version.rb +1 -1
  197. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +47 -0
  198. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +25 -0
  199. data/lib/ratatui_ruby/widgets/bar_chart.rb +239 -0
  200. data/lib/ratatui_ruby/widgets/block.rb +192 -0
  201. data/lib/ratatui_ruby/widgets/calendar.rb +84 -0
  202. data/lib/ratatui_ruby/widgets/canvas.rb +231 -0
  203. data/lib/ratatui_ruby/widgets/cell.rb +47 -0
  204. data/lib/ratatui_ruby/widgets/center.rb +59 -0
  205. data/lib/ratatui_ruby/widgets/chart.rb +185 -0
  206. data/lib/ratatui_ruby/widgets/clear.rb +54 -0
  207. data/lib/ratatui_ruby/widgets/cursor.rb +42 -0
  208. data/lib/ratatui_ruby/widgets/gauge.rb +72 -0
  209. data/lib/ratatui_ruby/widgets/line_gauge.rb +80 -0
  210. data/lib/ratatui_ruby/widgets/list.rb +127 -0
  211. data/lib/ratatui_ruby/widgets/list_item.rb +43 -0
  212. data/lib/ratatui_ruby/widgets/overlay.rb +43 -0
  213. data/lib/ratatui_ruby/widgets/paragraph.rb +99 -0
  214. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +31 -0
  215. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +36 -0
  216. data/lib/ratatui_ruby/widgets/row.rb +68 -0
  217. data/lib/ratatui_ruby/widgets/scrollbar.rb +143 -0
  218. data/lib/ratatui_ruby/widgets/shape/label.rb +68 -0
  219. data/lib/ratatui_ruby/widgets/sparkline.rb +134 -0
  220. data/lib/ratatui_ruby/widgets/table.rb +141 -0
  221. data/lib/ratatui_ruby/widgets/tabs.rb +85 -0
  222. data/lib/ratatui_ruby/widgets.rb +40 -0
  223. data/lib/ratatui_ruby.rb +64 -57
  224. data/sig/examples/app_all_events/view.rbs +1 -1
  225. data/sig/examples/app_all_events/view_state.rbs +1 -1
  226. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  227. data/sig/examples/widget_block_demo/app.rbs +32 -0
  228. data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
  229. data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
  230. data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
  231. data/sig/ratatui_ruby/event.rbs +11 -1
  232. data/sig/ratatui_ruby/frame.rbs +2 -0
  233. data/sig/ratatui_ruby/list_state.rbs +13 -0
  234. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
  235. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  236. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  237. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  238. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  239. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  240. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  241. data/sig/ratatui_ruby/schema/row.rbs +22 -0
  242. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  243. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  244. data/sig/ratatui_ruby/schema/text.rbs +9 -6
  245. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  246. data/sig/ratatui_ruby/session.rbs +41 -48
  247. data/sig/ratatui_ruby/table_state.rbs +15 -0
  248. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  249. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  250. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  251. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  252. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  253. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  254. data/sig/ratatui_ruby/tui/buffer_factories.rbs +10 -0
  255. data/sig/ratatui_ruby/tui/canvas_factories.rbs +14 -0
  256. data/sig/ratatui_ruby/tui/core.rbs +14 -0
  257. data/sig/ratatui_ruby/tui/layout_factories.rbs +19 -0
  258. data/sig/ratatui_ruby/tui/state_factories.rbs +12 -0
  259. data/sig/ratatui_ruby/tui/style_factories.rbs +10 -0
  260. data/sig/ratatui_ruby/tui/text_factories.rbs +14 -0
  261. data/sig/ratatui_ruby/tui/widget_factories.rbs +39 -0
  262. data/sig/ratatui_ruby/tui.rbs +19 -0
  263. data/tasks/autodoc/examples.rb +79 -0
  264. data/tasks/autodoc.rake +7 -35
  265. data/tasks/bump/changelog.rb +3 -3
  266. data/tasks/bump/links.rb +67 -0
  267. data/tasks/sourcehut.rake +64 -21
  268. data/tasks/terminal_preview/app_screenshot.rb +13 -3
  269. data/tasks/terminal_preview/saved_screenshot.rb +4 -3
  270. metadata +169 -48
  271. data/doc/contributors/dwim_dx.md +0 -366
  272. data/doc/images/app_analytics.png +0 -0
  273. data/doc/images/app_custom_widget.png +0 -0
  274. data/doc/images/app_mouse_events.png +0 -0
  275. data/doc/images/app_table_select.png +0 -0
  276. data/doc/images/widget_block_padding.png +0 -0
  277. data/doc/images/widget_block_titles.png +0 -0
  278. data/doc/images/widget_list_styles.png +0 -0
  279. data/doc/images/widget_table_flex.png +0 -0
  280. data/examples/app_all_events/model/events.rb +0 -180
  281. data/examples/app_all_events/model/highlight.rb +0 -57
  282. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
  283. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
  284. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
  285. data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
  286. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
  287. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
  288. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
  289. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
  290. data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
  291. data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
  292. data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
  293. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
  294. data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
  295. data/examples/app_all_events/view_state.rb +0 -42
  296. data/examples/app_color_picker/scene.rb +0 -201
  297. data/examples/widget_block_padding/app.rb +0 -67
  298. data/examples/widget_block_titles/app.rb +0 -69
  299. data/examples/widget_list_styles/app.rb +0 -141
  300. data/examples/widget_table_flex/app.rb +0 -95
  301. data/lib/ratatui_ruby/session/autodoc.rb +0 -417
  302. data/lib/ratatui_ruby/session.rb +0 -163
  303. data/sig/examples/widget_block_padding/app.rbs +0 -11
  304. data/sig/examples/widget_block_titles/app.rbs +0 -11
  305. data/sig/examples/widget_list_styles/app.rbs +0 -11
  306. data/tasks/autodoc/inventory.rb +0 -61
  307. data/tasks/autodoc/notice.rb +0 -26
  308. data/tasks/autodoc/rbs.rb +0 -38
  309. data/tasks/autodoc/rdoc.rb +0 -45
  310. data/tasks/bump/comparison_links.rb +0 -41
  311. /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
@@ -0,0 +1,101 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ use magnus::{prelude::*, Error, Value};
5
+
6
+ /// Calculate the display width of a string in terminal cells.
7
+ ///
8
+ /// Handles unicode correctly, including:
9
+ /// - Regular ASCII characters: 1 cell each
10
+ /// - CJK characters: 2 cells each (full-width)
11
+ /// - Emoji: typically 2 cells each (varies by terminal)
12
+ /// - Combining marks and zero-width characters: 0 cells
13
+ ///
14
+ /// This uses the same `unicode-width` crate that Ratatui uses internally.
15
+ ///
16
+ /// Returns the total display width in cells (not bytes or characters).
17
+ pub fn text_width(string: Value) -> Result<usize, Error> {
18
+ let ruby = magnus::Ruby::get().unwrap();
19
+
20
+ let s: String = String::try_convert(string).map_err(|_| {
21
+ Error::new(
22
+ ruby.exception_type_error(),
23
+ "expected a String or object that converts to String",
24
+ )
25
+ })?;
26
+
27
+ // Use unicode_width's width calculation.
28
+ // This is the same mechanism Ratatui uses internally for Paragraph.line_width().
29
+ let width = s
30
+ .chars()
31
+ .map(|c| unicode_width::UnicodeWidthChar::width(c).unwrap_or(0))
32
+ .sum();
33
+
34
+ Ok(width)
35
+ }
36
+
37
+ #[cfg(test)]
38
+ mod tests {
39
+ use unicode_width::UnicodeWidthChar;
40
+
41
+ fn measure_width(s: &str) -> usize {
42
+ s.chars()
43
+ .map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
44
+ .sum()
45
+ }
46
+
47
+ #[test]
48
+ fn test_ascii_width() {
49
+ // ASCII is 1 cell per character
50
+ assert_eq!(measure_width("hello"), 5);
51
+ assert_eq!(measure_width("Hello, World!"), 13);
52
+ }
53
+
54
+ #[test]
55
+ fn test_emoji_width() {
56
+ // Emoji typically take 2 cells
57
+ // 👍 is U+1F44D THUMBS UP SIGN, width 2
58
+ assert_eq!(measure_width("👍"), 2);
59
+ // 🌍 is U+1F30D EARTH GLOBE EUROPE-AFRICA, width 2
60
+ assert_eq!(measure_width("🌍"), 2);
61
+ // "Hello 👍" = 5 + 1 + 2 = 8
62
+ assert_eq!(measure_width("Hello 👍"), 8);
63
+ }
64
+
65
+ #[test]
66
+ fn test_cjk_width() {
67
+ // CJK characters are full-width, 2 cells each
68
+ // 你 (U+4F60) is width 2
69
+ assert_eq!(measure_width("你"), 2);
70
+ // 好 (U+597D) is width 2
71
+ assert_eq!(measure_width("好"), 2);
72
+ // "你好" should be 4
73
+ assert_eq!(measure_width("你好"), 4);
74
+ }
75
+
76
+ #[test]
77
+ fn test_mixed_width() {
78
+ // "a你b好" = 1 + 2 + 1 + 2 = 6
79
+ assert_eq!(measure_width("a你b好"), 6);
80
+ }
81
+
82
+ #[test]
83
+ fn test_empty_string() {
84
+ assert_eq!(measure_width(""), 0);
85
+ }
86
+
87
+ #[test]
88
+ fn test_spaces_and_punctuation() {
89
+ // Regular ASCII space and punctuation are 1 cell each
90
+ assert_eq!(measure_width("a b c"), 5);
91
+ assert_eq!(measure_width("!!!"), 3);
92
+ }
93
+
94
+ #[test]
95
+ fn test_combining_marks() {
96
+ // Zero-width marks don't add to width
97
+ // "a" + combining acute accent (U+0301)
98
+ let combining = "a\u{0301}";
99
+ assert_eq!(measure_width(combining), 1);
100
+ }
101
+ }
@@ -2,7 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use magnus::value::ReprValue;
5
- use magnus::Error;
5
+ use magnus::{Error, Module};
6
6
  use ratatui::{
7
7
  backend::{CrosstermBackend, TestBackend},
8
8
  Terminal,
@@ -21,28 +21,32 @@ pub fn init_terminal(focus_events: bool, bracketed_paste: bool) -> Result<(), Er
21
21
  let ruby = magnus::Ruby::get().unwrap();
22
22
  let mut term_lock = TERMINAL.lock().unwrap();
23
23
  if term_lock.is_none() {
24
+ let module = ruby.define_module("RatatuiRuby")?;
25
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
26
+ let error_class = error_base.const_get("Terminal")?;
27
+
24
28
  ratatui::crossterm::terminal::enable_raw_mode()
25
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
29
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
26
30
  let mut stdout = io::stdout();
27
31
  ratatui::crossterm::execute!(
28
32
  stdout,
29
33
  ratatui::crossterm::terminal::EnterAlternateScreen,
30
34
  ratatui::crossterm::event::EnableMouseCapture
31
35
  )
32
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
36
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
33
37
 
34
38
  if focus_events {
35
39
  ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableFocusChange)
36
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
40
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
37
41
  }
38
42
  if bracketed_paste {
39
43
  ratatui::crossterm::execute!(stdout, ratatui::crossterm::event::EnableBracketedPaste)
40
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
44
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
41
45
  }
42
46
 
43
47
  let backend = CrosstermBackend::new(stdout);
44
- let terminal = Terminal::new(backend)
45
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
48
+ let terminal =
49
+ Terminal::new(backend).map_err(|e| Error::new(error_class, e.to_string()))?;
46
50
  *term_lock = Some(TerminalWrapper::Crossterm(terminal));
47
51
  }
48
52
  Ok(())
@@ -52,8 +56,10 @@ pub fn init_test_terminal(width: u16, height: u16) -> Result<(), Error> {
52
56
  let ruby = magnus::Ruby::get().unwrap();
53
57
  let mut term_lock = TERMINAL.lock().unwrap();
54
58
  let backend = TestBackend::new(width, height);
55
- let terminal = Terminal::new(backend)
56
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
59
+ let module = ruby.define_module("RatatuiRuby")?;
60
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
61
+ let error_class = error_base.const_get("Terminal")?;
62
+ let terminal = Terminal::new(backend).map_err(|e| Error::new(error_class, e.to_string()))?;
57
63
  *term_lock = Some(TerminalWrapper::Test(terminal));
58
64
  Ok(())
59
65
  }
@@ -93,8 +99,11 @@ pub fn get_buffer_content() -> Result<String, Error> {
93
99
  }
94
100
  Ok(result)
95
101
  } else {
102
+ let module = ruby.define_module("RatatuiRuby")?;
103
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
104
+ let error_class = error_base.const_get("Terminal")?;
96
105
  Err(Error::new(
97
- ruby.exception_runtime_error(),
106
+ error_class,
98
107
  "Terminal is not initialized as TestBackend",
99
108
  ))
100
109
  }
@@ -104,13 +113,19 @@ pub fn get_cursor_position() -> Result<Option<(u16, u16)>, Error> {
104
113
  let ruby = magnus::Ruby::get().unwrap();
105
114
  let mut term_lock = TERMINAL.lock().unwrap();
106
115
  if let Some(TerminalWrapper::Test(terminal)) = term_lock.as_mut() {
116
+ let module = ruby.define_module("RatatuiRuby")?;
117
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
118
+ let error_class = error_base.const_get("Terminal")?;
107
119
  let pos = terminal
108
120
  .get_cursor_position()
109
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
121
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
110
122
  Ok(Some(pos.into()))
111
123
  } else {
124
+ let module = ruby.define_module("RatatuiRuby")?;
125
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
126
+ let error_class = error_base.const_get("Terminal")?;
112
127
  Err(Error::new(
113
- ruby.exception_runtime_error(),
128
+ error_class,
114
129
  "Terminal is not initialized as TestBackend",
115
130
  ))
116
131
  }
@@ -125,7 +140,10 @@ pub fn resize_terminal(width: u16, height: u16) -> Result<(), Error> {
125
140
  TerminalWrapper::Test(terminal) => {
126
141
  terminal.backend_mut().resize(width, height);
127
142
  if let Err(e) = terminal.resize(ratatui::layout::Rect::new(0, 0, width, height)) {
128
- return Err(Error::new(ruby.exception_runtime_error(), e.to_string()));
143
+ let module = ruby.define_module("RatatuiRuby")?;
144
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
145
+ let error_class = error_base.const_get("Terminal")?;
146
+ return Err(Error::new(error_class, e.to_string()));
129
147
  }
130
148
  }
131
149
  }
@@ -148,14 +166,20 @@ pub fn get_cell_at(x: u16, y: u16) -> Result<magnus::RHash, Error> {
148
166
  hash.aset("modifiers", modifiers_to_value(cell.modifier))?;
149
167
  Ok(hash)
150
168
  } else {
169
+ let module = ruby.define_module("RatatuiRuby")?;
170
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
171
+ let error_class = error_base.const_get("Terminal")?;
151
172
  Err(Error::new(
152
- ruby.exception_runtime_error(),
173
+ error_class,
153
174
  format!("Coordinates ({x}, {y}) out of bounds"),
154
175
  ))
155
176
  }
156
177
  } else {
178
+ let module = ruby.define_module("RatatuiRuby")?;
179
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
180
+ let error_class = error_base.const_get("Terminal")?;
157
181
  Err(Error::new(
158
- ruby.exception_runtime_error(),
182
+ error_class,
159
183
  "Terminal is not initialized as TestBackend",
160
184
  ))
161
185
  }
@@ -90,7 +90,7 @@ pub fn parse_text(value: Value) -> Result<Vec<Line<'static>>, Error> {
90
90
  }
91
91
 
92
92
  /// Parses a Ruby Span object into a ratatui Span.
93
- fn parse_span(value: Value) -> Result<Span<'static>, Error> {
93
+ pub fn parse_span(value: Value) -> Result<Span<'static>, Error> {
94
94
  let ruby = magnus::Ruby::get().unwrap();
95
95
 
96
96
  // Get class name
@@ -131,6 +131,8 @@ pub fn parse_line(value: Value) -> Result<Line<'static>, Error> {
131
131
 
132
132
  // Extract spans from the Ruby Line
133
133
  let spans_val: Value = value.funcall("spans", ())?;
134
+ // v0.7.0: Extract style from the Ruby Line
135
+ let style_val: Value = value.funcall("style", ())?;
134
136
 
135
137
  if spans_val.is_nil() {
136
138
  return Ok(Line::from(""));
@@ -164,11 +166,18 @@ pub fn parse_line(value: Value) -> Result<Line<'static>, Error> {
164
166
  }
165
167
  }
166
168
 
167
- if spans.is_empty() {
168
- Ok(Line::from(""))
169
+ let mut line = if spans.is_empty() {
170
+ Line::from("")
169
171
  } else {
170
- Ok(Line::from(spans))
172
+ Line::from(spans)
173
+ };
174
+
175
+ // v0.7.0: Apply line-level style if present
176
+ if !style_val.is_nil() {
177
+ line = line.style(parse_style(style_val)?);
171
178
  }
179
+
180
+ Ok(line)
172
181
  }
173
182
 
174
183
  #[cfg(test)]
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_bar_set, parse_block, parse_style};
5
+ use crate::text::{parse_line, parse_span};
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, RArray, Symbol, Value};
7
8
  use ratatui::{
@@ -11,6 +12,7 @@ use ratatui::{
11
12
  Frame,
12
13
  };
13
14
 
15
+ #[allow(clippy::too_many_lines)]
14
16
  pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
15
17
  let bump = Bump::new();
16
18
  let data_val: Value = node.funcall("data", ())?;
@@ -66,16 +68,32 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
66
68
 
67
69
  let label_val: Value = bar_obj.funcall("label", ())?;
68
70
  if !label_val.is_nil() {
69
- let s: String = label_val.funcall("to_s", ())?;
70
- let s_ref = bump.alloc_str(&s) as &str;
71
- bar = bar.label(Line::from(s_ref));
71
+ // Try to parse as Line (rich text)
72
+ if let Ok(line) = parse_line(label_val) {
73
+ bar = bar.label(line);
74
+ } else if let Ok(span) = parse_span(label_val) {
75
+ bar = bar.label(Line::from(vec![span]));
76
+ } else {
77
+ // Fallback to string
78
+ let s: String = label_val.funcall("to_s", ())?;
79
+ let s_ref = bump.alloc_str(&s) as &str;
80
+ bar = bar.label(Line::from(s_ref));
81
+ }
72
82
  }
73
83
 
74
84
  let text_val: Value = bar_obj.funcall("text_value", ())?;
75
85
  if !text_val.is_nil() {
76
- let s: String = text_val.funcall("to_s", ())?;
77
- let s_ref = bump.alloc_str(&s) as &str;
78
- bar = bar.text_value(s_ref);
86
+ // Try to parse as Line (rich text)
87
+ if let Ok(line) = parse_line(text_val) {
88
+ bar = bar.text_value(line);
89
+ } else if let Ok(span) = parse_span(text_val) {
90
+ bar = bar.text_value(Line::from(vec![span]));
91
+ } else {
92
+ // Fallback to string
93
+ let s: String = text_val.funcall("to_s", ())?;
94
+ let s_ref = bump.alloc_str(&s) as &str;
95
+ bar = bar.text_value(s_ref);
96
+ }
79
97
  }
80
98
 
81
99
  let style_val: Value = bar_obj.funcall("style", ())?;
@@ -57,7 +57,7 @@ pub fn render(frame: &mut Frame, area: ratatui::layout::Rect, node: Value) -> Re
57
57
  let class_name = unsafe { class.name() }.into_owned();
58
58
 
59
59
  match class_name.as_str() {
60
- "RatatuiRuby::Shape::Line" => {
60
+ "RatatuiRuby::Widgets::Shape::Line" => {
61
61
  let x1: f64 = shape_val.funcall("x1", ()).unwrap_or(0.0);
62
62
  let y1: f64 = shape_val.funcall("y1", ()).unwrap_or(0.0);
63
63
  let x2: f64 = shape_val.funcall("x2", ()).unwrap_or(0.0);
@@ -73,7 +73,7 @@ pub fn render(frame: &mut Frame, area: ratatui::layout::Rect, node: Value) -> Re
73
73
  color,
74
74
  });
75
75
  }
76
- "RatatuiRuby::Shape::Rectangle" => {
76
+ "RatatuiRuby::Widgets::Shape::Rectangle" => {
77
77
  let x: f64 = shape_val.funcall("x", ()).unwrap_or(0.0);
78
78
  let y: f64 = shape_val.funcall("y", ()).unwrap_or(0.0);
79
79
  let width: f64 = shape_val.funcall("width", ()).unwrap_or(0.0);
@@ -89,7 +89,7 @@ pub fn render(frame: &mut Frame, area: ratatui::layout::Rect, node: Value) -> Re
89
89
  color,
90
90
  });
91
91
  }
92
- "RatatuiRuby::Shape::Circle" => {
92
+ "RatatuiRuby::Widgets::Shape::Circle" => {
93
93
  let x: f64 = shape_val.funcall("x", ()).unwrap_or(0.0);
94
94
  let y: f64 = shape_val.funcall("y", ()).unwrap_or(0.0);
95
95
  let radius: f64 = shape_val.funcall("radius", ()).unwrap_or(0.0);
@@ -103,7 +103,7 @@ pub fn render(frame: &mut Frame, area: ratatui::layout::Rect, node: Value) -> Re
103
103
  color,
104
104
  });
105
105
  }
106
- "RatatuiRuby::Shape::Map" => {
106
+ "RatatuiRuby::Widgets::Shape::Map" => {
107
107
  let color_val: Value = shape_val.funcall("color", ()).unwrap();
108
108
  let color =
109
109
  parse_color(&color_val.to_string()).unwrap_or(ratatui::style::Color::Reset);
@@ -114,7 +114,7 @@ pub fn render(frame: &mut Frame, area: ratatui::layout::Rect, node: Value) -> Re
114
114
  };
115
115
  ctx.draw(&Map { color, resolution });
116
116
  }
117
- "RatatuiRuby::Shape::Label" => {
117
+ "RatatuiRuby::Widgets::Shape::Label" => {
118
118
  let x: f64 = shape_val.funcall("x", ()).unwrap_or(0.0);
119
119
  let y: f64 = shape_val.funcall("y", ()).unwrap_or(0.0);
120
120
  let text_val: Value = shape_val.funcall("text", ()).unwrap();
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::text::parse_span;
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, Value};
7
8
  use ratatui::{layout::Rect, widgets::Gauge, Frame};
@@ -17,8 +18,14 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
17
18
  let mut gauge = Gauge::default().ratio(ratio).use_unicode(use_unicode);
18
19
 
19
20
  if !label_val.is_nil() {
20
- let label_str: String = label_val.funcall("to_s", ())?;
21
- gauge = gauge.label(label_str);
21
+ // Try to parse as a Span (rich text)
22
+ if let Ok(span) = parse_span(label_val) {
23
+ gauge = gauge.label(span);
24
+ } else {
25
+ // Fallback to string
26
+ let label_str: String = label_val.funcall("to_s", ())?;
27
+ gauge = gauge.label(label_str);
28
+ }
22
29
  }
23
30
 
24
31
  if !style_val.is_nil() {
@@ -2,6 +2,7 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::text::parse_span;
5
6
  use bumpalo::Bump;
6
7
  use magnus::{prelude::*, Error, Value};
7
8
  use ratatui::{layout::Rect, widgets::LineGauge, Frame};
@@ -23,8 +24,14 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
23
24
  .unfilled_symbol(&unfilled_symbol_val);
24
25
 
25
26
  if !label_val.is_nil() {
26
- let label_str: String = label_val.funcall("to_s", ())?;
27
- gauge = gauge.label(label_str);
27
+ // Try to parse as a Span (rich text)
28
+ if let Ok(span) = parse_span(label_val) {
29
+ gauge = gauge.label(span);
30
+ } else {
31
+ // Fallback to string
32
+ let label_str: String = label_val.funcall("to_s", ())?;
33
+ gauge = gauge.label(label_str);
34
+ }
28
35
  }
29
36
 
30
37
  if !style_val.is_nil() {
@@ -2,12 +2,14 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use crate::style::{parse_block, parse_style};
5
+ use crate::text::{parse_line, parse_span};
6
+ use crate::widgets::list_state::RubyListState;
5
7
  use bumpalo::Bump;
6
8
  use magnus::{prelude::*, Error, Symbol, TryConvert, Value};
7
9
  use ratatui::{
8
10
  layout::Rect,
9
11
  text::Line,
10
- widgets::{HighlightSpacing, List, ListState},
12
+ widgets::{HighlightSpacing, List, ListItem, ListState},
11
13
  Frame,
12
14
  };
13
15
 
@@ -27,11 +29,12 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
27
29
  let scroll_padding_val: Value = node.funcall("scroll_padding", ())?;
28
30
  let block_val: Value = node.funcall("block", ())?;
29
31
 
30
- let mut items: Vec<String> = Vec::new();
32
+ let mut items: Vec<ListItem> = Vec::new();
31
33
  for i in 0..items_array.len() {
32
34
  let index = isize::try_from(i)
33
35
  .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
34
- let item: String = items_array.entry(index)?;
36
+ let item_val: Value = items_array.entry(index)?;
37
+ let item = parse_list_item(item_val)?;
35
38
  items.push(item);
36
39
  }
37
40
 
@@ -47,6 +50,12 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
47
50
  state.select(Some(index));
48
51
  }
49
52
 
53
+ let offset_val: Value = node.funcall("offset", ())?;
54
+ if !offset_val.is_nil() {
55
+ let offset: usize = offset_val.funcall("to_int", ())?;
56
+ *state.offset_mut() = offset;
57
+ }
58
+
50
59
  let mut list = List::new(items);
51
60
 
52
61
  let highlight_spacing = match highlight_spacing_sym.to_string().as_str() {
@@ -101,6 +110,173 @@ pub fn render(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Error> {
101
110
  Ok(())
102
111
  }
103
112
 
113
+ /// Renders a List with an external state object.
114
+ ///
115
+ /// This function ignores `selected_index` and `offset` from the widget.
116
+ /// The State object is the single source of truth for selection and scroll position.
117
+ pub fn render_stateful(
118
+ frame: &mut Frame,
119
+ area: Rect,
120
+ node: Value,
121
+ state_wrapper: Value,
122
+ ) -> Result<(), Error> {
123
+ let bump = Bump::new();
124
+ let ruby = magnus::Ruby::get().unwrap();
125
+
126
+ // Extract the RubyListState wrapper
127
+ let state: &RubyListState = TryConvert::try_convert(state_wrapper)?;
128
+
129
+ // Build items
130
+ let items_val: Value = node.funcall("items", ())?;
131
+ let items_array = magnus::RArray::from_value(items_val)
132
+ .ok_or_else(|| Error::new(ruby.exception_type_error(), "expected array"))?;
133
+
134
+ let mut items: Vec<ListItem> = Vec::new();
135
+ for i in 0..items_array.len() {
136
+ let index = isize::try_from(i)
137
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
138
+ let item_val: Value = items_array.entry(index)?;
139
+ let item = parse_list_item(item_val)?;
140
+ items.push(item);
141
+ }
142
+
143
+ // Build widget (ignoring selected_index and offset — State is truth)
144
+ let style_val: Value = node.funcall("style", ())?;
145
+ let highlight_style_val: Value = node.funcall("highlight_style", ())?;
146
+ let highlight_symbol_val: Value = node.funcall("highlight_symbol", ())?;
147
+ let repeat_highlight_symbol_val: Value = node.funcall("repeat_highlight_symbol", ())?;
148
+ let highlight_spacing_sym: Symbol = node.funcall("highlight_spacing", ())?;
149
+ let direction_val: Value = node.funcall("direction", ())?;
150
+ let scroll_padding_val: Value = node.funcall("scroll_padding", ())?;
151
+ let block_val: Value = node.funcall("block", ())?;
152
+
153
+ let symbol: String = if highlight_symbol_val.is_nil() {
154
+ String::new()
155
+ } else {
156
+ String::try_convert(highlight_symbol_val)?
157
+ };
158
+
159
+ let mut list = List::new(items);
160
+
161
+ let highlight_spacing = match highlight_spacing_sym.to_string().as_str() {
162
+ "always" => HighlightSpacing::Always,
163
+ "never" => HighlightSpacing::Never,
164
+ _ => HighlightSpacing::WhenSelected,
165
+ };
166
+ list = list.highlight_spacing(highlight_spacing);
167
+
168
+ if !highlight_symbol_val.is_nil() {
169
+ list = list.highlight_symbol(Line::from(symbol));
170
+ }
171
+
172
+ if !repeat_highlight_symbol_val.is_nil() {
173
+ let repeat: bool = TryConvert::try_convert(repeat_highlight_symbol_val)?;
174
+ list = list.repeat_highlight_symbol(repeat);
175
+ }
176
+
177
+ if !direction_val.is_nil() {
178
+ let direction_sym: magnus::Symbol = TryConvert::try_convert(direction_val)?;
179
+ let direction_str = direction_sym.name().unwrap();
180
+ match direction_str.as_ref() {
181
+ "top_to_bottom" => list = list.direction(ratatui::widgets::ListDirection::TopToBottom),
182
+ "bottom_to_top" => list = list.direction(ratatui::widgets::ListDirection::BottomToTop),
183
+ _ => {
184
+ return Err(Error::new(
185
+ ruby.exception_arg_error(),
186
+ "direction must be :top_to_bottom or :bottom_to_top",
187
+ ))
188
+ }
189
+ }
190
+ }
191
+
192
+ if !scroll_padding_val.is_nil() {
193
+ let padding: usize = TryConvert::try_convert(scroll_padding_val)?;
194
+ list = list.scroll_padding(padding);
195
+ }
196
+
197
+ if !style_val.is_nil() {
198
+ list = list.style(parse_style(style_val)?);
199
+ }
200
+
201
+ if !highlight_style_val.is_nil() {
202
+ list = list.highlight_style(parse_style(highlight_style_val)?);
203
+ }
204
+
205
+ if !block_val.is_nil() {
206
+ list = list.block(parse_block(block_val, &bump)?);
207
+ }
208
+
209
+ // Borrow the inner ListState, render, and release the borrow immediately
210
+ {
211
+ let mut inner_state = state.borrow_mut();
212
+ frame.render_stateful_widget(list, area, &mut inner_state);
213
+ }
214
+ // Borrow is now released
215
+
216
+ Ok(())
217
+ }
218
+
219
+ /// Parses a Ruby list item into a ratatui `ListItem`.
220
+ ///
221
+ /// Accepts:
222
+ /// - `String`: Plain text item
223
+ /// - `Text::Span`: A single styled fragment
224
+ /// - `Text::Line`: A line composed of multiple spans
225
+ /// - `RatatuiRuby::ListItem`: A `ListItem` object with content and optional style
226
+ fn parse_list_item(value: Value) -> Result<ListItem<'static>, Error> {
227
+ let ruby = magnus::Ruby::get().unwrap();
228
+
229
+ // Check if it's a RatatuiRuby::ListItem
230
+ if let Ok(class_obj) = value.funcall::<_, _, Value>("class", ()) {
231
+ if let Ok(class_name) = class_obj.funcall::<_, _, String>("name", ()) {
232
+ if class_name.contains("ListItem") {
233
+ // Extract content and style from the ListItem
234
+ let content_val: Value = value.funcall("content", ())?;
235
+ let style_val: Value = value.funcall("style", ())?;
236
+
237
+ // Parse content as a Line
238
+ let line = if let Ok(s) = String::try_convert(content_val) {
239
+ Line::from(s)
240
+ } else if let Ok(line) = parse_line(content_val) {
241
+ line
242
+ } else if let Ok(span) = parse_span(content_val) {
243
+ Line::from(vec![span])
244
+ } else {
245
+ Line::from("")
246
+ };
247
+
248
+ // Parse and apply style if present
249
+ let mut item = ListItem::new(line);
250
+ if !style_val.is_nil() {
251
+ item = item.style(parse_style(style_val)?);
252
+ }
253
+ return Ok(item);
254
+ }
255
+ }
256
+ }
257
+
258
+ // Try as String
259
+ if let Ok(s) = String::try_convert(value) {
260
+ return Ok(ListItem::new(Line::from(s)));
261
+ }
262
+
263
+ // Try as Line
264
+ if let Ok(line) = parse_line(value) {
265
+ return Ok(ListItem::new(line));
266
+ }
267
+
268
+ // Try as Span
269
+ if let Ok(span) = parse_span(value) {
270
+ return Ok(ListItem::new(Line::from(vec![span])));
271
+ }
272
+
273
+ // Fallback
274
+ Err(Error::new(
275
+ ruby.exception_type_error(),
276
+ "expected String, Text::Span, Text::Line, or ListItem",
277
+ ))
278
+ }
279
+
104
280
  #[cfg(test)]
105
281
  mod tests {
106
282
  use super::*;