ratatui_ruby 1.2.2 → 1.3.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 (269) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +54 -0
  3. data/.builds/ruby-3.3.yml +54 -0
  4. data/.builds/ruby-3.4.yml +54 -0
  5. data/.builds/ruby-4.0.0.yml +54 -0
  6. data/.pre-commit-config.yaml +16 -0
  7. data/.rubocop.yml +10 -0
  8. data/AGENTS.md +147 -0
  9. data/CHANGELOG.md +771 -0
  10. data/README.md +187 -0
  11. data/README.rdoc +302 -0
  12. data/Rakefile +11 -0
  13. data/Steepfile +50 -0
  14. data/doc/concepts/application_architecture.md +321 -0
  15. data/doc/concepts/application_testing.md +193 -0
  16. data/doc/concepts/async.md +190 -0
  17. data/doc/concepts/custom_widgets.md +247 -0
  18. data/doc/concepts/debugging.md +401 -0
  19. data/doc/concepts/event_handling.md +162 -0
  20. data/doc/concepts/interactive_design.md +146 -0
  21. data/doc/contributors/auditing/parity.md +239 -0
  22. data/doc/contributors/design/ruby_frontend.md +448 -0
  23. data/doc/contributors/design/rust_backend.md +434 -0
  24. data/doc/contributors/design.md +11 -0
  25. data/doc/contributors/developing_examples.md +400 -0
  26. data/doc/contributors/documentation_style.md +121 -0
  27. data/doc/contributors/index.md +21 -0
  28. data/doc/contributors/releasing.md +215 -0
  29. data/doc/contributors/todo/align/api_completeness_audit-finished.md +381 -0
  30. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +200 -0
  31. data/doc/contributors/todo/align/term.md +351 -0
  32. data/doc/contributors/todo/align/terminal.md +647 -0
  33. data/doc/contributors/todo/future_work.md +169 -0
  34. data/doc/contributors/upstream_requests/paragraph_span_rects.md +259 -0
  35. data/doc/contributors/upstream_requests/tab_rects.md +173 -0
  36. data/doc/contributors/upstream_requests/title_rects.md +132 -0
  37. data/doc/custom.css +22 -0
  38. data/doc/getting_started/quickstart.md +291 -0
  39. data/doc/getting_started/why.md +93 -0
  40. data/doc/images/app_all_events.png +0 -0
  41. data/doc/images/app_cli_rich_moments.gif +0 -0
  42. data/doc/images/app_color_picker.png +0 -0
  43. data/doc/images/app_debugging_showcase.gif +0 -0
  44. data/doc/images/app_debugging_showcase.png +0 -0
  45. data/doc/images/app_external_editor.gif +0 -0
  46. data/doc/images/app_login_form.png +0 -0
  47. data/doc/images/app_stateful_interaction.png +0 -0
  48. data/doc/images/verify_quickstart_dsl.png +0 -0
  49. data/doc/images/verify_quickstart_layout.png +0 -0
  50. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  51. data/doc/images/verify_readme_usage.png +0 -0
  52. data/doc/images/widget_barchart.png +0 -0
  53. data/doc/images/widget_block.png +0 -0
  54. data/doc/images/widget_box.png +0 -0
  55. data/doc/images/widget_calendar.png +0 -0
  56. data/doc/images/widget_canvas.png +0 -0
  57. data/doc/images/widget_cell.png +0 -0
  58. data/doc/images/widget_center.png +0 -0
  59. data/doc/images/widget_chart.png +0 -0
  60. data/doc/images/widget_gauge.png +0 -0
  61. data/doc/images/widget_layout_split.png +0 -0
  62. data/doc/images/widget_line_gauge.png +0 -0
  63. data/doc/images/widget_list.png +0 -0
  64. data/doc/images/widget_map.png +0 -0
  65. data/doc/images/widget_overlay.png +0 -0
  66. data/doc/images/widget_popup.png +0 -0
  67. data/doc/images/widget_ratatui_logo.png +0 -0
  68. data/doc/images/widget_ratatui_mascot.png +0 -0
  69. data/doc/images/widget_rect.png +0 -0
  70. data/doc/images/widget_render.png +0 -0
  71. data/doc/images/widget_rich_text.png +0 -0
  72. data/doc/images/widget_scroll_text.png +0 -0
  73. data/doc/images/widget_scrollbar.png +0 -0
  74. data/doc/images/widget_sparkline.png +0 -0
  75. data/doc/images/widget_style_colors.png +0 -0
  76. data/doc/images/widget_table.png +0 -0
  77. data/doc/images/widget_tabs.png +0 -0
  78. data/doc/images/widget_text_width.png +0 -0
  79. data/doc/index.md +34 -0
  80. data/doc/troubleshooting/async.md +4 -0
  81. data/doc/troubleshooting/terminal_limitations.md +131 -0
  82. data/doc/troubleshooting/tui_output.md +197 -0
  83. data/examples/app_all_events/README.md +114 -0
  84. data/examples/app_all_events/app.rb +98 -0
  85. data/examples/app_all_events/model/app_model.rb +159 -0
  86. data/examples/app_all_events/model/event_color_cycle.rb +43 -0
  87. data/examples/app_all_events/model/event_entry.rb +94 -0
  88. data/examples/app_all_events/model/msg.rb +39 -0
  89. data/examples/app_all_events/model/timestamp.rb +56 -0
  90. data/examples/app_all_events/update.rb +75 -0
  91. data/examples/app_all_events/view/app_view.rb +80 -0
  92. data/examples/app_all_events/view/controls_view.rb +54 -0
  93. data/examples/app_all_events/view/counts_view.rb +61 -0
  94. data/examples/app_all_events/view/live_view.rb +72 -0
  95. data/examples/app_all_events/view/log_view.rb +57 -0
  96. data/examples/app_all_events/view.rb +9 -0
  97. data/examples/app_cli_rich_moments/README.md +81 -0
  98. data/examples/app_cli_rich_moments/app.rb +189 -0
  99. data/examples/app_color_picker/README.md +156 -0
  100. data/examples/app_color_picker/app.rb +76 -0
  101. data/examples/app_color_picker/clipboard.rb +86 -0
  102. data/examples/app_color_picker/color.rb +193 -0
  103. data/examples/app_color_picker/controls.rb +92 -0
  104. data/examples/app_color_picker/copy_dialog.rb +168 -0
  105. data/examples/app_color_picker/export_pane.rb +128 -0
  106. data/examples/app_color_picker/harmony.rb +58 -0
  107. data/examples/app_color_picker/input.rb +176 -0
  108. data/examples/app_color_picker/main_container.rb +180 -0
  109. data/examples/app_color_picker/palette.rb +111 -0
  110. data/examples/app_debugging_showcase/README.md +119 -0
  111. data/examples/app_debugging_showcase/app.rb +318 -0
  112. data/examples/app_external_editor/README.md +62 -0
  113. data/examples/app_external_editor/app.rb +344 -0
  114. data/examples/app_login_form/README.md +58 -0
  115. data/examples/app_login_form/app.rb +109 -0
  116. data/examples/app_stateful_interaction/README.md +35 -0
  117. data/examples/app_stateful_interaction/app.rb +328 -0
  118. data/examples/timeout_demo.rb +45 -0
  119. data/examples/verify_quickstart_dsl/README.md +55 -0
  120. data/examples/verify_quickstart_dsl/app.rb +49 -0
  121. data/examples/verify_quickstart_layout/README.md +77 -0
  122. data/examples/verify_quickstart_layout/app.rb +73 -0
  123. data/examples/verify_quickstart_lifecycle/README.md +68 -0
  124. data/examples/verify_quickstart_lifecycle/app.rb +62 -0
  125. data/examples/verify_readme_usage/README.md +49 -0
  126. data/examples/verify_readme_usage/app.rb +42 -0
  127. data/examples/verify_website_managed/README.md +48 -0
  128. data/examples/verify_website_managed/app.rb +36 -0
  129. data/examples/verify_website_menu/README.md +60 -0
  130. data/examples/verify_website_menu/app.rb +84 -0
  131. data/examples/verify_website_spinner/README.md +44 -0
  132. data/examples/verify_website_spinner/app.rb +34 -0
  133. data/examples/widget_barchart/README.md +58 -0
  134. data/examples/widget_barchart/app.rb +240 -0
  135. data/examples/widget_block/README.md +44 -0
  136. data/examples/widget_block/app.rb +258 -0
  137. data/examples/widget_box/README.md +54 -0
  138. data/examples/widget_box/app.rb +255 -0
  139. data/examples/widget_calendar/README.md +48 -0
  140. data/examples/widget_calendar/app.rb +115 -0
  141. data/examples/widget_canvas/README.md +31 -0
  142. data/examples/widget_canvas/app.rb +130 -0
  143. data/examples/widget_cell/README.md +45 -0
  144. data/examples/widget_cell/app.rb +112 -0
  145. data/examples/widget_center/README.md +33 -0
  146. data/examples/widget_center/app.rb +118 -0
  147. data/examples/widget_chart/README.md +50 -0
  148. data/examples/widget_chart/app.rb +220 -0
  149. data/examples/widget_gauge/README.md +50 -0
  150. data/examples/widget_gauge/app.rb +229 -0
  151. data/examples/widget_layout_split/README.md +53 -0
  152. data/examples/widget_layout_split/app.rb +260 -0
  153. data/examples/widget_line_gauge/README.md +50 -0
  154. data/examples/widget_line_gauge/app.rb +219 -0
  155. data/examples/widget_list/README.md +58 -0
  156. data/examples/widget_list/app.rb +382 -0
  157. data/examples/widget_map/README.md +48 -0
  158. data/examples/widget_map/app.rb +95 -0
  159. data/examples/widget_overlay/README.md +45 -0
  160. data/examples/widget_overlay/app.rb +250 -0
  161. data/examples/widget_popup/README.md +45 -0
  162. data/examples/widget_popup/app.rb +106 -0
  163. data/examples/widget_ratatui_logo/README.md +43 -0
  164. data/examples/widget_ratatui_logo/app.rb +104 -0
  165. data/examples/widget_ratatui_mascot/README.md +43 -0
  166. data/examples/widget_ratatui_mascot/app.rb +95 -0
  167. data/examples/widget_rect/README.md +53 -0
  168. data/examples/widget_rect/app.rb +222 -0
  169. data/examples/widget_render/README.md +46 -0
  170. data/examples/widget_render/app.rb +186 -0
  171. data/examples/widget_render/app.rbs +41 -0
  172. data/examples/widget_rich_text/README.md +44 -0
  173. data/examples/widget_rich_text/app.rb +193 -0
  174. data/examples/widget_scroll_text/README.md +46 -0
  175. data/examples/widget_scroll_text/app.rb +109 -0
  176. data/examples/widget_scrollbar/README.md +46 -0
  177. data/examples/widget_scrollbar/app.rb +155 -0
  178. data/examples/widget_sparkline/README.md +51 -0
  179. data/examples/widget_sparkline/app.rb +277 -0
  180. data/examples/widget_style_colors/README.md +43 -0
  181. data/examples/widget_style_colors/app.rb +83 -0
  182. data/examples/widget_table/README.md +57 -0
  183. data/examples/widget_table/app.rb +285 -0
  184. data/examples/widget_tabs/README.md +50 -0
  185. data/examples/widget_tabs/app.rb +183 -0
  186. data/examples/widget_text_width/README.md +44 -0
  187. data/examples/widget_text_width/app.rb +117 -0
  188. data/ext/ratatui_ruby/Cargo.lock +1 -2
  189. data/ext/ratatui_ruby/Cargo.toml +1 -2
  190. data/ext/ratatui_ruby/src/events.rs +18 -157
  191. data/lib/ratatui_ruby/event/focus_gained.rb +50 -0
  192. data/lib/ratatui_ruby/event/focus_lost.rb +51 -0
  193. data/lib/ratatui_ruby/event/key/dwim.rb +301 -0
  194. data/lib/ratatui_ruby/event/key/modifier.rb +2 -0
  195. data/lib/ratatui_ruby/event/key.rb +9 -0
  196. data/lib/ratatui_ruby/event/mouse.rb +33 -0
  197. data/lib/ratatui_ruby/event/paste.rb +25 -0
  198. data/lib/ratatui_ruby/event/resize.rb +65 -0
  199. data/lib/ratatui_ruby/version.rb +1 -1
  200. data/migrate_to_buffer.rb +145 -0
  201. data/mise.toml +8 -0
  202. data/sig/ratatui_ruby/event.rbs +97 -0
  203. data/tasks/autodoc/examples.rb +87 -0
  204. data/tasks/autodoc/member.rb +58 -0
  205. data/tasks/autodoc/name.rb +21 -0
  206. data/tasks/autodoc.rake +21 -0
  207. data/tasks/bump/bump_workflow.rb +49 -0
  208. data/tasks/bump/cargo_lockfile.rb +21 -0
  209. data/tasks/bump/changelog.rb +104 -0
  210. data/tasks/bump/header.rb +32 -0
  211. data/tasks/bump/history.rb +32 -0
  212. data/tasks/bump/links.rb +69 -0
  213. data/tasks/bump/manifest.rb +33 -0
  214. data/tasks/bump/patch_release.rb +19 -0
  215. data/tasks/bump/release_branch.rb +17 -0
  216. data/tasks/bump/release_from_trunk.rb +49 -0
  217. data/tasks/bump/repository.rb +54 -0
  218. data/tasks/bump/ruby_gem.rb +29 -0
  219. data/tasks/bump/sem_ver.rb +44 -0
  220. data/tasks/bump/unreleased_section.rb +73 -0
  221. data/tasks/bump.rake +61 -0
  222. data/tasks/doc/documentation.rb +59 -0
  223. data/tasks/doc/link/file_url.rb +30 -0
  224. data/tasks/doc/link/relative_path.rb +61 -0
  225. data/tasks/doc/link/web_url.rb +55 -0
  226. data/tasks/doc/link.rb +52 -0
  227. data/tasks/doc/link_audit.rb +116 -0
  228. data/tasks/doc/problem.rb +40 -0
  229. data/tasks/doc/source_file.rb +93 -0
  230. data/tasks/doc.rake +905 -0
  231. data/tasks/example_viewer.html.erb +172 -0
  232. data/tasks/extension.rake +14 -0
  233. data/tasks/license/headers_md.rb +223 -0
  234. data/tasks/license/headers_rb.rb +210 -0
  235. data/tasks/license/license_utils.rb +130 -0
  236. data/tasks/license/snippets_md.rb +315 -0
  237. data/tasks/license/snippets_rdoc.rb +150 -0
  238. data/tasks/license.rake +91 -0
  239. data/tasks/lint.rake +170 -0
  240. data/tasks/rbs_predicates/predicate_catalog.rb +52 -0
  241. data/tasks/rbs_predicates/predicate_tests.rb +124 -0
  242. data/tasks/rbs_predicates/rbs_signature.rb +63 -0
  243. data/tasks/rbs_predicates.rake +31 -0
  244. data/tasks/rdoc_config.rb +29 -0
  245. data/tasks/resources/build.yml.erb +60 -0
  246. data/tasks/resources/index.html.erb +141 -0
  247. data/tasks/resources/rubies.yml +7 -0
  248. data/tasks/sourcehut.rake +122 -0
  249. data/tasks/steep.rake +11 -0
  250. data/tasks/terminal_preview/app_screenshot.rb +45 -0
  251. data/tasks/terminal_preview/crash_report.rb +54 -0
  252. data/tasks/terminal_preview/example_app.rb +27 -0
  253. data/tasks/terminal_preview/launcher_script.rb +48 -0
  254. data/tasks/terminal_preview/preview_collection.rb +60 -0
  255. data/tasks/terminal_preview/preview_timing.rb +24 -0
  256. data/tasks/terminal_preview/safety_confirmation.rb +58 -0
  257. data/tasks/terminal_preview/saved_screenshot.rb +56 -0
  258. data/tasks/terminal_preview/system_appearance.rb +13 -0
  259. data/tasks/terminal_preview/terminal_window.rb +138 -0
  260. data/tasks/terminal_preview/window_id.rb +16 -0
  261. data/tasks/terminal_preview.rake +30 -0
  262. data/tasks/test.rake +36 -0
  263. data/tasks/website/index_page.rb +30 -0
  264. data/tasks/website/version.rb +122 -0
  265. data/tasks/website/version_menu.rb +68 -0
  266. data/tasks/website/versioned_documentation.rb +83 -0
  267. data/tasks/website/website.rb +53 -0
  268. data/tasks/website.rake +28 -0
  269. metadata +256 -1
