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
@@ -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
@@ -6,5 +6,5 @@
6
6
  module RatatuiRuby
7
7
  # The version of the ratatui_ruby gem.
8
8
  # See https://semver.org/spec/v2.0.0.html
9
- VERSION = "0.5.0"
9
+ VERSION = "0.6.0"
10
10
  end
data/lib/ratatui_ruby.rb CHANGED
@@ -10,6 +10,7 @@ require_relative "ratatui_ruby/schema/layout"
10
10
  require_relative "ratatui_ruby/schema/block"
11
11
  require_relative "ratatui_ruby/schema/constraint"
12
12
  require_relative "ratatui_ruby/schema/list"
13
+ require_relative "ratatui_ruby/schema/list_item"
13
14
  require_relative "ratatui_ruby/schema/style"
14
15
  require_relative "ratatui_ruby/schema/gauge"
15
16
  require_relative "ratatui_ruby/schema/line_gauge"
@@ -35,6 +36,9 @@ require_relative "ratatui_ruby/schema/draw"
35
36
  require_relative "ratatui_ruby/event"
36
37
  require_relative "ratatui_ruby/cell"
37
38
  require_relative "ratatui_ruby/frame"
39
+ require_relative "ratatui_ruby/list_state"
40
+ require_relative "ratatui_ruby/table_state"
41
+ require_relative "ratatui_ruby/scrollbar_state"
38
42
 
39
43
  begin
40
44
  require "ratatui_ruby/ratatui_ruby"
@@ -52,7 +56,13 @@ end
52
56
  # Use `RatatuiRuby.run` to start your application.
53
57
  module RatatuiRuby
54
58
  # Generic error class for RatatuiRuby.
55
- class Error < StandardError; end
59
+ class Error < StandardError
60
+ # Raised when a terminal operation fails (e.g., I/O error, backend failure).
61
+ class Terminal < Error; end
62
+
63
+ # Raised when an API safety contract is violated (e.g., accessing a Frame outside its valid scope).
64
+ class Safety < Error; end
65
+ end
56
66
 
57
67
  ##
58
68
  # Initializes the terminal for TUI mode.
@@ -155,41 +165,54 @@ module RatatuiRuby
155
165
  ##
156
166
  # Checks for user input.
157
167
  #
158
- # Returns a discrete event (Key, Mouse, Resize) if one is available in the queue.
159
- # Returns RatatuiRuby::Event::None if the queue is empty (non-blocking).
168
+ # Interactive apps must respond to input. Loops need to poll without burning CPU.
160
169
  #
161
- # === Example
170
+ # This method checks for an event. It returns the event if one is found. It returns {RatatuiRuby::Event::None} if the timeout expires.
171
+ #
172
+ # [timeout] Float seconds to wait (default: 0.016).
173
+ # Pass <tt>nil</tt> to block indefinitely (wait forever).
174
+ # Pass <tt>0.0</tt> for a non-blocking check.
175
+ #
176
+ # === Examples
162
177
  #
178
+ # # Standard loop (approx 60 FPS)
163
179
  # event = RatatuiRuby.poll_event
164
- # if event.none?
165
- # puts "No input available"
166
- # elsif event.key?
167
- # puts "Key pressed"
168
- # end
169
180
  #
170
- def self.poll_event
171
- raw = _poll_event
172
- return Event::None.new if raw.nil?
181
+ # # Block until event (pauses execution)
182
+ # event = RatatuiRuby.poll_event(timeout: nil)
183
+ #
184
+ # # Non-blocking check (returns immediately)
185
+ # event = RatatuiRuby.poll_event(timeout: 0.0)
186
+ #
187
+ def self.poll_event(timeout: 0.016)
188
+ raise ArgumentError, "timeout must be non-negative" if timeout && timeout < 0
189
+
190
+ raw = _poll_event(timeout)
191
+ return Event::None.new.freeze if raw.nil?
173
192
 
174
193
  case raw[:type]
175
194
  when :key
176
- Event::Key.new(code: raw[:code], modifiers: raw[:modifiers] || [])
195
+ Event::Key.new(
196
+ code: raw[:code],
197
+ modifiers: (raw[:modifiers] || []).freeze,
198
+ kind: raw[:kind] || :standard
199
+ ).freeze
177
200
  when :mouse
178
201
  Event::Mouse.new(
179
202
  kind: raw[:kind].to_s,
180
203
  x: raw[:x],
181
204
  y: raw[:y],
182
205
  button: raw[:button].to_s,
183
- modifiers: raw[:modifiers] || []
184
- )
206
+ modifiers: (raw[:modifiers] || []).freeze
207
+ ).freeze
185
208
  when :resize
