ratatui_ruby 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (311) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +1 -1
  3. data/.builds/ruby-3.3.yml +1 -1
  4. data/.builds/ruby-3.4.yml +1 -1
  5. data/.builds/ruby-4.0.0.yml +1 -1
  6. data/AGENTS.md +10 -4
  7. data/CHANGELOG.md +79 -7
  8. data/README.md +37 -5
  9. data/REUSE.toml +2 -7
  10. data/doc/application_architecture.md +96 -22
  11. data/doc/application_testing.md +76 -30
  12. data/doc/contributors/architectural_overhaul/chat_conversations.md +4952 -0
  13. data/doc/contributors/architectural_overhaul/implementation_plan.md +60 -0
  14. data/doc/contributors/architectural_overhaul/task.md +37 -0
  15. data/doc/contributors/design/ruby_frontend.md +288 -56
  16. data/doc/contributors/design/rust_backend.md +349 -54
  17. data/doc/contributors/developing_examples.md +134 -49
  18. data/doc/contributors/index.md +7 -5
  19. data/doc/contributors/v1.0.0_blockers.md +1729 -0
  20. data/doc/event_handling.md +11 -3
  21. data/doc/images/app_all_events.png +0 -0
  22. data/doc/images/app_color_picker.png +0 -0
  23. data/doc/images/app_login_form.png +0 -0
  24. data/doc/images/app_stateful_interaction.png +0 -0
  25. data/doc/images/verify_quickstart_dsl.png +0 -0
  26. data/doc/images/verify_quickstart_layout.png +0 -0
  27. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  28. data/doc/images/verify_readme_usage.png +0 -0
  29. data/doc/images/widget_barchart_demo.png +0 -0
  30. data/doc/images/widget_block_demo.png +0 -0
  31. data/doc/images/widget_canvas_demo.png +0 -0
  32. data/doc/images/widget_cell_demo.png +0 -0
  33. data/doc/images/widget_center_demo.png +0 -0
  34. data/doc/images/widget_chart_demo.png +0 -0
  35. data/doc/images/widget_list_demo.png +0 -0
  36. data/doc/images/widget_overlay_demo.png +0 -0
  37. data/doc/images/widget_render.png +0 -0
  38. data/doc/images/widget_rich_text.png +0 -0
  39. data/doc/images/widget_scroll_text.png +0 -0
  40. data/doc/images/widget_sparkline_demo.png +0 -0
  41. data/doc/images/widget_table_demo.png +0 -0
  42. data/doc/images/widget_tabs_demo.png +0 -0
  43. data/doc/images/widget_text_width.png +0 -0
  44. data/doc/index.md +11 -6
  45. data/doc/interactive_design.md +2 -2
  46. data/doc/quickstart.md +127 -165
  47. data/doc/terminal_limitations.md +92 -0
  48. data/doc/v0.7.0_migration.md +236 -0
  49. data/doc/why.md +93 -0
  50. data/examples/app_all_events/README.md +47 -27
  51. data/examples/app_all_events/app.rb +38 -35
  52. data/examples/app_all_events/model/app_model.rb +157 -0
  53. data/examples/app_all_events/model/event_entry.rb +17 -0
  54. data/examples/app_all_events/model/msg.rb +37 -0
  55. data/examples/app_all_events/update.rb +73 -0
  56. data/examples/app_all_events/view/app_view.rb +9 -9
  57. data/examples/app_all_events/view/controls_view.rb +9 -7
  58. data/examples/app_all_events/view/counts_view.rb +13 -9
  59. data/examples/app_all_events/view/live_view.rb +9 -8
  60. data/examples/app_all_events/view/log_view.rb +11 -16
  61. data/examples/app_color_picker/README.md +84 -42
  62. data/examples/app_color_picker/app.rb +24 -62
  63. data/examples/app_color_picker/controls.rb +90 -0
  64. data/examples/app_color_picker/copy_dialog.rb +45 -49
  65. data/examples/app_color_picker/export_pane.rb +126 -0
  66. data/examples/app_color_picker/input.rb +99 -67
  67. data/examples/app_color_picker/main_container.rb +178 -0
  68. data/examples/app_color_picker/palette.rb +55 -26
  69. data/examples/app_login_form/README.md +49 -0
  70. data/examples/app_login_form/app.rb +2 -3
  71. data/examples/app_stateful_interaction/README.md +33 -0
  72. data/examples/app_stateful_interaction/app.rb +272 -0
  73. data/examples/timeout_demo.rb +43 -0
  74. data/examples/verify_quickstart_dsl/README.md +49 -0
  75. data/examples/verify_quickstart_dsl/app.rb +2 -0
  76. data/examples/verify_quickstart_layout/README.md +71 -0
  77. data/examples/verify_quickstart_layout/app.rb +2 -0
  78. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  79. data/examples/verify_quickstart_lifecycle/app.rb +10 -4
  80. data/examples/verify_readme_usage/README.md +43 -0
  81. data/examples/verify_readme_usage/app.rb +8 -2
  82. data/examples/widget_barchart_demo/README.md +50 -0
  83. data/examples/widget_barchart_demo/app.rb +5 -5
  84. data/examples/widget_block_demo/README.md +36 -0
  85. data/examples/widget_block_demo/app.rb +256 -0
  86. data/examples/widget_box_demo/README.md +45 -0
  87. data/examples/widget_calendar_demo/README.md +39 -0
  88. data/examples/widget_calendar_demo/app.rb +5 -1
  89. data/examples/widget_canvas_demo/README.md +27 -0
  90. data/examples/widget_canvas_demo/app.rb +123 -0
  91. data/examples/widget_cell_demo/README.md +36 -0
  92. data/examples/widget_cell_demo/app.rb +31 -24
  93. data/examples/widget_center_demo/README.md +29 -0
  94. data/examples/widget_center_demo/app.rb +116 -0
  95. data/examples/widget_chart_demo/README.md +41 -0
  96. data/examples/widget_chart_demo/app.rb +7 -2
  97. data/examples/widget_gauge_demo/README.md +41 -0
  98. data/examples/widget_layout_split/README.md +44 -0
  99. data/examples/widget_line_gauge_demo/README.md +41 -0
  100. data/examples/widget_list_demo/README.md +49 -0
  101. data/examples/widget_list_demo/app.rb +91 -107
  102. data/examples/widget_map_demo/README.md +39 -0
  103. data/examples/{app_map_demo → widget_map_demo}/app.rb +4 -4
  104. data/examples/widget_overlay_demo/README.md +36 -0
  105. data/examples/widget_overlay_demo/app.rb +248 -0
  106. data/examples/widget_popup_demo/README.md +36 -0
  107. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  108. data/examples/widget_ratatui_logo_demo/app.rb +1 -1
  109. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  110. data/examples/widget_rect/README.md +38 -0
  111. data/examples/widget_render/README.md +37 -0
  112. data/examples/widget_render/app.rb +3 -3
  113. data/examples/widget_rich_text/README.md +35 -0
  114. data/examples/widget_rich_text/app.rb +62 -33
  115. data/examples/widget_scroll_text/README.md +37 -0
  116. data/examples/widget_scroll_text/app.rb +0 -1
  117. data/examples/widget_scrollbar_demo/README.md +37 -0
  118. data/examples/widget_sparkline_demo/README.md +42 -0
  119. data/examples/widget_sparkline_demo/app.rb +4 -3
  120. data/examples/widget_style_colors/README.md +34 -0
  121. data/examples/widget_table_demo/README.md +48 -0
  122. data/examples/{app_table_select → widget_table_demo}/app.rb +65 -12
  123. data/examples/widget_tabs_demo/README.md +41 -0
  124. data/examples/widget_tabs_demo/app.rb +15 -1
  125. data/examples/widget_text_width/README.md +35 -0
  126. data/examples/widget_text_width/app.rb +113 -0
  127. data/exe/.gitkeep +0 -0
  128. data/ext/ratatui_ruby/Cargo.lock +11 -4
  129. data/ext/ratatui_ruby/Cargo.toml +2 -1
  130. data/ext/ratatui_ruby/src/events.rs +238 -26
  131. data/ext/ratatui_ruby/src/frame.rs +116 -3
  132. data/ext/ratatui_ruby/src/lib.rs +37 -6
  133. data/ext/ratatui_ruby/src/rendering.rs +22 -21
  134. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  135. data/ext/ratatui_ruby/src/terminal.rs +39 -15
  136. data/ext/ratatui_ruby/src/text.rs +13 -4
  137. data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
  138. data/ext/ratatui_ruby/src/widgets/canvas.rs +5 -5
  139. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  140. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  141. data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
  142. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  143. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  144. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
  145. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  146. data/ext/ratatui_ruby/src/widgets/table.rs +191 -34
  147. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  148. data/lib/ratatui_ruby/buffer/cell.rb +168 -0
  149. data/lib/ratatui_ruby/buffer.rb +15 -0
  150. data/lib/ratatui_ruby/cell.rb +4 -4
  151. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  152. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  153. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  154. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  155. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  156. data/lib/ratatui_ruby/event/key.rb +111 -51
  157. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  158. data/lib/ratatui_ruby/event/paste.rb +1 -1
  159. data/lib/ratatui_ruby/frame.rb +100 -4
  160. data/lib/ratatui_ruby/layout/constraint.rb +95 -0
  161. data/lib/ratatui_ruby/layout/layout.rb +106 -0
  162. data/lib/ratatui_ruby/layout/rect.rb +118 -0
  163. data/lib/ratatui_ruby/layout.rb +19 -0
  164. data/lib/ratatui_ruby/list_state.rb +88 -0
  165. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
  166. data/lib/ratatui_ruby/schema/cursor.rb +5 -0
  167. data/lib/ratatui_ruby/schema/gauge.rb +3 -1
  168. data/lib/ratatui_ruby/schema/layout.rb +1 -1
  169. data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
  170. data/lib/ratatui_ruby/schema/list.rb +25 -4
  171. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  172. data/lib/ratatui_ruby/schema/rect.rb +43 -0
  173. data/lib/ratatui_ruby/schema/row.rb +66 -0
  174. data/lib/ratatui_ruby/schema/style.rb +24 -4
  175. data/lib/ratatui_ruby/schema/table.rb +29 -11
  176. data/lib/ratatui_ruby/schema/text.rb +96 -3
  177. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  178. data/lib/ratatui_ruby/style/style.rb +81 -0
  179. data/lib/ratatui_ruby/style.rb +15 -0
  180. data/lib/ratatui_ruby/table_state.rb +90 -0
  181. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  182. data/lib/ratatui_ruby/test_helper/snapshot.rb +414 -0
  183. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  184. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  185. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  186. data/lib/ratatui_ruby/test_helper.rb +65 -358
  187. data/lib/ratatui_ruby/tui/buffer_factories.rb +20 -0
  188. data/lib/ratatui_ruby/tui/canvas_factories.rb +44 -0
  189. data/lib/ratatui_ruby/tui/core.rb +38 -0
  190. data/lib/ratatui_ruby/tui/layout_factories.rb +74 -0
  191. data/lib/ratatui_ruby/tui/state_factories.rb +33 -0
  192. data/lib/ratatui_ruby/tui/style_factories.rb +20 -0
  193. data/lib/ratatui_ruby/tui/text_factories.rb +44 -0
  194. data/lib/ratatui_ruby/tui/widget_factories.rb +195 -0
  195. data/lib/ratatui_ruby/tui.rb +75 -0
  196. data/lib/ratatui_ruby/version.rb +1 -1
  197. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +47 -0
  198. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +25 -0
  199. data/lib/ratatui_ruby/widgets/bar_chart.rb +239 -0
  200. data/lib/ratatui_ruby/widgets/block.rb +192 -0
  201. data/lib/ratatui_ruby/widgets/calendar.rb +84 -0
  202. data/lib/ratatui_ruby/widgets/canvas.rb +231 -0
  203. data/lib/ratatui_ruby/widgets/cell.rb +47 -0
  204. data/lib/ratatui_ruby/widgets/center.rb +59 -0
  205. data/lib/ratatui_ruby/widgets/chart.rb +185 -0
  206. data/lib/ratatui_ruby/widgets/clear.rb +54 -0
  207. data/lib/ratatui_ruby/widgets/cursor.rb +42 -0
  208. data/lib/ratatui_ruby/widgets/gauge.rb +72 -0
  209. data/lib/ratatui_ruby/widgets/line_gauge.rb +80 -0
  210. data/lib/ratatui_ruby/widgets/list.rb +127 -0
  211. data/lib/ratatui_ruby/widgets/list_item.rb +43 -0
  212. data/lib/ratatui_ruby/widgets/overlay.rb +43 -0
  213. data/lib/ratatui_ruby/widgets/paragraph.rb +99 -0
  214. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +31 -0
  215. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +36 -0
  216. data/lib/ratatui_ruby/widgets/row.rb +68 -0
  217. data/lib/ratatui_ruby/widgets/scrollbar.rb +143 -0
  218. data/lib/ratatui_ruby/widgets/shape/label.rb +68 -0
  219. data/lib/ratatui_ruby/widgets/sparkline.rb +134 -0
  220. data/lib/ratatui_ruby/widgets/table.rb +141 -0
  221. data/lib/ratatui_ruby/widgets/tabs.rb +85 -0
  222. data/lib/ratatui_ruby/widgets.rb +40 -0
  223. data/lib/ratatui_ruby.rb +64 -57
  224. data/sig/examples/app_all_events/view.rbs +1 -1
  225. data/sig/examples/app_all_events/view_state.rbs +1 -1
  226. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  227. data/sig/examples/widget_block_demo/app.rbs +32 -0
  228. data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
  229. data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
  230. data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
  231. data/sig/ratatui_ruby/event.rbs +11 -1
  232. data/sig/ratatui_ruby/frame.rbs +2 -0
  233. data/sig/ratatui_ruby/list_state.rbs +13 -0
  234. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
  235. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  236. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  237. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  238. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  239. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  240. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  241. data/sig/ratatui_ruby/schema/row.rbs +22 -0
  242. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  243. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  244. data/sig/ratatui_ruby/schema/text.rbs +9 -6
  245. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  246. data/sig/ratatui_ruby/session.rbs +41 -48
  247. data/sig/ratatui_ruby/table_state.rbs +15 -0
  248. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  249. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  250. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  251. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  252. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  253. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  254. data/sig/ratatui_ruby/tui/buffer_factories.rbs +10 -0
  255. data/sig/ratatui_ruby/tui/canvas_factories.rbs +14 -0
  256. data/sig/ratatui_ruby/tui/core.rbs +14 -0
  257. data/sig/ratatui_ruby/tui/layout_factories.rbs +19 -0
  258. data/sig/ratatui_ruby/tui/state_factories.rbs +12 -0
  259. data/sig/ratatui_ruby/tui/style_factories.rbs +10 -0
  260. data/sig/ratatui_ruby/tui/text_factories.rbs +14 -0
  261. data/sig/ratatui_ruby/tui/widget_factories.rbs +39 -0
  262. data/sig/ratatui_ruby/tui.rbs +19 -0
  263. data/tasks/autodoc/examples.rb +79 -0
  264. data/tasks/autodoc.rake +7 -35
  265. data/tasks/bump/changelog.rb +3 -3
  266. data/tasks/bump/links.rb +67 -0
  267. data/tasks/sourcehut.rake +64 -21
  268. data/tasks/terminal_preview/app_screenshot.rb +13 -3
  269. data/tasks/terminal_preview/saved_screenshot.rb +4 -3
  270. metadata +169 -48
  271. data/doc/contributors/dwim_dx.md +0 -366
  272. data/doc/images/app_analytics.png +0 -0
  273. data/doc/images/app_custom_widget.png +0 -0
  274. data/doc/images/app_mouse_events.png +0 -0
  275. data/doc/images/app_table_select.png +0 -0
  276. data/doc/images/widget_block_padding.png +0 -0
  277. data/doc/images/widget_block_titles.png +0 -0
  278. data/doc/images/widget_list_styles.png +0 -0
  279. data/doc/images/widget_table_flex.png +0 -0
  280. data/examples/app_all_events/model/events.rb +0 -180
  281. data/examples/app_all_events/model/highlight.rb +0 -57
  282. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
  283. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
  284. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
  285. data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
  286. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
  287. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
  288. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
  289. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
  290. data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
  291. data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
  292. data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
  293. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
  294. data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
  295. data/examples/app_all_events/view_state.rb +0 -42
  296. data/examples/app_color_picker/scene.rb +0 -201
  297. data/examples/widget_block_padding/app.rb +0 -67
  298. data/examples/widget_block_titles/app.rb +0 -69
  299. data/examples/widget_list_styles/app.rb +0 -141
  300. data/examples/widget_table_flex/app.rb +0 -95
  301. data/lib/ratatui_ruby/session/autodoc.rb +0 -417
  302. data/lib/ratatui_ruby/session.rb +0 -163
  303. data/sig/examples/widget_block_padding/app.rbs +0 -11
  304. data/sig/examples/widget_block_titles/app.rbs +0 -11
  305. data/sig/examples/widget_list_styles/app.rbs +0 -11
  306. data/tasks/autodoc/inventory.rb +0 -61
  307. data/tasks/autodoc/notice.rb +0 -26
  308. data/tasks/autodoc/rbs.rb +0 -38
  309. data/tasks/autodoc/rdoc.rb +0 -45
  310. data/tasks/bump/comparison_links.rb +0 -41
  311. /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ module RatatuiRuby