@@ -3,7 +3,7 @@
3
3
 
4
4
  [package]
5
5
  name = "ratatui_ruby"
6
- version = "1.2.2"
6
+ version = "1.3.0"
7
7
  edition = "2021"
8
8
 
9
9
  [lib]
@@ -11,7 +11,6 @@ crate-type = ["cdylib", "staticlib"]
11
11
 
12
12
  [dependencies]
13
13
  magnus = "0.8.2"
14
- rb-sys = "*"
15
14
  ratatui = { version = "0.30", features = ["widget-calendar", "layout-cache", "unstable-rendered-line-info", "palette"] }
16
15
  unicode-width = "0.1"
17
16
 
@@ -2,9 +2,7 @@
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;
6
5
  use std::cell::RefCell;
7
- use std::os::raw::c_void;
8
6
 
9
7
  /// Wrapper enum for test events - includes crossterm events and our Sync event.
10
8
  #[derive(Debug, Clone)]
@@ -316,92 +314,6 @@ pub fn clear_events() {
316
314
  EVENT_QUEUE.with(|q| q.borrow_mut().clear());
317
315
  }
318
316
 
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
-
405
317
  pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value, Error> {
406
318
  let event = EVENT_QUEUE.with(|q| {
407
319
  let mut queue = q.borrow_mut();
@@ -422,8 +334,24 @@ pub fn poll_event(ruby: &magnus::Ruby, timeout_val: Option<f64>) -> Result<Value
422
334
  return Ok(ruby.qnil().into_value_with(ruby));
423
335
  }
424
336
 
425
- let timeout = timeout_val.map(std::time::Duration::from_secs_f64);
426
- poll_crossterm_without_gvl(ruby, timeout)
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
+ }
427
355
  }
