ratatui_ruby 1.5.1-arm64-darwin-23

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 (295) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +15 -0
  3. data/LICENSES/AGPL-3.0-or-later.txt +661 -0
  4. data/LICENSES/CC-BY-SA-4.0.txt +427 -0
  5. data/LICENSES/CC0-1.0.txt +121 -0
  6. data/LICENSES/LGPL-3.0-or-later.txt +304 -0
  7. data/LICENSES/MIT-0.txt +16 -0
  8. data/LICENSES/MIT.txt +21 -0
  9. data/REUSE.toml +42 -0
  10. data/exe/.gitkeep +0 -0
  11. data/ext/ratatui_ruby/.cargo/config.toml +13 -0
  12. data/ext/ratatui_ruby/.gitignore +4 -0
  13. data/ext/ratatui_ruby/Cargo.lock +1730 -0
  14. data/ext/ratatui_ruby/Cargo.toml +23 -0
  15. data/ext/ratatui_ruby/clippy.toml +7 -0
  16. data/ext/ratatui_ruby/extconf.rb +21 -0
  17. data/ext/ratatui_ruby/src/color.rs +82 -0
  18. data/ext/ratatui_ruby/src/errors.rs +28 -0
  19. data/ext/ratatui_ruby/src/events.rs +706 -0
  20. data/ext/ratatui_ruby/src/frame.rs +241 -0
  21. data/ext/ratatui_ruby/src/lib.rs +343 -0
  22. data/ext/ratatui_ruby/src/lib_header.rs +11 -0
  23. data/ext/ratatui_ruby/src/rendering.rs +158 -0
  24. data/ext/ratatui_ruby/src/string_width.rs +26 -0
  25. data/ext/ratatui_ruby/src/style.rs +469 -0
  26. data/ext/ratatui_ruby/src/terminal/capabilities.rs +46 -0
  27. data/ext/ratatui_ruby/src/terminal/init.rs +233 -0
  28. data/ext/ratatui_ruby/src/terminal/mod.rs +42 -0
  29. data/ext/ratatui_ruby/src/terminal/mutations.rs +158 -0
  30. data/ext/ratatui_ruby/src/terminal/queries.rs +249 -0
  31. data/ext/ratatui_ruby/src/terminal/query.rs +400 -0
  32. data/ext/ratatui_ruby/src/terminal/storage.rs +109 -0
  33. data/ext/ratatui_ruby/src/terminal/wrapper.rs +16 -0
  34. data/ext/ratatui_ruby/src/text.rs +225 -0
  35. data/ext/ratatui_ruby/src/widgets/barchart.rs +169 -0
  36. data/ext/ratatui_ruby/src/widgets/block.rs +41 -0
  37. data/ext/ratatui_ruby/src/widgets/calendar.rs +84 -0
  38. data/ext/ratatui_ruby/src/widgets/canvas.rs +183 -0
  39. data/ext/ratatui_ruby/src/widgets/center.rs +79 -0
  40. data/ext/ratatui_ruby/src/widgets/chart.rs +222 -0
  41. data/ext/ratatui_ruby/src/widgets/clear.rs +39 -0
  42. data/ext/ratatui_ruby/src/widgets/cursor.rs +32 -0
  43. data/ext/ratatui_ruby/src/widgets/gauge.rs +65 -0
  44. data/ext/ratatui_ruby/src/widgets/layout.rs +379 -0
  45. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +100 -0
  46. data/ext/ratatui_ruby/src/widgets/list.rs +378 -0
  47. data/ext/ratatui_ruby/src/widgets/list_state.rs +173 -0
  48. data/ext/ratatui_ruby/src/widgets/mod.rs +26 -0
  49. data/ext/ratatui_ruby/src/widgets/overlay.rs +24 -0
  50. data/ext/ratatui_ruby/src/widgets/paragraph.rs +87 -0
  51. data/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +40 -0
  52. data/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +55 -0
  53. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +214 -0
  54. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  55. data/ext/ratatui_ruby/src/widgets/sparkline.rs +127 -0
  56. data/ext/ratatui_ruby/src/widgets/table.rs +415 -0
  57. data/ext/ratatui_ruby/src/widgets/table_state.rs +203 -0
  58. data/ext/ratatui_ruby/src/widgets/tabs.rs +194 -0
  59. data/lib/ratatui_ruby/3.2/ratatui_ruby.bundle +0 -0
  60. data/lib/ratatui_ruby/3.3/ratatui_ruby.bundle +0 -0
  61. data/lib/ratatui_ruby/3.4/ratatui_ruby.bundle +0 -0
  62. data/lib/ratatui_ruby/4.0/ratatui_ruby.bundle +0 -0
  63. data/lib/ratatui_ruby/backend/window_size.rb +50 -0
  64. data/lib/ratatui_ruby/backend.rb +59 -0
  65. data/lib/ratatui_ruby/buffer/cell.rb +212 -0
  66. data/lib/ratatui_ruby/buffer.rb +149 -0
  67. data/lib/ratatui_ruby/cell.rb +208 -0
  68. data/lib/ratatui_ruby/debug.rb +215 -0
  69. data/lib/ratatui_ruby/draw.rb +63 -0
  70. data/lib/ratatui_ruby/event/focus_gained.rb +125 -0
  71. data/lib/ratatui_ruby/event/focus_lost.rb +127 -0
  72. data/lib/ratatui_ruby/event/key/character.rb +53 -0
  73. data/lib/ratatui_ruby/event/key/dwim.rb +301 -0
  74. data/lib/ratatui_ruby/event/key/media.rb +46 -0
  75. data/lib/ratatui_ruby/event/key/modifier.rb +107 -0
  76. data/lib/ratatui_ruby/event/key/navigation.rb +72 -0
  77. data/lib/ratatui_ruby/event/key/system.rb +47 -0
  78. data/lib/ratatui_ruby/event/key.rb +479 -0
  79. data/lib/ratatui_ruby/event/mouse.rb +291 -0
  80. data/lib/ratatui_ruby/event/none.rb +53 -0
  81. data/lib/ratatui_ruby/event/paste.rb +130 -0
  82. data/lib/ratatui_ruby/event/resize.rb +221 -0
  83. data/lib/ratatui_ruby/event/sync.rb +52 -0
  84. data/lib/ratatui_ruby/event.rb +163 -0
  85. data/lib/ratatui_ruby/frame.rb +257 -0
  86. data/lib/ratatui_ruby/labs/a11y.rb +182 -0
  87. data/lib/ratatui_ruby/labs/frame_a11y_capture.rb +50 -0
  88. data/lib/ratatui_ruby/labs.rb +47 -0
  89. data/lib/ratatui_ruby/layout/alignment.rb +91 -0
  90. data/lib/ratatui_ruby/layout/constraint.rb +337 -0
  91. data/lib/ratatui_ruby/layout/layout.rb +258 -0
  92. data/lib/ratatui_ruby/layout/position.rb +81 -0
  93. data/lib/ratatui_ruby/layout/rect.rb +733 -0
  94. data/lib/ratatui_ruby/layout/size.rb +62 -0
  95. data/lib/ratatui_ruby/layout.rb +29 -0
  96. data/lib/ratatui_ruby/list_state.rb +201 -0
  97. data/lib/ratatui_ruby/output_guard.rb +171 -0
  98. data/lib/ratatui_ruby/scrollbar_state.rb +122 -0
  99. data/lib/ratatui_ruby/style/color.rb +149 -0
  100. data/lib/ratatui_ruby/style/style.rb +147 -0
  101. data/lib/ratatui_ruby/style.rb +19 -0
  102. data/lib/ratatui_ruby/symbols.rb +435 -0
  103. data/lib/ratatui_ruby/synthetic_events.rb +106 -0
  104. data/lib/ratatui_ruby/table_state.rb +251 -0
  105. data/lib/ratatui_ruby/terminal/capabilities.rb +316 -0
  106. data/lib/ratatui_ruby/terminal/viewport.rb +80 -0
  107. data/lib/ratatui_ruby/terminal.rb +66 -0
  108. data/lib/ratatui_ruby/terminal_lifecycle.rb +303 -0
  109. data/lib/ratatui_ruby/terminal_lifecycle.rb.bak +197 -0
  110. data/lib/ratatui_ruby/test_helper/event_injection.rb +244 -0
  111. data/lib/ratatui_ruby/test_helper/global_state.rb +111 -0
  112. data/lib/ratatui_ruby/test_helper/snapshot.rb +568 -0
  113. data/lib/ratatui_ruby/test_helper/snapshots/axis_labels_alignment.ansi +24 -0
  114. data/lib/ratatui_ruby/test_helper/snapshots/axis_labels_alignment.txt +24 -0
  115. data/lib/ratatui_ruby/test_helper/snapshots/barchart_styled_label.ansi +5 -0
  116. data/lib/ratatui_ruby/test_helper/snapshots/barchart_styled_label.txt +5 -0
  117. data/lib/ratatui_ruby/test_helper/snapshots/chart_rendering.ansi +24 -0
  118. data/lib/ratatui_ruby/test_helper/snapshots/chart_rendering.txt +24 -0
  119. data/lib/ratatui_ruby/test_helper/snapshots/half_block_marker.ansi +12 -0
  120. data/lib/ratatui_ruby/test_helper/snapshots/half_block_marker.txt +12 -0
  121. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_bottom.ansi +12 -0
  122. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_bottom.txt +12 -0
  123. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_left.ansi +12 -0
  124. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_left.txt +12 -0
  125. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_right.ansi +12 -0
  126. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_right.txt +12 -0
  127. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_top.ansi +12 -0
  128. data/lib/ratatui_ruby/test_helper/snapshots/legend_position_top.txt +12 -0
  129. data/lib/ratatui_ruby/test_helper/snapshots/my_snapshot.txt +1 -0
  130. data/lib/ratatui_ruby/test_helper/snapshots/styled_axis_title.ansi +10 -0
  131. data/lib/ratatui_ruby/test_helper/snapshots/styled_axis_title.txt +10 -0
  132. data/lib/ratatui_ruby/test_helper/snapshots/styled_dataset_name.ansi +10 -0
  133. data/lib/ratatui_ruby/test_helper/snapshots/styled_dataset_name.txt +10 -0
  134. data/lib/ratatui_ruby/test_helper/style_assertions.rb +449 -0
  135. data/lib/ratatui_ruby/test_helper/subprocess_timeout.rb +35 -0
  136. data/lib/ratatui_ruby/test_helper/terminal.rb +187 -0
  137. data/lib/ratatui_ruby/test_helper/test_doubles.rb +86 -0
  138. data/lib/ratatui_ruby/test_helper.rb +115 -0
  139. data/lib/ratatui_ruby/text/line.rb +245 -0
  140. data/lib/ratatui_ruby/text/span.rb +158 -0
  141. data/lib/ratatui_ruby/text.rb +99 -0
  142. data/lib/ratatui_ruby/tui/buffer_factories.rb +22 -0
  143. data/lib/ratatui_ruby/tui/canvas_factories.rb +149 -0
  144. data/lib/ratatui_ruby/tui/core.rb +67 -0
  145. data/lib/ratatui_ruby/tui/layout_factories.rb +153 -0
  146. data/lib/ratatui_ruby/tui/state_factories.rb +77 -0
  147. data/lib/ratatui_ruby/tui/style_factories.rb +22 -0
  148. data/lib/ratatui_ruby/tui/text_factories.rb +86 -0
  149. data/lib/ratatui_ruby/tui/widget_factories.rb +272 -0
  150. data/lib/ratatui_ruby/tui.rb +106 -0
  151. data/lib/ratatui_ruby/version.rb +12 -0
  152. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +51 -0
  153. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +29 -0
  154. data/lib/ratatui_ruby/widgets/bar_chart.rb +308 -0
  155. data/lib/ratatui_ruby/widgets/block.rb +266 -0
  156. data/lib/ratatui_ruby/widgets/calendar.rb +88 -0
  157. data/lib/ratatui_ruby/widgets/canvas.rb +297 -0
  158. data/lib/ratatui_ruby/widgets/cell.rb +59 -0
  159. data/lib/ratatui_ruby/widgets/center.rb +71 -0
  160. data/lib/ratatui_ruby/widgets/chart.rb +172 -0
  161. data/lib/ratatui_ruby/widgets/clear.rb +66 -0
  162. data/lib/ratatui_ruby/widgets/coerceable_widget.rb +77 -0
  163. data/lib/ratatui_ruby/widgets/cursor.rb +54 -0
  164. data/lib/ratatui_ruby/widgets/gauge.rb +146 -0
  165. data/lib/ratatui_ruby/widgets/line_gauge.rb +158 -0
  166. data/lib/ratatui_ruby/widgets/list.rb +252 -0
  167. data/lib/ratatui_ruby/widgets/list_item.rb +55 -0
  168. data/lib/ratatui_ruby/widgets/overlay.rb +55 -0
  169. data/lib/ratatui_ruby/widgets/paragraph.rb +113 -0
  170. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +35 -0
  171. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +40 -0
  172. data/lib/ratatui_ruby/widgets/row.rb +123 -0
  173. data/lib/ratatui_ruby/widgets/scrollbar.rb +147 -0
  174. data/lib/ratatui_ruby/widgets/shape/label.rb +80 -0
  175. data/lib/ratatui_ruby/widgets/sparkline.rb +153 -0
  176. data/lib/ratatui_ruby/widgets/table.rb +213 -0
  177. data/lib/ratatui_ruby/widgets/tabs.rb +91 -0
  178. data/lib/ratatui_ruby/widgets.rb +43 -0
  179. data/lib/ratatui_ruby.rb +556 -0
  180. data/sig/examples/app_all_events/app.rbs +11 -0
  181. data/sig/examples/app_all_events/model/app_model.rbs +23 -0
  182. data/sig/examples/app_all_events/model/event_entry.rbs +23 -0
  183. data/sig/examples/app_all_events/model/timestamp.rbs +11 -0
  184. data/sig/examples/app_all_events/view/app_view.rbs +8 -0
  185. data/sig/examples/app_all_events/view/controls_view.rbs +6 -0
  186. data/sig/examples/app_all_events/view/counts_view.rbs +6 -0
  187. data/sig/examples/app_all_events/view/live_view.rbs +6 -0
  188. data/sig/examples/app_all_events/view/log_view.rbs +6 -0
  189. data/sig/examples/app_all_events/view.rbs +14 -0
  190. data/sig/examples/app_cli_rich_moments/app.rbs +12 -0
  191. data/sig/examples/app_color_picker/app.rbs +17 -0
  192. data/sig/examples/app_external_editor/app.rbs +12 -0
  193. data/sig/examples/app_login_form/app.rbs +11 -0
  194. data/sig/examples/app_stateful_interaction/app.rbs +39 -0
  195. data/sig/examples/verify_quickstart_dsl/app.rbs +17 -0
  196. data/sig/examples/verify_quickstart_lifecycle/app.rbs +17 -0
  197. data/sig/examples/verify_readme_usage/app.rbs +17 -0
  198. data/sig/examples/widget_block_demo/app.rbs +38 -0
  199. data/sig/examples/widget_box_demo/app.rbs +17 -0
  200. data/sig/examples/widget_calendar_demo/app.rbs +17 -0
  201. data/sig/examples/widget_cell_demo/app.rbs +17 -0
  202. data/sig/examples/widget_chart_demo/app.rbs +17 -0
  203. data/sig/examples/widget_gauge_demo/app.rbs +17 -0
  204. data/sig/examples/widget_layout_split/app.rbs +16 -0
  205. data/sig/examples/widget_line_gauge_demo/app.rbs +17 -0
  206. data/sig/examples/widget_list_demo/app.rbs +17 -0
  207. data/sig/examples/widget_map_demo/app.rbs +17 -0
  208. data/sig/examples/widget_popup_demo/app.rbs +17 -0
  209. data/sig/examples/widget_ratatui_logo_demo/app.rbs +17 -0
  210. data/sig/examples/widget_ratatui_mascot_demo/app.rbs +17 -0
  211. data/sig/examples/widget_rect/app.rbs +18 -0
  212. data/sig/examples/widget_render/app.rbs +16 -0
  213. data/sig/examples/widget_rich_text/app.rbs +17 -0
  214. data/sig/examples/widget_scroll_text/app.rbs +17 -0
  215. data/sig/examples/widget_scrollbar_demo/app.rbs +17 -0
  216. data/sig/examples/widget_sparkline_demo/app.rbs +16 -0
  217. data/sig/examples/widget_style_colors/app.rbs +20 -0
  218. data/sig/examples/widget_table_demo/app.rbs +17 -0
  219. data/sig/examples/widget_text_width/app.rbs +16 -0
  220. data/sig/generated/event_key_predicates.rbs +1348 -0
  221. data/sig/manifest.yaml +5 -0
  222. data/sig/patches/data.rbs +26 -0
  223. data/sig/patches/debugger__.rbs +8 -0
  224. data/sig/ratatui_ruby/backend/window_size.rbs +17 -0
  225. data/sig/ratatui_ruby/backend.rbs +12 -0
  226. data/sig/ratatui_ruby/buffer/cell.rbs +46 -0
  227. data/sig/ratatui_ruby/buffer.rbs +18 -0
  228. data/sig/ratatui_ruby/cell.rbs +44 -0
  229. data/sig/ratatui_ruby/clear.rbs +18 -0
  230. data/sig/ratatui_ruby/constraint.rbs +26 -0
  231. data/sig/ratatui_ruby/debug.rbs +45 -0
  232. data/sig/ratatui_ruby/draw.rbs +30 -0
  233. data/sig/ratatui_ruby/event.rbs +249 -0
  234. data/sig/ratatui_ruby/frame.rbs +23 -0
  235. data/sig/ratatui_ruby/interfaces.rbs +25 -0
  236. data/sig/ratatui_ruby/labs.rbs +90 -0
  237. data/sig/ratatui_ruby/layout/alignment.rbs +26 -0
  238. data/sig/ratatui_ruby/layout/constraint.rbs +39 -0
  239. data/sig/ratatui_ruby/layout/layout.rbs +45 -0
  240. data/sig/ratatui_ruby/layout/position.rbs +18 -0
  241. data/sig/ratatui_ruby/layout/rect.rbs +64 -0
  242. data/sig/ratatui_ruby/layout/size.rbs +18 -0
  243. data/sig/ratatui_ruby/list_state.rbs +23 -0
  244. data/sig/ratatui_ruby/output_guard.rbs +23 -0
  245. data/sig/ratatui_ruby/ratatui_ruby.rbs +113 -0
  246. data/sig/ratatui_ruby/rect.rbs +17 -0
  247. data/sig/ratatui_ruby/scrollbar_state.rbs +24 -0
  248. data/sig/ratatui_ruby/session.rbs +93 -0
  249. data/sig/ratatui_ruby/style/color.rbs +22 -0
  250. data/sig/ratatui_ruby/style/style.rbs +29 -0
  251. data/sig/ratatui_ruby/symbols.rbs +141 -0
  252. data/sig/ratatui_ruby/synthetic_events.rbs +24 -0
  253. data/sig/ratatui_ruby/table_state.rbs +27 -0
  254. data/sig/ratatui_ruby/terminal/capabilities.rbs +38 -0
  255. data/sig/ratatui_ruby/terminal/viewport.rbs +33 -0
  256. data/sig/ratatui_ruby/terminal_lifecycle.rbs +39 -0
  257. data/sig/ratatui_ruby/test_helper/event_injection.rbs +22 -0
  258. data/sig/ratatui_ruby/test_helper/snapshot.rbs +37 -0
  259. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +77 -0
  260. data/sig/ratatui_ruby/test_helper/terminal.rbs +20 -0
  261. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +32 -0
  262. data/sig/ratatui_ruby/test_helper.rbs +18 -0
  263. data/sig/ratatui_ruby/text/line.rbs +27 -0
  264. data/sig/ratatui_ruby/text/span.rbs +23 -0
  265. data/sig/ratatui_ruby/text.rbs +12 -0
  266. data/sig/ratatui_ruby/tui/buffer_factories.rbs +16 -0
  267. data/sig/ratatui_ruby/tui/canvas_factories.rbs +38 -0
  268. data/sig/ratatui_ruby/tui/core.rbs +23 -0
  269. data/sig/ratatui_ruby/tui/layout_factories.rbs +39 -0
  270. data/sig/ratatui_ruby/tui/state_factories.rbs +23 -0
  271. data/sig/ratatui_ruby/tui/style_factories.rbs +18 -0
  272. data/sig/ratatui_ruby/tui/text_factories.rbs +23 -0
  273. data/sig/ratatui_ruby/tui/widget_factories.rbs +138 -0
  274. data/sig/ratatui_ruby/tui.rbs +25 -0
  275. data/sig/ratatui_ruby/version.rbs +12 -0
  276. data/sig/ratatui_ruby/widgets/bar_chart.rbs +95 -0
  277. data/sig/ratatui_ruby/widgets/block.rbs +51 -0
  278. data/sig/ratatui_ruby/widgets/calendar.rbs +45 -0
  279. data/sig/ratatui_ruby/widgets/canvas.rbs +95 -0
  280. data/sig/ratatui_ruby/widgets/chart.rbs +91 -0
  281. data/sig/ratatui_ruby/widgets/coerceable_widget.rbs +26 -0
  282. data/sig/ratatui_ruby/widgets/gauge.rbs +44 -0
  283. data/sig/ratatui_ruby/widgets/line_gauge.rbs +48 -0
  284. data/sig/ratatui_ruby/widgets/list.rbs +63 -0
  285. data/sig/ratatui_ruby/widgets/misc.rbs +158 -0
  286. data/sig/ratatui_ruby/widgets/paragraph.rbs +45 -0
  287. data/sig/ratatui_ruby/widgets/row.rbs +43 -0
  288. data/sig/ratatui_ruby/widgets/scrollbar.rbs +53 -0
  289. data/sig/ratatui_ruby/widgets/shape/label.rbs +37 -0
  290. data/sig/ratatui_ruby/widgets/sparkline.rbs +45 -0
  291. data/sig/ratatui_ruby/widgets/table.rbs +78 -0
  292. data/sig/ratatui_ruby/widgets/tabs.rbs +44 -0
  293. data/sig/ratatui_ruby/widgets.rbs +16 -0
  294. data/vendor/goodcop/base.yml +1047 -0
  295. metadata +738 -0