7
+ module TestHelper
8
+ ##
9
+ # Event injection helpers for testing TUI interactions.
10
+ #
11
+ # Testing keyboard navigation and mouse clicks requires simulating user input.
12
+ # Constructing event objects by hand for every test is verbose and repetitive.
13
+ #
14
+ # This mixin provides convenience methods to inject keys, clicks, and other events
15
+ # into the test terminal's event queue. Events are consumed by the next
16
+ # <tt>poll_event</tt> call.
17
+ #
18
+ # Use it to simulate user interactions: typing, clicking, dragging, pasting.
19
+ #
20
+ # === Examples
21
+ #
22
+ # with_test_terminal do
23
+ # inject_keys("h", "e", "l", "l", "o")
24
+ # inject_keys(:enter, :ctrl_s)
25
+ # inject_click(x: 10, y: 5)
26
+ # inject_event(RatatuiRuby::Event::Paste.new(content: "pasted text"))
27
+ #
28
+ # @app.run
29
+ # end
30
+ #
31
+ module EventInjection
32
+ ##
33
+ # Injects an event into the test terminal's event queue.
34
+ #
35
+ # Pass any <tt>RatatuiRuby::Event</tt> object. The event is returned by
36
+ # the next <tt>poll_event</tt> call.
37
+ #
38
+ # Raises <tt>RuntimeError</tt> if called outside a <tt>with_test_terminal</tt> block.
39
+ #
40
+ # === Examples
41
+ #
42
+ # inject_event(RatatuiRuby::Event::Key.new(code: "q"))
43
+ # inject_event(RatatuiRuby::Event::Mouse.new(kind: "down", button: "left", x: 10, y: 5))
44
+ # inject_event(RatatuiRuby::Event::Paste.new(content: "Hello"))
45
+ #
46
+ # [event] A <tt>RatatuiRuby::Event</tt> object.
47
+ def inject_event(event)
48
+ unless @_ratatui_test_terminal_active
49
+ raise "Events must be injected inside a `with_test_terminal` block. " \
50
+ "Calling this method outside the block causes a race condition where the event " \
51
+ "is flushed before the application starts."
52
+ end
53
+
54
+ case event
55
+ when RatatuiRuby::Event::Key
56
+ RatatuiRuby.inject_test_event("key", { code: event.code, modifiers: event.modifiers })
57
+ when RatatuiRuby::Event::Mouse
58
+ RatatuiRuby.inject_test_event("mouse", {
59
+ kind: event.kind,
60
+ button: event.button,
61
+ x: event.x,
62
+ y: event.y,
63
+ modifiers: event.modifiers,
64
+ })
65
+ when RatatuiRuby::Event::Resize
66
+ RatatuiRuby.inject_test_event("resize", { width: event.width, height: event.height })
67
+ when RatatuiRuby::Event::Paste
68
+ RatatuiRuby.inject_test_event("paste", { content: event.content })
69
+ when RatatuiRuby::Event::FocusGained
70
+ RatatuiRuby.inject_test_event("focus_gained", {})
71
+ when RatatuiRuby::Event::FocusLost
72
+ RatatuiRuby.inject_test_event("focus_lost", {})
73
+ else
74
+ raise ArgumentError, "Unknown event type: #{event.class}"
75
+ end
76
+ end
77
+
78
+ ##
79
+ # Injects a mouse event.
80
+ #
81
+ # === Example
82
+ #
83
+ # inject_mouse(x: 10, y: 5, kind: :down, button: :left)
84
+ #
85
+ # [x] Integer x-coordinate.
86
+ # [y] Integer y-coordinate.
87
+ # [kind] Symbol <tt>:down</tt>, <tt>:up</tt>, or <tt>:drag</tt>.
88
+ # [button] Symbol <tt>:left</tt>, <tt>:right</tt>, or <tt>:middle</tt>.
89
+ # [modifiers] Array of modifier strings.
90
+ def inject_mouse(x:, y:, kind: :down, modifiers: [], button: :left)
91
+ event = RatatuiRuby::Event::Mouse.new(
92
+ kind: kind.to_s,
93
+ x:,
94
+ y:,
95
+ button: button.to_s,
96
+ modifiers:
97
+ )
98
+ inject_event(event)
99
+ end
100
+
101
+ ##
102
+ # Injects a left mouse click.
103
+ #
104
+ # === Example
105
+ #
106
+ # inject_click(x: 10, y: 5)
107
+ def inject_click(x:, y:, modifiers: [])
108
+ inject_mouse(x:, y:, kind: :down, modifiers:, button: :left)
109
+ end
110
+
111
+ ##
112
+ # Injects a right mouse click.
113
+ #
114
+ # === Example
115
+ #
116
+ # inject_right_click(x: 10, y: 5)
117
+ def inject_right_click(x:, y:, modifiers: [])
118
+ inject_mouse(x:, y:, kind: :down, modifiers:, button: :right)
119
+ end
120
+
121
+ ##
122
+ # Injects a mouse drag event.
123
+ #
124
+ # === Example
125
+ #
126
+ # inject_drag(x: 10, y: 5)
127
+ def inject_drag(x:, y:, modifiers: [], button: :left)
128
+ inject_mouse(x:, y:, kind: :drag, modifiers:, button:)
129
+ end
130
+
131
+ ##
132
+ # Injects one or more key events.
133
+ #
134
+ # Accepts multiple formats for convenience:
135
+ # - String: Character key (e.g., <tt>"a"</tt>, <tt>"q"</tt>)
136
+ # - Symbol: Named key or modifier combo (e.g., <tt>:enter</tt>, <tt>:ctrl_c</tt>)
137
+ # - Hash: Passed to <tt>Key.new</tt>
138
+ # - Key: Passed directly
139
+ #
140
+ # === Examples
141
+ #
142
+ # inject_keys("a", "b", "c")
143
+ # inject_keys(:enter, :esc)
144
+ # inject_keys(:ctrl_c, :alt_shift_left)
145
+ # inject_keys("j", { code: "k", modifiers: ["ctrl"] })
146
+ def inject_keys(*args)
147
+ args.each do |arg|
148
+ event = case arg
149
+ when String
150
+ RatatuiRuby::Event::Key.new(code: arg)
151
+ when Symbol
152
+ parts = arg.to_s.split("_")
153
+ code = parts.pop
154
+ modifiers = parts
155
+ RatatuiRuby::Event::Key.new(code:, modifiers:)
156
+ when Hash
157
+ RatatuiRuby::Event::Key.new(**arg)
158
+ when RatatuiRuby::Event::Key
159
+ arg
160
+ else
161
+ raise ArgumentError, "Invalid key argument: #{arg.inspect}. Expected String, Symbol, Hash, or Key event."
162
+ end
163
+ inject_event(event)
164
+ end
165
+ end
166
+ alias inject_key inject_keys
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,414 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ require "fileutils"
7
+
8
+ module RatatuiRuby
9
+ module TestHelper
10
+ ##
11
+ # Snapshot testing assertions for terminal UIs.
12
+ #
13
+ # Verifying every character of a TUI screen by hand is tedious. Snapshots let you
14
+ # capture the screen once and compare against it in future runs.
15
+ #
16
+ # This mixin provides <tt>assert_snapshot</tt> for plain text and
17
+ # <tt>assert_rich_snapshot</tt> for styled ANSI output. Both auto-create
18
+ # snapshot files on first run.
19
+ #
20
+ # Use it to verify complex layouts, styles, and interactions without manual assertions.
21
+ #
22
+ # === Snapshot Files
23
+ #
24
+ # Snapshots live in a <tt>snapshots/</tt> subdirectory next to your test file:
25
+ #
26
+ # test/examples/my_app/test_app.rb
27
+ # test/examples/my_app/snapshots/initial_render.txt
28
+ # test/examples/my_app/snapshots/initial_render.ansi
29
+ #
30
+ # === Creating and Updating Snapshots
31
+ #
32
+ # Run tests with <tt>UPDATE_SNAPSHOTS=1</tt> to create or refresh snapshots:
33
+ #
34
+ # UPDATE_SNAPSHOTS=1 bundle exec rake test
35
+ #
36
+ # === Seeding Random Data
37
+ #
38
+ # Random data (scatter plots, generated content) breaks snapshot stability.
39
+ # Use a seeded <tt>Random</tt> instance instead of <tt>Kernel.rand</tt>:
40
+ #
41
+ # class MyApp
42
+ # def initialize(seed: nil)
43
+ # @rng = seed ? Random.new(seed) : Random.new
44
+ # end
45
+ #
46
+ # def generate_data
47
+ # (0..20).map { @rng.rand(0.0..10.0) }
48
+ # end
49
+ # end
50
+ #
51
+ # # In your test
52
+ # def setup
53
+ # @app = MyApp.new(seed: 42)
54
+ # end
55
+ #
56
+ # For libraries like Faker, see their docs on deterministic random:
57
+ # https://github.com/faker-ruby/faker#deterministic-random
58
+ #
59
+ # === Normalization Blocks
60
+ #
61
+ # Mask dynamic content (timestamps, IDs) with a normalization block:
62
+ #
63
+ # assert_snapshot("dashboard") do |lines|
64
+ # lines.map { |l| l.gsub(/\d{4}-\d{2}-\d{2}/, "YYYY-MM-DD") }
65
+ # end
66
+ #
67
+ module Snapshot
68
+ ##
69
+ # Asserts that the current screen content matches a stored snapshot.
70
+ #
71
+ # This method simplifies snapshot testing by automatically resolving the snapshot path
72
+ # relative to the test file calling this method. It assumes a "snapshots" directory
73
+ # exists in the same directory as the test file.
74
+ #
75
+ # # In test/test_login.rb
76
+ # assert_snapshot("login_screen")
77
+ # # Look for: test/snapshots/login_screen.txt
78
+ #
79
+ # # With normalization block
80
+ # assert_snapshot("clock") do |actual|
81
+ # actual.map { |l| l.gsub(/\d{2}:\d{2}/, "XX:XX") }
82
+ # end
83
+ #
84
+ # [name] String name of the snapshot (without extension).
85
+ # [msg] String optional failure message.
86
+ def assert_snapshot(name, msg = nil, &)
87
+ # Get the path of the test file calling this method
88
+ caller_path = caller_locations(1, 1).first.path
89
+ snapshot_dir = File.join(File.dirname(caller_path), "snapshots")
90
+ snapshot_path = File.join(snapshot_dir, "#{name}.txt")
91
+
92
+ assert_screen_matches(snapshot_path, msg, &)
93
+ end
94
+
95
+ ##
96
+ # Asserts that the current screen content matches the expected content.
97
+ #
98
+ # Users need to verify that the entire TUI screen looks exactly as expected.
99
+ # Manually checking every cell or line is tedious and error-prone.
100
+ #
101
+ # This helper compares the current buffer content against an expected string (file path)
102
+ # or array of strings. It supports automatic snapshot creation and updating via
103
+ # the +UPDATE_SNAPSHOTS+ environment variable.
104
+ #
105
+ # Use it to verify complex UI states, layouts, and renderings.
106
+ #
107
+ # == Usage
108
+ #
109
+ # # Direct comparison
110
+ # assert_screen_matches(["Line 1", "Line 2"])
111
+ #
112
+ # # File comparison
113
+ # assert_screen_matches("test/snapshots/login.txt")
114
+ #
115
+ # # With normalization (e.g., masking dynamic data)
116
+ # assert_screen_matches("test/snapshots/dashboard.txt") do |lines|
117
+ # lines.map { |l| l.gsub(/User ID: \d+/, "User ID: XXX") }
118
+ # end
119
+ #
120
+ # [expected] String (file path) or Array<String> (content).
121
+ # [msg] String optional failure message.
122
+ #
123
+ # == Non-Determinism
124
+ #
125
+ # To prevent flaky tests, this assertion performs a "Flakiness Check" when creating or updating
126
+ # snapshots. It captures the screen content, immediately re-renders the buffer, and compares
127
+ # the two results.
128
+ #
129
+ # Ensure your render logic is deterministic by seeding random number generators and stubbing
130
+ # time where necessary.
131
+ def assert_screen_matches(expected, msg = nil)
132
+ actual_lines = buffer_content
133
+
134
+ if block_given?
135
+ actual_lines = yield(actual_lines)
136
+ end
137
+
138
+ if expected.is_a?(String)
139
+ # Snapshot file mode
140
+ snapshot_path = expected
141
+ update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"
142
+
143
+ if !File.exist?(snapshot_path) || update_snapshots
144
+ FileUtils.mkdir_p(File.dirname(snapshot_path))
145
+
146
+ content_to_write = "#{actual_lines.join("\n")}\n"
147
+
148
+ begin
149
+ # Delete old file first to avoid git index stale-read issues
150
+ FileUtils.rm_f(snapshot_path)
151
+
152
+ # Write with explicit mode to ensure clean write
153
+ File.write(snapshot_path, content_to_write, mode: "w")
154
+
155
+ # Flush filesystem buffers to ensure durability
156
+ File.open(snapshot_path, "r", &:fsync) if File.exist?(snapshot_path)
157
+ rescue => e
158
+ warn "Failed to write snapshot #{snapshot_path}: #{e.message}"
159
+ raise
160
+ end
161
+
162
+ if update_snapshots
163
+ puts "Updated snapshot: #{snapshot_path}"
164
+ else
165
+ puts "Created snapshot: #{snapshot_path}"
166
+ end
167
+
168
+ end
169
+ expected_lines = File.readlines(snapshot_path, chomp: true)
170
+ else
171
+ # Direct comparison mode
172
+ expected_lines = expected
173
+ end
174
+
175
+ msg ||= "Screen content mismatch"
176
+
177
+ assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"
178
+
179
+ expected_lines.each_with_index do |expected_line, i|
180
+ actual_line = actual_lines[i]
181
+ assert_equal expected_line, actual_line,
182
+ "#{msg}: Line #{i + 1} mismatch.\nExpected: #{expected_line.inspect}\nActual: #{actual_line.inspect}"
183
+ end
184
+ end
185
+
186
+ ##
187
+ # Asserts that the current screen content (including colors!) matches a stored ANSI snapshot.
188
+ #
189
+ # Generates/Compares against a file with <tt>.ansi</tt> extension.
190
+ # You can <tt>cat</tt> this file to see exactly what the screen looked like.
191
+ #
192
+ # assert_rich_snapshot("login_screen")
193
+ #
194
+ # # With normalization
195
+ # assert_rich_snapshot("log_view") do |lines|
196
+ # lines.map { |l| l.gsub(/\d{2}:\d{2}:\d{2}/, "HH:MM:SS") }
197
+ # end
198
+ #
199
+ # [name] String snapshot name.
200
+ # [msg] String optional failure message.
201
+ def assert_rich_snapshot(name, msg = nil)
202
+ caller_path = caller_locations(1, 1).first.path
203
+ snapshot_dir = File.join(File.dirname(caller_path), "snapshots")
204
+ snapshot_path = File.join(snapshot_dir, "#{name}.ansi")
205
+
206
+ actual_content = _render_buffer_with_ansi
207
+
208
+ if block_given?
209
+ lines = actual_content.split("\n")
210
+ # Yield lines to user block for modification (e.g. masking IDs/Times)
211
+ lines = yield(lines)
212
+ actual_content = "#{lines.join("\n")}\n"
213
+ end
214
+
215
+ update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"
216
+
217
+ if !File.exist?(snapshot_path) || update_snapshots
218
+ FileUtils.mkdir_p(File.dirname(snapshot_path))
219
+
220
+ begin
221
+ # Delete old file first to avoid git index stale-read issues
222
+ FileUtils.rm_f(snapshot_path)
223
+
224
+ # Write with explicit mode to ensure clean write
225
+ File.write(snapshot_path, actual_content, mode: "w")
226
+
227
+ # Flush filesystem buffers to ensure durability
228
+ File.open(snapshot_path, "r", &:fsync) if File.exist?(snapshot_path)
229
+ rescue => e
230
+ warn "Failed to write rich snapshot #{snapshot_path}: #{e.message}"
231
+ raise
232
+ end
233
+
234
+ puts (update_snapshots ? "Updated" : "Created") + " rich snapshot: #{snapshot_path}"
235
+
236
+ end
237
+
238
+ expected_content = File.read(snapshot_path)
239
+
240
+ # Compare byte-for-byte first
241
+ if expected_content != actual_content
242
+ # Fallback to line-by-line diff for better error messages
243
+ expected_lines = expected_content.split("\n")
244
+ actual_lines = actual_content.split("\n")
245
+
246
+ assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"
247
+
248
+ expected_lines.each_with_index do |exp, i|
249
+ act = actual_lines[i]
250
+ assert_equal exp, act, "#{msg}: Rich content mismatch at line #{i + 1}"
251
+ end
252
+ end
253
+ end
254
+
255
+ ##
256
+ # Returns the current buffer content as an ANSI-encoded string.
257
+ #
258
+ # The rich snapshot assertion captures styled output. Sometimes you need the raw ANSI
259
+ # string for debugging, custom assertions, or programmatic inspection.
260
+ #
261
+ # This method renders the buffer with escape codes for colors and modifiers.
262
+ # You can `cat` the output to see exactly what the terminal would display.
263
+ #
264
+ # === Example
265
+ #
266
+ # with_test_terminal(80, 25) do
267
+ # RatatuiRuby.run do |tui|
268
+ # tui.draw tui.paragraph(text: "Hello", block: tui.block(title: "Test"))
269
+ # break
270
+ # end
271
+ # ansi_output = render_rich_buffer
272
+ # puts ansi_output # Shows styled output with escape codes
273
+ # end
274
+ #
275
+ def render_rich_buffer
276
+ _render_buffer_with_ansi
277
+ end
278
+
279
+ private def _render_buffer_with_ansi
280
+ RatatuiRuby.get_buffer_content # Ensure buffer is fresh if needed
281
+
282
+ lines = buffer_content
283
+ height = lines.size
284
+ width = lines.first&.length || 0
285
+
286
+ output = String.new
287
+
288
+ (0...height).each do |y|
289
+ current_fg = nil
290
+ current_bg = nil
291
+ current_modifiers = []
292
+
293
+ # Reset at start of line
294
+ output << "\e[0m"
295
+
296
+ (0...width).each do |x|
297
+ cell = RatatuiRuby.get_cell_at(x, y)
298
+ char = cell.char || " "
299
+
300
+ # Check for changes
301
+ fg_changed = cell.fg != current_fg
302
+ bg_changed = cell.bg != current_bg
303
+ mod_changed = cell.modifiers != current_modifiers
304
+
305
+ if fg_changed || bg_changed || mod_changed
306
+ # If modifiers change, easiest is to reset and re-apply everything
307
+ # because removing a modifier (e.g. bold) requires reset usually.
308
+ if mod_changed
309
+ output << "\e[0m"
310
+ output << _ansi_for_modifiers(cell.modifiers)
311
+ # Force re-apply colors after reset
312
+ output << _ansi_for_color(cell.fg, :fg)
313
+ output << _ansi_for_color(cell.bg, :bg)
314
+ else
315
+ # Modifiers same, just update colors if needed
316
+ output << _ansi_for_color(cell.fg, :fg) if fg_changed
317
+ output << _ansi_for_color(cell.bg, :bg) if bg_changed
318
+ end
319
+
320
+ current_fg = cell.fg
321
+ current_bg = cell.bg
322
+ current_modifiers = cell.modifiers
323
+ end
324
+
325
+ output << char
326
+ rescue
327
+ output << " "
328
+ end
329
+ output << "\e[0m\n" # Reset at end of line
330
+ end
331
+ output
332
+ end
333
+
334
+ private def _ansi_for_color(color, layer)
335
+ return "" if color.nil?
336
+
337
+ base = (layer == :fg) ? 38 : 48
338
+
339
+ case color
340
+ when Symbol
341
+ if color.to_s.start_with?("indexed_")
342
+ # Extracted indexed color :indexed_5 -> 5
343
+ idx = color.to_s.split("_").last.to_i
344
+ "\e[#{base};5;#{idx}m"
345
+ else
346
+ # Named colors
347
+ _ansi_named_color(color, layer == :fg)
348
+ end
349
+ when String
350
+ if color.start_with?("#")
351
+ # Hex color: #RRGGBB -> r;g;b
352
+ r = color[1..2].to_i(16)
353
+ g = color[3..4].to_i(16)
354
+ b = color[5..6].to_i(16)
355
+ "\e[#{base};2;#{r};#{g};#{b}m"
356
+ else
357
+ ""
358
+ end
359
+ else
360
+ ""
361
+ end
362
+ end
363
+
364
+ private def _ansi_named_color(name, is_fg)
365
+ # Map symbol to standard ANSI code offset
366
+ # FG: 30-37 (dim), 90-97 (bright)
367
+ # BG: 40-47 (dim), 100-107 (bright)
368
+
369
+ offset = is_fg ? 30 : 40
370
+
371
+ case name
372
+ when :black then "\e[#{offset}m"
373
+ when :red then "\e[#{offset + 1}m"
374
+ when :green then "\e[#{offset + 2}m"
375
+ when :yellow then "\e[#{offset + 3}m"
376
+ when :blue then "\e[#{offset + 4}m"
377
+ when :magenta then "\e[#{offset + 5}m"
378
+ when :cyan then "\e[#{offset + 6}m"
379
+ when :gray then is_fg ? "\e[90m" : "\e[100m" # Dark gray usually
380
+ when :dark_gray then is_fg ? "\e[90m" : "\e[100m"
381
+ when :light_red then "\e[#{offset + 60 + 1}m"
382
+ when :light_green then "\e[#{offset + 60 + 2}m"
383
+ when :light_yellow then "\e[#{offset + 60 + 3}m"
384
+ when :light_blue then "\e[#{offset + 60 + 4}m"
385
+ when :light_magenta then "\e[#{offset + 60 + 5}m"
386
+ when :light_cyan then "\e[#{offset + 60 + 6}m"
387
+ when :white then "\e[#{offset + 60 + 7}m"
388
+ else ""
389
+ end
390
+ end
391
+
392
+ private def _ansi_for_modifiers(modifiers)
393
+ return "" if modifiers.nil? || modifiers.empty?
394
+
395
+ seq = []
396
+ seq << "1" if modifiers.include?(:bold)
397
+ seq << "2" if modifiers.include?(:dim)
398
+ seq << "3" if modifiers.include?(:italic)
399
+ seq << "4" if modifiers.include?(:underlined)
400
+ seq << "5" if modifiers.include?(:slow_blink)
401
+ seq << "6" if modifiers.include?(:rapid_blink)
402
+ seq << "7" if modifiers.include?(:reversed)
403
+ seq << "8" if modifiers.include?(:hidden)
404
+ seq << "9" if modifiers.include?(:crossed_out)
405
+
406
+ if seq.any?
407
+ "\e[#{seq.join(';')}m"
408
+ else
409
+ ""
410
+ end
411
+ end
412
+ end
413
+ end
414
+ end