ratatui_ruby 1.3.0 → 1.4.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 (263) hide show
  1. checksums.yaml +4 -4
  2. data/ext/ratatui_ruby/Cargo.lock +2 -1
  3. data/ext/ratatui_ruby/Cargo.toml +2 -1
  4. data/ext/ratatui_ruby/src/events.rs +157 -18
  5. data/lib/ratatui_ruby/test_helper/snapshot.rb +58 -10
  6. data/lib/ratatui_ruby/test_helper/subprocess_timeout.rb +35 -0
  7. data/lib/ratatui_ruby/test_helper.rb +2 -0
  8. data/lib/ratatui_ruby/version.rb +1 -1
  9. metadata +17 -270
  10. data/.builds/ruby-3.2.yml +0 -54
  11. data/.builds/ruby-3.3.yml +0 -54
  12. data/.builds/ruby-3.4.yml +0 -54
  13. data/.builds/ruby-4.0.0.yml +0 -54
  14. data/.pre-commit-config.yaml +0 -16
  15. data/.rubocop.yml +0 -10
  16. data/AGENTS.md +0 -147
  17. data/CHANGELOG.md +0 -771
  18. data/README.md +0 -187
  19. data/README.rdoc +0 -302
  20. data/Rakefile +0 -11
  21. data/Steepfile +0 -50
  22. data/doc/concepts/application_architecture.md +0 -321
  23. data/doc/concepts/application_testing.md +0 -193
  24. data/doc/concepts/async.md +0 -190
  25. data/doc/concepts/custom_widgets.md +0 -247
  26. data/doc/concepts/debugging.md +0 -401
  27. data/doc/concepts/event_handling.md +0 -162
  28. data/doc/concepts/interactive_design.md +0 -146
  29. data/doc/contributors/auditing/parity.md +0 -239
  30. data/doc/contributors/design/ruby_frontend.md +0 -448
  31. data/doc/contributors/design/rust_backend.md +0 -434
  32. data/doc/contributors/design.md +0 -11
  33. data/doc/contributors/developing_examples.md +0 -400
  34. data/doc/contributors/documentation_style.md +0 -121
  35. data/doc/contributors/index.md +0 -21
  36. data/doc/contributors/releasing.md +0 -215
  37. data/doc/contributors/todo/align/api_completeness_audit-finished.md +0 -381
  38. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +0 -200
  39. data/doc/contributors/todo/align/term.md +0 -351
  40. data/doc/contributors/todo/align/terminal.md +0 -647
  41. data/doc/contributors/todo/future_work.md +0 -169
  42. data/doc/contributors/upstream_requests/paragraph_span_rects.md +0 -259
  43. data/doc/contributors/upstream_requests/tab_rects.md +0 -173
  44. data/doc/contributors/upstream_requests/title_rects.md +0 -132
  45. data/doc/custom.css +0 -22
  46. data/doc/getting_started/quickstart.md +0 -291
  47. data/doc/getting_started/why.md +0 -93
  48. data/doc/images/app_all_events.png +0 -0
  49. data/doc/images/app_cli_rich_moments.gif +0 -0
  50. data/doc/images/app_color_picker.png +0 -0
  51. data/doc/images/app_debugging_showcase.gif +0 -0
  52. data/doc/images/app_debugging_showcase.png +0 -0
  53. data/doc/images/app_external_editor.gif +0 -0
  54. data/doc/images/app_login_form.png +0 -0
  55. data/doc/images/app_stateful_interaction.png +0 -0
  56. data/doc/images/verify_quickstart_dsl.png +0 -0
  57. data/doc/images/verify_quickstart_layout.png +0 -0
  58. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  59. data/doc/images/verify_readme_usage.png +0 -0
  60. data/doc/images/widget_barchart.png +0 -0
  61. data/doc/images/widget_block.png +0 -0
  62. data/doc/images/widget_box.png +0 -0
  63. data/doc/images/widget_calendar.png +0 -0
  64. data/doc/images/widget_canvas.png +0 -0
  65. data/doc/images/widget_cell.png +0 -0
  66. data/doc/images/widget_center.png +0 -0
  67. data/doc/images/widget_chart.png +0 -0
  68. data/doc/images/widget_gauge.png +0 -0
  69. data/doc/images/widget_layout_split.png +0 -0
  70. data/doc/images/widget_line_gauge.png +0 -0
  71. data/doc/images/widget_list.png +0 -0
  72. data/doc/images/widget_map.png +0 -0
  73. data/doc/images/widget_overlay.png +0 -0
  74. data/doc/images/widget_popup.png +0 -0
  75. data/doc/images/widget_ratatui_logo.png +0 -0
  76. data/doc/images/widget_ratatui_mascot.png +0 -0
  77. data/doc/images/widget_rect.png +0 -0
  78. data/doc/images/widget_render.png +0 -0
  79. data/doc/images/widget_rich_text.png +0 -0
  80. data/doc/images/widget_scroll_text.png +0 -0
  81. data/doc/images/widget_scrollbar.png +0 -0
  82. data/doc/images/widget_sparkline.png +0 -0
  83. data/doc/images/widget_style_colors.png +0 -0
  84. data/doc/images/widget_table.png +0 -0
  85. data/doc/images/widget_tabs.png +0 -0
  86. data/doc/images/widget_text_width.png +0 -0
  87. data/doc/index.md +0 -34
  88. data/doc/troubleshooting/async.md +0 -4
  89. data/doc/troubleshooting/terminal_limitations.md +0 -131
  90. data/doc/troubleshooting/tui_output.md +0 -197
  91. data/examples/app_all_events/README.md +0 -114
  92. data/examples/app_all_events/app.rb +0 -98
  93. data/examples/app_all_events/model/app_model.rb +0 -159
  94. data/examples/app_all_events/model/event_color_cycle.rb +0 -43
  95. data/examples/app_all_events/model/event_entry.rb +0 -94
  96. data/examples/app_all_events/model/msg.rb +0 -39
  97. data/examples/app_all_events/model/timestamp.rb +0 -56
  98. data/examples/app_all_events/update.rb +0 -75
  99. data/examples/app_all_events/view/app_view.rb +0 -80
  100. data/examples/app_all_events/view/controls_view.rb +0 -54
  101. data/examples/app_all_events/view/counts_view.rb +0 -61
  102. data/examples/app_all_events/view/live_view.rb +0 -72
  103. data/examples/app_all_events/view/log_view.rb +0 -57
  104. data/examples/app_all_events/view.rb +0 -9
  105. data/examples/app_cli_rich_moments/README.md +0 -81
  106. data/examples/app_cli_rich_moments/app.rb +0 -189
  107. data/examples/app_color_picker/README.md +0 -156
  108. data/examples/app_color_picker/app.rb +0 -76
  109. data/examples/app_color_picker/clipboard.rb +0 -86
  110. data/examples/app_color_picker/color.rb +0 -193
  111. data/examples/app_color_picker/controls.rb +0 -92
  112. data/examples/app_color_picker/copy_dialog.rb +0 -168
  113. data/examples/app_color_picker/export_pane.rb +0 -128
  114. data/examples/app_color_picker/harmony.rb +0 -58
  115. data/examples/app_color_picker/input.rb +0 -176
  116. data/examples/app_color_picker/main_container.rb +0 -180
  117. data/examples/app_color_picker/palette.rb +0 -111
  118. data/examples/app_debugging_showcase/README.md +0 -119
  119. data/examples/app_debugging_showcase/app.rb +0 -318
  120. data/examples/app_external_editor/README.md +0 -62
  121. data/examples/app_external_editor/app.rb +0 -344
  122. data/examples/app_login_form/README.md +0 -58
  123. data/examples/app_login_form/app.rb +0 -109
  124. data/examples/app_stateful_interaction/README.md +0 -35
  125. data/examples/app_stateful_interaction/app.rb +0 -328
  126. data/examples/timeout_demo.rb +0 -45
  127. data/examples/verify_quickstart_dsl/README.md +0 -55
  128. data/examples/verify_quickstart_dsl/app.rb +0 -49
  129. data/examples/verify_quickstart_layout/README.md +0 -77
  130. data/examples/verify_quickstart_layout/app.rb +0 -73
  131. data/examples/verify_quickstart_lifecycle/README.md +0 -68
  132. data/examples/verify_quickstart_lifecycle/app.rb +0 -62
  133. data/examples/verify_readme_usage/README.md +0 -49
  134. data/examples/verify_readme_usage/app.rb +0 -42
  135. data/examples/verify_website_managed/README.md +0 -48
  136. data/examples/verify_website_managed/app.rb +0 -36
  137. data/examples/verify_website_menu/README.md +0 -60
  138. data/examples/verify_website_menu/app.rb +0 -84
  139. data/examples/verify_website_spinner/README.md +0 -44
  140. data/examples/verify_website_spinner/app.rb +0 -34
  141. data/examples/widget_barchart/README.md +0 -58
  142. data/examples/widget_barchart/app.rb +0 -240
  143. data/examples/widget_block/README.md +0 -44
  144. data/examples/widget_block/app.rb +0 -258
  145. data/examples/widget_box/README.md +0 -54
  146. data/examples/widget_box/app.rb +0 -255
  147. data/examples/widget_calendar/README.md +0 -48
  148. data/examples/widget_calendar/app.rb +0 -115
  149. data/examples/widget_canvas/README.md +0 -31
  150. data/examples/widget_canvas/app.rb +0 -130
  151. data/examples/widget_cell/README.md +0 -45
  152. data/examples/widget_cell/app.rb +0 -112
  153. data/examples/widget_center/README.md +0 -33
  154. data/examples/widget_center/app.rb +0 -118
  155. data/examples/widget_chart/README.md +0 -50
  156. data/examples/widget_chart/app.rb +0 -220
  157. data/examples/widget_gauge/README.md +0 -50
  158. data/examples/widget_gauge/app.rb +0 -229
  159. data/examples/widget_layout_split/README.md +0 -53
  160. data/examples/widget_layout_split/app.rb +0 -260
  161. data/examples/widget_line_gauge/README.md +0 -50
  162. data/examples/widget_line_gauge/app.rb +0 -219
  163. data/examples/widget_list/README.md +0 -58
  164. data/examples/widget_list/app.rb +0 -382
  165. data/examples/widget_map/README.md +0 -48
  166. data/examples/widget_map/app.rb +0 -95
  167. data/examples/widget_overlay/README.md +0 -45
  168. data/examples/widget_overlay/app.rb +0 -250
  169. data/examples/widget_popup/README.md +0 -45
  170. data/examples/widget_popup/app.rb +0 -106
  171. data/examples/widget_ratatui_logo/README.md +0 -43
  172. data/examples/widget_ratatui_logo/app.rb +0 -104
  173. data/examples/widget_ratatui_mascot/README.md +0 -43
  174. data/examples/widget_ratatui_mascot/app.rb +0 -95
  175. data/examples/widget_rect/README.md +0 -53
  176. data/examples/widget_rect/app.rb +0 -222
  177. data/examples/widget_render/README.md +0 -46
  178. data/examples/widget_render/app.rb +0 -186
  179. data/examples/widget_render/app.rbs +0 -41
  180. data/examples/widget_rich_text/README.md +0 -44
  181. data/examples/widget_rich_text/app.rb +0 -193
  182. data/examples/widget_scroll_text/README.md +0 -46
  183. data/examples/widget_scroll_text/app.rb +0 -109
  184. data/examples/widget_scrollbar/README.md +0 -46
  185. data/examples/widget_scrollbar/app.rb +0 -155
  186. data/examples/widget_sparkline/README.md +0 -51
  187. data/examples/widget_sparkline/app.rb +0 -277
  188. data/examples/widget_style_colors/README.md +0 -43
  189. data/examples/widget_style_colors/app.rb +0 -83
  190. data/examples/widget_table/README.md +0 -57
  191. data/examples/widget_table/app.rb +0 -285
  192. data/examples/widget_tabs/README.md +0 -50
  193. data/examples/widget_tabs/app.rb +0 -183
  194. data/examples/widget_text_width/README.md +0 -44
  195. data/examples/widget_text_width/app.rb +0 -117
  196. data/migrate_to_buffer.rb +0 -145
  197. data/mise.toml +0 -8
  198. data/tasks/autodoc/examples.rb +0 -87
  199. data/tasks/autodoc/member.rb +0 -58
  200. data/tasks/autodoc/name.rb +0 -21
  201. data/tasks/autodoc.rake +0 -21
  202. data/tasks/bump/bump_workflow.rb +0 -49
  203. data/tasks/bump/cargo_lockfile.rb +0 -21
  204. data/tasks/bump/changelog.rb +0 -104
  205. data/tasks/bump/header.rb +0 -32
  206. data/tasks/bump/history.rb +0 -32
  207. data/tasks/bump/links.rb +0 -69
  208. data/tasks/bump/manifest.rb +0 -33
  209. data/tasks/bump/patch_release.rb +0 -19
  210. data/tasks/bump/release_branch.rb +0 -17
  211. data/tasks/bump/release_from_trunk.rb +0 -49
  212. data/tasks/bump/repository.rb +0 -54
  213. data/tasks/bump/ruby_gem.rb +0 -29
  214. data/tasks/bump/sem_ver.rb +0 -44
  215. data/tasks/bump/unreleased_section.rb +0 -73
  216. data/tasks/bump.rake +0 -61
  217. data/tasks/doc/documentation.rb +0 -59
  218. data/tasks/doc/link/file_url.rb +0 -30
  219. data/tasks/doc/link/relative_path.rb +0 -61
  220. data/tasks/doc/link/web_url.rb +0 -55
  221. data/tasks/doc/link.rb +0 -52
  222. data/tasks/doc/link_audit.rb +0 -116
  223. data/tasks/doc/problem.rb +0 -40
  224. data/tasks/doc/source_file.rb +0 -93
  225. data/tasks/doc.rake +0 -905
  226. data/tasks/example_viewer.html.erb +0 -172
  227. data/tasks/extension.rake +0 -14
  228. data/tasks/license/headers_md.rb +0 -223
  229. data/tasks/license/headers_rb.rb +0 -210
  230. data/tasks/license/license_utils.rb +0 -130
  231. data/tasks/license/snippets_md.rb +0 -315
  232. data/tasks/license/snippets_rdoc.rb +0 -150
  233. data/tasks/license.rake +0 -91
  234. data/tasks/lint.rake +0 -170
  235. data/tasks/rbs_predicates/predicate_catalog.rb +0 -52
  236. data/tasks/rbs_predicates/predicate_tests.rb +0 -124
  237. data/tasks/rbs_predicates/rbs_signature.rb +0 -63
  238. data/tasks/rbs_predicates.rake +0 -31
  239. data/tasks/rdoc_config.rb +0 -29
  240. data/tasks/resources/build.yml.erb +0 -60
  241. data/tasks/resources/index.html.erb +0 -141
  242. data/tasks/resources/rubies.yml +0 -7
  243. data/tasks/sourcehut.rake +0 -122
  244. data/tasks/steep.rake +0 -11
  245. data/tasks/terminal_preview/app_screenshot.rb +0 -45
  246. data/tasks/terminal_preview/crash_report.rb +0 -54
  247. data/tasks/terminal_preview/example_app.rb +0 -27
  248. data/tasks/terminal_preview/launcher_script.rb +0 -48
  249. data/tasks/terminal_preview/preview_collection.rb +0 -60
  250. data/tasks/terminal_preview/preview_timing.rb +0 -24
  251. data/tasks/terminal_preview/safety_confirmation.rb +0 -58
  252. data/tasks/terminal_preview/saved_screenshot.rb +0 -56
  253. data/tasks/terminal_preview/system_appearance.rb +0 -13
  254. data/tasks/terminal_preview/terminal_window.rb +0 -138
  255. data/tasks/terminal_preview/window_id.rb +0 -16
  256. data/tasks/terminal_preview.rake +0 -30
  257. data/tasks/test.rake +0 -36
  258. data/tasks/website/index_page.rb +0 -30
  259. data/tasks/website/version.rb +0 -122
  260. data/tasks/website/version_menu.rb +0 -68
  261. data/tasks/website/versioned_documentation.rb +0 -83
  262. data/tasks/website/website.rb +0 -53
  263. data/tasks/website.rake +0 -28
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 65cec825c4cedc983b69cfa150cf30bfc295b1aff70c4866dc18161e97207da0
4
- data.tar.gz: '085eef207a3cb0d29e0992c1885ff788867387a725925d596bba309086ce6c2d'
3
+ metadata.gz: 3e12c46f758cf25eb436da52bbdcbf46f5cd256b804cb3f67a2d6edeeb8d4062
4
+ data.tar.gz: c4aa2e4c6251a6a75b8ac57e3c7f6133c88066185ee5e9cd1633fbc459edf612
5
5
  SHA512:
