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
@@ -1,8 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "timeout"
4
- require "minitest/mock"
5
3
  require "fileutils"
4
+ require_relative "test_helper/terminal"
5
+ require_relative "test_helper/snapshot"
6
+ require_relative "test_helper/event_injection"
7
+ require_relative "test_helper/style_assertions"
8
+ require_relative "test_helper/test_doubles"
6
9
 
7
10
  # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
8
11
  # SPDX-License-Identifier: AGPL-3.0-or-later
@@ -11,374 +14,78 @@ module RatatuiRuby
11
14
  ##
12
15
  # Helpers for testing RatatuiRuby applications.
13
16
  #
14
- # This module provides methods to set up a test terminal, capture buffer content,
15
- # and inject events, making it easier to write unit tests for your TUI apps.
17
+ # Writing TUI tests by hand is tedious. You need a headless terminal, event
18
+ # injection, snapshot comparisons, and style assertions. Wiring all that up
19
+ # yourself is error-prone.
16
20
  #
17
- # == Usage
21
+ # This module bundles everything you need. Include it in your test class and
22
+ # start writing tests immediately.
23
+ #
24
+ # == Included Mixins
25
+ #
26
+ # [Terminal] Sets up a headless terminal and queries its buffer.
27
+ # [Snapshot] Compares the screen against stored reference files.
28
+ # [EventInjection] Simulates keypresses, mouse clicks, and resize events.
29
+ # [StyleAssertions] Checks foreground color, background color, and text modifiers.
30
+ # [TestDoubles] Provides mocks and stubs for testing views in isolation.
31
+ #
32
+ # == Example
18
33
  #
19
34
  # require "ratatui_ruby/test_helper"
20
35
  #
21
- # class MyTest < Minitest::Test
36
+ # class TestMyApp < Minitest::Test
22
37
  # include RatatuiRuby::TestHelper
23
38
  #
24
- # def test_rendering
39
+ # def test_initial_render
25
40
  # with_test_terminal(80, 24) do
26
- # # ... render your app ...
27
- # assert_includes buffer_content, "Hello World"
41
+ # MyApp.new.run_once
42
+ # assert_snapshot("initial")
43
+ # end
44
+ # end
45
+ #
46
+ # def test_themes
47
+ # with_test_terminal do
48
+ # app = ThemeDemo.new
49
+ # app.run_once
50
+ # assert_rich_snapshot("default_theme")
51
+ #
52
+ # inject_key("t", modifiers: [:ctrl])
53
+ # app.run_once
54
+ # assert_rich_snapshot("dark_theme")
55
+ #
56
+ # inject_key("t", modifiers: [:ctrl])
57
+ # app.run_once
58
+ # assert_rich_snapshot("high_contrast_theme")
59
+ # end
60
+ # end
61
+ #
62
+ # def test_highlighter_applies_selection_style
63
+ # with_test_terminal(40, 5) do
64
+ # RatatuiRuby.draw do |frame|
65
+ # highlighter = MyApp::UI::Highlighter.new(:yellow)
66
+ # highlighter.render_at(frame, 0, 2, "Selected Item")
67
+ # end
68
+ #
69
+ # assert_fg_color(:yellow, 0, 2)
70
+ # assert_bold(0, 2)
28
71
  # end
29
72
  # end
30
73
  #
31
- # def test_key_handling
32
- # inject_event(RatatuiRuby::Event::Key.new(code: "q"))
33
- # result = @app.handle_input
34
- # assert_equal :quit, result
74
+ # def test_view_in_isolation
75
+ # frame = MockFrame.new
76
+ # area = StubRect.new(width: 60, height: 20)
77
+ #
78
+ # MyView.new.call(state, tui, frame, area)
79
+ #
80
+ # widget = frame.rendered_widgets.first[:widget]
81
+ # assert_equal "Dashboard", widget.block.title
35
82
  # end
36
83
  # end