428
356
 
429
357
  fn handle_test_event(event: TestEvent) -> Result<Value, Error> {
@@ -631,70 +559,3 @@ fn handle_focus_event(event_type: &str) -> Result<Value, Error> {
631
559
  hash.aset(ruby.to_symbol("type"), ruby.to_symbol(event_type))?;
632
560
  Ok(hash.into_value_with(&ruby))
633
561
  }
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
- }
@@ -70,6 +70,56 @@ module RatatuiRuby
70
70
  def ==(other)
71
71
  other.is_a?(FocusGained)
72
72
  end
73
+
74
+ # =========================================================================
75
+ # DWIM Predicates
76
+ # =========================================================================
77
+
78
+ # Returns true. The terminal window is now in focus.
79
+ #
80
+ # event.focus? # => true
81
+ def focus?
82
+ true
83
+ end
84
+ alias focused? focus?
85
+
86
+ # Returns true. The application gained focus.
87
+ #
88
+ # event.gained? # => true
89
+ def gained?
90
+ true
91
+ end
92
+
93
+ # Returns false. This is not a focus lost event.
94
+ #
95
+ # event.lost? # => false
96
+ def lost?
97
+ false
98
+ end
99
+
100
+ # Returns false. Blur is the opposite of focus gained.
101
+ #
102
+ # event.blur? # => false
103
+ def blur?
104
+ false
105
+ end
106
+ alias blurred? blur?
107
+
108
+ # Returns true. The application is active.
109
+ #
110
+ # event.active? # => true
111
+ def active?
112
+ true
113
+ end
114
+ alias foreground? active?
115
+
116
+ # Returns false. The application is not inactive.
117
+ #
118
+ # event.inactive? # => false
119
+ def inactive?
120
+ false
121
+ end
122
+ alias background? inactive?
73
123
  end