6
- metadata.gz: c3e69e5096a3d8790d6b81ec6a3fa0589565d5cbf3661f6bc78070c2ee91d29bd068fb5a31fe84e81364544e127e629f080faf6cb533d729fda58a18ed862ba1
7
- data.tar.gz: 5deeb497d1c9663d7c35efee84f8d2ca8b501810772eaf57750f2d452939e0d45c7847b13d18f59943872e3c46f649141b1e9cce09fa2242f6593168c83d3922
6
+ metadata.gz: dca9adc8ecef9e432c163b5b7b9e2488d42f9f1db98212a5c140a70bf21dc6c19fa687a97fb30d52ef9cadf8c5dd34a3aecbe471562ba5d9bfdb8c1ff85b99bf
7
+ data.tar.gz: 5f1e347a863736fae0ea9854a37402bd3115c311a228c23b24848113c23a21beeb97b5a33a9177611cd83c7e9d61ed6b4c4c9cf85c110e89f358e805f456465c
@@ -1059,12 +1059,13 @@ dependencies = [
1059
1059
 
1060
1060
  [[package]]
1061
1061
  name = "ratatui_ruby"
1062
- version = "1.3.0"
1062
+ version = "1.4.0"
1063
1063
  dependencies = [
1064
1064
  "bumpalo",
1065
1065
  "lazy_static",
1066
1066
  "magnus",
1067
1067
  "ratatui",
1068
+ "rb-sys",
1068
1069
  "time",
1069
1070
  "unicode-width 0.1.14",
1070
1071
  ]
@@ -3,7 +3,7 @@
3
3
 
4
4
  [package]
5
5
  name = "ratatui_ruby"
6
- version = "1.3.0"
6
+ version = "1.4.0"
7
7
  edition = "2021"
8
8
 
9
9
  [lib]
@@ -11,6 +11,7 @@ crate-type = ["cdylib", "staticlib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = "0.8.2"
14
+ rb-sys = "*"
14
15
  ratatui = { version = "0.30", features = ["widget-calendar", "layout-cache", "unstable-rendered-line-info", "palette"] }
15
16
  unicode-width = "0.1"
16
17
 
@@ -2,7 +2,9 @@
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
4
  use magnus::{Error, IntoValue, TryConvert, Value};
5
+ use rb_sys::rb_thread_call_without_gvl;
5
6
  use std::cell::RefCell;
7
+ use std::os::raw::c_void;
6
8
 
7
9
  /// Wrapper enum for test events - includes crossterm events and our Sync event.
8
10
  #[derive(Debug, Clone)]
@@ -314,6 +316,92 @@ pub fn clear_events() {
314
316
  EVENT_QUEUE.with(|q| q.borrow_mut().clear());
315
317
  }
316
318
 
319
+ /// Result of polling crossterm from outside the GVL.
320
+ ///
321
+ /// The blocking I/O (poll + read) happens without the Ruby GVL held,
322
+ /// so other Ruby threads can run concurrently. This enum carries the
323
+ /// result back across the GVL boundary for Ruby object construction.
324
+ #[derive(Debug)]
325
+ enum PollResult {
326
+ /// A crossterm event was read successfully.
327
+ Event(ratatui::crossterm::event::Event),
328
+ /// The timeout expired with no event available.
329
+ NoEvent,
330
+ /// An I/O error occurred during poll or read.
331
+ Error(String),
332
+ }
333
+
334
+ /// Data passed to the GVL-free polling callback.
335
+ ///
336
+ /// The `timeout` field controls the poll behavior:
337
+ /// - `Some(duration)`: poll with a timeout, return `NoEvent` if it expires.
338
+ /// - `None`: block indefinitely until an event arrives.
339
+ struct PollData {
340
+ timeout: Option<std::time::Duration>,
341
+ result: PollResult,
342
+ }
343
+
344
+ /// Polls crossterm for events without the Ruby GVL held.
345
+ ///
346
+ /// This is the work function passed to `rb_thread_call_without_gvl`.
347
+ /// It performs blocking I/O that would otherwise starve Ruby threads.
348
+ extern "C" fn poll_without_gvl(data: *mut c_void) -> *mut c_void {
349
+ // SAFETY: `data` is a valid pointer to `PollData`, passed by
350
+ // `poll_crossterm_without_gvl` via `rb_thread_call_without_gvl`. The
351
+ // pointer remains valid for the duration of this callback because the
352
+ // caller owns the `PollData` on the stack and waits for us to return.
353
+ let data = unsafe { &mut *data.cast::<PollData>() };
354
+
355
+ data.result = match data.timeout {
356
+ Some(duration) => match ratatui::crossterm::event::poll(duration) {
357
+ Ok(true) => match ratatui::crossterm::event::read() {
358
+ Ok(event) => PollResult::Event(event),
359
+ Err(e) => PollResult::Error(e.to_string()),
360
+ },
361
+ Ok(false) => PollResult::NoEvent,
362
+ Err(e) => PollResult::Error(e.to_string()),
363
+ },
364
+ None => match ratatui::crossterm::event::read() {
365
+ Ok(event) => PollResult::Event(event),
366
+ Err(e) => PollResult::Error(e.to_string()),
367
+ },
368
+ };
369
+
370
+ std::ptr::null_mut()
371
+ }
372
+
373
+ /// Polls crossterm with the GVL released, then converts the result
374
+ /// to a Ruby value after reacquiring the GVL.
375
+ fn poll_crossterm_without_gvl(
376
+ ruby: &magnus::Ruby,
377
+ timeout: Option<std::time::Duration>,
378
+ ) -> Result<Value, Error> {
379
+ let mut data = PollData {
380
+ timeout,
381
+ result: PollResult::NoEvent,
382
+ };
383
+
384
+ // SAFETY: `data` is a valid stack-local `PollData` whose lifetime
385
+ // spans this entire call. `poll_without_gvl` only reads/writes
386
+ // through the pointer while `rb_thread_call_without_gvl` blocks,
387
+ // and we don't access `data` again until that function returns.
388
+ unsafe {
389
+ rb_thread_call_without_gvl(
390
+ Some(poll_without_gvl),
391
+ (&raw mut data).cast::<c_void>(),
392
+ None,
393
+ std::ptr::null_mut(),
394
+ );
395
+ }
396
+
397
+ // GVL is now re-held — safe to create Ruby objects.
398
+ match data.result {
399
+ PollResult::Event(event) => handle_crossterm_event(event),
400
+ PollResult::NoEvent => Ok(ruby.qnil().into_value_with(ruby)),
401
+ PollResult::Error(msg) => Err(Error::new(ruby.exception_runtime_error(), msg)),
402
+ }
403
+ }
404
+
317
405
  pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value, Error> {
318
406
  let event = EVENT_QUEUE.with(|q| {
319
407
  let mut queue = q.borrow_mut();
@@ -334,24 +422,8 @@ pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value
334
422
  return Ok(ruby.qnil().into_value_with(ruby));
335
423
  }
336
424
 
337
- if let Some(secs) = timeout_val {
338
- // Timed poll: wait up to the specified duration
339
- let duration = std::time::Duration::from_secs_f64(secs);
340
- if ratatui::crossterm::event::poll(duration)
341
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?
342
- {
343
- let event = ratatui::crossterm::event::read()
344
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
345
- handle_crossterm_event(event)
346
- } else {
347
- Ok(ruby.qnil().into_value_with(ruby))
348
- }
349
- } else {
350
- // Blocking: wait indefinitely for an event
351
- let event = ratatui::crossterm::event::read()
352
- .map_err(|e| Error::new(ruby.exception_runtime_error(), e.to_string()))?;
353
- handle_crossterm_event(event)
354
- }
425
+ let timeout = timeout_val.map(std::time::Duration::from_secs_f64);
426
+ poll_crossterm_without_gvl(ruby, timeout)
355
427
  }
356
428
 
357
429
  fn handle_test_event(event: TestEvent) -> Result<Value, Error> {
@@ -559,3 +631,70 @@ fn handle_focus_event(event_type: &str) -> Result<Value, Error> {
559
631
  hash.aset(ruby.to_symbol("type"), ruby.to_symbol(event_type))?;
560
632
  Ok(hash.into_value_with(&ruby))
561
633
  }
634
+
635
+ #[cfg(test)]
636
+ mod tests {
637
+ use super::*;
638
+
639
+ #[test]
640
+ fn poll_data_defaults_to_no_event() {
641
+ let data = PollData {
642
+ timeout: Some(std::time::Duration::from_millis(0)),
643
+ result: PollResult::NoEvent,
644
+ };
645
+ assert!(matches!(data.result, PollResult::NoEvent));
646
+ }
647
+
648
+ #[test]
649
+ fn poll_data_accepts_none_timeout_for_indefinite_blocking() {
650
+ let data = PollData {
651
+ timeout: None,
652
+ result: PollResult::NoEvent,
653
+ };
654
+ assert!(data.timeout.is_none());
655
+ }
656
+
657
+ #[test]
658
+ fn poll_result_error_preserves_message() {
659
+ let result = PollResult::Error("connection reset".to_string());
660
+ match result {
661
+ PollResult::Error(msg) => assert_eq!(msg, "connection reset"),
662
+ _ => panic!("Expected PollResult::Error"),
663
+ }
664
+ }
665
+
666
+ #[test]
667
+ fn poll_result_event_wraps_crossterm_event() {
668
+ let key_event =
669
+ ratatui::crossterm::event::Event::Key(ratatui::crossterm::event::KeyEvent::new(
670
+ ratatui::crossterm::event::KeyCode::Char('q'),
671
+ ratatui::crossterm::event::KeyModifiers::empty(),
672
+ ));
673
+ let result = PollResult::Event(key_event.clone());
674
+ match result {
675
+ PollResult::Event(e) => assert_eq!(format!("{e:?}"), format!("{key_event:?}")),
676
+ _ => panic!("Expected PollResult::Event"),
677
+ }
678
+ }
679
+
680
+ #[test]
681
+ fn poll_without_gvl_returns_no_event_on_zero_timeout() {
682
+ // With a zero-duration timeout, poll returns immediately with no event.
683
+ // In headless environments (CI), crossterm may return an Error because
684
+ // there is no terminal to read from — that's also a valid outcome.
685
+ let mut data = PollData {
686
+ timeout: Some(std::time::Duration::from_millis(0)),
687
+ result: PollResult::Error("sentinel — should be overwritten".to_string()),
688
+ };
689
+
690
+ poll_without_gvl(&mut data as *mut PollData as *mut c_void);
691
+
692
+ // The callback must have overwritten the sentinel value.
693
+ match &data.result {
694
+ PollResult::Error(msg) if msg == "sentinel — should be overwritten" => {
695
+ panic!("poll_without_gvl did not write to data.result")
696
+ }
697
+ _ => {} // NoEvent, Event, or a *different* Error are all fine.
698
+ }
699
+ }
700
+ }
@@ -99,7 +99,62 @@ module RatatuiRuby
99
99
  #--
100
100
  # SPDX-SnippetEnd
101
101
  #++
102
+ #
103
+ # === Class-Wide Normalization
104
+ #
105
+ # When every test in a class faces the same dynamic content, override
106
+ # <tt>normalize_snapshots</tt> instead of repeating the block. Return a callable
107
+ # (or Array of callables) that transforms lines. The hook runs before any per-call
108
+ # block, so the two compose naturally. See <tt>normalize_snapshots</tt> for details.
102
109
  module Snapshot
110
+ # Override this method to normalize all snapshots in a test class.
111
+ #
112
+ # Snapshot assertions compare screen content against stored files. Dynamic content
113
+ # (timestamps, temp paths, random IDs) breaks those comparisons. Passing a normalization
114
+ # block to each assertion fixes one test but creates duplication when every test in a
115
+ # class faces the same dynamic content.
116
+ #
117
+ # Override <tt>normalize_snapshots</tt> in your test class to define class-wide
118
+ # normalization. Accept an Array of Strings (lines) and return the transformed Array.
119
+ #
120
+ # The hook runs before any per-call normalization block, so the two compose naturally:
121
+ # the hook handles class-wide concerns and the block handles one-off masking.
122
+ #
123
+ # Returns <tt>lines</tt> unchanged by default (no normalization).
124
+ #
125
+ # === Example
126
+ #
127
+ #--
128
+ # SPDX-SnippetBegin
129
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
130
+ # SPDX-License-Identifier: MIT-0
131
+ #++
132
+ # class TestFileExplorer < Minitest::Test
133
+ # include RatatuiRuby::TestHelper
134
+ #
135
+ # private def normalize_snapshots(lines)
136
+ # lines.map { |l| l.gsub(Dir.pwd, "STABLE_PATH") }
137
+ # end
138
+ #
139
+ # def test_initial_render
140
+ # # normalize_snapshots runs automatically — no block needed
141
+ # assert_snapshots("initial_render")
142
+ # end
143
+ #
144
+ # def test_after_scroll
145
+ # # Per-call block composes with the hook (hook runs first)
146
+ # assert_snapshots("after_scroll") do |lines|
147
+ # lines.map { |l| l.gsub(/\d{2}:\d{2}/, "XX:XX") }
148
+ # end
149
+ # end
150
+ # end
151
+ #
152
+ #--
153
+ # SPDX-SnippetEnd
154
+ #++
155
+ private def normalize_snapshots(lines)
156
+ lines
157
+ end
103
158
  ##
104
159
  # Asserts that the current screen content matches a stored plain text snapshot.
105
160
  #
@@ -181,7 +236,7 @@ module RatatuiRuby
181
236
  # Ensure your render logic is deterministic by seeding random number generators and stubbing
182
237
  # time where necessary.
183
238
  def assert_screen_matches(expected, msg = nil)
184
- actual_lines = buffer_content
239
+ actual_lines = normalize_snapshots(buffer_content)
185
240
 
186
241
  if block_given?
187
242
  actual_lines = yield(actual_lines)
@@ -203,9 +258,6 @@ module RatatuiRuby
203
258
 
204
259
  # Write with explicit mode to ensure clean write
205
260
  File.write(snapshot_path, content_to_write, mode: "w")
206
-
207
- # Flush filesystem buffers to ensure durability
208
- File.open(snapshot_path, "r", &:fsync) if File.exist?(snapshot_path)
209
261
  rescue => e
210
262
  warn "Failed to write snapshot #{snapshot_path}: #{e.message}"
211
263
  raise
@@ -269,12 +321,11 @@ module RatatuiRuby
269
321
 
270
322
  actual_content = _render_buffer_with_ansi
271
323
 
324
+ lines = normalize_snapshots(actual_content.split("\n"))
272
325
  if block_given?
273
- lines = actual_content.split("\n")
274
- # Yield lines to user block for modification (e.g. masking IDs/Times)
275
326
  lines = yield(lines)
276
- actual_content = "#{lines.join("\n")}\n"
277
327
  end
328
+ actual_content = "#{lines.join("\n")}\n"
278
329
 
279
330
  update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"
280
331
 
@@ -287,9 +338,6 @@ module RatatuiRuby
287
338
 
288
339
  # Write with explicit mode to ensure clean write
289
340
  File.write(snapshot_path, actual_content, mode: "w")
290
-
291
- # Flush filesystem buffers to ensure durability
292
- File.open(snapshot_path, "r", &:fsync) if File.exist?(snapshot_path)
293
341
  rescue => e
294
342
  warn "Failed to write rich snapshot #{snapshot_path}: #{e.message}"
295
343
  raise
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ require "timeout"
9
+
10
+ module RatatuiRuby
11
+ module TestHelper
12
+ ##
13
+ # Portable subprocess timeout helper.
14
+ module SubprocessTimeout
15
+ private def popen_with_timeout(env, cmd, timeout: 2)
16
+ output = +""
17
+ IO.popen(env, cmd, err: [:child, :out]) do |io|
18
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
19
+ loop do
20
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
21
+ break if remaining <= 0
22
+ break unless io.wait_readable(remaining)
23
+
24
+ chunk = io.read_nonblock(4096, exception: false)
25
+ break if chunk.nil? || chunk == :wait_readable
26
+
27
+ output << chunk
28
+ end
29
+ Process.kill("KILL", io.pid) rescue nil # rubocop:disable Style/RescueModifier
30
+ end
31
+ output.strip
32
+ end
33
+ end
34
+ end
35
+ end
@@ -12,6 +12,7 @@ require_relative "test_helper/event_injection"
12
12
  require_relative "test_helper/style_assertions"
13
13
  require_relative "test_helper/test_doubles"
14
14
  require_relative "test_helper/global_state"
15
+ require_relative "test_helper/subprocess_timeout"
15
16
 
16
17
  module RatatuiRuby
17
18
  ##
@@ -109,5 +110,6 @@ module RatatuiRuby
109
110
  include StyleAssertions
110
111
  include TestDoubles
111
112
  include GlobalState
113
+ include SubprocessTimeout
112
114
  end
113
115
  end
@@ -8,5 +8,5 @@
8
8
  module RatatuiRuby
9
9
  # The version of the ratatui_ruby gem.
10
10
  # See https://semver.org/spec/v2.0.0.html
11
- VERSION = "1.3.0"
11
+ VERSION = "1.4.0"
12
12
  end