37
84
  module TestHelper
38
- ##
39
- # Initializes a test terminal context with specified dimensions.
40
- # Restores the original terminal state after the block executes.
41
- #
42
- # +width+:: width of the test terminal (default: 80)
43
- # +height+:: height of the test terminal (default: 24)
44
- #
45
- # +timeout+:: maximum execution time in seconds (default: 2). Pass nil to disable.
46
- #
47
- # If a block is given, it is executed within the test terminal context.
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
- # Returns the current content of the terminal buffer as an array of strings.
74
- # Each string represents a row in the terminal.
75
- #
76
- # buffer_content
77
- # # => ["Row 1 text", "Row 2 text", ...]
78
- def buffer_content
79
- RatatuiRuby.get_buffer_content.split("\n")
80
- end
81
-
82
- ##
83
- # Returns the current cursor position as a hash with +:x+ and +:y+ keys.
84
- #
85
- # cursor_position
86
- # # => { x: 0, y: 0 }
87
- def cursor_position
88
- x, y = RatatuiRuby.get_cursor_position
89
- { x:, y: }
90
- end
91
-
92
- ##
93
- # Injects an event into the event queue for testing.
94
- #
95
- # Pass any RatatuiRuby::Event object. The event will be returned by
96
- # the next call to RatatuiRuby.poll_event.
97
- #
98
- # Raises a +RuntimeError+ if called outside of a +with_test_terminal+ block.
99
- #
100
- # == Examples
101
- #
102
- # with_test_terminal do
103
- # # Key events
104
- # inject_event(RatatuiRuby::Event::Key.new(code: "q"))
105
- # inject_event(RatatuiRuby::Event::Key.new(code: "s", modifiers: ["ctrl"]))
106
- #
107
- # # Mouse events
108
- # inject_event(RatatuiRuby::Event::Mouse.new(kind: "down", button: "left", x: 10, y: 5))
109
- #
110
- # # Resize events
111
- # inject_event(RatatuiRuby::Event::Resize.new(width: 120, height: 40))
112
- #
113
- # # Paste events
114
- # inject_event(RatatuiRuby::Event::Paste.new(content: "Hello"))
115
- #
116
- # # Focus events
117
- # inject_event(RatatuiRuby::Event::FocusGained.new)
118
- # inject_event(RatatuiRuby::Event::FocusLost.new)
119
- # end
120
- def inject_event(event)
121
- unless @_ratatui_test_terminal_active
122
- raise "Events must be injected inside a `with_test_terminal` block. " \
123
- "Calling this method outside the block causes a race condition where the event " \
124
- "is flushed before the application starts."
125
- end
126
-
127
- case event
128
- when RatatuiRuby::Event::Key
129
- RatatuiRuby.inject_test_event("key", { code: event.code, modifiers: event.modifiers })
130
- when RatatuiRuby::Event::Mouse
131
- RatatuiRuby.inject_test_event("mouse", {
132
- kind: event.kind,
133
- button: event.button,
134
- x: event.x,
135
- y: event.y,
136
- modifiers: event.modifiers,
137
- })
138
- when RatatuiRuby::Event::Resize
139
- RatatuiRuby.inject_test_event("resize", { width: event.width, height: event.height })
140
- when RatatuiRuby::Event::Paste
141
- RatatuiRuby.inject_test_event("paste", { content: event.content })
142
- when RatatuiRuby::Event::FocusGained
143
- RatatuiRuby.inject_test_event("focus_gained", {})
144
- when RatatuiRuby::Event::FocusLost
145
- RatatuiRuby.inject_test_event("focus_lost", {})
146
- else
147
- raise ArgumentError, "Unknown event type: #{event.class}"
148
- end
149
- end
150
-
151
- ##
152
- # Injects a mouse event.
153
- #
154
- # inject_mouse(x: 10, y: 5, kind: :down, button: :left)
155
- def inject_mouse(x:, y:, kind: :down, modifiers: [], button: :left)
156
- event = RatatuiRuby::Event::Mouse.new(
157
- kind: kind.to_s,
158
- x:,
159
- y:,
160
- button: button.to_s,
161
- modifiers:
162
- )
163
- inject_event(event)
164
- end
165
-
166
- ##
167
- # Injects a mouse left click (down) event.
168
- #
169
- # inject_click(x: 10, y: 5)
170
- def inject_click(x:, y:, modifiers: [])
171
- inject_mouse(x:, y:, kind: :down, modifiers:, button: :left)
172
- end
173
-
174
- ##
175
- # Injects a mouse right click (down) event.
176
- #
177
- # inject_right_click(x: 10, y: 5)
178
- def inject_right_click(x:, y:, modifiers: [])
179
- inject_mouse(x:, y:, kind: :down, modifiers:, button: :right)
180
- end
181
-
182
- ##
183
- # Injects a mouse drag event.
184
- #
185
- # inject_drag(x: 10, y: 5)
186
- def inject_drag(x:, y:, modifiers: [], button: :left)
187
- inject_mouse(x:, y:, kind: :drag, modifiers:, button:)
188
- end
189
-
190
- ##
191
- # Injects multiple Key events into the queue.
192
- #
193
- # Supports multiple formats for convenience:
194
- #
195
- # * String: Converted to a Key event with that code.
196
- # * Symbol: Parsed as modifier_code (e.g., <tt>:ctrl_c</tt>, <tt>:enter</tt>).
197
- # * Hash: Passed to Key.new constructor.
198
- # * Key: Passed directly.
199
- #
200
- # == Examples
201
- #
202
- # with_test_terminal do
203
- # inject_keys("a", "b", "c")
204
- # inject_keys(:enter, :esc)
205
- # inject_keys(:ctrl_c, :alt_shift_left)
206
- # inject_keys("j", { code: "k", modifiers: ["ctrl"] })
207
- # end
208
- def inject_keys(*args)
209
- args.each do |arg|
210
- event = case arg
211
- when String
212
- RatatuiRuby::Event::Key.new(code: arg)
213
- when Symbol
214
- parts = arg.to_s.split("_")
215
- code = parts.pop
216
- modifiers = parts
217
- RatatuiRuby::Event::Key.new(code:, modifiers:)
218
- when Hash
219
- RatatuiRuby::Event::Key.new(**arg)
220
- when RatatuiRuby::Event::Key
221
- arg
222
- else
223
- raise ArgumentError, "Invalid key argument: #{arg.inspect}. Expected String, Symbol, Hash, or Key event."
224
- end
225
- inject_event(event)
226
- end
227
- end
228
- alias inject_key inject_keys
229
-
230
- ##
231
- # Returns the cell attributes at the given coordinates.
232
- #
233
- # get_cell(0, 0)
234
- # # => { "symbol" => "H", "fg" => :red, "bg" => nil }
235
- def get_cell(x, y)
236
- RatatuiRuby.get_cell_at(x, y)
237
- end
238
-
239
- ##
240
- # Asserts that the cell at the given coordinates has the expected attributes.
241
- #
242
- # assert_cell_style(0, 0, char: "H", fg: :red)
243
- def assert_cell_style(x, y, **expected_attributes)
244
- cell = get_cell(x, y)
245
- expected_attributes.each do |key, value|
246
- actual_value = cell.public_send(key)
247
- if value.nil?
248
- assert_nil actual_value, "Expected cell at (#{x}, #{y}) to have #{key}=nil, but got #{actual_value.inspect}"
249
- else
250
- assert_equal value, actual_value, "Expected cell at (#{x}, #{y}) to have #{key}=#{value.inspect}, but got #{actual_value.inspect}"
251
- end
252
- end
253
- end
254
-
255
- ##
256
- # Mock frame for unit testing views.
257
- #
258
- # Captures widgets passed to +render_widget+ for inspection.
259
- # Does not render anything—purely captures the output.
260
- #
261
- # == Examples
262
- #
263
- # frame = MockFrame.new
264
- # View::Log.new.call(state, tui, frame, area)
265
- # widget = frame.rendered_widgets.first[:widget]
266
- # assert_equal "Event Log", widget.block.title
267
- MockFrame = Data.define(:rendered_widgets) do
268
- def initialize(rendered_widgets: [])
269
- super
270
- end
271
-
272
- def render_widget(widget, area)
273
- rendered_widgets << { widget:, area: }
274
- end
275
- end
276
-
277
- ##
278
- # Stub area for unit testing views.
279
- #
280
- # Provides the minimal interface views expect (+width+, +height+).
281
- #
282
- # == Examples
283
- #
284
- # area = StubRect.new(width: 60, height: 20)
285
- StubRect = Data.define(:x, :y, :width, :height) do
286
- def initialize(x: 0, y: 0, width: 80, height: 24)
287
- super
288
- end
289
- end
290
-
291
- ##
292
- # Asserts that the current screen content matches a stored snapshot.
293
- #
294
- # This method simplifies snapshot testing by automatically resolving the snapshot path
295
- # relative to the test file calling this method. It assumes a "snapshots" directory
296
- # exists in the same directory as the test file.
297
- #
298
- # # In test/test_login.rb
299
- # assert_snapshot("login_screen")
300
- # # Look for: test/snapshots/login_screen.txt
301
- #
302
- # # With normalization block
303
- # assert_snapshot("clock") do |actual|
304
- # actual.map { |l| l.gsub(/\d{2}:\d{2}/, "XX:XX") }
305
- # end
306
- #
307
- # [name] String name of the snapshot (without extension).
308
- # [msg] String optional failure message.
309
- def assert_snapshot(name, msg = nil, &)
310
- # Get the path of the test file calling this method
311
- caller_path = caller_locations(1, 1).first.path
312
- snapshot_dir = File.join(File.dirname(caller_path), "snapshots")
313
- snapshot_path = File.join(snapshot_dir, "#{name}.txt")
314
-
315
- assert_screen_matches(snapshot_path, msg, &)
316
- end
317
-
318
- ##
319
- # Asserts that the current screen content matches the expected content.
320
- #
321
- # Users need to verify that the entire TUI screen looks exactly as expected.
322
- # Manually checking every cell or line is tedious and error-prone.
323
- #
324
- # This helper compares the current buffer content against an expected string (file path)
325
- # or array of strings. It supports automatic snapshot creation and updating via
326
- # the +UPDATE_SNAPSHOTS+ environment variable.
327
- #
328
- # Use it to verify complex UI states, layouts, and renderings.
329
- #
330
- # == Usage
331
- #
332
- # # Direct comparison
333
- # assert_screen_matches(["Line 1", "Line 2"])
334
- #
335
- # # File comparison
336
- # assert_screen_matches("test/snapshots/login.txt")
337
- #
338
- # # With normalization (e.g., masking dynamic data)
339
- # assert_screen_matches("test/snapshots/dashboard.txt") do |lines|
340
- # lines.map { |l| l.gsub(/User ID: \d+/, "User ID: XXX") }
341
- # end
342
- #
343
- # [expected] String (file path) or Array<String> (content).
344
- # [msg] String optional failure message.
345
- def assert_screen_matches(expected, msg = nil)
346
- actual_lines = buffer_content
347
-
348
- if block_given?
349
- actual_lines = yield(actual_lines)
350
- end
351
-
352
- if expected.is_a?(String)
353
- # Snapshot file mode
354
- snapshot_path = expected
355
- update_snapshots = ENV["UPDATE_SNAPSHOTS"] == "1" || ENV["UPDATE_SNAPSHOTS"] == "true"
356
-
357
- if !File.exist?(snapshot_path) || update_snapshots
358
- FileUtils.mkdir_p(File.dirname(snapshot_path))
359
- File.write(snapshot_path, "#{actual_lines.join("\n")}\n")
360
- if update_snapshots
361
- puts "Updated snapshot: #{snapshot_path}"
362
- else
363
- puts "Created snapshot: #{snapshot_path}"
364
- end
365
- end
366
-
367
- expected_lines = File.readlines(snapshot_path, chomp: true)
368
- else
369
- # Direct comparison mode
370
- expected_lines = expected
371
- end
372
-
373
- msg ||= "Screen content mismatch"
374
-
375
- assert_equal expected_lines.size, actual_lines.size, "#{msg}: Line count mismatch"
376
-
377
- expected_lines.each_with_index do |expected_line, i|
378
- actual_line = actual_lines[i]
379
- assert_equal expected_line, actual_line,
380
- "#{msg}: Line #{i + 1} mismatch.\nExpected: #{expected_line.inspect}\nActual: #{actual_line.inspect}"
381
- end
382
- end
85
+ include Terminal
86
+ include Snapshot
87
+ include EventInjection
88
+ include StyleAssertions
89
+ include TestDoubles
383
90
  end