74
124
  end
75
125
  end
@@ -71,6 +71,57 @@ module RatatuiRuby
71
71
  def ==(other)
72
72
  other.is_a?(FocusLost)
73
73
  end
74
+
75
+ # =========================================================================
76
+ # DWIM Predicates
77
+ # =========================================================================
78
+
79
+ # Returns true. The terminal has lost focus (blur).
80
+ #
81
+ # event.blur? # => true
82
+ def blur?
83
+ true
84
+ end
85
+ alias blurred? blur?
86
+
87
+ # Returns true. The application lost focus.
88
+ #
89
+ # event.lost? # => true
90
+ def lost?
91
+ true
92
+ end
93
+ alias unfocused? lost?
94
+
95
+ # Returns false. This is not a focus gained event.
96
+ #
97
+ # event.focus? # => false
98
+ def focus?
99
+ false
100
+ end
101
+ alias focused? focus?
102
+
103
+ # Returns false. This is not a gained event.
104
+ #
105
+ # event.gained? # => false
106
+ def gained?
107
+ false
108
+ end
109
+
110
+ # Returns true. The application is inactive.
111
+ #
112
+ # event.inactive? # => true
113
+ def inactive?
114
+ true
115
+ end
116
+ alias background? inactive?
117
+
118
+ # Returns false. The application is not active.
119
+ #
120
+ # event.active? # => false
121
+ def active?
122
+ false
123
+ end
124
+ alias foreground? active?
74
125
  end
