ratatui_ruby 0.4.0 → 0.6.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 (441) 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 +98 -176
  7. data/CHANGELOG.md +80 -6
  8. data/README.md +19 -7
  9. data/REUSE.toml +15 -0
  10. data/doc/application_architecture.md +179 -45
  11. data/doc/application_testing.md +80 -32
  12. data/doc/contributors/design/ruby_frontend.md +48 -8
  13. data/doc/contributors/design/rust_backend.md +1 -0
  14. data/doc/contributors/developing_examples.md +191 -48
  15. data/doc/contributors/documentation_style.md +7 -0
  16. data/doc/contributors/examples_audit/p1_high.md +21 -0
  17. data/doc/contributors/examples_audit/p2_moderate.md +81 -0
  18. data/doc/contributors/examples_audit.md +41 -0
  19. data/doc/contributors/index.md +2 -0
  20. data/doc/event_handling.md +21 -7
  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_box_demo.png +0 -0
  32. data/doc/images/widget_calendar_demo.png +0 -0
  33. data/doc/images/widget_canvas_demo.png +0 -0
  34. data/doc/images/widget_cell_demo.png +0 -0
  35. data/doc/images/widget_center_demo.png +0 -0
  36. data/doc/images/widget_chart_demo.png +0 -0
  37. data/doc/images/widget_gauge_demo.png +0 -0
  38. data/doc/images/widget_layout_split.png +0 -0
  39. data/doc/images/widget_line_gauge_demo.png +0 -0
  40. data/doc/images/widget_list_demo.png +0 -0
  41. data/doc/images/widget_overlay_demo.png +0 -0
  42. data/doc/images/widget_ratatui_logo_demo.png +0 -0
  43. data/doc/images/widget_ratatui_mascot_demo.png +0 -0
  44. data/doc/images/widget_render.png +0 -0
  45. data/doc/images/widget_rich_text.png +0 -0
  46. data/doc/images/widget_scroll_text.png +0 -0
  47. data/doc/images/widget_scrollbar_demo.png +0 -0
  48. data/doc/images/widget_sparkline_demo.png +0 -0
  49. data/doc/images/widget_style_colors.png +0 -0
  50. data/doc/images/widget_table_demo.png +0 -0
  51. data/doc/images/widget_table_flex.png +0 -0
  52. data/doc/images/widget_tabs_demo.png +0 -0
  53. data/doc/images/widget_text_width.png +0 -0
  54. data/doc/interactive_design.md +25 -30
  55. data/doc/quickstart.md +150 -130
  56. data/doc/terminal_limitations.md +92 -0
  57. data/examples/app_all_events/README.md +99 -0
  58. data/examples/app_all_events/app.rb +96 -0
  59. data/examples/app_all_events/model/app_model.rb +157 -0
  60. data/examples/app_all_events/model/event_color_cycle.rb +41 -0
  61. data/examples/app_all_events/model/event_entry.rb +92 -0
  62. data/examples/app_all_events/model/msg.rb +37 -0
  63. data/examples/app_all_events/model/timestamp.rb +54 -0
  64. data/examples/app_all_events/update.rb +73 -0
  65. data/examples/app_all_events/view/app_view.rb +78 -0
  66. data/examples/app_all_events/view/controls_view.rb +52 -0
  67. data/examples/app_all_events/view/counts_view.rb +59 -0
  68. data/examples/app_all_events/view/live_view.rb +70 -0
  69. data/examples/app_all_events/view/log_view.rb +55 -0
  70. data/examples/app_all_events/view.rb +7 -0
  71. data/examples/app_color_picker/README.md +134 -0
  72. data/examples/app_color_picker/app.rb +74 -0
  73. data/examples/app_color_picker/clipboard.rb +84 -0
  74. data/examples/app_color_picker/color.rb +191 -0
  75. data/examples/app_color_picker/controls.rb +90 -0
  76. data/examples/app_color_picker/copy_dialog.rb +166 -0
  77. data/examples/app_color_picker/export_pane.rb +126 -0
  78. data/examples/app_color_picker/harmony.rb +56 -0
  79. data/examples/app_color_picker/input.rb +174 -0
  80. data/examples/app_color_picker/main_container.rb +178 -0
  81. data/examples/app_color_picker/palette.rb +109 -0
  82. data/examples/app_login_form/README.md +47 -0
  83. data/examples/{login_form → app_login_form}/app.rb +38 -42
  84. data/examples/app_stateful_interaction/README.md +31 -0
  85. data/examples/app_stateful_interaction/app.rb +272 -0
  86. data/examples/timeout_demo.rb +43 -0
  87. data/examples/verify_quickstart_dsl/README.md +48 -0
  88. data/examples/{quickstart_dsl → verify_quickstart_dsl}/app.rb +17 -6
  89. data/examples/verify_quickstart_layout/README.md +71 -0
  90. data/examples/verify_quickstart_layout/app.rb +71 -0
  91. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  92. data/examples/verify_quickstart_lifecycle/app.rb +54 -0
  93. data/examples/verify_readme_usage/README.md +43 -0
  94. data/examples/verify_readme_usage/app.rb +40 -0
  95. data/examples/widget_barchart_demo/README.md +49 -0
  96. data/examples/widget_barchart_demo/app.rb +238 -0
  97. data/examples/widget_block_demo/README.md +34 -0
  98. data/examples/widget_block_demo/app.rb +256 -0
  99. data/examples/widget_box_demo/README.md +45 -0
  100. data/examples/{box_demo → widget_box_demo}/app.rb +99 -65
  101. data/examples/widget_calendar_demo/README.md +39 -0
  102. data/examples/widget_calendar_demo/app.rb +109 -0
  103. data/examples/widget_canvas_demo/README.md +27 -0
  104. data/examples/widget_canvas_demo/app.rb +123 -0
  105. data/examples/widget_cell_demo/README.md +36 -0
  106. data/examples/widget_cell_demo/app.rb +111 -0
  107. data/examples/widget_center_demo/README.md +29 -0
  108. data/examples/widget_center_demo/app.rb +116 -0
  109. data/examples/widget_chart_demo/README.md +41 -0
  110. data/examples/widget_chart_demo/app.rb +218 -0
  111. data/examples/widget_gauge_demo/README.md +41 -0
  112. data/examples/widget_gauge_demo/app.rb +212 -0
  113. data/examples/widget_layout_split/README.md +44 -0
  114. data/examples/widget_layout_split/app.rb +246 -0
  115. data/examples/widget_line_gauge_demo/README.md +41 -0
  116. data/examples/widget_line_gauge_demo/app.rb +217 -0
  117. data/examples/widget_list_demo/README.md +49 -0
  118. data/examples/widget_list_demo/app.rb +366 -0
  119. data/examples/widget_map_demo/README.md +39 -0
  120. data/examples/{map_demo → widget_map_demo}/app.rb +24 -21
  121. data/examples/widget_overlay_demo/app.rb +248 -0
  122. data/examples/widget_popup_demo/README.md +36 -0
  123. data/examples/widget_popup_demo/app.rb +104 -0
  124. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  125. data/examples/widget_ratatui_logo_demo/app.rb +103 -0
  126. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  127. data/examples/widget_ratatui_mascot_demo/app.rb +93 -0
  128. data/examples/widget_rect/README.md +38 -0
  129. data/examples/widget_rect/app.rb +205 -0
  130. data/examples/widget_render/README.md +37 -0
  131. data/examples/widget_render/app.rb +184 -0
  132. data/examples/widget_rich_text/README.md +35 -0
  133. data/examples/widget_rich_text/app.rb +166 -0
  134. data/examples/widget_scroll_text/README.md +37 -0
  135. data/examples/widget_scroll_text/app.rb +107 -0
  136. data/examples/widget_scrollbar_demo/README.md +37 -0
  137. data/examples/widget_scrollbar_demo/app.rb +153 -0
  138. data/examples/widget_sparkline_demo/README.md +42 -0
  139. data/examples/widget_sparkline_demo/app.rb +275 -0
  140. data/examples/widget_style_colors/README.md +34 -0
  141. data/examples/widget_style_colors/app.rb +19 -21
  142. data/examples/widget_table_demo/README.md +48 -0
  143. data/examples/widget_table_demo/app.rb +239 -0
  144. data/examples/widget_tabs_demo/README.md +41 -0
  145. data/examples/widget_tabs_demo/app.rb +181 -0
  146. data/examples/widget_text_width/README.md +35 -0
  147. data/examples/widget_text_width/app.rb +106 -0
  148. data/ext/ratatui_ruby/Cargo.lock +11 -4
  149. data/ext/ratatui_ruby/Cargo.toml +2 -1
  150. data/ext/ratatui_ruby/src/events.rs +359 -62
  151. data/ext/ratatui_ruby/src/frame.rs +227 -0
  152. data/ext/ratatui_ruby/src/lib.rs +110 -27
  153. data/ext/ratatui_ruby/src/rendering.rs +8 -4
  154. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  155. data/ext/ratatui_ruby/src/style.rs +138 -57
  156. data/ext/ratatui_ruby/src/terminal.rs +42 -22
  157. data/ext/ratatui_ruby/src/text.rs +14 -7
  158. data/ext/ratatui_ruby/src/widgets/barchart.rs +74 -54
  159. data/ext/ratatui_ruby/src/widgets/block.rs +7 -6
  160. data/ext/ratatui_ruby/src/widgets/canvas.rs +21 -3
  161. data/ext/ratatui_ruby/src/widgets/chart.rs +20 -10
  162. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  163. data/ext/ratatui_ruby/src/widgets/layout.rs +9 -4
  164. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  165. data/ext/ratatui_ruby/src/widgets/list.rs +211 -12
  166. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  167. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  168. data/ext/ratatui_ruby/src/widgets/overlay.rs +2 -1
  169. data/ext/ratatui_ruby/src/widgets/paragraph.rs +1 -1
  170. data/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +19 -8
  171. data/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +17 -10
  172. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +97 -3
  173. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  174. data/ext/ratatui_ruby/src/widgets/sparkline.rs +14 -11
  175. data/ext/ratatui_ruby/src/widgets/table.rs +121 -5
  176. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  177. data/ext/ratatui_ruby/src/widgets/tabs.rs +11 -11
  178. data/lib/ratatui_ruby/cell.rb +7 -7
  179. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  180. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  181. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  182. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  183. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  184. data/lib/ratatui_ruby/event/key.rb +112 -52
  185. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  186. data/lib/ratatui_ruby/event/none.rb +43 -0
  187. data/lib/ratatui_ruby/event/paste.rb +1 -1
  188. data/lib/ratatui_ruby/event.rb +56 -4
  189. data/lib/ratatui_ruby/frame.rb +183 -0
  190. data/lib/ratatui_ruby/list_state.rb +88 -0
  191. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +13 -13
  192. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +1 -5
  193. data/lib/ratatui_ruby/schema/bar_chart.rb +217 -217
  194. data/lib/ratatui_ruby/schema/block.rb +163 -168
  195. data/lib/ratatui_ruby/schema/calendar.rb +66 -67
  196. data/lib/ratatui_ruby/schema/canvas.rb +63 -63
  197. data/lib/ratatui_ruby/schema/center.rb +46 -46
  198. data/lib/ratatui_ruby/schema/chart.rb +135 -143
  199. data/lib/ratatui_ruby/schema/clear.rb +42 -42
  200. data/lib/ratatui_ruby/schema/constraint.rb +76 -76
  201. data/lib/ratatui_ruby/schema/cursor.rb +30 -25
  202. data/lib/ratatui_ruby/schema/gauge.rb +54 -52
  203. data/lib/ratatui_ruby/schema/layout.rb +87 -87
  204. data/lib/ratatui_ruby/schema/line_gauge.rb +62 -62
  205. data/lib/ratatui_ruby/schema/list.rb +103 -80
  206. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  207. data/lib/ratatui_ruby/schema/overlay.rb +31 -31
  208. data/lib/ratatui_ruby/schema/paragraph.rb +80 -80
  209. data/lib/ratatui_ruby/schema/ratatui_logo.rb +10 -6
  210. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +10 -5
  211. data/lib/ratatui_ruby/schema/rect.rb +99 -56
  212. data/lib/ratatui_ruby/schema/scrollbar.rb +119 -119
  213. data/lib/ratatui_ruby/schema/shape/label.rb +1 -1
  214. data/lib/ratatui_ruby/schema/sparkline.rb +111 -110
  215. data/lib/ratatui_ruby/schema/style.rb +66 -46
  216. data/lib/ratatui_ruby/schema/table.rb +126 -115
  217. data/lib/ratatui_ruby/schema/tabs.rb +66 -67
  218. data/lib/ratatui_ruby/schema/text.rb +69 -1
  219. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  220. data/lib/ratatui_ruby/session/autodoc.rb +482 -0
  221. data/lib/ratatui_ruby/session.rb +55 -23
  222. data/lib/ratatui_ruby/table_state.rb +90 -0
  223. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  224. data/lib/ratatui_ruby/test_helper/snapshot.rb +390 -0
  225. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  226. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  227. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  228. data/lib/ratatui_ruby/test_helper.rb +66 -193
  229. data/lib/ratatui_ruby/version.rb +1 -1
  230. data/lib/ratatui_ruby.rb +100 -51
  231. data/{examples/sparkline_demo → sig/examples/app_all_events}/app.rbs +3 -2
  232. data/sig/examples/app_all_events/model/event_entry.rbs +16 -0
  233. data/sig/examples/app_all_events/model/events.rbs +15 -0
  234. data/sig/examples/app_all_events/model/timestamp.rbs +11 -0
  235. data/sig/examples/app_all_events/view/app_view.rbs +8 -0
  236. data/sig/examples/app_all_events/view/controls_view.rbs +6 -0
  237. data/sig/examples/app_all_events/view/counts_view.rbs +6 -0
  238. data/sig/examples/app_all_events/view/live_view.rbs +6 -0
  239. data/sig/examples/app_all_events/view/log_view.rbs +6 -0
  240. data/sig/examples/app_all_events/view.rbs +8 -0
  241. data/sig/examples/app_all_events/view_state.rbs +15 -0
  242. data/{examples/list_demo → sig/examples/app_color_picker}/app.rbs +2 -2
  243. data/sig/examples/app_login_form/app.rbs +11 -0
  244. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  245. data/sig/examples/verify_quickstart_dsl/app.rbs +11 -0
  246. data/sig/examples/verify_quickstart_lifecycle/app.rbs +11 -0
  247. data/sig/examples/verify_readme_usage/app.rbs +11 -0
  248. data/sig/examples/widget_block_demo/app.rbs +32 -0
  249. data/sig/examples/widget_box_demo/app.rbs +11 -0
  250. data/sig/examples/widget_calendar_demo/app.rbs +11 -0
  251. data/sig/examples/widget_cell_demo/app.rbs +11 -0
  252. data/sig/examples/widget_chart_demo/app.rbs +11 -0
  253. data/{examples/gauge_demo → sig/examples/widget_gauge_demo}/app.rbs +4 -0
  254. data/sig/examples/widget_layout_split/app.rbs +10 -0
  255. data/sig/examples/widget_line_gauge_demo/app.rbs +11 -0
  256. data/sig/examples/widget_list_demo/app.rbs +12 -0
  257. data/sig/examples/widget_map_demo/app.rbs +11 -0
  258. data/sig/examples/widget_popup_demo/app.rbs +11 -0
  259. data/sig/examples/widget_ratatui_logo_demo/app.rbs +11 -0
  260. data/sig/examples/widget_ratatui_mascot_demo/app.rbs +11 -0
  261. data/sig/examples/widget_rect/app.rbs +12 -0
  262. data/sig/examples/widget_render/app.rbs +10 -0
  263. data/sig/examples/widget_rich_text/app.rbs +11 -0
  264. data/sig/examples/widget_scroll_text/app.rbs +11 -0
  265. data/sig/examples/widget_scrollbar_demo/app.rbs +11 -0
  266. data/sig/examples/widget_sparkline_demo/app.rbs +10 -0
  267. data/{examples → sig/examples}/widget_style_colors/app.rbs +1 -1
  268. data/sig/examples/widget_table_demo/app.rbs +11 -0
  269. data/sig/examples/widget_text_width/app.rbs +10 -0
  270. data/sig/ratatui_ruby/event.rbs +11 -1
  271. data/sig/ratatui_ruby/frame.rbs +11 -0
  272. data/sig/ratatui_ruby/list_state.rbs +13 -0
  273. data/sig/ratatui_ruby/ratatui_ruby.rbs +5 -4
  274. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  275. data/sig/ratatui_ruby/schema/draw.rbs +4 -0
  276. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  277. data/sig/ratatui_ruby/schema/layout.rbs +1 -1
  278. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  279. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  280. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  281. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  282. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  283. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  284. data/sig/ratatui_ruby/schema/text.rbs +8 -6
  285. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  286. data/sig/ratatui_ruby/session.rbs +107 -0
  287. data/sig/ratatui_ruby/table_state.rbs +15 -0
  288. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  289. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  290. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  291. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  292. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  293. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  294. data/tasks/autodoc/examples.rb +79 -0
  295. data/tasks/autodoc/inventory.rb +63 -0
  296. data/tasks/autodoc/member.rb +56 -0
  297. data/tasks/autodoc/name.rb +19 -0
  298. data/tasks/autodoc/notice.rb +26 -0
  299. data/tasks/autodoc/rbs.rb +38 -0
  300. data/tasks/autodoc/rdoc.rb +45 -0
  301. data/tasks/autodoc.rake +53 -0
  302. data/tasks/bump/changelog.rb +3 -3
  303. data/tasks/bump/history.rb +2 -2
  304. data/tasks/bump/links.rb +67 -0
  305. data/tasks/doc.rake +600 -6
  306. data/tasks/example_viewer.html.erb +172 -0
  307. data/tasks/lint.rake +8 -4
  308. data/tasks/resources/index.html.erb +6 -0
  309. data/tasks/sourcehut.rake +70 -30
  310. data/tasks/terminal_preview/app_screenshot.rb +14 -6
  311. data/tasks/terminal_preview/crash_report.rb +7 -9
  312. data/tasks/terminal_preview/launcher_script.rb +4 -6
  313. data/tasks/terminal_preview/preview_collection.rb +4 -6
  314. data/tasks/terminal_preview/safety_confirmation.rb +3 -5
  315. data/tasks/terminal_preview/saved_screenshot.rb +10 -11
  316. data/tasks/terminal_preview/terminal_window.rb +7 -9
  317. data/tasks/test.rake +1 -1
  318. data/tasks/website/index_page.rb +3 -3
  319. data/tasks/website/version.rb +10 -10
  320. data/tasks/website/version_menu.rb +10 -12
  321. data/tasks/website/versioned_documentation.rb +49 -17
  322. data/tasks/website/website.rb +6 -8
  323. data/tasks/website.rake +4 -4
  324. metadata +232 -127
  325. data/LICENSES/BSD-2-Clause.txt +0 -9
  326. data/doc/contributors/better_dx.md +0 -543
  327. data/doc/contributors/example_analysis.md +0 -82
  328. data/doc/images/all_events.png +0 -0
  329. data/doc/images/block_padding.png +0 -0
  330. data/doc/images/block_titles.png +0 -0
  331. data/doc/images/box_demo.png +0 -0
  332. data/doc/images/calendar_demo.png +0 -0
  333. data/doc/images/cell_demo.png +0 -0
  334. data/doc/images/chart_demo.png +0 -0
  335. data/doc/images/flex_layout.png +0 -0
  336. data/doc/images/gauge_demo.png +0 -0
  337. data/doc/images/line_gauge_demo.png +0 -0
  338. data/doc/images/list_demo.png +0 -0
  339. data/doc/images/list_styles.png +0 -0
  340. data/doc/images/login_form.png +0 -0
  341. data/doc/images/quickstart_dsl.png +0 -0
  342. data/doc/images/quickstart_lifecycle.png +0 -0
  343. data/doc/images/readme_usage.png +0 -0
  344. data/doc/images/rich_text.png +0 -0
  345. data/doc/images/scroll_text.png +0 -0
  346. data/doc/images/scrollbar_demo.png +0 -0
  347. data/doc/images/sparkline_demo.png +0 -0
  348. data/doc/images/table_flex.png +0 -0
  349. data/doc/images/table_select.png +0 -0
  350. data/examples/all_events/app.rb +0 -169
  351. data/examples/all_events/app.rbs +0 -7
  352. data/examples/all_events/test_app.rb +0 -139
  353. data/examples/analytics/app.rb +0 -258
  354. data/examples/analytics/app.rbs +0 -7
  355. data/examples/analytics/test_app.rb +0 -132
  356. data/examples/block_padding/app.rb +0 -63
  357. data/examples/block_padding/app.rbs +0 -7
  358. data/examples/block_padding/test_app.rb +0 -31
  359. data/examples/block_titles/app.rb +0 -61
  360. data/examples/block_titles/app.rbs +0 -7
  361. data/examples/block_titles/test_app.rb +0 -34
  362. data/examples/box_demo/app.rbs +0 -7
  363. data/examples/box_demo/test_app.rb +0 -88
  364. data/examples/calendar_demo/app.rb +0 -101
  365. data/examples/calendar_demo/app.rbs +0 -7
  366. data/examples/calendar_demo/test_app.rb +0 -108
  367. data/examples/cell_demo/app.rb +0 -108
  368. data/examples/cell_demo/app.rbs +0 -7
  369. data/examples/cell_demo/test_app.rb +0 -36
  370. data/examples/chart_demo/app.rb +0 -203
  371. data/examples/chart_demo/app.rbs +0 -7
  372. data/examples/chart_demo/test_app.rb +0 -102
  373. data/examples/custom_widget/app.rb +0 -51
  374. data/examples/custom_widget/app.rbs +0 -7
  375. data/examples/custom_widget/test_app.rb +0 -30
  376. data/examples/flex_layout/app.rb +0 -156
  377. data/examples/flex_layout/app.rbs +0 -7
  378. data/examples/flex_layout/test_app.rb +0 -65
  379. data/examples/gauge_demo/app.rb +0 -182
  380. data/examples/gauge_demo/test_app.rb +0 -120
  381. data/examples/hit_test/app.rb +0 -175
  382. data/examples/hit_test/app.rbs +0 -7
  383. data/examples/hit_test/test_app.rb +0 -102
  384. data/examples/line_gauge_demo/app.rb +0 -190
  385. data/examples/line_gauge_demo/app.rbs +0 -7
  386. data/examples/line_gauge_demo/test_app.rb +0 -129
  387. data/examples/list_demo/app.rb +0 -253
  388. data/examples/list_demo/test_app.rb +0 -237
  389. data/examples/list_styles/app.rb +0 -140
  390. data/examples/list_styles/app.rbs +0 -7
  391. data/examples/list_styles/test_app.rb +0 -157
  392. data/examples/login_form/app.rbs +0 -7
  393. data/examples/login_form/test_app.rb +0 -51
  394. data/examples/map_demo/app.rbs +0 -7
  395. data/examples/map_demo/test_app.rb +0 -149
  396. data/examples/mouse_events/app.rb +0 -97
  397. data/examples/mouse_events/app.rbs +0 -7
  398. data/examples/mouse_events/test_app.rb +0 -53
  399. data/examples/popup_demo/app.rb +0 -103
  400. data/examples/popup_demo/app.rbs +0 -7
  401. data/examples/popup_demo/test_app.rb +0 -54
  402. data/examples/quickstart_dsl/app.rbs +0 -7
  403. data/examples/quickstart_dsl/test_app.rb +0 -29
  404. data/examples/quickstart_lifecycle/app.rb +0 -39
  405. data/examples/quickstart_lifecycle/app.rbs +0 -7
  406. data/examples/quickstart_lifecycle/test_app.rb +0 -29
  407. data/examples/ratatui_logo_demo/app.rb +0 -79
  408. data/examples/ratatui_logo_demo/app.rbs +0 -7
  409. data/examples/ratatui_logo_demo/test_app.rb +0 -51
  410. data/examples/ratatui_mascot_demo/app.rb +0 -84
  411. data/examples/ratatui_mascot_demo/app.rbs +0 -7
  412. data/examples/ratatui_mascot_demo/test_app.rb +0 -47
  413. data/examples/readme_usage/app.rb +0 -29
  414. data/examples/readme_usage/app.rbs +0 -7
  415. data/examples/readme_usage/test_app.rb +0 -29
  416. data/examples/rich_text/app.rb +0 -141
  417. data/examples/rich_text/app.rbs +0 -7
  418. data/examples/rich_text/test_app.rb +0 -166
  419. data/examples/scroll_text/app.rb +0 -103
  420. data/examples/scroll_text/app.rbs +0 -7
  421. data/examples/scroll_text/test_app.rb +0 -110
  422. data/examples/scrollbar_demo/app.rb +0 -143
  423. data/examples/scrollbar_demo/app.rbs +0 -7
  424. data/examples/scrollbar_demo/test_app.rb +0 -77
  425. data/examples/sparkline_demo/app.rb +0 -240
  426. data/examples/sparkline_demo/test_app.rb +0 -107
  427. data/examples/table_flex/app.rb +0 -65
  428. data/examples/table_flex/app.rbs +0 -7
  429. data/examples/table_flex/test_app.rb +0 -36
  430. data/examples/table_select/app.rb +0 -198
  431. data/examples/table_select/app.rbs +0 -7
  432. data/examples/table_select/test_app.rb +0 -180
  433. data/examples/widget_style_colors/test_app.rb +0 -48
  434. data/tasks/bump/comparison_links.rb +0 -41
  435. /data/doc/images/{analytics.png → app_analytics.png} +0 -0
  436. /data/doc/images/{custom_widget.png → app_custom_widget.png} +0 -0
  437. /data/doc/images/{mouse_events.png → app_mouse_events.png} +0 -0
  438. /data/doc/images/{map_demo.png → widget_map_demo.png} +0 -0
  439. /data/doc/images/{popup_demo.png → widget_popup_demo.png} +0 -0
  440. /data/doc/images/{hit_test.png → widget_rect.png} +0 -0
  441. /data/{doc/images/ratatui_logo_demo.png → exe/.gitkeep} +0 -0