384
91
  end
@@ -0,0 +1,20 @@
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
+ class TUI
8
+ # Buffer inspection factory methods for Session.
9
+ #
10
+ # Provides convenient access to Buffer::Cell for testing
11
+ # and buffer inspection purposes.
12
+ module BufferFactories
13
+ # Creates a Buffer::Cell (for testing).
14
+ # @return [Buffer::Cell]
15
+ def cell(...)
16
+ Buffer::Cell.new(...)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,44 @@
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
+ class TUI
8
+ # Canvas shape factory methods for Session.
9
+ #
10
+ # Provides convenient access to Widgets::Shape::* classes
11
+ # for creating custom drawings on Canvas widgets.
12
+ module CanvasFactories
13
+ # Creates a map shape for Canvas.
14
+ # @return [Widgets::Shape::Map]
15
+ def shape_map(...)
16
+ Widgets::Shape::Map.new(...)
17
+ end
18
+
19
+ # Creates a line shape for Canvas.
20
+ # @return [Widgets::Shape::Line]
21
+ def shape_line(...)
22
+ Widgets::Shape::Line.new(...)
23
+ end
24
+
25
+ # Creates a point (single pixel) shape for Canvas.
26
+ # @return [Widgets::Shape::Point]
27
+ def shape_point(...)
28
+ Widgets::Shape::Point.new(...)
29
+ end
30
+
31
+ # Creates a circle shape for Canvas.
32
+ # @return [Widgets::Shape::Circle]
33
+ def shape_circle(...)
34
+ Widgets::Shape::Circle.new(...)
35
+ end
36
+
37
+ # Creates a rectangle shape for Canvas.
38
+ # @return [Widgets::Shape::Rectangle]
39
+ def shape_rectangle(...)
40
+ Widgets::Shape::Rectangle.new(...)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,38 @@
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
+ class TUI
8
+ # Core terminal methods delegated to RatatuiRuby module.
9
+ #
10
+ # These are the fundamental operations for the render loop:
11
+ # drawing UI, polling events, and inspecting the buffer.
12
+ module Core
13
+ # Draws the given UI node tree to the terminal.
14
+ # @see RatatuiRuby.draw
15
+ def draw(tree = nil, &)
16
+ RatatuiRuby.draw(tree, &)
17
+ end
18
+
19
+ # Checks for user input.
20
+ # @see RatatuiRuby.poll_event
21
+ def poll_event(timeout: 0.016)
22
+ RatatuiRuby.poll_event(timeout:)
23
+ end
24
+
25
+ # Inspects the terminal buffer at specific coordinates.
26
+ # @see RatatuiRuby.get_cell_at
27
+ def get_cell_at(x, y)
28
+ RatatuiRuby.get_cell_at(x, y)
29
+ end
30
+
31
+ # Creates a Draw::CellCmd for placing a cell at coordinates.
32
+ # @return [Draw::CellCmd]
33
+ def draw_cell(x, y, cell)
34
+ Draw.cell(x, y, cell)
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,74 @@
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
+ class TUI
8
+ # Layout factory methods for Session.
9
+ #
10
+ # Provides convenient access to Layout::Rect, Layout::Constraint,
11
+ # and Layout::Layout without fully qualifying the class names.
12
+ module LayoutFactories
13
+ # Creates a Layout::Rect.
14
+ # @return [Layout::Rect]
15
+ def rect(...)
16
+ Layout::Rect.new(...)
17
+ end
18
+
19
+ # Creates a Layout::Constraint.
20
+ # @return [Layout::Constraint]
21
+ def constraint(...)
22
+ Layout::Constraint.new(...)
23
+ end
24
+
25
+ # Creates a Layout::Constraint.length.
26
+ # @return [Layout::Constraint]
27
+ def constraint_length(n)
28
+ Layout::Constraint.length(n)
29
+ end
30
+
31
+ # Creates a Layout::Constraint.percentage.
32
+ # @return [Layout::Constraint]
33
+ def constraint_percentage(n)
34
+ Layout::Constraint.percentage(n)
35
+ end
36
+
37
+ # Creates a Layout::Constraint.min.
38
+ # @return [Layout::Constraint]
39
+ def constraint_min(n)
40
+ Layout::Constraint.min(n)
41
+ end
42
+
43
+ # Creates a Layout::Constraint.max.
44
+ # @return [Layout::Constraint]
45
+ def constraint_max(n)
46
+ Layout::Constraint.max(n)
47
+ end
48
+
49
+ # Creates a Layout::Constraint.fill.
50
+ # @return [Layout::Constraint]
51
+ def constraint_fill(n = 1)
52
+ Layout::Constraint.fill(n)
53
+ end
54
+
55
+ # Creates a Layout::Constraint.ratio.
56
+ # @return [Layout::Constraint]
57
+ def constraint_ratio(numerator, denominator)
58
+ Layout::Constraint.ratio(numerator, denominator)
59
+ end
60
+
61
+ # Creates a Layout::Layout.
62
+ # @return [Layout::Layout]
63
+ def layout(...)
64
+ Layout::Layout.new(...)
65
+ end
66
+
67
+ # Splits an area using Layout::Layout.split.
68
+ # @return [Array<Layout::Rect>]
69
+ def layout_split(area, direction: :vertical, constraints:, flex: :legacy)
70
+ Layout::Layout.split(area, direction:, constraints:, flex:)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,33 @@
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
+ class TUI
8
+ # State object factory methods for Session.
9
+ #
10
+ # Provides convenient access to stateful widget state objects
11
+ # (ListState, TableState, ScrollbarState) without fully
12
+ # qualifying the class names.
13
+ module StateFactories
14
+ # Creates a ListState.
15
+ # @return [ListState]
16
+ def list_state(...)
17
+ ListState.new(...)
18
+ end
19
+
20
+ # Creates a TableState.
21
+ # @return [TableState]
22
+ def table_state(...)
23
+ TableState.new(...)
24
+ end
25
+
26
+ # Creates a ScrollbarState.
27
+ # @return [ScrollbarState]
28
+ def scrollbar_state(...)
29
+ ScrollbarState.new(...)
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,20 @@
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
+ class TUI
8
+ # Style factory methods for Session.
9
+ #
10
+ # Provides convenient access to Style::Style without fully
11
+ # qualifying the class name.
12
+ module StyleFactories
13
+ # Creates a Style::Style.
14
+ # @return [Style::Style]
15
+ def style(...)
16
+ Style::Style.new(...)
17
+ end
18
+ end
19
+ end
20
+ end