75
126
  end
76
127
  end
@@ -0,0 +1,301 @@
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
+ module RatatuiRuby
9
+ class Event
10
+ class Key < Event
11
+ # DWIM predicates for common key patterns.
12
+ #
13
+ # These predicates anticipate what developers intuitively try. Space bars,
14
+ # character categories, Unix signals, and Vim-style navigation.
15
+ module Dwim
16
+ # Returns true if the key is a space character.
17
+ #
18
+ # event.space? # => true for " "
19
+ def space?
20
+ @code == " " && @modifiers.empty?
21
+ end
22
+
23
+ alias spacebar? space?
24
+
25
+ # Returns true if the key is Enter. Alias for carriage return.
26
+ #
27
+ # event.cr? # => true for enter
28
+ def cr?
29
+ @code == "enter" && @modifiers.empty?
30
+ end
31
+
32
+ alias carriagereturn? cr?
33
+ alias linefeed? cr?
34
+ alias newline? cr?
35
+ alias lf? cr?
36
+
37
+ # Returns true if the key is a single letter (a-z, A-Z).
38
+ #
39
+ # Event::Key.new(code: "a").letter? # => true
40
+ def letter?
41
+ @code.length == 1 && @code.match?(/\A[A-Za-z]\z/)
42
+ end
43
+
44
+ # Returns true if the key is a digit (0-9).
45
+ #
46
+ # Event::Key.new(code: "5").digit? # => true
47
+ def digit?
48
+ @code.length == 1 && @code.match?(/\A[0-9]\z/)
49
+ end
50
+
51
+ # Returns true if the key is alphanumeric.
52
+ #
53
+ # Event::Key.new(code: "a").alphanumeric? # => true
54
+ def alphanumeric?
55
+ letter? || digit?
56
+ end
57
+
58
+ # Returns true if the key is punctuation.
59
+ #
60
+ # Event::Key.new(code: "@", modifiers: ["shift"]).punctuation? # => true
61
+ def punctuation?
62
+ return false unless @code.length == 1
63
+ !letter? && !digit? && !whitespace?
64
+ end
65
+
66
+ # Returns true if the key is whitespace (space, enter, tab).
67
+ #
68
+ # Event::Key.new(code: " ").whitespace? # => true
69
+ def whitespace?
70
+ @code == " " || @code == "enter" || @code == "tab"
71
+ end
72
+
73
+ # Returns true for interrupt (Ctrl+C).
74
+ #
75
+ # event.interrupt? # => true for Ctrl+C
76
+ def interrupt?
77
+ @code == "c" && @modifiers == ["ctrl"]
78
+ end
79
+
80
+ # Returns true for end-of-file (Ctrl+D).
81
+ #
82
+ # event.eof? # => true for Ctrl+D
83
+ def eof?
84
+ @code == "d" && @modifiers == ["ctrl"]
85
+ end
86
+
87
+ # Returns true for cancel (Esc or Ctrl+C).
88
+ #
89
+ # event.cancel? # => true for Esc or Ctrl+C
90
+ def cancel?
91
+ (@code == "esc" && @modifiers.empty?) || interrupt?
92
+ end
93
+
94
+ # Returns true for SIGINT (Ctrl+C).
95
+ #
96
+ # event.sigint? # => true for Ctrl+C
97
+ def sigint?
98
+ interrupt?
99
+ end
100
+
101
+ alias int? sigint?
102
+
103
+ # Returns true for SIGTSTP (Ctrl+Z) - suspend/stop.
104
+ #
105
+ # event.suspend? # => true for Ctrl+Z
106
+ def suspend?
107
+ @code == "z" && @modifiers == ["ctrl"]
108
+ end
109
+
110
+ alias sigtstp? suspend?
111
+ alias tstp? suspend?
112
+
113
+ # Returns true for SIGQUIT (Ctrl+\).
114
+ #
115
+ # event.quit? # => true for Ctrl+\
116
+ def quit?
117
+ @code == "\\" && @modifiers == ["ctrl"]
118
+ end
119
+
120
+ alias sigquit? quit?
121
+
122
+ NAVIGATION_KEYS = %w[up down left right home end page_up page_down].freeze # :nodoc:
123
+
124
+ # Returns true if key is a navigation key.
125
+ #
126
+ # Event::Key.new(code: "up").navigation? # => true
127
+ def navigation?
128
+ NAVIGATION_KEYS.include?(@code) && @modifiers.empty?
129
+ end
130
+
131
+ ARROW_KEYS = %w[up down left right].freeze # :nodoc:
132
+
133
+ # Returns true if key is an arrow key.
134
+ #
135
+ # Event::Key.new(code: "up").arrow? # => true
136
+ def arrow?
137
+ ARROW_KEYS.include?(@code) && @modifiers.empty?
138
+ end
139
+
140
+ VIM_MOVEMENT_KEYS = %w[h j k l w b g G].freeze # :nodoc:
141
+
142
+ # Returns true if key is a Vim movement key.
143
+ #
144
+ # Event::Key.new(code: "j").vim? # => true
145
+ def vim?
146
+ return true if VIM_MOVEMENT_KEYS.include?(@code) && @modifiers.empty?
147
+ @code == "G" && @modifiers == ["shift"]
148
+ end
149
+
150
+ # Returns true for Vim left (h).
151
+ def vim_left?
152
+ @code == "h" && @modifiers.empty?
153
+ end
154
+
155
+ # Returns true for Vim down (j).
156
+ def vim_down?
157
+ @code == "j" && @modifiers.empty?
158
+ end
159
+
160
+ # Returns true for Vim up (k).
161
+ def vim_up?
162
+ @code == "k" && @modifiers.empty?
163
+ end
164
+
165
+ # Returns true for Vim right (l).
166
+ def vim_right?
167
+ @code == "l" && @modifiers.empty?
168
+ end
169
+
170
+ # Returns true for Vim word forward (w).
171
+ def vim_word_forward?
172
+ @code == "w" && @modifiers.empty?
173
+ end
174
+
175
+ # Returns true for Vim word backward (b).
176
+ def vim_word_backward?
177
+ @code == "b" && @modifiers.empty?
178
+ end
179
+
180
+ # Returns true for Vim go to top (gg pattern, here just g).
181
+ def vim_top?
182
+ @code == "g" && @modifiers.empty?
183
+ end
184
+
185
+ # Returns true for Vim go to bottom (G).
186
+ def vim_bottom?
187
+ @code == "G" && @modifiers == ["shift"]
188
+ end
189
+
190
+ # Punctuation name predicates - generated at load time for performance.
191
+ # Maps intuitive names to their symbol characters.
192
+ # :nodoc:
193
+ PUNCTUATION_NAMES = {
194
+ # Navigation shortcuts (the original use case!)
195
+ tilde: "~",
196
+ slash: "/",
197
+ forwardslash: "/",
198
+ backslash: "\\",
199
+
200
+ # Common punctuation
201
+ comma: ",",
202
+ period: ".",
203
+ dot: ".",
204
+ colon: ":",
205
+ semicolon: ";",
206
+
207
+ # Question and exclamation
208
+ question: "?",
209
+ questionmark: "?",
210
+ exclamation: "!",
211
+ exclamationmark: "!",
212
+ exclamationpoint: "!",
213
+ bang: "!",
214
+
215
+ # Programming symbols
216
+ at: "@",
217
+ atsign: "@",
218
+ hash: "#",
219
+ pound: "#",
220
+ numbersign: "#",
221
+ dollar: "$",
222
+ dollarsign: "$",
223
+ percent: "%",
224
+ caret: "^",
225
+ circumflex: "^",
226
+ ampersand: "&",
227
+ asterisk: "*",
228
+ star: "*",
229
+
230
+ # Arithmetic and comparison
231
+ underscore: "_",
232
+ hyphen: "-",
233
+ dash: "-",
234
+ minus: "-",
235
+ plus: "+",
236
+ equals: "=",
237
+ equalsign: "=",
238
+ pipe: "|",
239
+ bar: "|",
240
+ lessthan: "<",
241
+ lt: "<",
242
+ greaterthan: ">",
243
+ gt: ">",
244
+
245
+ # Brackets and parens
246
+ lparen: "(",
247
+ leftparen: "(",
248
+ openparen: "(",
249
+ leftparenthesis: "(",
250
+ openparenthesis: "(",
251
+ rparen: ")",
252
+ rightparen: ")",
253
+ closeparen: ")",
254
+ rightparenthesis: ")",
255
+ closeparenthesis: ")",
256
+ lbracket: "[",
257
+ leftbracket: "[",
258
+ openbracket: "[",
259
+ leftsquarebracket: "[",
260
+ opensquarebracket: "[",
261
+ rbracket: "]",
262
+ rightbracket: "]",
263
+ closebracket: "]",
264
+ rightsquarebracket: "]",
265
+ closesquarebracket: "]",
266
+ lbrace: "{",
267
+ leftbrace: "{",
268
+ openbrace: "{",
269
+ leftcurlybrace: "{",
270
+ opencurlybrace: "{",
271
+ rbrace: "}",
272
+ rightbrace: "}",
273
+ closebrace: "}",
274
+ rightcurlybrace: "}",
275
+ closecurlybrace: "}",
276
+
277
+ # Quotes
278
+ backtick: "`",
279
+ grave: "`",
280
+ singlequote: "'",
281
+ apostrophe: "'",
282
+ doublequote: "\"",
283
+ }.freeze
284
+
285
+ # Generate predicate methods at load time (faster than method_missing)
286
+ PUNCTUATION_NAMES.each do |name, char|
287
+ class_eval <<~RUBY, __FILE__, __LINE__ + 1
288
+ def #{name}?
289
+ @code == #{char.inspect}
290
+ end
291
+ RUBY
292
+ end
293
+
294
+ # quote? matches both single and double quotes
295
+ def quote?
296
+ @code == "'" || @code == "\""
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -40,6 +40,8 @@ module RatatuiRuby
40
40
  # Alias for {#super?}.
