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,351 @@
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
+ # Assertions for verifying cell-level styling in terminal UIs.
10
+ #
11
+ # TUI styling is invisible to plain text comparisons. Colors, bold, italic,
12
+ # and other modifiers define the visual hierarchy. Without style assertions,
13
+ # you cannot verify that your highlight is actually highlighted.
14
+ #
15
+ # This mixin provides assertions to check foreground, background, and modifiers
16
+ # at specific coordinates or across entire regions.
17
+ #
18
+ # Use it to verify selection highlights, error colors, or themed areas.
19
+ #
20
+ # === Examples
21
+ #
22
+ # # Single cell
23
+ # assert_cell_style(0, 0, fg: :red, modifiers: [:bold])
24
+ #
25
+ # # Foreground color at coordinate
26
+ # assert_color(:green, x: 5, y: 2)
27
+ #
28
+ # # Entire header region
29
+ # assert_area_style({ x: 0, y: 0, w: 80, h: 1 }, bg: :blue)
30
+ #
31
+ module StyleAssertions
32
+ ##
33
+ # Asserts that a cell has the expected style attributes.
34
+ #
35
+ # === Example
36
+ #
37
+ # assert_cell_style(0, 0, char: "H", fg: :red)
38
+ #
39
+ # [x] Integer x-coordinate.
40
+ # [y] Integer y-coordinate.
41
+ # [expected_attributes] Hash of attribute names to expected values.
42
+ def assert_cell_style(x, y, **expected_attributes)
43
+ cell = get_cell(x, y)
44
+ expected_attributes.each do |key, value|
45
+ actual_value = cell.public_send(key)
46
+ if value.nil?
47
+ assert_nil actual_value, "Expected cell at (#{x}, #{y}) to have #{key}=nil, but got #{actual_value.inspect}"
48
+ else
49
+ assert_equal value, actual_value, "Expected cell at (#{x}, #{y}) to have #{key}=#{value.inspect}, but got #{actual_value.inspect}"
50
+ end
51
+ end
52
+ end
53
+
54
+ ##
55
+ # Asserts foreground or background color at a coordinate.
56
+ #
57
+ # Accepts symbols (<tt>:red</tt>), indexed colors (integers), or hex strings.
58
+ #
59
+ # === Examples
60
+ #
61
+ # assert_color(:red, x: 10, y: 5)
62
+ # assert_color(5, x: 10, y: 5, layer: :bg)
63
+ # assert_color("#ff00ff", x: 10, y: 5)
64
+ #
65
+ # [expected] Symbol, Integer, or String (hex).
66
+ # [x] Integer x-coordinate.
67
+ # [y] Integer y-coordinate.
68
+ # [layer] <tt>:fg</tt> (default) or <tt>:bg</tt>.
69
+ def assert_color(expected, x:, y:, layer: :fg)
70
+ cell = get_cell(x, y)
71
+ actual = cell.public_send(layer)
72
+
73
+ # Normalize expected integer to symbol if needed (RatatuiRuby returns :indexed_N)
74
+ expected_normalized = if expected.is_a?(Integer)
75
+ :"indexed_#{expected}"
76
+ else
77
+ expected
78
+ end
79
+
80
+ assert_equal expected_normalized, actual,
81
+ "Expected #{layer} at (#{x}, #{y}) to be #{expected.inspect}, but got #{actual.inspect}"
82
+ end
83
+
84
+ ##
85
+ # Asserts that all cells in an area have the expected style.
86
+ #
87
+ # === Examples
88
+ #
89
+ # header = RatatuiRuby::Rect.new(x: 0, y: 0, width: 80, height: 1)
90
+ # assert_area_style(header, bg: :blue, modifiers: [:bold])
91
+ #
92
+ # assert_area_style({ x: 0, y: 0, w: 10, h: 1 }, fg: :red)
93
+ #
94
+ # [area] Rect-like object or Hash with x, y, width/w, height/h.
95
+ # [attributes] Style attributes to verify.
96
+ def assert_area_style(area, **attributes)
97
+ if area.is_a?(Hash)
98
+ x = area[:x] || 0
99
+ y = area[:y] || 0
100
+ w = area[:width] || area[:w] || 0
101
+ h = area[:height] || area[:h] || 0
102
+ else
103
+ x = area.x
104
+ y = area.y
105
+ w = area.width
106
+ h = area.height
107
+ end
108
+
109
+ (y...(y + h)).each do |row|
110
+ (x...(x + w)).each do |col|
111
+ assert_cell_style(col, row, **attributes)
112
+ end
113
+ end
114
+ end
115
+
116
+ ##
117
+ # Asserts the foreground color at a coordinate.
118
+ #
119
+ # Convenience alias for <tt>assert_color(expected, x:, y:, layer: :fg)</tt>.
120
+ #
121
+ # === Example
122
+ #
123
+ # assert_fg_color(:yellow, 0, 2)
124
+ #
125
+ # [expected] Symbol, Integer, or String (hex).
126
+ # [x] Integer x-coordinate.
127
+ # [y] Integer y-coordinate.
128
+ def assert_fg_color(expected, x, y)
129
+ assert_color(expected, x:, y:, layer: :fg)
130
+ end
131
+ alias assert_fg assert_fg_color
132
+
133
+ ##
134
+ # Asserts the background color at a coordinate.
135
+ #
136
+ # Convenience alias for <tt>assert_color(expected, x:, y:, layer: :bg)</tt>.
137
+ #
138
+ # === Example
139
+ #
140
+ # assert_bg_color(:blue, 0, 2)
141
+ #
142
+ # [expected] Symbol, Integer, or String (hex).
143
+ # [x] Integer x-coordinate.
144
+ # [y] Integer y-coordinate.
145
+ def assert_bg_color(expected, x, y)
146
+ assert_color(expected, x:, y:, layer: :bg)
147
+ end
148
+ alias assert_bg assert_bg_color
149
+
150
+ ##
151
+ # Asserts that a cell has the bold modifier.
152
+ #
153
+ # === Example
154
+ #
155
+ # assert_bold(0, 2)
156
+ #
157
+ # [x] Integer x-coordinate.
158
+ # [y] Integer y-coordinate.
159
+ def assert_bold(x, y)
160
+ cell = get_cell(x, y)
161
+ modifiers = (cell.modifiers || []).map(&:to_sym)
162
+ assert modifiers.include?(:bold),
163
+ "Expected cell at (#{x}, #{y}) to be bold, but modifiers were #{modifiers.inspect}"
164
+ end
165
+
166
+ ##
167
+ # Asserts that a cell has the italic modifier.
168
+ #
169
+ # === Example
170
+ #
171
+ # assert_italic(0, 2)
172
+ #
173
+ # [x] Integer x-coordinate.
174
+ # [y] Integer y-coordinate.
175
+ def assert_italic(x, y)
176
+ cell = get_cell(x, y)
177
+ modifiers = (cell.modifiers || []).map(&:to_sym)
178
+ assert modifiers.include?(:italic),
179
+ "Expected cell at (#{x}, #{y}) to be italic, but modifiers were #{modifiers.inspect}"
180
+ end
181
+
182
+ ##
183
+ # Asserts that a cell has the underlined modifier.
184
+ #
185
+ # === Example
186
+ #
187
+ # assert_underlined(0, 2)
188
+ #
189
+ # [x] Integer x-coordinate.
190
+ # [y] Integer y-coordinate.
191
+ def assert_underlined(x, y)
192
+ cell = get_cell(x, y)
193
+ modifiers = (cell.modifiers || []).map(&:to_sym)
194
+ assert modifiers.include?(:underlined),
195
+ "Expected cell at (#{x}, #{y}) to be underlined, but modifiers were #{modifiers.inspect}"
196
+ end
197
+ alias assert_underline assert_underlined
198
+
199
+ ##
200
+ # Asserts that a cell has the dim modifier.
201
+ #
202
+ # === Example
203
+ #
204
+ # assert_dim(0, 2)
205
+ #
206
+ # [x] Integer x-coordinate.
207
+ # [y] Integer y-coordinate.
208
+ def assert_dim(x, y)
209
+ cell = get_cell(x, y)
210
+ modifiers = (cell.modifiers || []).map(&:to_sym)
211
+ assert modifiers.include?(:dim),
212
+ "Expected cell at (#{x}, #{y}) to be dim, but modifiers were #{modifiers.inspect}"
213
+ end
214
+
215
+ ##
216
+ # Asserts that a cell has the reversed (inverse video) modifier.
217
+ #
218
+ # === Example
219
+ #
220
+ # assert_reversed(0, 2)
221
+ #
222
+ # [x] Integer x-coordinate.
223
+ # [y] Integer y-coordinate.
224
+ def assert_reversed(x, y)
225
+ cell = get_cell(x, y)
226
+ modifiers = (cell.modifiers || []).map(&:to_sym)
227
+ assert modifiers.include?(:reversed),
228
+ "Expected cell at (#{x}, #{y}) to be reversed, but modifiers were #{modifiers.inspect}"
229
+ end
230
+ alias assert_inverse assert_reversed
231
+ alias assert_inverse_video assert_reversed
232
+
233
+ ##
234
+ # Asserts that a cell has the crossed_out (strikethrough) modifier.
235
+ #
236
+ # === Example
237
+ #
238
+ # assert_crossed_out(0, 2)
239
+ #
240
+ # [x] Integer x-coordinate.
241
+ # [y] Integer y-coordinate.
242
+ def assert_crossed_out(x, y)
243
+ cell = get_cell(x, y)
244
+ modifiers = (cell.modifiers || []).map(&:to_sym)
245
+ assert modifiers.include?(:crossed_out),
246
+ "Expected cell at (#{x}, #{y}) to be crossed_out, but modifiers were #{modifiers.inspect}"
247
+ end
248
+ alias assert_strikethrough assert_crossed_out
249
+ alias assert_strike assert_crossed_out
250
+
251
+ ##
252
+ # Asserts that a cell has the hidden modifier.
253
+ #
254
+ # [x] Integer x-coordinate.
255
+ # [y] Integer y-coordinate.
256
+ def assert_hidden(x, y)
257
+ cell = get_cell(x, y)
258
+ modifiers = (cell.modifiers || []).map(&:to_sym)
259
+ assert modifiers.include?(:hidden),
260
+ "Expected cell at (#{x}, #{y}) to be hidden, but modifiers were #{modifiers.inspect}"
261
+ end
262
+
263
+ ##
264
+ # Asserts that a cell has the slow_blink modifier.
265
+ #
266
+ # [x] Integer x-coordinate.
267
+ # [y] Integer y-coordinate.
268
+ def assert_slow_blink(x, y)
269
+ cell = get_cell(x, y)
270
+ modifiers = (cell.modifiers || []).map(&:to_sym)
271
+ assert modifiers.include?(:slow_blink),
272
+ "Expected cell at (#{x}, #{y}) to have slow_blink, but modifiers were #{modifiers.inspect}"
273
+ end
274
+ alias assert_blink assert_slow_blink
275
+
276
+ ##
277
+ # Asserts that a cell has the rapid_blink modifier.
278
+ #
279
+ # [x] Integer x-coordinate.
280
+ # [y] Integer y-coordinate.
281
+ def assert_rapid_blink(x, y)
282
+ cell = get_cell(x, y)
283
+ modifiers = (cell.modifiers || []).map(&:to_sym)
284
+ assert modifiers.include?(:rapid_blink),
285
+ "Expected cell at (#{x}, #{y}) to have rapid_blink, but modifiers were #{modifiers.inspect}"
286
+ end
287
+ # Color-specific assertion helpers.
288
+ #
289
+ # Manually specifying <tt>:red</tt> or <tt>:blue</tt> in every <tt>assert_color</tt> call is repetitive.
290
+ # It hides the intent of the test behind boilerplate arguments.
291
+ #
292
+ # These meta-programmed helpers provide a punchy, intent-focused API. Use them to
293
+ # verify colors with minimal ceremony.
294
+ #
295
+ # === Standard Foreground Color Aliases
296
+ #
297
+ # - <tt>assert_black(x, y)</tt>
298
+ # - <tt>assert_red(x, y)</tt>
299
+ # - <tt>assert_green(x, y)</tt>
300
+ # - <tt>assert_yellow(x, y)</tt>
301
+ # - <tt>assert_blue(x, y)</tt>
302
+ # - <tt>assert_magenta(x, y)</tt>
303
+ # - <tt>assert_cyan(x, y)</tt>
304
+ # - <tt>assert_gray(x, y)</tt>
305
+ # - <tt>assert_dark_gray(x, y)</tt>
306
+ # - <tt>assert_light_red(x, y)</tt>
307
+ # - <tt>assert_light_green(x, y)</tt>
308
+ # - <tt>assert_light_yellow(x, y)</tt>
309
+ # - <tt>assert_light_blue(x, y)</tt>
310
+ # - <tt>assert_light_magenta(x, y)</tt>
311
+ # - <tt>assert_light_cyan(x, y)</tt>
312
+ # - <tt>assert_white(x, y)</tt>
313
+ #
314
+ # === Standard Background Color Aliases
315
+ #
316
+ # - <tt>assert_bg_black(x, y)</tt>
317
+ # - <tt>assert_bg_red(x, y)</tt>
318
+ # - ...and so on for all standard colors.
319
+ [
320
+ :black,
321
+ :red,
322
+ :green,
323
+ :yellow,
324
+ :blue,
325
+ :magenta,
326
+ :cyan,
327
+ :gray,
328
+ :dark_gray,
329
+ :light_red,
330
+ :light_green,
331
+ :light_yellow,
332
+ :light_blue,
333
+ :light_magenta,
334
+ :light_cyan,
335
+ :white,
336
+ ].each do |color|
337
+ # :method: assert_#{color}
338
+ # Asserts the foreground color at (x, y) is <tt>:#{color}</tt>.
339
+ define_method(:"assert_#{color}") do |x, y|
340
+ assert_fg_color(color, x, y)
341
+ end
342
+
343
+ # :method: assert_bg_#{color}
344
+ # Asserts the background color at (x, y) is <tt>:#{color}</tt>.
345
+ define_method(:"assert_bg_#{color}") do |x, y|
346
+ assert_bg_color(color, x, y)
347
+ end
348
+ end
349
+ end
350
+ end
351
+ end
@@ -0,0 +1,127 @@
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 "timeout"
7
+ require "minitest/mock"
8
+
9
+ module RatatuiRuby
10
+ module TestHelper
11
+ ##
12
+ # Terminal setup and buffer inspection for TUI tests.
13
+ #
14
+ # Testing TUIs against a real terminal is slow, flaky, and hard to automate.
15
+ # Initializing, cleaning up, and inspecting terminal state by hand is tedious.
16
+ #
17
+ # This mixin wraps a headless test terminal. It handles setup, teardown,
18
+ # and provides methods to query buffer content, cursor position, and cell styles.
19
+ #
20
+ # Use it to write fast, deterministic tests for your TUI applications.
21
+ #
22
+ # === Example
23
+ #
24
+ # class MyTest < Minitest::Test
25
+ # include RatatuiRuby::TestHelper
26
+ #
27
+ # def test_rendering
28
+ # with_test_terminal(80, 24) do
29
+ # MyApp.new.run_once
30
+ # assert_includes buffer_content.join, "Hello"
31
+ # end
32
+ # end
33
+ # end
34
+ module Terminal
35
+ ##
36
+ # Initializes a test terminal context with specified dimensions.
37
+ # Restores the original terminal state after the block executes.
38
+ #
39
+ # [width] Integer width of the test terminal (default: 80).
40
+ # [height] Integer height of the test terminal (default: 24).
41
+ # [timeout] Integer maximum execution time in seconds (default: 2). Pass <tt>nil</tt> to disable.
42
+ #
43
+ # === Example
44
+ #
45
+ # with_test_terminal(120, 40) do
46
+ # # render and test your app
47
+ # end
48
+ def with_test_terminal(width = 80, height = 24, **opts)
49
+ RatatuiRuby.init_test_terminal(width, height)
50
+ # Flush any lingering events from previous tests
51
+ while (event = RatatuiRuby.poll_event) && !event.none?; end
52
+
53
+ RatatuiRuby.stub :init_terminal, nil do
54
+ RatatuiRuby.stub :restore_terminal, nil do
55
+ @_ratatui_test_terminal_active = true
56
+ timeout = opts.fetch(:timeout, 2)
57
+ if timeout
58
+ Timeout.timeout(timeout) do
59
+ yield
60
+ end
61
+ else
62
+ yield
63
+ end
64
+ ensure
65
+ @_ratatui_test_terminal_active = false
66
+ end
67
+ end
68
+ ensure
69
+ RatatuiRuby.restore_terminal
70
+ end
71
+
72
+ ##
73
+ # Current content of the terminal buffer as an array of strings.
74
+ # Each string represents one row.
75
+ #
76
+ # === Example
77
+ #
78
+ # buffer_content
79
+ # # => ["Row 1 text", "Row 2 text", ...]
80
+ def buffer_content
81
+ RatatuiRuby.get_buffer_content.split("\n")
82
+ end
83
+
84
+ ##
85
+ # Current cursor position as a hash with <tt>:x</tt> and <tt>:y</tt> keys.
86
+ #
87
+ # === Example
88
+ #
89
+ # cursor_position
90
+ # # => { x: 0, y: 0 }
91
+ def cursor_position
92
+ x, y = RatatuiRuby.get_cursor_position
93
+ { x:, y: }
94
+ end
95
+
96
+ ##
97
+ # Cell attributes at the given coordinates.
98
+ #
99
+ # Returns a hash with <tt>"symbol"</tt>, <tt>"fg"</tt>, and <tt>"bg"</tt> keys.
100
+ #
101
+ # [x] Integer column position (0-indexed).
102
+ # [y] Integer row position (0-indexed).
103
+ #
104
+ # === Example
105
+ #
106
+ # get_cell(0, 0)
107
+ # # => { "symbol" => "H", "fg" => :red, "bg" => nil }
108
+ def get_cell(x, y)
109
+ RatatuiRuby.get_cell_at(x, y)
110
+ end
111
+
112
+ ##
113
+ # Prints the current buffer to STDOUT with full ANSI colors.
114
+ # Useful for debugging test failures.
115
+ #
116
+ # === Example
117
+ #
118
+ # with_test_terminal do
119
+ # MyApp.new.render
120
+ # print_buffer # see exactly what would display
121
+ # end
122
+ def print_buffer
123
+ puts _render_buffer_with_ansi
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,68 @@
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
+ # Test doubles for view testing.
10
+ #
11
+ # View tests verify widget rendering without a real terminal. Real frames draw
12
+ # to the screen. Real rects come from terminal dimensions. Mocking both by hand
13
+ # is tedious.
14
+ #
15
+ # This mixin provides <tt>MockFrame</tt> to capture rendered widgets and
16
+ # <tt>StubRect</tt> to supply fixed dimensions.
17
+ #
18
+ # Use them to test view logic in isolation.
19
+ #
20
+ # === Example
21
+ #
22
+ # frame = MockFrame.new
23
+ # area = StubRect.new(width: 60, height: 20)
24
+ # MyView.new.call(state, tui, frame, area)
25
+ #
26
+ # widget = frame.rendered_widgets.first[:widget]
27
+ # assert_equal "Dashboard", widget.block.title
28
+ module TestDoubles
29
+ ##
30
+ # Mock frame for view tests.
31
+ #
32
+ # Captures widgets passed to <tt>render_widget</tt> for later inspection.
33
+ #
34
+ # === Example
35
+ #
36
+ # frame = MockFrame.new
37
+ # View::Log.new.call(state, tui, frame, area)
38
+ # widget = frame.rendered_widgets.first[:widget]
39
+ # assert_equal "Event Log", widget.block.title
40
+ MockFrame = Data.define(:rendered_widgets) do
41
+ def initialize(rendered_widgets: [])
42
+ super
43
+ end
44
+
45
+ def render_widget(widget, area)
46
+ rendered_widgets << { widget:, area: }
47
+ end
48
+ end
49
+
50
+ ##
51
+ # Stub rect with fixed dimensions.
52
+ #
53
+ # [x] Integer left edge (default: 0).
54
+ # [y] Integer top edge (default: 0).
55
+ # [width] Integer width in cells (default: 80).
56
+ # [height] Integer height in cells (default: 24).
57
+ #
58
+ # === Example
59
+ #
60
+ # area = StubRect.new(width: 60, height: 20)
61
+ StubRect = Data.define(:x, :y, :width, :height) do
62
+ def initialize(x: 0, y: 0, width: 80, height: 24)
63
+ super
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end