ratatui_ruby 0.5.0 → 0.6.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 (234) 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 +6 -0
  7. data/CHANGELOG.md +44 -7
  8. data/README.md +11 -4
  9. data/REUSE.toml +2 -7
  10. data/doc/application_architecture.md +84 -10
  11. data/doc/application_testing.md +75 -29
  12. data/doc/contributors/design/ruby_frontend.md +39 -3
  13. data/doc/contributors/design/rust_backend.md +1 -0
  14. data/doc/contributors/developing_examples.md +129 -44
  15. data/doc/contributors/examples_audit/p1_high.md +21 -0
  16. data/doc/contributors/examples_audit/p2_moderate.md +81 -0
  17. data/doc/contributors/examples_audit.md +41 -0
  18. data/doc/event_handling.md +11 -3
  19. data/doc/images/app_all_events.png +0 -0
  20. data/doc/images/app_color_picker.png +0 -0
  21. data/doc/images/app_login_form.png +0 -0
  22. data/doc/images/app_stateful_interaction.png +0 -0
  23. data/doc/images/verify_quickstart_dsl.png +0 -0
  24. data/doc/images/verify_quickstart_layout.png +0 -0
  25. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  26. data/doc/images/verify_readme_usage.png +0 -0
  27. data/doc/images/widget_barchart_demo.png +0 -0
  28. data/doc/images/widget_block_demo.png +0 -0
  29. data/doc/images/widget_canvas_demo.png +0 -0
  30. data/doc/images/widget_cell_demo.png +0 -0
  31. data/doc/images/widget_center_demo.png +0 -0
  32. data/doc/images/widget_chart_demo.png +0 -0
  33. data/doc/images/widget_list_demo.png +0 -0
  34. data/doc/images/widget_overlay_demo.png +0 -0
  35. data/doc/images/widget_render.png +0 -0
  36. data/doc/images/widget_rich_text.png +0 -0
  37. data/doc/images/widget_scroll_text.png +0 -0
  38. data/doc/images/widget_sparkline_demo.png +0 -0
  39. data/doc/images/widget_table_demo.png +0 -0
  40. data/doc/images/widget_tabs_demo.png +0 -0
  41. data/doc/images/widget_text_width.png +0 -0
  42. data/doc/quickstart.md +69 -76
  43. data/doc/terminal_limitations.md +92 -0
  44. data/examples/app_all_events/README.md +45 -27
  45. data/examples/app_all_events/app.rb +38 -35
  46. data/examples/app_all_events/model/app_model.rb +157 -0
  47. data/examples/app_all_events/model/event_entry.rb +17 -0
  48. data/examples/app_all_events/model/msg.rb +37 -0
  49. data/examples/app_all_events/update.rb +73 -0
  50. data/examples/app_all_events/view/app_view.rb +8 -8
  51. data/examples/app_all_events/view/controls_view.rb +8 -6
  52. data/examples/app_all_events/view/counts_view.rb +12 -8
  53. data/examples/app_all_events/view/live_view.rb +8 -7
  54. data/examples/app_all_events/view/log_view.rb +10 -15
  55. data/examples/app_color_picker/README.md +84 -44
  56. data/examples/app_color_picker/app.rb +24 -62
  57. data/examples/app_color_picker/controls.rb +90 -0
  58. data/examples/app_color_picker/copy_dialog.rb +45 -49
  59. data/examples/app_color_picker/export_pane.rb +126 -0
  60. data/examples/app_color_picker/input.rb +99 -67
  61. data/examples/app_color_picker/main_container.rb +178 -0
  62. data/examples/app_color_picker/palette.rb +55 -26
  63. data/examples/app_login_form/README.md +47 -0
  64. data/examples/app_login_form/app.rb +2 -3
  65. data/examples/app_stateful_interaction/README.md +31 -0
  66. data/examples/app_stateful_interaction/app.rb +272 -0
  67. data/examples/timeout_demo.rb +43 -0
  68. data/examples/verify_quickstart_dsl/README.md +48 -0
  69. data/examples/verify_quickstart_dsl/app.rb +2 -0
  70. data/examples/verify_quickstart_layout/README.md +71 -0
  71. data/examples/verify_quickstart_layout/app.rb +2 -0
  72. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  73. data/examples/verify_quickstart_lifecycle/app.rb +8 -2
  74. data/examples/verify_readme_usage/README.md +43 -0
  75. data/examples/verify_readme_usage/app.rb +8 -2
  76. data/examples/widget_barchart_demo/README.md +49 -0
  77. data/examples/widget_barchart_demo/app.rb +5 -5
  78. data/examples/widget_block_demo/README.md +34 -0
  79. data/examples/widget_block_demo/app.rb +256 -0
  80. data/examples/widget_box_demo/README.md +45 -0
  81. data/examples/widget_calendar_demo/README.md +39 -0
  82. data/examples/widget_canvas_demo/README.md +27 -0
  83. data/examples/widget_canvas_demo/app.rb +123 -0
  84. data/examples/widget_cell_demo/README.md +36 -0
  85. data/examples/widget_cell_demo/app.rb +31 -24
  86. data/examples/widget_center_demo/README.md +29 -0
  87. data/examples/widget_center_demo/app.rb +116 -0
  88. data/examples/widget_chart_demo/README.md +41 -0
  89. data/examples/widget_chart_demo/app.rb +7 -2
  90. data/examples/widget_gauge_demo/README.md +41 -0
  91. data/examples/widget_layout_split/README.md +44 -0
  92. data/examples/widget_line_gauge_demo/README.md +41 -0
  93. data/examples/widget_list_demo/README.md +49 -0
  94. data/examples/widget_list_demo/app.rb +91 -107
  95. data/examples/widget_map_demo/README.md +39 -0
  96. data/examples/{app_map_demo → widget_map_demo}/app.rb +2 -2
  97. data/examples/widget_overlay_demo/app.rb +248 -0
  98. data/examples/widget_popup_demo/README.md +36 -0
  99. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  100. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  101. data/examples/widget_rect/README.md +38 -0
  102. data/examples/widget_render/README.md +37 -0
  103. data/examples/widget_rich_text/README.md +35 -0
  104. data/examples/widget_rich_text/app.rb +62 -33
  105. data/examples/widget_scroll_text/README.md +37 -0
  106. data/examples/widget_scroll_text/app.rb +0 -1
  107. data/examples/widget_scrollbar_demo/README.md +37 -0
  108. data/examples/widget_sparkline_demo/README.md +42 -0
  109. data/examples/widget_sparkline_demo/app.rb +4 -3
  110. data/examples/widget_style_colors/README.md +34 -0
  111. data/examples/widget_table_demo/README.md +48 -0
  112. data/examples/{app_table_select → widget_table_demo}/app.rb +46 -8
  113. data/examples/widget_tabs_demo/README.md +41 -0
  114. data/examples/widget_tabs_demo/app.rb +15 -1
  115. data/examples/widget_text_width/README.md +35 -0
  116. data/examples/widget_text_width/app.rb +106 -0
  117. data/exe/.gitkeep +0 -0
  118. data/ext/ratatui_ruby/Cargo.lock +11 -4
  119. data/ext/ratatui_ruby/Cargo.toml +2 -1
  120. data/ext/ratatui_ruby/src/events.rs +238 -26
  121. data/ext/ratatui_ruby/src/frame.rs +113 -1
  122. data/ext/ratatui_ruby/src/lib.rs +34 -4
  123. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  124. data/ext/ratatui_ruby/src/terminal.rs +39 -15
  125. data/ext/ratatui_ruby/src/text.rs +1 -1
  126. data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
  127. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  128. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  129. data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
  130. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  131. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  132. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
  133. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  134. data/ext/ratatui_ruby/src/widgets/table.rs +113 -1
  135. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  136. data/lib/ratatui_ruby/cell.rb +4 -4
  137. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  138. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  139. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  140. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  141. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  142. data/lib/ratatui_ruby/event/key.rb +111 -51
  143. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  144. data/lib/ratatui_ruby/event/paste.rb +1 -1
  145. data/lib/ratatui_ruby/frame.rb +96 -0
  146. data/lib/ratatui_ruby/list_state.rb +88 -0
  147. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
  148. data/lib/ratatui_ruby/schema/cursor.rb +5 -0
  149. data/lib/ratatui_ruby/schema/gauge.rb +3 -1
  150. data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
  151. data/lib/ratatui_ruby/schema/list.rb +25 -4
  152. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  153. data/lib/ratatui_ruby/schema/rect.rb +43 -0
  154. data/lib/ratatui_ruby/schema/style.rb +24 -4
  155. data/lib/ratatui_ruby/schema/table.rb +21 -3
  156. data/lib/ratatui_ruby/schema/text.rb +69 -1
  157. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  158. data/lib/ratatui_ruby/session/autodoc.rb +65 -0
  159. data/lib/ratatui_ruby/session.rb +22 -7
  160. data/lib/ratatui_ruby/table_state.rb +90 -0
  161. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  162. data/lib/ratatui_ruby/test_helper/snapshot.rb +390 -0
  163. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  164. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  165. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  166. data/lib/ratatui_ruby/test_helper.rb +65 -358
  167. data/lib/ratatui_ruby/version.rb +1 -1
  168. data/lib/ratatui_ruby.rb +42 -19
  169. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  170. data/sig/examples/widget_block_demo/app.rbs +32 -0
  171. data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
  172. data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
  173. data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
  174. data/sig/ratatui_ruby/event.rbs +11 -1
  175. data/sig/ratatui_ruby/frame.rbs +2 -0
  176. data/sig/ratatui_ruby/list_state.rbs +13 -0
  177. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
  178. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  179. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  180. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  181. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  182. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  183. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  184. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  185. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  186. data/sig/ratatui_ruby/schema/text.rbs +8 -6
  187. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  188. data/sig/ratatui_ruby/session.rbs +13 -0
  189. data/sig/ratatui_ruby/table_state.rbs +15 -0
  190. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  191. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  192. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  193. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  194. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  195. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  196. data/tasks/autodoc/examples.rb +79 -0
  197. data/tasks/autodoc/inventory.rb +9 -7
  198. data/tasks/autodoc.rake +11 -5
  199. data/tasks/bump/changelog.rb +3 -3
  200. data/tasks/bump/links.rb +67 -0
  201. data/tasks/sourcehut.rake +61 -21
  202. data/tasks/terminal_preview/app_screenshot.rb +13 -3
  203. data/tasks/terminal_preview/saved_screenshot.rb +4 -3
  204. metadata +111 -37
  205. data/doc/images/app_table_select.png +0 -0
  206. data/doc/images/widget_block_padding.png +0 -0
  207. data/doc/images/widget_block_titles.png +0 -0
  208. data/doc/images/widget_list_styles.png +0 -0
  209. data/examples/app_all_events/model/events.rb +0 -180
  210. data/examples/app_all_events/model/highlight.rb +0 -57
  211. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
  212. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
  213. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
  214. data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
  215. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
  216. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
  217. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
  218. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
  219. data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
  220. data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
  221. data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
  222. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
  223. data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
  224. data/examples/app_all_events/view_state.rb +0 -42
  225. data/examples/app_color_picker/scene.rb +0 -201
  226. data/examples/widget_block_padding/app.rb +0 -67
  227. data/examples/widget_block_titles/app.rb +0 -69
  228. data/examples/widget_list_styles/app.rb +0 -141
  229. data/examples/widget_table_flex/app.rb +0 -95
  230. data/sig/examples/widget_block_padding/app.rbs +0 -11
  231. data/sig/examples/widget_block_titles/app.rbs +0 -11
  232. data/sig/examples/widget_list_styles/app.rbs +0 -11
  233. data/tasks/bump/comparison_links.rb +0 -41
  234. /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