@@ -0,0 +1,227 @@
1
+ // SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ //! Frame wrapper for exposing Ratatui's Frame to Ruby.
5
+ //!
6
+ //! This module provides `RubyFrame`, a struct that wraps `ratatui::Frame` and exposes
7
+ //! it to Ruby via Magnus. It enables explicit widget placement through `render_widget`,
8
+ //! aligning `RatatuiRuby` with native Rust Ratatui patterns.
9
+ //!
10
+ //! # Safety
11
+ //!
12
+ //! `RubyFrame` uses raw pointer casting to store a `Frame` reference with an erased
13
+ //! lifetime. This is safe because:
14
+ //! 1. `RubyFrame` is only created within `Terminal::draw()` callbacks
15
+ //! 2. `RubyFrame` is never returned from or stored beyond the callback scope
16
+ //! 3. The Ruby block receiving `RubyFrame` completes before the callback returns
17
+ //!
18
+ //! The `'static` lifetime is a lie, but a safe one within these constraints.
19
+
20
+ use crate::rendering;
21
+ use crate::widgets;
22
+ use magnus::{prelude::*, Error, Value};
23
+ use ratatui::layout::Rect;
24
+ use ratatui::Frame;
25
+ use std::cell::UnsafeCell;
26
+ use std::ptr::NonNull;
27
+ use std::sync::atomic::{AtomicBool, Ordering};
28
+ use std::sync::Arc;
29
+
30
+ /// A wrapper around Ratatui's `Frame` that can be exposed to Ruby.
31
+ ///
32
+ /// This struct uses raw pointers to hold a mutable reference to the frame,
33
+ /// which is valid only for the duration of the draw callback.
34
+ ///
35
+ /// # Safety
36
+ ///
37
+ /// We implement `Send` manually because:
38
+ /// 1. `RubyFrame` is only created and used within a single `Terminal::draw()` callback
39
+ /// 2. The Ruby VM is single-threaded (GVL), so the frame pointer is never accessed
40
+ /// from multiple threads simultaneously
41
+ /// 3. `RubyFrame` never escapes the draw callback scope
42
+ ///
43
+ /// The `active` flag provides runtime safety by preventing use after the draw
44
+ /// callback completes. Without this, a user could store the frame and cause
45
+ /// undefined behavior by accessing it after the underlying pointer is invalid.
46
+ #[magnus::wrap(class = "RatatuiRuby::Frame")]
47
+ pub struct RubyFrame {
48
+ /// Pointer to the underlying frame. Valid only during the draw callback.
49
+ inner: UnsafeCell<NonNull<Frame<'static>>>,
50
+ /// Shared flag to invalidate the frame when the block finishes.
51
+ /// Set to `true` during draw, `false` immediately after yield returns.
52
+ active: Arc<AtomicBool>,
53
+ }
54
+
55
+ // SAFETY: RubyFrame is only used within Terminal::draw() callbacks, which are
56
+ // single-threaded. The Ruby VM's GVL ensures no concurrent access.
57
+ unsafe impl Send for RubyFrame {}
58
+
59
+ impl RubyFrame {
60
+ /// Creates a new `RubyFrame` wrapping the given frame reference.
61
+ ///
62
+ /// # Arguments
63
+ ///
64
+ /// * `frame` - Mutable reference to the underlying Ratatui frame
65
+ /// * `active` - Shared atomic flag that controls frame validity
66
+ ///
67
+ /// # Safety
68
+ ///
69
+ /// The caller must ensure that:
70
+ /// 1. The `RubyFrame` does not outlive the frame reference
71
+ /// 2. No other mutable references to the frame exist while `RubyFrame` is in use
72
+ /// 3. The `active` flag is set to `false` after the draw callback completes
73
+ pub fn new(frame: &mut Frame<'_>, active: Arc<AtomicBool>) -> Self {
74
+ // SAFETY: We cast the frame pointer to 'static lifetime. This is safe because:
75
+ // - RubyFrame is only used within Terminal::draw() callbacks
76
+ // - The Ruby block completes before the callback returns
77
+ // - No reference to RubyFrame escapes the callback scope
78
+ let ptr = NonNull::from(frame);
79
+ let static_ptr: NonNull<Frame<'static>> =
80
+ // SAFETY: Lifetime erasure is safe within the draw callback scope.
81
+ // The frame pointer remains valid for the entire callback duration.
82
+ unsafe { std::mem::transmute(ptr) };
83
+
84
+ Self {
85
+ inner: UnsafeCell::new(static_ptr),
86
+ active,
87
+ }
88
+ }
89
+
90
+ /// Checks that the frame is still valid for use.
91
+ ///
92
+ /// Returns `Ok(())` if the frame can be used, or an error if the draw
93
+ /// callback has already completed.
94
+ fn ensure_active(&self) -> Result<(), Error> {
95
+ if self.active.load(Ordering::Relaxed) {
96
+ Ok(())
97
+ } else {
98
+ let ruby = magnus::Ruby::get().unwrap();
99
+ let module = ruby.define_module("RatatuiRuby")?;
100
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
101
+ let error_class = error_base.const_get("Safety")?;
102
+ Err(Error::new(
103
+ error_class,
104
+ "Frame cannot be used outside of the draw block",
105
+ ))
106
+ }
107
+ }
108
+
109
+ /// Returns the terminal area as a Ruby `RatatuiRuby::Rect`.
110
+ ///
111
+ /// This mirrors `frame.area()` in Rust Ratatui.
112
+ pub fn area(&self) -> Result<Value, Error> {
113
+ self.ensure_active()?;
114
+ let ruby = magnus::Ruby::get().unwrap();
115
+
116
+ // SAFETY: The frame pointer is valid for the duration of the draw callback.
117
+ // We only read from the frame, which is safe with an immutable reference.
118
+ // The ensure_active() check above guarantees we're still in the callback.
119
+ let area = unsafe { (*self.inner.get()).as_ref().area() };
120
+
121
+ // Create a Ruby Rect object
122
+ let module = ruby.define_module("RatatuiRuby")?;
123
+ let class = module.const_get::<_, magnus::RClass>("Rect")?;
124
+ class.funcall("new", (area.x, area.y, area.width, area.height))
125
+ }
126
+
127
+ /// Renders a widget at the specified area.
128
+ ///
129
+ /// This mirrors `frame.render_widget(widget, area)` in Rust Ratatui.
130
+ ///
131
+ /// # Arguments
132
+ ///
133
+ /// * `widget` - A Ruby widget object (e.g., `RatatuiRuby::Paragraph`)
134
+ /// * `area` - A Ruby `Rect` or hash-like object with `x`, `y`, `width`, `height`
135
+ pub fn render_widget(&self, widget: Value, area: Value) -> Result<(), Error> {
136
+ self.ensure_active()?;
137
+
138
+ // Parse the Ruby area into a Rust Rect
139
+ let x: u16 = area.funcall("x", ())?;
140
+ let y: u16 = area.funcall("y", ())?;
141
+ let width: u16 = area.funcall("width", ())?;
142
+ let height: u16 = area.funcall("height", ())?;
143
+ let rect = Rect::new(x, y, width, height);
144
+
145
+ // SAFETY: The frame pointer is valid for the duration of the draw callback.
146
+ // We take a mutable reference which is safe because:
147
+ // 1. RubyFrame is only used within Terminal::draw() callbacks
148
+ // 2. Ruby's GVL ensures single-threaded access
149
+ // 3. No other code holds a reference to the frame during this call
150
+ // 4. ensure_active() above guarantees we're still in the callback
151
+ let frame = unsafe { (*self.inner.get()).as_mut() };
152
+
153
+ // Delegate to the existing render_node function
154
+ rendering::render_node(frame, rect, widget)
155
+ }
156
+
157
+ /// Renders a stateful widget at the specified area.
158
+ ///
159
+ /// This mirrors `frame.render_stateful_widget(widget, area, &mut state)` in Rust Ratatui.
160
+ /// The State object is the single source of truth for selection and offset.
161
+ /// Widget properties (`selected_index`, `selected_row`, `offset`) are ignored.
162
+ ///
163
+ /// # Arguments
164
+ ///
165
+ /// * `widget` - A Ruby widget object (e.g., `RatatuiRuby::List`)
166
+ /// * `area` - A Ruby `Rect`
167
+ /// * `state` - A Ruby state object (e.g., `RatatuiRuby::ListState`)
168
+ pub fn render_stateful_widget(
169
+ &self,
170
+ widget: Value,
171
+ area: Value,
172
+ state: Value,
173
+ ) -> Result<(), Error> {
174
+ self.ensure_active()?;
175
+ let ruby = magnus::Ruby::get().unwrap();
176
+
177
+ // Parse the Ruby area into a Rust Rect
178
+ let x: u16 = area.funcall("x", ())?;
179
+ let y: u16 = area.funcall("y", ())?;
180
+ let width: u16 = area.funcall("width", ())?;
181
+ let height: u16 = area.funcall("height", ())?;
182
+ let rect = Rect::new(x, y, width, height);
183
+
184
+ // SAFETY: The frame pointer is valid for the duration of the draw callback.
185
+ let frame = unsafe { (*self.inner.get()).as_mut() };
186
+
187
+ // SAFETY: Immediate conversion to owned string avoids GC-unsafe borrowed reference.
188
+ let widget_class = unsafe { widget.class().name() }.into_owned();
189
+ // SAFETY: Immediate conversion to owned string avoids GC-unsafe borrowed reference.
190
+ let state_class = unsafe { state.class().name() }.into_owned();
191
+
192
+ match (widget_class.as_str(), state_class.as_str()) {
193
+ ("RatatuiRuby::List", "RatatuiRuby::ListState") => {
194
+ widgets::list::render_stateful(frame, rect, widget, state)
195
+ }
196
+ ("RatatuiRuby::Table", "RatatuiRuby::TableState") => {
197
+ widgets::table::render_stateful(frame, rect, widget, state)
198
+ }
199
+ ("RatatuiRuby::Scrollbar", "RatatuiRuby::ScrollbarState") => {
200
+ widgets::scrollbar::render_stateful(frame, rect, widget, state)
201
+ }
202
+ _ => Err(Error::new(
203
+ ruby.exception_arg_error(),
204
+ format!("Unsupported widget/state combination: {widget_class} with {state_class}"),
205
+ )),
206
+ }
207
+ }
208
+
209
+ /// Sets the cursor position in the terminal.
210
+ ///
211
+ /// This mirrors `frame.set_cursor_position((x, y))` in Rust Ratatui.
212
+ /// Use this for text input fields to show the cursor at the correct location.
213
+ ///
214
+ /// # Arguments
215
+ ///
216
+ /// * `x` - Column position (0-indexed from left)
217
+ /// * `y` - Row position (0-indexed from top)
218
+ pub fn set_cursor_position(&self, x: u16, y: u16) -> Result<(), Error> {
219
+ self.ensure_active()?;
220
+
221
+ // SAFETY: The frame pointer is valid for the duration of the draw callback.
222
+ // ensure_active() above guarantees we're still in the callback.
223
+ let frame = unsafe { (*self.inner.get()).as_mut() };
224
+ frame.set_cursor_position((x, y));
225
+ Ok(())
226
+ }
227
+ }
@@ -11,36 +11,97 @@
11
11
  #![allow(clippy::module_name_repetitions)]
