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
@@ -28,25 +28,120 @@ pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(),
28
28
  Ok(())
29
29
  }
30
30
 
31
+ /// Parses a `snake_case` string to `MediaKeyCode`.
32
+ ///
33
+ /// Accepts both the new `media_`-prefixed codes (canonical) and the legacy
34
+ /// unprefixed codes for backward compatibility with existing tests.
35
+ fn parse_media_key(s: &str) -> Option<ratatui::crossterm::event::MediaKeyCode> {
36
+ use ratatui::crossterm::event::MediaKeyCode;
37
+ match s {
38
+ // New canonical codes (media_ prefix)
39
+ "media_play" | "play" => Some(MediaKeyCode::Play),
40
+ "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),
52
+ _ => None,
53
+ }
54
+ }
55
+
56
+ /// Parses a `snake_case` string to `ModifierKeyCode`.
57
+ fn parse_modifier_key(s: &str) -> Option<ratatui::crossterm::event::ModifierKeyCode> {
58
+ use ratatui::crossterm::event::ModifierKeyCode;
59
+ match s {
60
+ "left_shift" => Some(ModifierKeyCode::LeftShift),
61
+ "left_control" => Some(ModifierKeyCode::LeftControl),
62
+ "left_alt" => Some(ModifierKeyCode::LeftAlt),
63
+ "left_super" => Some(ModifierKeyCode::LeftSuper),
64
+ "left_hyper" => Some(ModifierKeyCode::LeftHyper),
65
+ "left_meta" => Some(ModifierKeyCode::LeftMeta),
66
+ "right_shift" => Some(ModifierKeyCode::RightShift),
67
+ "right_control" => Some(ModifierKeyCode::RightControl),
68
+ "right_alt" => Some(ModifierKeyCode::RightAlt),
69
+ "right_super" => Some(ModifierKeyCode::RightSuper),
70
+ "right_hyper" => Some(ModifierKeyCode::RightHyper),
71
+ "right_meta" => Some(ModifierKeyCode::RightMeta),
72
+ "iso_level3_shift" => Some(ModifierKeyCode::IsoLevel3Shift),
73
+ "iso_level5_shift" => Some(ModifierKeyCode::IsoLevel5Shift),
74
+ _ => None,
75
+ }
76
+ }
77
+
31
78
  fn parse_key_event(
32
79
  data: magnus::RHash,
33
80
  ruby: &magnus::Ruby,
34
81
  ) -> Result<ratatui::crossterm::event::Event, Error> {
82
+ use ratatui::crossterm::event::KeyCode;
83
+
35
84
  let code_val: Value = data
36
85
  .get(ruby.to_symbol("code"))
37
86
  .ok_or_else(|| Error::new(ruby.exception_arg_error(), "Missing 'code' in key event"))?;
38
87
  let code_str: String = String::try_convert(code_val)?;
39
88
  let code = match code_str.as_str() {
40
- "up" => ratatui::crossterm::event::KeyCode::Up,
41
- "down" => ratatui::crossterm::event::KeyCode::Down,
42
- "left" => ratatui::crossterm::event::KeyCode::Left,
43
- "right" => ratatui::crossterm::event::KeyCode::Right,
44
- "enter" => ratatui::crossterm::event::KeyCode::Enter,
45
- "esc" => ratatui::crossterm::event::KeyCode::Esc,
46
- "backspace" => ratatui::crossterm::event::KeyCode::Backspace,
47
- "tab" => ratatui::crossterm::event::KeyCode::Tab,
48
- c if c.len() == 1 => ratatui::crossterm::event::KeyCode::Char(c.chars().next().unwrap()),
49
- _ => ratatui::crossterm::event::KeyCode::Null,
89
+ // Arrow keys
90
+ "up" => KeyCode::Up,
91
+ "down" => KeyCode::Down,
92
+ "left" => KeyCode::Left,
93
+ "right" => KeyCode::Right,
94
+ // Common keys
95
+ "enter" => KeyCode::Enter,
96
+ "esc" => KeyCode::Esc,
97
+ "backspace" => KeyCode::Backspace,
98
+ "tab" => KeyCode::Tab,
99
+ "back_tab" => KeyCode::BackTab,
100
+ // Navigation keys
101
+ "home" => KeyCode::Home,
102
+ "end" => KeyCode::End,
103
+ "page_up" => KeyCode::PageUp,
104
+ "page_down" => KeyCode::PageDown,
105
+ "insert" => KeyCode::Insert,
106
+ "delete" => KeyCode::Delete,
107
+ // Lock keys
108
+ "caps_lock" => KeyCode::CapsLock,
109
+ "scroll_lock" => KeyCode::ScrollLock,
110
+ "num_lock" => KeyCode::NumLock,
111
+ // System keys
112
+ "print_screen" => KeyCode::PrintScreen,
113
+ "pause" => KeyCode::Pause,
114
+ "menu" => KeyCode::Menu,
115
+ "keypad_begin" => KeyCode::KeypadBegin,
116
+ "null" => KeyCode::Null,
117
+ // Dynamic parsing for media, modifiers, function keys, and characters
118
+ s => {
119
+ // Media keys (check first - "fast_forward" starts with 'f' but isn't F-key)
120
+ if let Some(m) = parse_media_key(s) {
121
+ KeyCode::Media(m)
122
+ }
123
+ // Modifier keys
124
+ else if let Some(m) = parse_modifier_key(s) {
125
+ KeyCode::Modifier(m)
126
+ }
127
+ // Function keys: f1, f2, ..., f12, etc.
128
+ else if let Some(num_str) = s.strip_prefix('f') {
129
+ if let Ok(n) = num_str.parse::<u8>() {
130
+ KeyCode::F(n)
131
+ } else {
132
+ // "f" alone or invalid suffix - treat as character
133
+ KeyCode::Char(s.chars().next().unwrap_or('\0'))
134
+ }
135
+ }
136
+ // Single character
137
+ else if s.len() == 1 {
138
+ KeyCode::Char(s.chars().next().unwrap())
139
+ }
140
+ // Unknown - default to Null
141
+ else {
142
+ KeyCode::Null
143
+ }
144
+ }
50
145
  };
51
146
 
52
147
  let mut modifiers = ratatui::crossterm::event::KeyModifiers::empty();
@@ -178,7 +273,7 @@ pub fn clear_events() {
178
273
  EVENT_QUEUE.lock().unwrap().clear();
179
274
  }
180
275
 
181
- pub fn poll_event(ruby: &magnus::Ruby) -> Result<Value, Error> {
276
+ pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value, Error> {
182
277
  let event = {
183
278
  let mut queue = EVENT_QUEUE.lock().unwrap();
184
279
  if queue.is_empty() {
@@ -204,14 +299,23 @@ pub fn poll_event(ruby: &magnus::Ruby) -> Result<Value, Error> {
204
299
  return Ok(ruby.qnil().into_value_with(ruby));
205
300
  }
206
301
 
207
- if ratatui::crossterm::event::poll(std::time::Duration::from_millis(16))
208
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?
209
- {
302
+ if let Some(secs) = timeout_val {
303
+ // Timed poll: wait up to the specified duration
304
+ let duration = std::time::Duration::from_secs_f64(secs);
305
+ if ratatui::crossterm::event::poll(duration)
306
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?
307
+ {
308
+ let event = ratatui::crossterm::event::read()
309
+ .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
310
+ handle_event(event)
311
+ } else {
312
+ Ok(ruby.qnil().into_value_with(ruby))
313
+ }
314
+ } else {
315
+ // Blocking: wait indefinitely for an event
210
316
  let event = ratatui::crossterm::event::read()
211
317
  .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
212
318
  handle_event(event)
213
- } else {
214
- Ok(ruby.qnil().into_value_with(ruby))
215
319
  }
216
320
  }
217
321
 
@@ -226,26 +330,134 @@ fn handle_event(event: ratatui::crossterm::event::Event) -> Result<Value, Error>
226
330
  }
227
331
  }
228
332
 
333
+ /// Converts `MediaKeyCode` to `snake_case` string.
334
+ ///
335
+ /// All media keys are consistently prefixed with `media_` to reflect that they
336
+ /// belong to the `KeyCode::Media(_)` variant in Crossterm. This allows Ruby's
337
+ /// "Smart Predicates" to provide DWIM behavior (e.g., `pause?` matching both
338
+ /// system and media pause).
339
+ fn media_key_to_string(m: ratatui::crossterm::event::MediaKeyCode) -> &'static str {
340
+ use ratatui::crossterm::event::MediaKeyCode;
341
+ match m {
342
+ MediaKeyCode::Play => "media_play",
343
+ MediaKeyCode::Pause => "media_pause",
344
+ MediaKeyCode::PlayPause => "media_play_pause",
345
+ MediaKeyCode::Reverse => "media_reverse",
346
+ MediaKeyCode::Stop => "media_stop",
347
+ MediaKeyCode::FastForward => "media_fast_forward",
348
+ MediaKeyCode::Rewind => "media_rewind",
349
+ MediaKeyCode::TrackNext => "media_track_next",
350
+ MediaKeyCode::TrackPrevious => "media_track_previous",
351
+ MediaKeyCode::Record => "media_record",
352
+ MediaKeyCode::LowerVolume => "media_lower_volume",
353
+ MediaKeyCode::RaiseVolume => "media_raise_volume",
354
+ MediaKeyCode::MuteVolume => "media_mute_volume",
355
+ }
356
+ }
357
+
358
+ /// Converts `ModifierKeyCode` to `snake_case` string.
359
+ fn modifier_key_to_string(m: ratatui::crossterm::event::ModifierKeyCode) -> &'static str {
360
+ use ratatui::crossterm::event::ModifierKeyCode;
361
+ match m {
362
+ ModifierKeyCode::LeftShift => "left_shift",
363
+ ModifierKeyCode::LeftControl => "left_control",
364
+ ModifierKeyCode::LeftAlt => "left_alt",
365
+ ModifierKeyCode::LeftSuper => "left_super",
366
+ ModifierKeyCode::LeftHyper => "left_hyper",
367
+ ModifierKeyCode::LeftMeta => "left_meta",
368
+ ModifierKeyCode::RightShift => "right_shift",
369
+ ModifierKeyCode::RightControl => "right_control",
370
+ ModifierKeyCode::RightAlt => "right_alt",
371
+ ModifierKeyCode::RightSuper => "right_super",
372
+ ModifierKeyCode::RightHyper => "right_hyper",
373
+ ModifierKeyCode::RightMeta => "right_meta",
374
+ ModifierKeyCode::IsoLevel3Shift => "iso_level3_shift",
375
+ ModifierKeyCode::IsoLevel5Shift => "iso_level5_shift",
376
+ }
377
+ }
378
+
229
379
  fn handle_key_event(key: ratatui::crossterm::event::KeyEvent) -> Result<Value, Error> {
380
+ use ratatui::crossterm::event::KeyCode;
381
+
230
382
  let ruby = magnus::Ruby::get().unwrap();
231
383
  if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
232
384
  return Ok(ruby.qnil().into_value_with(&ruby));
233
385
  }
234
386
  let hash = ruby.hash_new();
235
387
  hash.aset(ruby.to_symbol("type"), ruby.to_symbol("key"))?;
388
+
389
+ // Determine the kind (category) of the key
390
+ let kind = match key.code {
391
+ KeyCode::Char(_)
392
+ | KeyCode::Enter
393
+ | KeyCode::Tab
394
+ | KeyCode::Backspace
395
+ | KeyCode::BackTab
396
+ | KeyCode::Up
397
+ | KeyCode::Down
398
+ | KeyCode::Left
399
+ | KeyCode::Right
400
+ | KeyCode::Home
401
+ | KeyCode::End
402
+ | KeyCode::PageUp
403
+ | KeyCode::PageDown
404
+ | KeyCode::Insert
405
+ | KeyCode::Delete
406
+ | KeyCode::Null => "standard",
407
+ KeyCode::F(_) => "function",
408
+ KeyCode::Media(_) => "media",
409
+ KeyCode::Modifier(_) => "modifier",
410
+ KeyCode::Esc
411
+ | KeyCode::CapsLock
412
+ | KeyCode::ScrollLock
413
+ | KeyCode::NumLock
414
+ | KeyCode::PrintScreen
415
+ | KeyCode::Pause
416
+ | KeyCode::Menu
417
+ | KeyCode::KeypadBegin => "system",
418
+ };
419
+
236
420
  let code = match key.code {
237
- ratatui::crossterm::event::KeyCode::Char(c) => c.to_string(),
238
- ratatui::crossterm::event::KeyCode::Up => "up".to_string(),
239
- ratatui::crossterm::event::KeyCode::Down => "down".to_string(),
240
- ratatui::crossterm::event::KeyCode::Left => "left".to_string(),
241
- ratatui::crossterm::event::KeyCode::Right => "right".to_string(),
242
- ratatui::crossterm::event::KeyCode::Enter => "enter".to_string(),
243
- ratatui::crossterm::event::KeyCode::Esc => "esc".to_string(),
244
- ratatui::crossterm::event::KeyCode::Backspace => "backspace".to_string(),
245
- ratatui::crossterm::event::KeyCode::Tab => "tab".to_string(),
246
- _ => "unknown".to_string(),
421
+ // Characters
422
+ KeyCode::Char(c) => c.to_string(),
423
+ // Arrow keys
424
+ KeyCode::Up => "up".to_string(),
425
+ KeyCode::Down => "down".to_string(),
426
+ KeyCode::Left => "left".to_string(),
427
+ KeyCode::Right => "right".to_string(),
428
+ // Common keys
429
+ KeyCode::Enter => "enter".to_string(),
430
+ KeyCode::Esc => "esc".to_string(),
431
+ KeyCode::Backspace => "backspace".to_string(),
432
+ KeyCode::Tab => "tab".to_string(),
433
+ KeyCode::BackTab => "back_tab".to_string(),
434
+ // Navigation keys
435
+ KeyCode::Home => "home".to_string(),
436
+ KeyCode::End => "end".to_string(),
437
+ KeyCode::PageUp => "page_up".to_string(),
438
+ KeyCode::PageDown => "page_down".to_string(),
439
+ KeyCode::Insert => "insert".to_string(),
440
+ KeyCode::Delete => "delete".to_string(),
441
+ // Function keys
442
+ KeyCode::F(n) => format!("f{n}"),
443
+ // Lock keys
444
+ KeyCode::CapsLock => "caps_lock".to_string(),
445
+ KeyCode::ScrollLock => "scroll_lock".to_string(),
446
+ KeyCode::NumLock => "num_lock".to_string(),
447
+ // System keys
448
+ KeyCode::PrintScreen => "print_screen".to_string(),
449
+ KeyCode::Pause => "pause".to_string(),
450
+ KeyCode::Menu => "menu".to_string(),
451
+ KeyCode::KeypadBegin => "keypad_begin".to_string(),
452
+ KeyCode::Null => "null".to_string(),
453
+ // Compound variants
454
+ KeyCode::Media(m) => media_key_to_string(m).to_string(),
455
+ KeyCode::Modifier(m) => modifier_key_to_string(m).to_string(),
247
456
  };
457
+
248
458
  hash.aset(ruby.to_symbol("code"), code)?;
459
+ hash.aset(ruby.to_symbol("kind"), ruby.to_symbol(kind))?;
460
+
249
461
  let mut modifiers = Vec::new();
250
462
  if key
251
463
  .modifiers
@@ -18,11 +18,14 @@
18
18
  //! The `'static` lifetime is a lie, but a safe one within these constraints.
19
19
 
20
20
  use crate::rendering;
21
+ use crate::widgets;
21
22
  use magnus::{prelude::*, Error, Value};
22
23
  use ratatui::layout::Rect;
23
24
  use ratatui::Frame;
24
25
  use std::cell::UnsafeCell;
25
26
  use std::ptr::NonNull;
27
+ use std::sync::atomic::{AtomicBool, Ordering};
28
+ use std::sync::Arc;
26
29
 
27
30
  /// A wrapper around Ratatui's `Frame` that can be exposed to Ruby.
28
31
  ///
@@ -36,10 +39,17 @@ use std::ptr::NonNull;
36
39
  /// 2. The Ruby VM is single-threaded (GVL), so the frame pointer is never accessed
37
40
  /// from multiple threads simultaneously
38
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.
39
46
  #[magnus::wrap(class = "RatatuiRuby::Frame")]
40
47
  pub struct RubyFrame {
41
48
  /// Pointer to the underlying frame. Valid only during the draw callback.
42
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>,
43
53
  }
44
54
 
45
55
  // SAFETY: RubyFrame is only used within Terminal::draw() callbacks, which are
@@ -49,12 +59,18 @@ unsafe impl Send for RubyFrame {}
49
59
  impl RubyFrame {
50
60
  /// Creates a new `RubyFrame` wrapping the given frame reference.
51
61
  ///
62
+ /// # Arguments
63
+ ///
64
+ /// * `frame` - Mutable reference to the underlying Ratatui frame
65
+ /// * `active` - Shared atomic flag that controls frame validity
66
+ ///
52
67
  /// # Safety
53
68
  ///
54
69
  /// The caller must ensure that:
55
70
  /// 1. The `RubyFrame` does not outlive the frame reference
56
71
  /// 2. No other mutable references to the frame exist while `RubyFrame` is in use
57
- pub fn new(frame: &mut Frame<'_>) -> Self {
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 {
58
74
  // SAFETY: We cast the frame pointer to 'static lifetime. This is safe because:
59
75
  // - RubyFrame is only used within Terminal::draw() callbacks
60
76
  // - The Ruby block completes before the callback returns
@@ -67,6 +83,26 @@ impl RubyFrame {
67
83
 
68
84
  Self {
69
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
+ ))
70
106
  }
71
107
  }
72
108
 
@@ -74,15 +110,18 @@ impl RubyFrame {
74
110
  ///
75
111
  /// This mirrors `frame.area()` in Rust Ratatui.
76
112
  pub fn area(&self) -> Result<Value, Error> {
113
+ self.ensure_active()?;
77
114
  let ruby = magnus::Ruby::get().unwrap();
78
115
 
79
116
  // SAFETY: The frame pointer is valid for the duration of the draw callback.
80
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.
81
119
  let area = unsafe { (*self.inner.get()).as_ref().area() };
82
120
 
83
- // Create a Ruby Rect object
121
+ // Create a Ruby Layout::Rect object
84
122
  let module = ruby.define_module("RatatuiRuby")?;
85
- let class = module.const_get::<_, magnus::RClass>("Rect")?;
123
+ let layout_mod = module.const_get::<_, magnus::RModule>("Layout")?;
124
+ let class = layout_mod.const_get::<_, magnus::RClass>("Rect")?;
86
125
  class.funcall("new", (area.x, area.y, area.width, area.height))
87
126
  }
88
127
 
@@ -95,6 +134,8 @@ impl RubyFrame {
95
134
  /// * `widget` - A Ruby widget object (e.g., `RatatuiRuby::Paragraph`)
96
135
  /// * `area` - A Ruby `Rect` or hash-like object with `x`, `y`, `width`, `height`
97
136
  pub fn render_widget(&self, widget: Value, area: Value) -> Result<(), Error> {
137
+ self.ensure_active()?;
138
+
98
139
  // Parse the Ruby area into a Rust Rect
99
140
  let x: u16 = area.funcall("x", ())?;
100
141
  let y: u16 = area.funcall("y", ())?;
@@ -107,9 +148,81 @@ impl RubyFrame {
107
148
  // 1. RubyFrame is only used within Terminal::draw() callbacks
108
149
  // 2. Ruby's GVL ensures single-threaded access
109
150
  // 3. No other code holds a reference to the frame during this call
151
+ // 4. ensure_active() above guarantees we're still in the callback
110
152
  let frame = unsafe { (*self.inner.get()).as_mut() };
111
153
 
112
154
  // Delegate to the existing render_node function
113
155
  rendering::render_node(frame, rect, widget)
114
156
  }
157
+
158
+ /// Renders a stateful widget at the specified area.
159
+ ///
160
+ /// This mirrors `frame.render_stateful_widget(widget, area, &mut state)` in Rust Ratatui.
161
+ /// The State object is the single source of truth for selection and offset.
162
+ /// Widget properties (`selected_index`, `selected_row`, `offset`) are ignored.
163
+ ///
164
+ /// # Arguments
165
+ ///
166
+ /// * `widget` - A Ruby widget object (e.g., `RatatuiRuby::List`)
167
+ /// * `area` - A Ruby `Rect`
168
+ /// * `state` - A Ruby state object (e.g., `RatatuiRuby::ListState`)
169
+ pub fn render_stateful_widget(
170
+ &self,
171
+ widget: Value,
172
+ area: Value,
173
+ state: Value,
174
+ ) -> Result<(), Error> {
175
+ self.ensure_active()?;
176
+ let ruby = magnus::Ruby::get().unwrap();
177
+
178
+ // Parse the Ruby area into a Rust Rect
179
+ let x: u16 = area.funcall("x", ())?;
180
+ let y: u16 = area.funcall("y", ())?;
181
+ let width: u16 = area.funcall("width", ())?;
182
+ let height: u16 = area.funcall("height", ())?;
183
+ let rect = Rect::new(x, y, width, height);
184
+
185
+ // SAFETY: The frame pointer is valid for the duration of the draw callback.
186
+ let frame = unsafe { (*self.inner.get()).as_mut() };
187
+
188
+ // SAFETY: Immediate conversion to owned string avoids GC-unsafe borrowed reference.
189
+ let widget_class = unsafe { widget.class().name() }.into_owned();
190
+ // SAFETY: Immediate conversion to owned string avoids GC-unsafe borrowed reference.
191
+ let state_class = unsafe { state.class().name() }.into_owned();
192
+
193
+ match (widget_class.as_str(), state_class.as_str()) {
194
+ ("RatatuiRuby::Widgets::List", "RatatuiRuby::ListState") => {
195
+ widgets::list::render_stateful(frame, rect, widget, state)
196
+ }
197
+ ("RatatuiRuby::Widgets::Table", "RatatuiRuby::TableState") => {
198
+ widgets::table::render_stateful(frame, rect, widget, state)
199
+ }
200
+ ("RatatuiRuby::Widgets::Scrollbar", "RatatuiRuby::ScrollbarState") => {
201
+ widgets::scrollbar::render_stateful(frame, rect, widget, state)
202
+ }
203
+ _ => Err(Error::new(
204
+ ruby.exception_arg_error(),
205
+ format!("Unsupported widget/state combination: {widget_class} with {state_class}"),
206
+ )),
207
+ }
208
+ }
209
+
210
+ /// Sets the cursor position in the terminal.
211
+ ///
212
+ /// This mirrors `frame.set_cursor_position((x, y))` in Rust Ratatui.
213
+ /// Use this for text input fields to show the cursor at the correct location.
214
+ ///
215
+ /// # Arguments
216
+ ///
217
+ /// * `x` - Column position (0-indexed from left)
218
+ /// * `y` - Row position (0-indexed from top)
219
+ pub fn set_cursor_position(&self, x: u16, y: u16) -> Result<(), Error> {
220
+ self.ensure_active()?;
221
+
222
+ // SAFETY: The frame pointer is valid for the duration of the draw callback.
223
+ // ensure_active() above guarantees we're still in the callback.
224
+ let frame = unsafe { (*self.inner.get()).as_mut() };
225
+ frame.set_cursor_position((x, y));
226
+ Ok(())
227
+ }
115
228
  }
@@ -13,6 +13,7 @@
13
13
  mod events;
14
14
  mod frame;
15
15
  mod rendering;
16
+ mod string_width;
16
17
  mod style;
17
18
  mod terminal;
18
19
  mod text;
@@ -67,10 +68,17 @@ fn draw(args: &[Value]) -> Result<(), Error> {
67
68
  let mut draw_callback = |f: &mut ratatui::Frame<'_>| {
68
69
  if block_given {
69
70
  // New API: yield RubyFrame to block
70
- let ruby_frame = RubyFrame::new(f);
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());
71
75
  if let Err(e) = ruby.yield_value::<_, Value>(ruby_frame) {
72
76
  render_error = Some(e);
73
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);
74
82
  } else if let Some(tree_value) = tree {
75
83
  // Legacy API: render tree to full area
76
84
  if let Err(e) = rendering::render_node(f, f.area(), tree_value) {
@@ -82,12 +90,18 @@ fn draw(args: &[Value]) -> Result<(), Error> {
82
90
  if let Some(wrapper) = term_lock.as_mut() {
83
91
  match wrapper {
84
92
  terminal::TerminalWrapper::Crossterm(term) => {
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")?;
85
96
  term.draw(&mut draw_callback)
86
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
97
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
87
98
  }
88
99
  terminal::TerminalWrapper::Test(term) => {
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")?;
89
103
  term.draw(&mut draw_callback)
90
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
104
+ .map_err(|e| Error::new(error_class, e.to_string()))?;
91
105
  }
92
106
  }
93
107
  } else {
@@ -114,10 +128,23 @@ fn init() -> Result<(), Error> {
114
128
  let frame_class = m.define_class("Frame", ruby.class_object())?;
115
129
  frame_class.define_method("area", method!(RubyFrame::area, 0))?;
116
130
  frame_class.define_method("render_widget", method!(RubyFrame::render_widget, 2))?;
117
- m.define_module_function("_poll_event", function!(events::poll_event, 0))?;
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))?;
118
140
  m.define_module_function("inject_test_event", function!(events::inject_test_event, 2))?;
119
141
  m.define_module_function("clear_events", function!(events::clear_events, 0))?;
120
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
+
121
148
  // Test backend helpers
122
149
  m.define_module_function(
123
150
  "init_test_terminal",
@@ -134,8 +161,9 @@ fn init() -> Result<(), Error> {
134
161
  m.define_module_function("_get_cell_at", function!(terminal::get_cell_at, 2))?;
135
162
  m.define_module_function("resize_terminal", function!(terminal::resize_terminal, 2))?;
136
163
 
137
- // Register Layout.split on the Layout class
138
- let layout_class = m.const_get::<_, magnus::RClass>("Layout")?;
164
+ // Register Layout.split on the Layout::Layout class (inside the Layout module)
165
+ let layout_mod = m.const_get::<_, magnus::RModule>("Layout")?;
166
+ let layout_class = layout_mod.const_get::<_, magnus::RClass>("Layout")?;
139
167
  layout_class.define_singleton_method("_split", function!(widgets::layout::split_layout, 4))?;
140
168
 
141
169
  // Paragraph metrics
@@ -151,6 +179,9 @@ fn init() -> Result<(), Error> {
151
179
  // Tabs metrics
152
180
  m.define_module_function("_tabs_width", function!(widgets::tabs::width, 1))?;
153
181
 
182
+ // Text measurement
183
+ m.define_module_function("_text_width", function!(string_width::text_width, 1))?;
184
+
154
185
  Ok(())
155
186
  }
156
187
 
@@ -11,7 +11,8 @@ pub fn render_node(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Err
11
11
  let ruby = magnus::Ruby::get().unwrap();
12
12
  let ruby_area = {
13
13
  let module = ruby.define_module("RatatuiRuby")?;
14
- let class = module.const_get::<_, magnus::RClass>("Rect")?;
14
+ let layout_mod = module.const_get::<_, magnus::RModule>("Layout")?;
15
+ let class = layout_mod.const_get::<_, magnus::RClass>("Rect")?;
15
16
  class.funcall::<_, _, Value>("new", (area.x, area.y, area.width, area.height))?
16
17
  };
17
18
 
@@ -35,28 +36,28 @@ pub fn render_node(frame: &mut Frame, area: Rect, node: Value) -> Result<(), Err
35
36
  let class_name = unsafe { node.class().name() }.into_owned();
36
37
 
37
38
  match class_name.as_str() {
38
- "RatatuiRuby::Paragraph" => widgets::paragraph::render(frame, area, node)?,
39
- "RatatuiRuby::Clear" => widgets::clear::render(frame, area, node)?,
40
- "RatatuiRuby::Cursor" => widgets::cursor::render(frame, area, node)?,
41
- "RatatuiRuby::Overlay" => widgets::overlay::render(frame, area, node)?,
42
- "RatatuiRuby::Center" => widgets::center::render(frame, area, node)?,
43
- "RatatuiRuby::Layout" => widgets::layout::render(frame, area, node)?,
44
- "RatatuiRuby::List" => widgets::list::render(frame, area, node)?,
45
- "RatatuiRuby::Gauge" => widgets::gauge::render(frame, area, node)?,
46
- "RatatuiRuby::LineGauge" => widgets::line_gauge::render(frame, area, node)?,
47
- "RatatuiRuby::Table" => widgets::table::render(frame, area, node)?,
48
- "RatatuiRuby::Block" => widgets::block::render(frame, area, node)?,
49
- "RatatuiRuby::Tabs" => widgets::tabs::render(frame, area, node)?,
50
- "RatatuiRuby::Scrollbar" => widgets::scrollbar::render(frame, area, node)?,
51
- "RatatuiRuby::BarChart" => widgets::barchart::render(frame, area, node)?,
52
- "RatatuiRuby::Canvas" => widgets::canvas::render(frame, area, node)?,
53
- "RatatuiRuby::Calendar" => widgets::calendar::render(frame, area, node)?,
54
- "RatatuiRuby::Sparkline" => widgets::sparkline::render(frame, area, node)?,
55
- "RatatuiRuby::Chart" | "RatatuiRuby::LineChart" => {
39
+ "RatatuiRuby::Widgets::Paragraph" => widgets::paragraph::render(frame, area, node)?,
40
+ "RatatuiRuby::Widgets::Clear" => widgets::clear::render(frame, area, node)?,
41
+ "RatatuiRuby::Widgets::Cursor" => widgets::cursor::render(frame, area, node)?,
42
+ "RatatuiRuby::Widgets::Overlay" => widgets::overlay::render(frame, area, node)?,
43
+ "RatatuiRuby::Widgets::Center" => widgets::center::render(frame, area, node)?,
44
+ "RatatuiRuby::Layout::Layout" => widgets::layout::render(frame, area, node)?,
45
+ "RatatuiRuby::Widgets::List" => widgets::list::render(frame, area, node)?,
46
+ "RatatuiRuby::Widgets::Gauge" => widgets::gauge::render(frame, area, node)?,
47
+ "RatatuiRuby::Widgets::LineGauge" => widgets::line_gauge::render(frame, area, node)?,
48
+ "RatatuiRuby::Widgets::Table" => widgets::table::render(frame, area, node)?,
49
+ "RatatuiRuby::Widgets::Block" => widgets::block::render(frame, area, node)?,
50
+ "RatatuiRuby::Widgets::Tabs" => widgets::tabs::render(frame, area, node)?,
51
+ "RatatuiRuby::Widgets::Scrollbar" => widgets::scrollbar::render(frame, area, node)?,
52
+ "RatatuiRuby::Widgets::BarChart" => widgets::barchart::render(frame, area, node)?,
53
+ "RatatuiRuby::Widgets::Canvas" => widgets::canvas::render(frame, area, node)?,
54
+ "RatatuiRuby::Widgets::Calendar" => widgets::calendar::render(frame, area, node)?,
55
+ "RatatuiRuby::Widgets::Sparkline" => widgets::sparkline::render(frame, area, node)?,
56
+ "RatatuiRuby::Widgets::Chart" | "RatatuiRuby::LineChart" => {
56
57
  widgets::chart::render(frame, area, node)?;
57
58
  }
58
- "RatatuiRuby::RatatuiLogo" => widgets::ratatui_logo::render(frame, area, node),
59
- "RatatuiRuby::RatatuiMascot" => {
59
+ "RatatuiRuby::Widgets::RatatuiLogo" => widgets::ratatui_logo::render(frame, area, node),
60
+ "RatatuiRuby::Widgets::RatatuiMascot" => {
60
61
  widgets::ratatui_mascot::render_ratatui_mascot(frame, area, node)?;
61
62
  }
62
63
  _ => {}