@@ -0,0 +1,90 @@
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
+ # Mutable state object for Table widgets.
8
+ #
9
+ # When using {Frame#render_stateful_widget}, the State object is the
10
+ # *single source of truth* for selection and scroll offset. Widget
11
+ # properties (+selected_row+, +selected_column+, +offset+) are *ignored*
12
+ # in stateful mode.
13
+ #
14
+ # == Example
15
+ #
16
+ # @table_state = RatatuiRuby::TableState.new
17
+ # @table_state.select(1) # Select second row
18
+ # @table_state.select_column(0) # Select first column
19
+ #
20
+ # RatatuiRuby.draw do |frame|
21
+ # table = RatatuiRuby::Table.new(rows: [...], widths: [...])
22
+ # frame.render_stateful_widget(table, frame.area, @table_state)
23
+ # end
24
+ #
25
+ class TableState
26
+ ##
27
+ # :method: new
28
+ # :call-seq: new(selected = nil) -> TableState
29
+ #
30
+ # Creates a new TableState with optional initial row selection.
31
+ #
32
+ # (Native method implemented in Rust)
33
+
34
+ ##
35
+ # :method: select
36
+ # :call-seq: select(index) -> nil
37
+ #
38
+ # Sets the selected row index. Pass +nil+ to deselect.
39
+ #
40
+ # (Native method implemented in Rust)
41
+
42
+ ##
43
+ # :method: selected
44
+ # :call-seq: selected() -> Integer or nil
45
+ #
46
+ # Returns the currently selected row index.
47
+ #
48
+ # (Native method implemented in Rust)
49
+
50
+ ##
51
+ # :method: select_column
52
+ # :call-seq: select_column(index) -> nil
53
+ #
54
+ # Sets the selected column index. Pass +nil+ to deselect.
55
+ #
56
+ # (Native method implemented in Rust)
57
+
58
+ ##
59
+ # :method: selected_column
60
+ # :call-seq: selected_column() -> Integer or nil
61
+ #
62
+ # Returns the currently selected column index.
63
+ #
64
+ # (Native method implemented in Rust)
65
+
66
+ ##
67
+ # :method: offset
68
+ # :call-seq: offset() -> Integer
69
+ #
70
+ # Returns the current scroll offset.
71
+ #
72
+ # (Native method implemented in Rust)
73
+
74
+ ##
75
+ # :method: scroll_down_by
76
+ # :call-seq: scroll_down_by(n) -> nil
77
+ #
78
+ # Scrolls down by +n+ rows.
79
+ #
80
+ # (Native method implemented in Rust)
81
+
82
+ ##
83
+ # :method: scroll_up_by
84
+ # :call-seq: scroll_up_by(n) -> nil
85
+ #
86
+ # Scrolls up by +n+ rows.
87
+ #
88
+ # (Native method implemented in Rust)
89
+ end
90
+ end
@@ -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,390 @@
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
+ private def _render_buffer_with_ansi
256
+ RatatuiRuby.get_buffer_content # Ensure buffer is fresh if needed
257
+
258
+ lines = buffer_content
259
+ height = lines.size
260
+ width = lines.first&.length || 0
261
+
262
+ output = String.new
263
+
264
+ (0...height).each do |y|
265
+ current_fg = nil
266
+ current_bg = nil
267
+ current_modifiers = []
268
+
269
+ # Reset at start of line
270
+ output << "\e[0m"
271
+
272
+ (0...width).each do |x|
273
+ cell = RatatuiRuby.get_cell_at(x, y)
274
+ char = cell.char || " "
275
+
276
+ # Check for changes
277
+ fg_changed = cell.fg != current_fg
278
+ bg_changed = cell.bg != current_bg
279
+ mod_changed = cell.modifiers != current_modifiers
280
+
281
+ if fg_changed || bg_changed || mod_changed
282
+ # If modifiers change, easiest is to reset and re-apply everything
283
+ # because removing a modifier (e.g. bold) requires reset usually.
284
+ if mod_changed
285
+ output << "\e[0m"
286
+ output << _ansi_for_modifiers(cell.modifiers)
287
+ # Force re-apply colors after reset
288
+ output << _ansi_for_color(cell.fg, :fg)
289
+ output << _ansi_for_color(cell.bg, :bg)
290
+ else
291
+ # Modifiers same, just update colors if needed
292
+ output << _ansi_for_color(cell.fg, :fg) if fg_changed
293
+ output << _ansi_for_color(cell.bg, :bg) if bg_changed
294
+ end
295
+
296
+ current_fg = cell.fg
297
+ current_bg = cell.bg
298
+ current_modifiers = cell.modifiers
299
+ end
300
+
301
+ output << char
302
+ rescue
303
+ output << " "
304
+ end
305
+ output << "\e[0m\n" # Reset at end of line
306
+ end
307
+ output
308
+ end
309
+
310
+ private def _ansi_for_color(color, layer)
311
+ return "" if color.nil?
312
+
313
+ base = (layer == :fg) ? 38 : 48
314
+
315
+ case color
316
+ when Symbol
317
+ if color.to_s.start_with?("indexed_")
318
+ # Extracted indexed color :indexed_5 -> 5
319
+ idx = color.to_s.split("_").last.to_i
320
+ "\e[#{base};5;#{idx}m"
321
+ else
322
+ # Named colors
323
+ _ansi_named_color(color, layer == :fg)
324
+ end
325
+ when String
326
+ if color.start_with?("#")
327
+ # Hex color: #RRGGBB -> r;g;b
328
+ r = color[1..2].to_i(16)
329
+ g = color[3..4].to_i(16)
330
+ b = color[5..6].to_i(16)
331
+ "\e[#{base};2;#{r};#{g};#{b}m"
332
+ else
333
+ ""
334
+ end
335
+ else
336
+ ""
337
+ end
338
+ end
339
+
340
+ private def _ansi_named_color(name, is_fg)
341
+ # Map symbol to standard ANSI code offset
342
+ # FG: 30-37 (dim), 90-97 (bright)
343
+ # BG: 40-47 (dim), 100-107 (bright)
344
+
345
+ offset = is_fg ? 30 : 40
346
+
347
+ case name
348
+ when :black then "\e[#{offset}m"
349
+ when :red then "\e[#{offset + 1}m"
350
+ when :green then "\e[#{offset + 2}m"
351
+ when :yellow then "\e[#{offset + 3}m"
352
+ when :blue then "\e[#{offset + 4}m"
353
+ when :magenta then "\e[#{offset + 5}m"
354
+ when :cyan then "\e[#{offset + 6}m"
355
+ when :gray then is_fg ? "\e[90m" : "\e[100m" # Dark gray usually
356
+ when :dark_gray then is_fg ? "\e[90m" : "\e[100m"
357
+ when :light_red then "\e[#{offset + 60 + 1}m"
358
+ when :light_green then "\e[#{offset + 60 + 2}m"
359
+ when :light_yellow then "\e[#{offset + 60 + 3}m"
360
+ when :light_blue then "\e[#{offset + 60 + 4}m"
361
+ when :light_magenta then "\e[#{offset + 60 + 5}m"
362
+ when :light_cyan then "\e[#{offset + 60 + 6}m"
363
+ when :white then "\e[#{offset + 60 + 7}m"
364
+ else ""
365
+ end
366
+ end
367
+
368
+ private def _ansi_for_modifiers(modifiers)
369
+ return "" if modifiers.nil? || modifiers.empty?
370
+
371
+ seq = []
372
+ seq << "1" if modifiers.include?(:bold)
373
+ seq << "2" if modifiers.include?(:dim)
374
+ seq << "3" if modifiers.include?(:italic)
375
+ seq << "4" if modifiers.include?(:underlined)
376
+ seq << "5" if modifiers.include?(:slow_blink)
377
+ seq << "6" if modifiers.include?(:rapid_blink)
378
+ seq << "7" if modifiers.include?(:reversed)
379
+ seq << "8" if modifiers.include?(:hidden)
380
+ seq << "9" if modifiers.include?(:crossed_out)
381
+
382
+ if seq.any?
383
+ "\e[#{seq.join(';')}m"
384
+ else
385
+ ""
386
+ end
387
+ end
388
+ end
389
+ end
390
+ end