@@ -0,0 +1,706 @@
1
+ // SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
2
+ // SPDX-License-Identifier: LGPL-3.0-or-later
3
+
4
+ use magnus::{Error, IntoValue, TryConvert, Value};
5
+ use rb_sys::rb_thread_call_without_gvl;
6
+ use std::cell::RefCell;
7
+ use std::os::raw::c_void;
8
+
9
+ /// Wrapper enum for test events - includes crossterm events and our Sync event.
10
+ #[derive(Debug, Clone)]
11
+ enum TestEvent {
12
+ Crossterm(ratatui::crossterm::event::Event),
13
+ Sync,
14
+ None,
15
+ }
16
+
17
+ thread_local! {
18
+ static EVENT_QUEUE: RefCell<Vec<TestEvent>> = const { RefCell::new(Vec::new()) };
19
+ }
20
+
21
+ use ratatui::crossterm::event::{KeyCode, KeyModifiers, MediaKeyCode, ModifierKeyCode};
22
+
23
+ /// Single source of truth for base key code mappings.
24
+ const BASE_KEY_MAPPINGS: &[(&str, KeyCode)] = &[
25
+ // Arrow keys
26
+ ("up", KeyCode::Up),
27
+ ("down", KeyCode::Down),
28
+ ("left", KeyCode::Left),
29
+ ("right", KeyCode::Right),
30
+ // Common keys
31
+ ("enter", KeyCode::Enter),
32
+ ("esc", KeyCode::Esc),
33
+ ("backspace", KeyCode::Backspace),
34
+ ("tab", KeyCode::Tab),
35
+ ("back_tab", KeyCode::BackTab),
36
+ ("null", KeyCode::Null),
37
+ // Navigation keys
38
+ ("home", KeyCode::Home),
39
+ ("end", KeyCode::End),
40
+ ("page_up", KeyCode::PageUp),
41
+ ("page_down", KeyCode::PageDown),
42
+ ("insert", KeyCode::Insert),
43
+ ("delete", KeyCode::Delete),
44
+ // Lock keys
45
+ ("caps_lock", KeyCode::CapsLock),
46
+ ("scroll_lock", KeyCode::ScrollLock),
47
+ ("num_lock", KeyCode::NumLock),
48
+ // System keys
49
+ ("print_screen", KeyCode::PrintScreen),
50
+ ("pause", KeyCode::Pause),
51
+ ("menu", KeyCode::Menu),
52
+ ("keypad_begin", KeyCode::KeypadBegin),
53
+ ];
54
+
55
+ /// Single source of truth for media key mappings.
56
+ const MEDIA_KEY_MAPPINGS: &[(&str, MediaKeyCode)] = &[
57
+ ("media_play", MediaKeyCode::Play),
58
+ ("media_pause", MediaKeyCode::Pause),
59
+ ("media_play_pause", MediaKeyCode::PlayPause),
60
+ ("media_reverse", MediaKeyCode::Reverse),
61
+ ("media_stop", MediaKeyCode::Stop),
62
+ ("media_fast_forward", MediaKeyCode::FastForward),
63
+ ("media_rewind", MediaKeyCode::Rewind),
64
+ ("media_track_next", MediaKeyCode::TrackNext),
65
+ ("media_track_previous", MediaKeyCode::TrackPrevious),
66
+ ("media_record", MediaKeyCode::Record),
67
+ ("media_lower_volume", MediaKeyCode::LowerVolume),
68
+ ("media_raise_volume", MediaKeyCode::RaiseVolume),
69
+ ("media_mute_volume", MediaKeyCode::MuteVolume),
70
+ ];
71
+
72
+ /// Single source of truth for modifier key mappings.
73
+ const MODIFIER_KEY_MAPPINGS: &[(&str, ModifierKeyCode)] = &[
74
+ ("left_shift", ModifierKeyCode::LeftShift),
75
+ ("left_control", ModifierKeyCode::LeftControl),
76
+ ("left_alt", ModifierKeyCode::LeftAlt),
77
+ ("left_super", ModifierKeyCode::LeftSuper),
78
+ ("left_hyper", ModifierKeyCode::LeftHyper),
79
+ ("left_meta", ModifierKeyCode::LeftMeta),
80
+ ("right_shift", ModifierKeyCode::RightShift),
81
+ ("right_control", ModifierKeyCode::RightControl),
82
+ ("right_alt", ModifierKeyCode::RightAlt),
83
+ ("right_super", ModifierKeyCode::RightSuper),
84
+ ("right_hyper", ModifierKeyCode::RightHyper),
85
+ ("right_meta", ModifierKeyCode::RightMeta),
86
+ ("iso_level3_shift", ModifierKeyCode::IsoLevel3Shift),
87
+ ("iso_level5_shift", ModifierKeyCode::IsoLevel5Shift),
88
+ ];
89
+
90
+ /// Single source of truth for keyboard modifier flag mappings.
91
+ const KEYBOARD_MODIFIER_MAPPINGS: &[(&str, KeyModifiers)] = &[
92
+ ("ctrl", KeyModifiers::CONTROL),
93
+ ("alt", KeyModifiers::ALT),
94
+ ("shift", KeyModifiers::SHIFT),
95
+ ];
96
+
97
+ /// Returns all supported key codes for RBS generation.
98
+ pub fn all_key_codes() -> magnus::RHash {
99
+ let ruby = magnus::Ruby::get().unwrap();
100
+ let hash = ruby.hash_new();
101
+
102
+ let base: Vec<&str> = BASE_KEY_MAPPINGS.iter().map(|(s, _)| *s).collect();
103
+ let media: Vec<&str> = MEDIA_KEY_MAPPINGS.iter().map(|(s, _)| *s).collect();
104
+ let modifier_keys: Vec<&str> = MODIFIER_KEY_MAPPINGS.iter().map(|(s, _)| *s).collect();
105
+ let keyboard_modifiers: Vec<&str> =
106
+ KEYBOARD_MODIFIER_MAPPINGS.iter().map(|(s, _)| *s).collect();
107
+
108
+ let _ = hash.aset(ruby.to_symbol("base_keys"), base);
109
+ let _ = hash.aset(ruby.to_symbol("media_keys"), media);
110
+ let _ = hash.aset(ruby.to_symbol("modifier_keys"), modifier_keys);
111
+ let _ = hash.aset(ruby.to_symbol("keyboard_modifiers"), keyboard_modifiers);
112
+
113
+ hash
114
+ }
115
+
116
+ #[allow(clippy::needless_pass_by_value)]
117
+ pub fn inject_test_event(event_type: String, data: magnus::RHash) -> Result<(), Error> {
118
+ let ruby = magnus::Ruby::get().unwrap();
119
+ let event = match event_type.as_str() {
120
+ "key" => TestEvent::Crossterm(parse_key_event(data, &ruby)?),
121
+ "mouse" => TestEvent::Crossterm(parse_mouse_event(data, &ruby)?),
122
+ "resize" => TestEvent::Crossterm(parse_resize_event(data, &ruby)?),
123
+ "paste" => TestEvent::Crossterm(parse_paste_event(data, &ruby)?),
124
+ "focus_gained" => TestEvent::Crossterm(ratatui::crossterm::event::Event::FocusGained),
125
+ "focus_lost" => TestEvent::Crossterm(ratatui::crossterm::event::Event::FocusLost),
126
+ "sync" => TestEvent::Sync,
127
+ "none" => TestEvent::None,
128
+ _ => {
129
+ return Err(Error::new(
130
+ ruby.exception_arg_error(),
131
+ format!("Unknown event type: {event_type}"),
132
+ ))
133
+ }
134
+ };
135
+
136
+ EVENT_QUEUE.with(|q| q.borrow_mut().push(event));
137
+ Ok(())
138
+ }
139
+
140
+ fn parse_base_key(s: &str) -> Option<KeyCode> {
141
+ BASE_KEY_MAPPINGS
142
+ .iter()
143
+ .find(|(key, _)| *key == s)
144
+ .map(|(_, code)| *code)
145
+ }
146
+
147
+ fn parse_media_key(s: &str) -> Option<MediaKeyCode> {
148
+ MEDIA_KEY_MAPPINGS
149
+ .iter()
150
+ .find(|(key, _)| *key == s)
151
+ .map(|(_, code)| *code)
152
+ }
153
+
154
+ fn parse_modifier_key(s: &str) -> Option<ModifierKeyCode> {
155
+ MODIFIER_KEY_MAPPINGS
156
+ .iter()
157
+ .find(|(key, _)| *key == s)
158
+ .map(|(_, code)| *code)
159
+ }
160
+
161
+ fn parse_keyboard_modifier(s: &str) -> Option<KeyModifiers> {
162
+ KEYBOARD_MODIFIER_MAPPINGS
163
+ .iter()
164
+ .find(|(key, _)| *key == s)
165
+ .map(|(_, mods)| *mods)
166
+ }
167
+
168
+ fn parse_key_event(
169
+ data: magnus::RHash,
170
+ ruby: &magnus::Ruby,
171
+ ) -> Result<ratatui::crossterm::event::Event, Error> {
172
+ let code_val: Value = data
173
+ .get(ruby.to_symbol("code"))
174
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "Missing 'code' in key event"))?;
175
+ let code_str: String = String::try_convert(code_val)?;
176
+
177
+ let code = if let Some(kc) = parse_base_key(&code_str) {
178
+ kc
179
+ } else if let Some(m) = parse_media_key(&code_str) {
180
+ KeyCode::Media(m)
181
+ } else if let Some(m) = parse_modifier_key(&code_str) {
182
+ KeyCode::Modifier(m)
183
+ } else if let Some(num_str) = code_str.strip_prefix('f') {
184
+ if let Ok(n) = num_str.parse::<u8>() {
185
+ KeyCode::F(n)
186
+ } else {
187
+ KeyCode::Char(code_str.chars().next().unwrap_or('\0'))
188
+ }
189
+ } else if code_str.len() == 1 {
190
+ KeyCode::Char(code_str.chars().next().unwrap())
191
+ } else {
192
+ KeyCode::Null
193
+ };
194
+
195
+ let mut modifiers = KeyModifiers::empty();
196
+ if let Some(mods_val) = data.get(ruby.to_symbol("modifiers")) {
197
+ let mods: Vec<String> = Vec::try_convert(mods_val)?;
198
+ for m in mods {
199
+ if let Some(mod_flag) = parse_keyboard_modifier(&m) {
200
+ modifiers |= mod_flag;
201
+ }
202
+ }
203
+ }
204
+
205
+ Ok(ratatui::crossterm::event::Event::Key(
206
+ ratatui::crossterm::event::KeyEvent::new(code, modifiers),
207
+ ))
208
+ }
209
+
210
+ fn parse_mouse_event(
211
+ data: magnus::RHash,
212
+ ruby: &magnus::Ruby,
213
+ ) -> Result<ratatui::crossterm::event::Event, Error> {
214
+ let kind_val: Value = data
215
+ .get(ruby.to_symbol("kind"))
216
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "Missing 'kind' in mouse event"))?;
217
+ let kind_str: String = String::try_convert(kind_val)?;
218
+
219
+ let button = if let Some(btn_val) = data.get(ruby.to_symbol("button")) {
220
+ let button_str: String = String::try_convert(btn_val)?;
221
+ match button_str.as_str() {
222
+ "right" => ratatui::crossterm::event::MouseButton::Right,
223
+ "middle" => ratatui::crossterm::event::MouseButton::Middle,
224
+ _ => ratatui::crossterm::event::MouseButton::Left,
225
+ }
226
+ } else {
227
+ ratatui::crossterm::event::MouseButton::Left
228
+ };
229
+
230
+ let x_val: Value = data
231
+ .get(ruby.to_symbol("x"))
232
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "Missing 'x' in mouse event"))?;
233
+ let x: u16 = u16::try_convert(x_val)?;
234
+
235
+ let y_val: Value = data
236
+ .get(ruby.to_symbol("y"))
237
+ .ok_or_else(|| Error::new(ruby.exception_arg_error(), "Missing 'y' in mouse event"))?;
238
+ let y: u16 = u16::try_convert(y_val)?;
239
+
240
+ let kind = match kind_str.as_str() {
241
+ "down" => ratatui::crossterm::event::MouseEventKind::Down(button),
242
+ "up" => ratatui::crossterm::event::MouseEventKind::Up(button),
243
+ "drag" => ratatui::crossterm::event::MouseEventKind::Drag(button),
244
+ "moved" => ratatui::crossterm::event::MouseEventKind::Moved,
245
+ "scroll_down" => ratatui::crossterm::event::MouseEventKind::ScrollDown,
246
+ "scroll_up" => ratatui::crossterm::event::MouseEventKind::ScrollUp,
247
+ "scroll_left" => ratatui::crossterm::event::MouseEventKind::ScrollLeft,
248
+ "scroll_right" => ratatui::crossterm::event::MouseEventKind::ScrollRight,
249
+ _ => {
250
+ return Err(Error::new(
251
+ ruby.exception_arg_error(),
252
+ format!("Unknown mouse kind: {kind_str}"),
253
+ ))
254
+ }
255
+ };
256
+
257
+ let mut modifiers = ratatui::crossterm::event::KeyModifiers::empty();
258
+ if let Some(mods_val) = data.get(ruby.to_symbol("modifiers")) {
259
+ let mods: Vec<String> = Vec::try_convert(mods_val)?;
260
+ for m in mods {
261
+ match m.as_str() {
262
+ "ctrl" => modifiers |= ratatui::crossterm::event::KeyModifiers::CONTROL,
263
+ "alt" => modifiers |= ratatui::crossterm::event::KeyModifiers::ALT,
264
+ "shift" => modifiers |= ratatui::crossterm::event::KeyModifiers::SHIFT,
265
+ _ => {}
266
+ }
267
+ }
268
+ }
269
+
270
+ Ok(ratatui::crossterm::event::Event::Mouse(
271
+ ratatui::crossterm::event::MouseEvent {
272
+ kind,
273
+ column: x,
274
+ row: y,
275
+ modifiers,
276
+ },
277
+ ))
278
+ }
279
+
280
+ fn parse_resize_event(
281
+ data: magnus::RHash,
282
+ ruby: &magnus::Ruby,
283
+ ) -> Result<ratatui::crossterm::event::Event, Error> {
284
+ let width_val: Value = data.get(ruby.to_symbol("width")).ok_or_else(|| {
285
+ Error::new(
286
+ ruby.exception_arg_error(),
287
+ "Missing 'width' in resize event",
288
+ )
289
+ })?;
290
+ let width: u16 = u16::try_convert(width_val)?;
291
+
292
+ let height_val: Value = data.get(ruby.to_symbol("height")).ok_or_else(|| {
293
+ Error::new(
294
+ ruby.exception_arg_error(),
295
+ "Missing 'height' in resize event",
296
+ )
297
+ })?;
298
+ let height: u16 = u16::try_convert(height_val)?;
299
+
300
+ Ok(ratatui::crossterm::event::Event::Resize(width, height))
301
+ }
302
+
303
+ fn parse_paste_event(
304
+ data: magnus::RHash,
305
+ ruby: &magnus::Ruby,
306
+ ) -> Result<ratatui::crossterm::event::Event, Error> {
307
+ let content_val: Value = data.get(ruby.to_symbol("content")).ok_or_else(|| {
308
+ Error::new(
309
+ ruby.exception_arg_error(),
310
+ "Missing 'content' in paste event",
311
+ )
312
+ })?;
313
+ let content: String = String::try_convert(content_val)?;
314
+ Ok(ratatui::crossterm::event::Event::Paste(content))
315
+ }
316
+
317
+ pub fn clear_events() {
318
+ EVENT_QUEUE.with(|q| q.borrow_mut().clear());
319
+ }
320
+
321
+ /// Result of polling crossterm from outside the GVL.
322
+ ///
323
+ /// The blocking I/O (poll + read) happens without the Ruby GVL held,
324
+ /// so other Ruby threads can run concurrently. This enum carries the
325
+ /// result back across the GVL boundary for Ruby object construction.
326
+ #[derive(Debug)]
327
+ enum PollResult {
328
+ /// A crossterm event was read successfully.
329
+ Event(ratatui::crossterm::event::Event),
330
+ /// The timeout expired with no event available.
331
+ NoEvent,
332
+ /// An I/O error occurred during poll or read.
333
+ Error(String),
334
+ }
335
+
336
+ /// Data passed to the GVL-free polling callback.
337
+ ///
338
+ /// The `timeout` field controls the poll behavior:
339
+ /// - `Some(duration)`: poll with a timeout, return `NoEvent` if it expires.
340
+ /// - `None`: block indefinitely until an event arrives.
341
+ struct PollData {
342
+ timeout: Option<std::time::Duration>,
343
+ result: PollResult,
344
+ }
345
+
346
+ /// Polls crossterm for events without the Ruby GVL held.
347
+ ///
348
+ /// This is the work function passed to `rb_thread_call_without_gvl`.
349
+ /// It performs blocking I/O that would otherwise starve Ruby threads.
350
+ extern "C" fn poll_without_gvl(data: *mut c_void) -> *mut c_void {
351
+ // SAFETY: `data` is a valid pointer to `PollData`, passed by
352
+ // `poll_crossterm_without_gvl` via `rb_thread_call_without_gvl`. The
353
+ // pointer remains valid for the duration of this callback because the
354
+ // caller owns the `PollData` on the stack and waits for us to return.
355
+ let data = unsafe { &mut *data.cast::<PollData>() };
356
+
357
+ data.result = match data.timeout {
358
+ Some(duration) => match ratatui::crossterm::event::poll(duration) {
359
+ Ok(true) => match ratatui::crossterm::event::read() {
360
+ Ok(event) => PollResult::Event(event),
361
+ Err(e) => PollResult::Error(e.to_string()),
362
+ },
363
+ Ok(false) => PollResult::NoEvent,
364
+ Err(e) => PollResult::Error(e.to_string()),
365
+ },
366
+ None => match ratatui::crossterm::event::read() {
367
+ Ok(event) => PollResult::Event(event),
368
+ Err(e) => PollResult::Error(e.to_string()),
369
+ },
370
+ };
371
+
372
+ std::ptr::null_mut()
373
+ }
374
+
375
+ /// Polls crossterm with the GVL released, then converts the result
376
+ /// to a Ruby value after reacquiring the GVL.
377
+ fn poll_crossterm_without_gvl(
378
+ ruby: &magnus::Ruby,
379
+ timeout: Option<std::time::Duration>,
380
+ ) -> Result<Value, Error> {
381
+ let mut data = PollData {
382
+ timeout,
383
+ result: PollResult::NoEvent,
384
+ };
385
+
386
+ // SAFETY: `data` is a valid stack-local `PollData` whose lifetime
387
+ // spans this entire call. `poll_without_gvl` only reads/writes
388
+ // through the pointer while `rb_thread_call_without_gvl` blocks,
389
+ // and we don't access `data` again until that function returns.
390
+ unsafe {
391
+ rb_thread_call_without_gvl(
392
+ Some(poll_without_gvl),
393
+ (&raw mut data).cast::<c_void>(),
394
+ None,
395
+ std::ptr::null_mut(),
396
+ );
397
+ }
398
+
399
+ // GVL is now re-held — safe to create Ruby objects.
400
+ match data.result {
401
+ PollResult::Event(event) => handle_crossterm_event(event),
402
+ PollResult::NoEvent => Ok(ruby.qnil().into_value_with(ruby)),
403
+ PollResult::Error(msg) => Err(Error::new(ruby.exception_runtime_error(), msg)),
404
+ }
405
+ }
406
+
407
+ pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value, Error> {
408
+ let event = EVENT_QUEUE.with(|q| {
409
+ let mut queue = q.borrow_mut();
410
+ if queue.is_empty() {
411
+ None
412
+ } else {
413
+ Some(queue.remove(0))
414
+ }
415
+ });
416
+
417
+ if let Some(e) = event {
418
+ return handle_test_event(e);
419
+ }
420
+
421
+ let is_test_mode = crate::terminal::with_query(|q| q.is_test_mode()).unwrap_or(false);
422
+
423
+ if is_test_mode {
424
+ return Ok(ruby.qnil().into_value_with(ruby));
425
+ }
426
+
427
+ let timeout = timeout_val.map(std::time::Duration::from_secs_f64);
428
+ poll_crossterm_without_gvl(ruby, timeout)
429
+ }
430
+
431
+ fn handle_test_event(event: TestEvent) -> Result<Value, Error> {
432
+ match event {
433
+ TestEvent::Crossterm(e) => handle_crossterm_event(e),
434
+ TestEvent::Sync => handle_sync_event(),
435
+ TestEvent::None => {
436
+ let ruby = magnus::Ruby::get().unwrap();
437
+ Ok(ruby.qnil().into_value_with(&ruby))
438
+ }
439
+ }
440
+ }
441
+
442
+ fn handle_crossterm_event(event: ratatui::crossterm::event::Event) -> Result<Value, Error> {
443
+ match event {
444
+ ratatui::crossterm::event::Event::Key(key) => handle_key_event(key),
445
+ ratatui::crossterm::event::Event::Mouse(event) => handle_mouse_event(event),
446
+ ratatui::crossterm::event::Event::Resize(w, h) => handle_resize_event(w, h),
447
+ ratatui::crossterm::event::Event::Paste(content) => handle_paste_event(content),
448
+ ratatui::crossterm::event::Event::FocusGained => handle_focus_event("focus_gained"),
449
+ ratatui::crossterm::event::Event::FocusLost => handle_focus_event("focus_lost"),
450
+ }
451
+ }
452
+
453
+ fn handle_sync_event() -> Result<Value, Error> {
454
+ let ruby = magnus::Ruby::get().unwrap();
455
+ let hash = ruby.hash_new();
456
+ hash.aset(ruby.to_symbol("type"), ruby.to_symbol("sync"))?;
457
+ Ok(hash.into_value_with(&ruby))
458
+ }
459
+
460
+ fn media_key_to_string(m: MediaKeyCode) -> &'static str {
461
+ MEDIA_KEY_MAPPINGS
462
+ .iter()
463
+ .find(|(_, code)| *code == m)
464
+ .map_or("unknown", |(s, _)| *s)
465
+ }
466
+
467
+ fn modifier_key_to_string(m: ModifierKeyCode) -> &'static str {
468
+ MODIFIER_KEY_MAPPINGS
469
+ .iter()
470
+ .find(|(_, code)| *code == m)
471
+ .map_or("unknown", |(s, _)| *s)
472
+ }
473
+
474
+ fn base_key_to_string(kc: KeyCode) -> Option<&'static str> {
475
+ BASE_KEY_MAPPINGS
476
+ .iter()
477
+ .find(|(_, code)| *code == kc)
478
+ .map(|(s, _)| *s)
479
+ }
480
+
481
+ fn handle_key_event(key: ratatui::crossterm::event::KeyEvent) -> Result<Value, Error> {
482
+ use ratatui::crossterm::event::KeyCode;
483
+
484
+ let ruby = magnus::Ruby::get().unwrap();
485
+ if key.kind != ratatui::crossterm::event::KeyEventKind::Press {
486
+ return Ok(ruby.qnil().into_value_with(&ruby));
487
+ }
488
+
489
+ let hash = ruby.hash_new();
490
+ hash.aset(ruby.to_symbol("type"), ruby.to_symbol("key"))?;
491
+
492
+ // Determine the kind (category) of the key
493
+ let kind = match key.code {
494
+ KeyCode::Char(_)
495
+ | KeyCode::Enter
496
+ | KeyCode::Tab
497
+ | KeyCode::Backspace
498
+ | KeyCode::BackTab
499
+ | KeyCode::Up
500
+ | KeyCode::Down
501
+ | KeyCode::Left
502
+ | KeyCode::Right
503
+ | KeyCode::Home
504
+ | KeyCode::End
505
+ | KeyCode::PageUp
506
+ | KeyCode::PageDown
507
+ | KeyCode::Insert
508
+ | KeyCode::Delete
509
+ | KeyCode::Null => "standard",
510
+ KeyCode::F(_) => "function",
511
+ KeyCode::Media(_) => "media",
512
+ KeyCode::Modifier(_) => "modifier",
513
+ KeyCode::Esc
514
+ | KeyCode::CapsLock
515
+ | KeyCode::ScrollLock
516
+ | KeyCode::NumLock
517
+ | KeyCode::PrintScreen
518
+ | KeyCode::Pause
519
+ | KeyCode::Menu
520
+ | KeyCode::KeypadBegin => "system",
521
+ };
522
+
523
+ let code = if let KeyCode::Char(c) = key.code {
524
+ c.to_string()
525
+ } else if let KeyCode::F(n) = key.code {
526
+ format!("f{n}")
527
+ } else if let KeyCode::Media(m) = key.code {
528
+ media_key_to_string(m).to_string()
529
+ } else if let KeyCode::Modifier(m) = key.code {
530
+ modifier_key_to_string(m).to_string()
531
+ } else if let Some(s) = base_key_to_string(key.code) {
532
+ s.to_string()
533
+ } else {
534
+ "unknown".to_string()
535
+ };
536
+
537
+ hash.aset(ruby.to_symbol("code"), code)?;
538
+ hash.aset(ruby.to_symbol("kind"), ruby.to_symbol(kind))?;
539
+
540
+ let mut modifiers = Vec::new();
541
+ for (name, flag) in KEYBOARD_MODIFIER_MAPPINGS {
542
+ if key.modifiers.contains(*flag) {
543
+ modifiers.push(*name);
544
+ }
545
+ }
546
+ if !modifiers.is_empty() {
547
+ hash.aset(ruby.to_symbol("modifiers"), modifiers)?;
548
+ }
549
+ Ok(hash.into_value_with(&ruby))
550
+ }
551
+
552
+ fn handle_mouse_event(event: ratatui::crossterm::event::MouseEvent) -> Result<Value, Error> {
553
+ let ruby = magnus::Ruby::get().unwrap();
554
+ let hash = ruby.hash_new();
555
+ hash.aset(ruby.to_symbol("type"), ruby.to_symbol("mouse"))?;
556
+ let (kind, button) = match event.kind {
557
+ ratatui::crossterm::event::MouseEventKind::Down(btn) => ("down", btn),
558
+ ratatui::crossterm::event::MouseEventKind::Up(btn) => ("up", btn),
559
+ ratatui::crossterm::event::MouseEventKind::Drag(btn) => ("drag", btn),
560
+ ratatui::crossterm::event::MouseEventKind::Moved => {
561
+ ("moved", ratatui::crossterm::event::MouseButton::Left)
562
+ }
563
+ ratatui::crossterm::event::MouseEventKind::ScrollDown => {
564
+ ("scroll_down", ratatui::crossterm::event::MouseButton::Left)
565
+ }
566
+ ratatui::crossterm::event::MouseEventKind::ScrollUp => {
567
+ ("scroll_up", ratatui::crossterm::event::MouseButton::Left)
568
+ }
569
+ ratatui::crossterm::event::MouseEventKind::ScrollLeft => {
570
+ ("scroll_left", ratatui::crossterm::event::MouseButton::Left)
571
+ }
572
+ ratatui::crossterm::event::MouseEventKind::ScrollRight => {
573
+ ("scroll_right", ratatui::crossterm::event::MouseButton::Left)
574
+ }
575
+ };
576
+ hash.aset(ruby.to_symbol("kind"), ruby.to_symbol(kind))?;
577
+ if matches!(
578
+ event.kind,
579
+ ratatui::crossterm::event::MouseEventKind::Down(_)
580
+ | ratatui::crossterm::event::MouseEventKind::Up(_)
581
+ | ratatui::crossterm::event::MouseEventKind::Drag(_)
582
+ ) {
583
+ let btn_sym = match button {
584
+ ratatui::crossterm::event::MouseButton::Left => "left",
585
+ ratatui::crossterm::event::MouseButton::Right => "right",
586
+ ratatui::crossterm::event::MouseButton::Middle => "middle",
587
+ };
588
+ hash.aset(ruby.to_symbol("button"), ruby.to_symbol(btn_sym))?;
589
+ } else {
590
+ hash.aset(ruby.to_symbol("button"), ruby.to_symbol("none"))?;
591
+ }
592
+ hash.aset(ruby.to_symbol("x"), event.column)?;
593
+ hash.aset(ruby.to_symbol("y"), event.row)?;
594
+ let mut modifiers = Vec::new();
595
+ if event
596
+ .modifiers
597
+ .contains(ratatui::crossterm::event::KeyModifiers::CONTROL)
598
+ {
599
+ modifiers.push("ctrl");
600
+ }
601
+ if event
602
+ .modifiers
603
+ .contains(ratatui::crossterm::event::KeyModifiers::ALT)
604
+ {
605
+ modifiers.push("alt");
606
+ }
607
+ if event
608
+ .modifiers
609
+ .contains(ratatui::crossterm::event::KeyModifiers::SHIFT)
610
+ {
611
+ modifiers.push("shift");
612
+ }
613
+ hash.aset(ruby.to_symbol("modifiers"), modifiers)?;
614
+ Ok(hash.into_value_with(&ruby))
615
+ }
616
+
617
+ fn handle_resize_event(w: u16, h: u16) -> Result<Value, Error> {
618
+ let ruby = magnus::Ruby::get().unwrap();
619
+ let hash = ruby.hash_new();
620
+ hash.aset(ruby.to_symbol("type"), ruby.to_symbol("resize"))?;
621
+ hash.aset(ruby.to_symbol("width"), w)?;
622
+ hash.aset(ruby.to_symbol("height"), h)?;
623
+ Ok(hash.into_value_with(&ruby))
624
+ }
625
+
626
+ fn handle_paste_event(content: String) -> Result<Value, Error> {
627
+ let ruby = magnus::Ruby::get().unwrap();
628
+ let hash = ruby.hash_new();
629
+ hash.aset(ruby.to_symbol("type"), ruby.to_symbol("paste"))?;
630
+ hash.aset(ruby.to_symbol("content"), content)?;
631
+ Ok(hash.into_value_with(&ruby))
632
+ }
633
+
634
+ fn handle_focus_event(event_type: &str) -> Result<Value, Error> {
635
+ let ruby = magnus::Ruby::get().unwrap();
636
+ let hash = ruby.hash_new();
637
+ hash.aset(ruby.to_symbol("type"), ruby.to_symbol(event_type))?;
638
+ Ok(hash.into_value_with(&ruby))
639
+ }
640
+
641
+ #[cfg(test)]
642
+ mod tests {
643
+ use super::*;
644
+
645
+ #[test]
646
+ fn poll_data_defaults_to_no_event() {
647
+ let data = PollData {
648
+ timeout: Some(std::time::Duration::from_millis(0)),
649
+ result: PollResult::NoEvent,
650
+ };
651
+ assert!(matches!(data.result, PollResult::NoEvent));
652
+ }
653
+
654
+ #[test]
655
+ fn poll_data_accepts_none_timeout_for_indefinite_blocking() {
656
+ let data = PollData {
657
+ timeout: None,
658
+ result: PollResult::NoEvent,
659
+ };
660
+ assert!(data.timeout.is_none());
661
+ }
662
+
663
+ #[test]
664
+ fn poll_result_error_preserves_message() {
665
+ let result = PollResult::Error("connection reset".to_string());
666
+ match result {
667
+ PollResult::Error(msg) => assert_eq!(msg, "connection reset"),
668
+ _ => panic!("Expected PollResult::Error"),
669
+ }
670
+ }
671
+
672
+ #[test]
673
+ fn poll_result_event_wraps_crossterm_event() {
674
+ let key_event =
675
+ ratatui::crossterm::event::Event::Key(ratatui::crossterm::event::KeyEvent::new(
676
+ ratatui::crossterm::event::KeyCode::Char('q'),
677
+ ratatui::crossterm::event::KeyModifiers::empty(),
678
+ ));
679
+ let result = PollResult::Event(key_event.clone());
680
+ match result {
681
+ PollResult::Event(e) => assert_eq!(format!("{e:?}"), format!("{key_event:?}")),
682
+ _ => panic!("Expected PollResult::Event"),
683
+ }
684
+ }
685
+
686
+ #[test]
687
+ fn poll_without_gvl_returns_no_event_on_zero_timeout() {
688
+ // With a zero-duration timeout, poll returns immediately with no event.
689
+ // In headless environments (CI), crossterm may return an Error because
690
+ // there is no terminal to read from — that's also a valid outcome.
691
+ let mut data = PollData {
692
+ timeout: Some(std::time::Duration::from_millis(0)),
693
+ result: PollResult::Error("sentinel — should be overwritten".to_string()),
694
+ };
695
+
696
+ poll_without_gvl(&mut data as *mut PollData as *mut c_void);
697
+
698
+ // The callback must have overwritten the sentinel value.
699
+ match &data.result {
700
+ PollResult::Error(msg) if msg == "sentinel — should be overwritten" => {
701
+ panic!("poll_without_gvl did not write to data.result")
702
+ }
703
+ _ => {} // NoEvent, Event, or a *different* Error are all fine.
704
+ }
705
+ }
706
+ }