41
41
  alias win? super?
42
42
  # Alias for {#super?}.
43
+ alias windows? super?
44
+ # Alias for {#super?}.
43
45
  alias command? super?
44
46
  # Alias for {#super?}.
45
47
  alias cmd? super?
@@ -6,6 +6,7 @@
6
6
  #++
7
7
 
8
8
  require_relative "key/character"
9
+ require_relative "key/dwim"
9
10
  require_relative "key/media"
10
11
  require_relative "key/modifier"
11
12
  require_relative "key/navigation"
@@ -88,6 +89,7 @@ module RatatuiRuby
88
89
  # These keys will not work in Terminal.app, iTerm2, or GNOME Terminal.
89
90
  class Key < Event
90
91
  include Character
92
+ include Dwim
91
93
  include Media
92
94
  include Modifier
93
95
  include Navigation
@@ -435,6 +437,13 @@ module RatatuiRuby
435
437
  normalized_code = @code.delete("_")
436
438
  return true if normalized_predicate == normalized_code && @modifiers.empty?
437
439
 
440
+ # DWIM: Underscore variants delegate to existing methods
441
+ # space_bar? → spacebar? → space?, sig_int? → sigint?
442
+ normalized_method = :"#{normalized_predicate}?"
443
+ if normalized_method != name && respond_to?(normalized_method)
444
+ return public_send(normalized_method)
445
+ end
446
+
438
447
  false
439
448
  else
440
449
  super