186
- Event::Resize.new(width: raw[:width], height: raw[:height])
209
+ Event::Resize.new(width: raw[:width], height: raw[:height]).freeze
187
210
  when :paste
188
- Event::Paste.new(content: raw[:content])
211
+ Event::Paste.new(content: raw[:content]).freeze
189
212
  when :focus_gained
190
- Event::FocusGained.new
213
+ Event::FocusGained.new.freeze
191
214
  when :focus_lost
192
- Event::FocusLost.new
215
+ Event::FocusLost.new.freeze
193
216
  else
194
217
  # Fallback for unknown events, though ideally we cover them all
195
218
  nil
@@ -0,0 +1,33 @@
1
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
+ # SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ class AppStatefulInteraction
5
+ @tables: Array[String]
6
+ @data: Hash[String, Array[Array[String]]]
7
+ @list_state: RatatuiRuby::ListState
8
+ @table_state: RatatuiRuby::TableState
9
+ @active_pane: :list | :table
10
+ @tui: untyped
11
+ @style_active: RatatuiRuby::Style
12
+ @style_inactive: RatatuiRuby::Style
13
+ @style_highlight: RatatuiRuby::Style
14
+ @list_area: RatatuiRuby::Rect
15
+ @table_area: RatatuiRuby::Rect
16
+
17
+ # @public
18
+ def self.new: () -> AppStatefulInteraction
19
+
20
+ # @public
21
+ def run: () -> void
22
+
23
+ private
24
+
25
+ def render: () -> void
26
+ def render_list: (untyped frame, RatatuiRuby::Rect area) -> void
27
+ def render_table: (untyped frame, RatatuiRuby::Rect area) -> void
28
+ def handle_input: () -> (:quit | nil)
29
+ def scroll_active: (Integer delta) -> void
30
+ def handle_click: (Integer x, Integer y) -> void
31
+ def handle_list_click: (Integer mouse_y) -> void
32
+ def handle_table_click: (Integer mouse_y) -> void
33
+ end
@@ -0,0 +1,32 @@
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
+ class WidgetBlockDemo
7
+ @tui: RatatuiRuby::Tui
8
+ @hotkey_style: RatatuiRuby::Style
9
+ @title_configs: Array[{name: String, title: String?}]
10
+ @title_index: Integer
11
+ @titles_configs: Array[{name: String, titles: Array[RatatuiRuby::Block::title_input]}]
12
+ @titles_index: Integer
13
+ @alignment_configs: Array[{name: String, alignment: RatatuiRuby::Alignment}]
14
+ @alignment_index: Integer
15
+ @title_styles: Array[{name: String, style: RatatuiRuby::Style?}]
16
+ @title_style_index: Integer
17
+ @border_configs: Array[{name: String, borders: Array[RatatuiRuby::Block::border_option]}]
18
+ @border_index: Integer
19
+ @border_type_configs: Array[{name: String, type: RatatuiRuby::Block::border_type?, set: Hash[Symbol, String]?}]
20
+ @border_type_index: Integer
21
+ @border_styles: Array[{name: String, style: RatatuiRuby::Style?}]
22
+ @border_style_index: Integer
23
+ @base_styles: Array[{name: String, style: RatatuiRuby::Style?}]
24
+ @base_style_index: Integer
25
+ @padding_configs: Array[{name: String, padding: RatatuiRuby::Block::padding_input}]
26
+ @padding_index: Integer
27
+
28
+ def self.new: () -> WidgetBlockDemo
29
+ def run: () -> void
30
+ private def render: () -> void
31
+ private def handle_input: () -> (:quit)?
32
+ end
@@ -2,9 +2,9 @@
2
2
  #
3
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- class AppMapDemo
5
+ class WidgetMapDemo
6
6
  # @public
7
- def self.new: () -> AppMapDemo
7
+ def self.new: () -> WidgetMapDemo
8
8
 
9
9
  # @public
10
10
  def run: () -> void
@@ -2,9 +2,9 @@
2
2
  #
3
3
  # SPDX-License-Identifier: AGPL-3.0-or-later
4
4
 
5
- class AppTableSelect
5
+ class WidgetTableDemo
6
6
  # @public
7
- def self.new: () -> AppTableSelect
7
+ def self.new: () -> WidgetTableDemo
8
8
 
9
9
  # @public
10
10
  def run: () -> void
@@ -1,10 +1,9 @@
1
1
  # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
2
- #
3
2
  # SPDX-License-Identifier: AGPL-3.0-or-later
4
3
 
5
- class WidgetTableFlex
4
+ class WidgetTextWidth
6
5
  # @public
7
- def self.new: () -> WidgetTableFlex
6
+ def self.new: () -> WidgetTextWidth
8
7
 
9
8
  # @public
10
9
  def run: () -> void