12
12
 
13
13
  mod events;
14
+ mod frame;
14
15
  mod rendering;
16
+ mod string_width;
15
17
  mod style;
16
18
  mod terminal;
17
19
  mod text;
18
20
  mod widgets;
19
21
 
20
- use magnus::{function, Error, Module, Object, Value};
22
+ use frame::RubyFrame;
23
+ use magnus::{function, method, Error, Module, Object, Ruby, Value};
21
24
  use terminal::{init_terminal, restore_terminal, TERMINAL};
22
25
 
23
- fn draw(tree: Value) -> Result<(), Error> {
24
- let ruby = magnus::Ruby::get().unwrap();
26
+ /// Draw to the terminal.
27
+ ///
28
+ /// Supports two calling conventions:
29
+ /// - Legacy: `RatatuiRuby.draw(tree)` - Renders a widget tree to the full terminal area
30
+ /// - New: `RatatuiRuby.draw { |frame| ... }` - Yields a Frame for explicit widget placement
31
+ fn draw(args: &[Value]) -> Result<(), Error> {
32
+ let ruby = Ruby::get().unwrap();
33
+
34
+ // Parse arguments: check for optional tree argument
35
+ let tree: Option<Value> = if args.is_empty() {
36
+ None
37
+ } else if args.len() == 1 {
38
+ Some(args[0])
39
+ } else {
40
+ return Err(Error::new(
41
+ ruby.exception_arg_error(),
42
+ format!(
43
+ "wrong number of arguments (given {}, expected 0..1)",
44
+ args.len()
45
+ ),
46
+ ));
47
+ };
48
+ let block_given = ruby.block_given();
49
+
50
+ // Validate: must have either tree or block, but not both
51
+ if tree.is_some() && block_given {
52
+ return Err(Error::new(
53
+ ruby.exception_arg_error(),
54
+ "Cannot provide both a tree and a block to draw",
55
+ ));
56
+ }
57
+ if tree.is_none() && !block_given {
58
+ return Err(Error::new(
59
+ ruby.exception_arg_error(),
60
+ "Must provide either a tree or a block to draw",
61
+ ));
62
+ }
63
+
25
64
  let mut term_lock = TERMINAL.lock().unwrap();
26
- let mut render_error = None;
65
+ let mut render_error: Option<Error> = None;
66
+
67
+ // Helper closure to execute the draw callback logic for either terminal type
68
+ let mut draw_callback = |f: &mut ratatui::Frame<'_>| {
69
+ if block_given {
70
+ // New API: yield RubyFrame to block
71
+ // Create validity flag — set to true while the block is executing
72
+ let active = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true));
73
+
74
+ let ruby_frame = RubyFrame::new(f, active.clone());
75
+ if let Err(e) = ruby.yield_value::<_, Value>(ruby_frame) {
76
+ render_error = Some(e);
77
+ }
78
+
79
+ // Invalidate frame immediately after block returns
80
+ // This prevents use-after-free if user stored the frame object
81
+ active.store(false, std::sync::atomic::Ordering::Relaxed);
82
+ } else if let Some(tree_value) = tree {
83
+ // Legacy API: render tree to full area
84
+ if let Err(e) = rendering::render_node(f, f.area(), tree_value) {
85
+ render_error = Some(e);
86
+ }
87
+ }
88
+ };
89
+
27
90
  if let Some(wrapper) = term_lock.as_mut() {
28
91
  match wrapper {
29
92
  terminal::TerminalWrapper::Crossterm(term) => {
30
- term.draw(|f| {
31
- if let Err(e) = rendering::render_node(f, f.area(), tree) {
32
- render_error = Some(e);
33
- }
34
- })
35
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
93
+ let module = ruby.define_module("RatatuiRuby")?;
94
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
95
+ let error_class = error_base.const_get("Terminal")?;
96
+ term.draw(&mut draw_callback)
97
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
36
98
  }
37
99
  terminal::TerminalWrapper::Test(term) => {
38
- term.draw(|f| {
39
- if let Err(e) = rendering::render_node(f, f.area(), tree) {
40
- render_error = Some(e);
41
- }
42
- })
43
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
100
+ let module = ruby.define_module("RatatuiRuby")?;
101
+ let error_base = module.const_get::<_, magnus::RClass>("Error")?;
102
+ let error_class = error_base.const_get("Terminal")?;
103
+ term.draw(&mut draw_callback)
104
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
44
105
  }
45
106
  }
46
107
  } else {
@@ -59,15 +120,31 @@ fn init() -> Result<(), Error> {
59
120
  let ruby = magnus::Ruby::get().unwrap();
60
121
  let m = ruby.define_module("RatatuiRuby")?;
61
122
 
62
-
63
-
64
123
  m.define_module_function("_init_terminal", function!(init_terminal, 2))?;
65
124
  m.define_module_function("restore_terminal", function!(restore_terminal, 0))?;
66
- m.define_module_function("draw", function!(draw, 1))?;
67
- m.define_module_function("_poll_event", function!(events::poll_event, 0))?;
125
+ m.define_module_function("_draw", function!(draw, -1))?;
126
+
127
+ // Register Frame class
128
+ let frame_class = m.define_class("Frame", ruby.class_object())?;
129
+ frame_class.define_method("area", method!(RubyFrame::area, 0))?;
130
+ frame_class.define_method("render_widget", method!(RubyFrame::render_widget, 2))?;
131
+ frame_class.define_method(
132
+ "render_stateful_widget",
133
+ method!(RubyFrame::render_stateful_widget, 3),
134
+ )?;
135
+ frame_class.define_method(
136
+ "set_cursor_position",
137
+ method!(RubyFrame::set_cursor_position, 2),
138
+ )?;
139
+ m.define_module_function("_poll_event", function!(events::poll_event, 1))?;
68
140
  m.define_module_function("inject_test_event", function!(events::inject_test_event, 2))?;
69
141
  m.define_module_function("clear_events", function!(events::clear_events, 0))?;
70
142
 
143
+ // Register State classes
144
+ widgets::list_state::register(&ruby, m)?;
145
+ widgets::table_state::register(&ruby, m)?;
146
+ widgets::scrollbar_state::register(&ruby, m)?;
147
+
71
148
  // Test backend helpers
72
149
  m.define_module_function(
73
150
  "init_test_terminal",
@@ -81,23 +158,29 @@ fn init() -> Result<(), Error> {
81
158
  "get_cursor_position",
82
159
  function!(terminal::get_cursor_position, 0),
83
160
  )?;
84
- m.define_module_function(
85
- "_get_cell_at",
86
- function!(terminal::get_cell_at, 2),
87
- )?;
161
+ m.define_module_function("_get_cell_at", function!(terminal::get_cell_at, 2))?;
88
162
  m.define_module_function("resize_terminal", function!(terminal::resize_terminal, 2))?;
89
163
 
90
164
  // Register Layout.split on the Layout class
91
165
  let layout_class = m.const_get::<_, magnus::RClass>("Layout")?;
92
166
  layout_class.define_singleton_method("_split", function!(widgets::layout::split_layout, 4))?;
93
-
167
+
94
168
  // Paragraph metrics
95
- m.define_module_function("_paragraph_line_count", function!(widgets::paragraph::line_count, 2))?;
96
- m.define_module_function("_paragraph_line_width", function!(widgets::paragraph::line_width, 1))?;
169
+ m.define_module_function(
170
+ "_paragraph_line_count",
171
+ function!(widgets::paragraph::line_count, 2),
172
+ )?;
173
+ m.define_module_function(
174
+ "_paragraph_line_width",
175
+ function!(widgets::paragraph::line_width, 1),
176
+ )?;
97
177
 
98
178
  // Tabs metrics
99
179
  m.define_module_function("_tabs_width", function!(widgets::tabs::width, 1))?;
100
180
 
181
+ // Text measurement
182
+ m.define_module_function("_text_width", function!(string_width::text_width, 1))?;
183
+
101
184
  Ok(())
102
185
  }
103
186
 
@@ -22,7 +22,8 @@ pub fn render_node(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Err
22
22
  if let Some(arr) = RArray::from_value(commands) {
23
23
  for i in 0..arr.len() {
24
24
  let ruby = magnus::Ruby::get().unwrap();
25
- let index = isize::try_from(i).map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
25
+ let index = isize::try_from(i)
26
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
26
27
  let cmd: Value = arr.entry(index)?;
27
28
  process_draw_command(frame.buffer_mut(), cmd)?;
28
29
  }
@@ -54,8 +55,10 @@ pub fn render_node(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Err
54
55
  "RatatuiRuby::Chart" | "RatatuiRuby::LineChart" => {
55
56
  widgets::chart::render(frame, area, node)?;
56
57
  }
57
- "RatatuiRuby::RatatuiLogo" => widgets::ratatui_logo::render(frame, area, node)?,
58
- "RatatuiRuby::RatatuiMascot" => widgets::ratatui_mascot::render_ratatui_mascot(frame, area, node)?,
58
+ "RatatuiRuby::RatatuiLogo" => widgets::ratatui_logo::render(frame, area, node),
59
+ "RatatuiRuby::RatatuiMascot" => {
60
+ widgets::ratatui_mascot::render_ratatui_mascot(frame, area, node)?;
61
+ }
59
62
  _ => {}
60
63
  }
61
64
  Ok(())
@@ -105,7 +108,8 @@ fn process_draw_command(buffer: &mut Buffer, cmd: Value) -> Result<(), Error> {
105
108
 
106
109
  if let Some(mods_array) = RArray::from_value(modifiers_val) {
107
110
  for i in 0..mods_array.len() {
108
- let index = isize::try_from(i).map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
111
+ let index = isize::try_from(i)
112
+ .map_err(|e| Error::new(ruby.exception_range_error(), e.to_string()))?;
109
113
  let mod_str: String = mods_array.entry::<String>(index)?;
110
114
  if let Some(modifier) = parse_modifier_str(&mod_str) {
111
115
  style = style.add_modifier(modifier);
@@ -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
+ }