ratatui_ruby 0.3.1 → 0.4.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 (300) hide show
  1. checksums.yaml +4 -4
  2. data/.builds/ruby-3.2.yml +14 -12
  3. data/.builds/ruby-3.3.yml +14 -12
  4. data/.builds/ruby-3.4.yml +14 -12
  5. data/.builds/ruby-4.0.0.yml +14 -12
  6. data/AGENTS.md +54 -13
  7. data/CHANGELOG.md +186 -1
  8. data/README.md +17 -15
  9. data/doc/application_architecture.md +116 -0
  10. data/doc/application_testing.md +12 -7
  11. data/doc/contributors/better_dx.md +543 -0
  12. data/doc/contributors/design/ruby_frontend.md +1 -1
  13. data/doc/contributors/developing_examples.md +203 -0
  14. data/doc/contributors/documentation_style.md +97 -0
  15. data/doc/contributors/dwim_dx.md +366 -0
  16. data/doc/contributors/example_analysis.md +82 -0
  17. data/doc/custom.css +14 -0
  18. data/doc/event_handling.md +119 -0
  19. data/doc/images/all_events.png +0 -0
  20. data/doc/images/analytics.png +0 -0
  21. data/doc/images/block_padding.png +0 -0
  22. data/doc/images/block_titles.png +0 -0
  23. data/doc/images/box_demo.png +0 -0
  24. data/doc/images/calendar_demo.png +0 -0
  25. data/doc/images/cell_demo.png +0 -0
  26. data/doc/images/chart_demo.png +0 -0
  27. data/doc/images/custom_widget.png +0 -0
  28. data/doc/images/flex_layout.png +0 -0
  29. data/doc/images/gauge_demo.png +0 -0
  30. data/doc/images/hit_test.png +0 -0
  31. data/doc/images/line_gauge_demo.png +0 -0
  32. data/doc/images/list_demo.png +0 -0
  33. data/doc/images/list_styles.png +0 -0
  34. data/doc/images/login_form.png +0 -0
  35. data/doc/images/map_demo.png +0 -0
  36. data/doc/images/mouse_events.png +0 -0
  37. data/doc/images/popup_demo.png +0 -0
  38. data/doc/images/quickstart_dsl.png +0 -0
  39. data/doc/images/quickstart_lifecycle.png +0 -0
  40. data/doc/images/ratatui_logo_demo.png +0 -0
  41. data/doc/images/readme_usage.png +0 -0
  42. data/doc/images/rich_text.png +0 -0
  43. data/doc/images/scroll_text.png +0 -0
  44. data/doc/images/scrollbar_demo.png +0 -0
  45. data/doc/images/sparkline_demo.png +0 -0
  46. data/doc/images/table_flex.png +0 -0
  47. data/doc/images/table_select.png +0 -0
  48. data/doc/images/widget_style_colors.png +0 -0
  49. data/doc/index.md +1 -0
  50. data/doc/interactive_design.md +121 -0
  51. data/doc/quickstart.md +147 -72
  52. data/examples/all_events/app.rb +169 -0
  53. data/examples/all_events/app.rbs +7 -0
  54. data/examples/all_events/test_app.rb +139 -0
  55. data/examples/analytics/app.rb +258 -0
  56. data/examples/analytics/app.rbs +7 -0
  57. data/examples/analytics/test_app.rb +132 -0
  58. data/examples/block_padding/app.rb +63 -0
  59. data/examples/block_padding/app.rbs +7 -0
  60. data/examples/block_padding/test_app.rb +31 -0
  61. data/examples/block_titles/app.rb +61 -0
  62. data/examples/block_titles/app.rbs +7 -0
  63. data/examples/block_titles/test_app.rb +34 -0
  64. data/examples/box_demo/app.rb +216 -0
  65. data/examples/box_demo/app.rbs +7 -0
  66. data/examples/box_demo/test_app.rb +88 -0
  67. data/examples/calendar_demo/app.rb +101 -0
  68. data/examples/calendar_demo/app.rbs +7 -0
  69. data/examples/calendar_demo/test_app.rb +108 -0
  70. data/examples/cell_demo/app.rb +108 -0
  71. data/examples/cell_demo/app.rbs +7 -0
  72. data/examples/cell_demo/test_app.rb +36 -0
  73. data/examples/chart_demo/app.rb +203 -0
  74. data/examples/chart_demo/app.rbs +7 -0
  75. data/examples/chart_demo/test_app.rb +102 -0
  76. data/examples/custom_widget/app.rb +51 -0
  77. data/examples/custom_widget/app.rbs +7 -0
  78. data/examples/custom_widget/test_app.rb +30 -0
  79. data/examples/flex_layout/app.rb +156 -0
  80. data/examples/flex_layout/app.rbs +7 -0
  81. data/examples/flex_layout/test_app.rb +65 -0
  82. data/examples/gauge_demo/app.rb +182 -0
  83. data/examples/gauge_demo/app.rbs +7 -0
  84. data/examples/gauge_demo/test_app.rb +120 -0
  85. data/examples/hit_test/app.rb +175 -0
  86. data/examples/hit_test/app.rbs +7 -0
  87. data/examples/hit_test/test_app.rb +102 -0
  88. data/examples/line_gauge_demo/app.rb +190 -0
  89. data/examples/line_gauge_demo/app.rbs +7 -0
  90. data/examples/line_gauge_demo/test_app.rb +129 -0
  91. data/examples/list_demo/app.rb +253 -0
  92. data/examples/list_demo/app.rbs +12 -0
  93. data/examples/list_demo/test_app.rb +237 -0
  94. data/examples/list_styles/app.rb +140 -0
  95. data/examples/list_styles/app.rbs +7 -0
  96. data/examples/list_styles/test_app.rb +157 -0
  97. data/examples/{login_form.rb → login_form/app.rb} +12 -16
  98. data/examples/login_form/app.rbs +7 -0
  99. data/examples/login_form/test_app.rb +51 -0
  100. data/examples/map_demo/app.rb +90 -0
  101. data/examples/map_demo/app.rbs +7 -0
  102. data/examples/map_demo/test_app.rb +149 -0
  103. data/examples/{mouse_events.rb → mouse_events/app.rb} +29 -27
  104. data/examples/mouse_events/app.rbs +7 -0
  105. data/examples/mouse_events/test_app.rb +53 -0
  106. data/examples/{popup_demo.rb → popup_demo/app.rb} +15 -17
  107. data/examples/popup_demo/app.rbs +7 -0
  108. data/examples/{test_popup_demo.rb → popup_demo/test_app.rb} +18 -26
  109. data/examples/quickstart_dsl/app.rb +36 -0
  110. data/examples/quickstart_dsl/app.rbs +7 -0
  111. data/examples/quickstart_dsl/test_app.rb +29 -0
  112. data/examples/quickstart_lifecycle/app.rb +39 -0
  113. data/examples/quickstart_lifecycle/app.rbs +7 -0
  114. data/examples/quickstart_lifecycle/test_app.rb +29 -0
  115. data/examples/ratatui_logo_demo/app.rb +79 -0
  116. data/examples/ratatui_logo_demo/app.rbs +7 -0
  117. data/examples/ratatui_logo_demo/test_app.rb +51 -0
  118. data/examples/ratatui_mascot_demo/app.rb +84 -0
  119. data/examples/ratatui_mascot_demo/app.rbs +7 -0
  120. data/examples/ratatui_mascot_demo/test_app.rb +47 -0
  121. data/examples/readme_usage/app.rb +29 -0
  122. data/examples/readme_usage/app.rbs +7 -0
  123. data/examples/readme_usage/test_app.rb +29 -0
  124. data/examples/rich_text/app.rb +141 -0
  125. data/examples/rich_text/app.rbs +7 -0
  126. data/examples/rich_text/test_app.rb +166 -0
  127. data/examples/scroll_text/app.rb +103 -0
  128. data/examples/scroll_text/app.rbs +7 -0
  129. data/examples/scroll_text/test_app.rb +110 -0
  130. data/examples/scrollbar_demo/app.rb +143 -0
  131. data/examples/scrollbar_demo/app.rbs +7 -0
  132. data/examples/scrollbar_demo/test_app.rb +77 -0
  133. data/examples/sparkline_demo/app.rb +240 -0
  134. data/examples/sparkline_demo/app.rbs +10 -0
  135. data/examples/sparkline_demo/test_app.rb +107 -0
  136. data/examples/table_flex/app.rb +65 -0
  137. data/examples/table_flex/app.rbs +7 -0
  138. data/examples/table_flex/test_app.rb +36 -0
  139. data/examples/table_select/app.rb +198 -0
  140. data/examples/table_select/app.rbs +7 -0
  141. data/examples/table_select/test_app.rb +180 -0
  142. data/examples/widget_style_colors/app.rb +104 -0
  143. data/examples/widget_style_colors/app.rbs +14 -0
  144. data/examples/widget_style_colors/test_app.rb +48 -0
  145. data/ext/ratatui_ruby/Cargo.lock +889 -115
  146. data/ext/ratatui_ruby/Cargo.toml +4 -3
  147. data/ext/ratatui_ruby/clippy.toml +7 -0
  148. data/ext/ratatui_ruby/extconf.rb +7 -0
  149. data/ext/ratatui_ruby/src/events.rs +218 -229
  150. data/ext/ratatui_ruby/src/lib.rs +38 -10
  151. data/ext/ratatui_ruby/src/rendering.rs +90 -10
  152. data/ext/ratatui_ruby/src/style.rs +281 -98
  153. data/ext/ratatui_ruby/src/terminal.rs +119 -25
  154. data/ext/ratatui_ruby/src/text.rs +171 -0
  155. data/ext/ratatui_ruby/src/widgets/barchart.rs +97 -24
  156. data/ext/ratatui_ruby/src/widgets/block.rs +31 -3
  157. data/ext/ratatui_ruby/src/widgets/calendar.rs +45 -44
  158. data/ext/ratatui_ruby/src/widgets/canvas.rs +46 -29
  159. data/ext/ratatui_ruby/src/widgets/chart.rs +69 -27
  160. data/ext/ratatui_ruby/src/widgets/clear.rs +3 -1
  161. data/ext/ratatui_ruby/src/widgets/gauge.rs +11 -4
  162. data/ext/ratatui_ruby/src/widgets/layout.rs +218 -15
  163. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +92 -0
  164. data/ext/ratatui_ruby/src/widgets/list.rs +91 -11
  165. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  166. data/ext/ratatui_ruby/src/widgets/overlay.rs +3 -2
  167. data/ext/ratatui_ruby/src/widgets/paragraph.rs +35 -13
  168. data/ext/ratatui_ruby/src/widgets/ratatui_logo.rs +29 -0
  169. data/ext/ratatui_ruby/src/widgets/ratatui_mascot.rs +44 -0
  170. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +59 -7
  171. data/ext/ratatui_ruby/src/widgets/sparkline.rs +70 -6
  172. data/ext/ratatui_ruby/src/widgets/table.rs +173 -64
  173. data/ext/ratatui_ruby/src/widgets/tabs.rs +105 -5
  174. data/lib/ratatui_ruby/cell.rb +166 -0
  175. data/lib/ratatui_ruby/event/focus_gained.rb +49 -0
  176. data/lib/ratatui_ruby/event/focus_lost.rb +50 -0
  177. data/lib/ratatui_ruby/event/key.rb +211 -0
  178. data/lib/ratatui_ruby/event/mouse.rb +124 -0
  179. data/lib/ratatui_ruby/event/paste.rb +71 -0
  180. data/lib/ratatui_ruby/event/resize.rb +80 -0
  181. data/lib/ratatui_ruby/event.rb +79 -0
  182. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +45 -0
  183. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +27 -0
  184. data/lib/ratatui_ruby/schema/bar_chart.rb +228 -19
  185. data/lib/ratatui_ruby/schema/block.rb +186 -14
  186. data/lib/ratatui_ruby/schema/calendar.rb +74 -17
  187. data/lib/ratatui_ruby/schema/canvas.rb +215 -48
  188. data/lib/ratatui_ruby/schema/center.rb +49 -11
  189. data/lib/ratatui_ruby/schema/chart.rb +151 -41
  190. data/lib/ratatui_ruby/schema/clear.rb +41 -72
  191. data/lib/ratatui_ruby/schema/constraint.rb +82 -22
  192. data/lib/ratatui_ruby/schema/cursor.rb +27 -9
  193. data/lib/ratatui_ruby/schema/draw.rb +53 -0
  194. data/lib/ratatui_ruby/schema/gauge.rb +59 -15
  195. data/lib/ratatui_ruby/schema/layout.rb +95 -13
  196. data/lib/ratatui_ruby/schema/line_gauge.rb +78 -0
  197. data/lib/ratatui_ruby/schema/list.rb +93 -19
  198. data/lib/ratatui_ruby/schema/overlay.rb +34 -8
  199. data/lib/ratatui_ruby/schema/paragraph.rb +87 -30
  200. data/lib/ratatui_ruby/schema/ratatui_logo.rb +25 -0
  201. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +29 -0
  202. data/lib/ratatui_ruby/schema/rect.rb +64 -15
  203. data/lib/ratatui_ruby/schema/scrollbar.rb +132 -24
  204. data/lib/ratatui_ruby/schema/shape/label.rb +66 -0
  205. data/lib/ratatui_ruby/schema/sparkline.rb +122 -15
  206. data/lib/ratatui_ruby/schema/style.rb +49 -21
  207. data/lib/ratatui_ruby/schema/table.rb +119 -21
  208. data/lib/ratatui_ruby/schema/tabs.rb +75 -13
  209. data/lib/ratatui_ruby/schema/text.rb +90 -0
  210. data/lib/ratatui_ruby/session.rb +146 -0
  211. data/lib/ratatui_ruby/test_helper.rb +156 -13
  212. data/lib/ratatui_ruby/version.rb +1 -1
  213. data/lib/ratatui_ruby.rb +143 -23
  214. data/sig/ratatui_ruby/event.rbs +69 -0
  215. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -1
  216. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +16 -0
  217. data/sig/ratatui_ruby/schema/bar_chart/bar_group.rbs +13 -0
  218. data/sig/ratatui_ruby/schema/bar_chart.rbs +20 -2
  219. data/sig/ratatui_ruby/schema/block.rbs +5 -4
  220. data/sig/ratatui_ruby/schema/calendar.rbs +6 -2
  221. data/sig/ratatui_ruby/schema/canvas.rbs +52 -39
  222. data/sig/ratatui_ruby/schema/center.rbs +3 -3
  223. data/sig/ratatui_ruby/schema/chart.rbs +8 -5
  224. data/sig/ratatui_ruby/schema/constraint.rbs +8 -5
  225. data/sig/ratatui_ruby/schema/cursor.rbs +1 -1
  226. data/sig/ratatui_ruby/schema/draw.rbs +23 -0
  227. data/sig/ratatui_ruby/schema/gauge.rbs +4 -2
  228. data/sig/ratatui_ruby/schema/layout.rbs +11 -1
  229. data/sig/ratatui_ruby/schema/line_gauge.rbs +16 -0
  230. data/sig/ratatui_ruby/schema/list.rbs +5 -1
  231. data/sig/ratatui_ruby/schema/paragraph.rbs +4 -1
  232. data/{lib/ratatui_ruby/output.rb → sig/ratatui_ruby/schema/ratatui_logo.rbs} +3 -2
  233. data/sig/ratatui_ruby/{buffer.rbs → schema/ratatui_mascot.rbs} +4 -3
  234. data/sig/ratatui_ruby/schema/rect.rbs +2 -1
  235. data/sig/ratatui_ruby/schema/scrollbar.rbs +18 -2
  236. data/sig/ratatui_ruby/schema/sparkline.rbs +6 -2
  237. data/sig/ratatui_ruby/schema/table.rbs +8 -1
  238. data/sig/ratatui_ruby/schema/tabs.rbs +5 -1
  239. data/sig/ratatui_ruby/schema/text.rbs +22 -0
  240. data/tasks/resources/build.yml.erb +13 -11
  241. data/tasks/terminal_preview/app_screenshot.rb +35 -0
  242. data/tasks/terminal_preview/crash_report.rb +54 -0
  243. data/tasks/terminal_preview/example_app.rb +25 -0
  244. data/tasks/terminal_preview/launcher_script.rb +48 -0
  245. data/tasks/terminal_preview/preview_collection.rb +60 -0
  246. data/tasks/terminal_preview/preview_timing.rb +22 -0
  247. data/tasks/terminal_preview/safety_confirmation.rb +58 -0
  248. data/tasks/terminal_preview/saved_screenshot.rb +55 -0
  249. data/tasks/terminal_preview/system_appearance.rb +11 -0
  250. data/tasks/terminal_preview/terminal_window.rb +138 -0
  251. data/tasks/terminal_preview/window_id.rb +14 -0
  252. data/tasks/terminal_preview.rake +28 -0
  253. data/tasks/test.rake +1 -1
  254. metadata +174 -53
  255. data/doc/images/examples-analytics.rb.png +0 -0
  256. data/doc/images/examples-box_demo.rb.png +0 -0
  257. data/doc/images/examples-calendar_demo.rb.png +0 -0
  258. data/doc/images/examples-chart_demo.rb.png +0 -0
  259. data/doc/images/examples-custom_widget.rb.png +0 -0
  260. data/doc/images/examples-dashboard.rb.png +0 -0
  261. data/doc/images/examples-list_styles.rb.png +0 -0
  262. data/doc/images/examples-login_form.rb.png +0 -0
  263. data/doc/images/examples-map_demo.rb.png +0 -0
  264. data/doc/images/examples-mouse_events.rb.png +0 -0
  265. data/doc/images/examples-popup_demo.rb.gif +0 -0
  266. data/doc/images/examples-quickstart_lifecycle.rb.png +0 -0
  267. data/doc/images/examples-scroll_text.rb.png +0 -0
  268. data/doc/images/examples-scrollbar_demo.rb.png +0 -0
  269. data/doc/images/examples-stock_ticker.rb.png +0 -0
  270. data/doc/images/examples-system_monitor.rb.png +0 -0
  271. data/doc/images/examples-table_select.rb.png +0 -0
  272. data/examples/analytics.rb +0 -88
  273. data/examples/box_demo.rb +0 -71
  274. data/examples/calendar_demo.rb +0 -55
  275. data/examples/chart_demo.rb +0 -84
  276. data/examples/custom_widget.rb +0 -43
  277. data/examples/dashboard.rb +0 -72
  278. data/examples/list_styles.rb +0 -66
  279. data/examples/map_demo.rb +0 -58
  280. data/examples/quickstart_dsl.rb +0 -30
  281. data/examples/quickstart_lifecycle.rb +0 -40
  282. data/examples/readme_usage.rb +0 -21
  283. data/examples/scroll_text.rb +0 -74
  284. data/examples/scrollbar_demo.rb +0 -75
  285. data/examples/stock_ticker.rb +0 -93
  286. data/examples/system_monitor.rb +0 -94
  287. data/examples/table_select.rb +0 -70
  288. data/examples/test_analytics.rb +0 -65
  289. data/examples/test_box_demo.rb +0 -38
  290. data/examples/test_calendar_demo.rb +0 -66
  291. data/examples/test_dashboard.rb +0 -38
  292. data/examples/test_list_styles.rb +0 -61
  293. data/examples/test_login_form.rb +0 -63
  294. data/examples/test_map_demo.rb +0 -100
  295. data/examples/test_scroll_text.rb +0 -130
  296. data/examples/test_stock_ticker.rb +0 -39
  297. data/examples/test_system_monitor.rb +0 -40
  298. data/examples/test_table_select.rb +0 -37
  299. data/ext/ratatui_ruby/src/buffer.rs +0 -54
  300. data/lib/ratatui_ruby/dsl.rb +0 -64
@@ -0,0 +1,116 @@
1
+ # Core Concepts
2
+
3
+ This guide explains the core concepts and patterns available in `ratatui_ruby` for structuring your terminal applications.
4
+
5
+ ## 1. Lifecycle Management
6
+
7
+ Managing the terminal state is critical. You must enter "alternate screen" and "raw mode" on startup, and **always** restore the terminal on exit (even on errors), otherwise the user's terminal will be left in a broken state.
8
+
9
+ ### `RatatuiRuby.run` (Recommended)
10
+
11
+ The `run` method acts as a **Context Manager**. It handles the initialization and restoration for you, ensuring the terminal is always restored even if your code raises an exception. We recommend using `run` for all applications, as it provides a safe sandbox for your TUI.
12
+
13
+ ```ruby
14
+ RatatuiRuby.run do |tui|
15
+ loop do
16
+ # Your code here
17
+ tui.draw(...)
18
+ end
19
+ end
20
+ # Terminal is restored here
21
+ ```
22
+
23
+ ### Manual Management (Advanced)
24
+
25
+ You can manage this manually if you need granular control, but use `ensure` blocks!
26
+
27
+ ```ruby
28
+ RatatuiRuby.init_terminal
29
+ begin
30
+ # Your code here
31
+ RatatuiRuby.draw(...)
32
+ ensure
33
+ RatatuiRuby.restore_terminal
34
+ end
35
+ ```
36
+
37
+ ## 2. API Convenience
38
+
39
+ ### Session API (Recommended)
40
+
41
+ The block yielded by `run` is a `RatatuiRuby::Session` instance (`tui`).
42
+ It provides factory methods for every widget class (converting snake_case to CamelCase) and aliases for module functions.
43
+
44
+ **Why use it?** It significantly reduces verbosity and repeated `RatatuiRuby::` namespacing, making the UI tree structure easier to read.
45
+
46
+ ```ruby
47
+ RatatuiRuby.run do |tui|
48
+ loop do
49
+ layout = tui.layout(
50
+ direction: :horizontal,
51
+ constraints: [
52
+ RatatuiRuby::Constraint.length(20),
53
+ RatatuiRuby::Constraint.min(0)
54
+ ],
55
+ children: [
56
+ tui.paragraph(
57
+ text: tui.text_line(spans: [
58
+ tui.text_span(content: "Side", style: tui.style(fg: :blue)),
59
+ tui.text_span(content: "bar")
60
+ ]),
61
+ block: tui.block(borders: [:all], title: "Nav")
62
+ ),
63
+ tui.paragraph(
64
+ text: "Main Content",
65
+ style: tui.style(fg: :green),
66
+ block: tui.block(borders: [:all], title: "Content")
67
+ )
68
+ ]
69
+ )
70
+
71
+ tui.draw(layout)
72
+
73
+ event = tui.poll_event
74
+ break if event == "q" || event == :ctrl_c
75
+ end
76
+ end
77
+ ```
78
+
79
+ ### Raw API
80
+
81
+ You can always use the raw module methods and classes directly. This is useful if you are building your own abstractions or prefer explicit class instantiation.
82
+
83
+ **Comparison:** Notice how much more verbose the same UI definition is.
84
+
85
+ ```ruby
86
+ RatatuiRuby.run do
87
+ loop do
88
+ layout = RatatuiRuby::Layout.new(
89
+ direction: :horizontal,
90
+ constraints: [
91
+ RatatuiRuby::Constraint.length(20),
92
+ RatatuiRuby::Constraint.min(0)
93
+ ],
94
+ children: [
95
+ RatatuiRuby::Paragraph.new(
96
+ text: RatatuiRuby::Text::Line.new(spans: [
97
+ RatatuiRuby::Text::Span.new(content: "Side", style: RatatuiRuby::Style.new(fg: :blue)),
98
+ RatatuiRuby::Text::Span.new(content: "bar")
99
+ ]),
100
+ block: RatatuiRuby::Block.new(borders: [:all], title: "Nav")
101
+ ),
102
+ RatatuiRuby::Paragraph.new(
103
+ text: "Main Content",
104
+ style: RatatuiRuby::Style.new(fg: :green),
105
+ block: RatatuiRuby::Block.new(borders: [:all], title: "Content")
106
+ )
107
+ ]
108
+ )
109
+
110
+ RatatuiRuby.draw(layout)
111
+
112
+ event = RatatuiRuby.poll_event
113
+ break if event == "q" || event == :ctrl_c
114
+ end
115
+ end
116
+ ```
@@ -44,8 +44,8 @@ Wrap your test assertions in `with_test_terminal`. This sets up a temporary, in-
44
44
 
45
45
  ```ruby
46
46
  def test_rendering
47
- # Create a 80x24 terminal
48
- with_test_terminal(80, 24) do
47
+ # Uses default 80x24 terminal
48
+ with_test_terminal do
49
49
  # 1. Instantiate your app/component
50
50
  widget = RatatuiRuby::Paragraph.new(text: "Hello World")
51
51
 
@@ -82,13 +82,18 @@ assert_equal 2, pos[:y]
82
82
 
83
83
  Injects a mock event into the event queue. This is the preferred way to simulate user input instead of stubbing `poll_event`.
84
84
 
85
+ > [!IMPORTANT]
86
+ > You must call `inject_event` inside a `with_test_terminal` block. Calling it outside leads to race conditions where events are flushed before the application starts.
87
+
85
88
  ```ruby
86
- # Simulate 'q' key press
87
- inject_event("key", { code: "q" })
89
+ with_test_terminal do
90
+ # Simulate 'q' key press
91
+ inject_event("key", { code: "q" })
88
92
 
89
- # Now poll_event will return the 'q' key event
90
- event = RatatuiRuby.poll_event
91
- assert_equal "q", event[:code]
93
+ # Now poll_event will return the 'q' key event
94
+ event = RatatuiRuby.poll_event
95
+ assert_equal "q", event.code
96
+ end
92
97
  ```
93
98
 
94
99
  ## Example
@@ -0,0 +1,543 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # Improving DX for Layout & Hit Testing
7
+
8
+ ## Problem Statement
9
+
10
+ Interactive TUI applications require hit testing: determining which UI region the user clicked. Current RatatuiRuby practice duplicates layout logic between rendering and input handling.
11
+
12
+ ### Current Pattern (Duplication)
13
+
14
+ ```ruby
15
+ def run
16
+ loop do
17
+ calculate_layout # Phase 1: Manually calculate rects
18
+ render # Phase 2: Build UI tree (repeating the same layout logic)
19
+ handle_input # Phase 3: Use cached rects from Phase 1
20
+ end
21
+ end
22
+
23
+ def calculate_layout
24
+ full_area = RatatuiRuby::Rect.new(x: 0, y: 0, width: 80, height: 24)
25
+
26
+ @main_area, @control_area = RatatuiRuby::Layout.split(
27
+ full_area,
28
+ direction: :vertical,
29
+ constraints: [
30
+ RatatuiRuby::Constraint.fill(1),
31
+ RatatuiRuby::Constraint.length(7)
32
+ ]
33
+ )
34
+
35
+ @left_rect, @right_rect = RatatuiRuby::Layout.split(
36
+ @main_area,
37
+ direction: :horizontal,
38
+ constraints: [
39
+ RatatuiRuby::Constraint.percentage(50),
40
+ RatatuiRuby::Constraint.percentage(50)
41
+ ]
42
+ )
43
+ end
44
+
45
+ def render
46
+ # Rebuilds the SAME layout internally, but we can't access those rects
47
+ layout = RatatuiRuby::Layout.new(
48
+ direction: :vertical,
49
+ constraints: [
50
+ RatatuiRuby::Constraint.fill(1),
51
+ RatatuiRuby::Constraint.length(7)
52
+ ],
53
+ children: [...]
54
+ )
55
+ RatatuiRuby.draw(layout)
56
+ end
57
+
58
+ def handle_input
59
+ event = RatatuiRuby.poll_event
60
+ if @left_rect&.contains?(event.x, event.y)
61
+ # hit test using cached rect
62
+ end
63
+ end
64
+ ```
65
+
66
+ **Problems:**
67
+ 1. **Duplication**: Layout constraints are written twice—once in `calculate_layout`, once in the UI tree.
68
+ 2. **Fragility**: If layout changes in `render`, the cached rects in `@left_rect` become stale. The user must remember to update both places.
69
+ 3. **Maintainability**: Adding new UI regions requires changes in two places and explicit rect caching.
70
+ 4. **Performance**: Layout is calculated twice per frame (once manually, once internally during render).
71
+
72
+ ### Ideal Pattern (No Duplication)
73
+
74
+ ```ruby
75
+ def run
76
+ loop do
77
+ render # Single source of truth
78
+ break if handle_input == :quit
79
+ end
80
+ end
81
+
82
+ def render
83
+ layout = RatatuiRuby::Layout.new(
84
+ direction: :vertical,
85
+ constraints: [
86
+ RatatuiRuby::Constraint.fill(1),
87
+ RatatuiRuby::Constraint.length(7)
88
+ ],
89
+ children: [...]
90
+ )
91
+
92
+ @layout_info = RatatuiRuby.draw(layout) # Returns layout metadata
93
+ end
94
+
95
+ def handle_input
96
+ event = RatatuiRuby.poll_event
97
+ # Query the layout info returned from draw()
98
+ if @layout_info[:left_panel]&.contains?(event.x, event.y)
99
+ # No manual caching needed; rects come from the same render pass
100
+ end
101
+ end
102
+ ```
103
+
104
+ ## Proposed Solution
105
+
106
+ Extend `RatatuiRuby.draw()` to return a `LayoutInfo` object containing the rectangles where widgets were rendered.
107
+
108
+ ### API Changes
109
+
110
+ #### Option A: Return a Plain Hash (Simpler)
111
+
112
+ ```ruby
113
+ # Layout.rb with optional layout_id parameter
114
+ class Layout < Data
115
+ def self.new(
116
+ direction: :vertical,
117
+ constraints: [],
118
+ children: [],
119
+ flex: :legacy,
120
+ layout_id: nil # NEW: Optional semantic identifier
121
+ )
122
+ # ...
123
+ end
124
+ end
125
+
126
+ # Block.rb with optional layout_id parameter
127
+ class Block < Data
128
+ def self.new(
129
+ title: nil,
130
+ borders: [],
131
+ border_type: :rounded,
132
+ style: nil,
133
+ layout_id: nil # NEW: Optional semantic identifier
134
+ )
135
+ # ...
136
+ end
137
+ end
138
+
139
+ # Usage in app:
140
+ layout = RatatuiRuby::Layout.new(
141
+ direction: :vertical,
142
+ constraints: [...],
143
+ layout_id: :main, # Tag this layout
144
+ children: [
145
+ RatatuiRuby::Block.new(
146
+ title: "Left",
147
+ layout_id: :left_panel, # Tag this block
148
+ ...
149
+ ),
150
+ RatatuiRuby::Block.new(
151
+ title: "Right",
152
+ layout_id: :right_panel,
153
+ ...
154
+ )
155
+ ]
156
+ )
157
+
158
+ layout_info = RatatuiRuby.draw(layout)
159
+
160
+ # layout_info is a Hash:
161
+ # {
162
+ # left_panel: #<Rect x=0 y=0 width=40 height=24>,
163
+ # right_panel: #<Rect x=40 y=0 width=40 height=24>,
164
+ # main: #<Rect x=0 y=0 width=80 height=24>
165
+ # }
166
+
167
+ if layout_info[:left_panel]&.contains?(event.x, event.y)
168
+ handle_left_click
169
+ end
170
+ ```
171
+
172
+ #### Option B: Return a LayoutInfo Class (More Structured)
173
+
174
+ ```ruby
175
+ class LayoutInfo
176
+ attr_reader :rects # Hash of layout_id => Rect
177
+
178
+ def [](key)
179
+ rects[key]
180
+ end
181
+
182
+ def get(key)
183
+ rects[key]
184
+ end
185
+
186
+ def contains?(key, x, y)
187
+ rects[key]&.contains?(x, y)
188
+ end
189
+ end
190
+
191
+ # Usage:
192
+ layout_info = RatatuiRuby.draw(layout)
193
+ if layout_info.contains?(:left_panel, event.x, event.y)
194
+ handle_left_click
195
+ end
196
+ ```
197
+
198
+ **Recommendation:** Start with Option A (Plain Hash). It's simpler, aligns with RatatuiRuby's minimal design, and can evolve to Option B if needed.
199
+
200
+ ### Implementation Sketch
201
+
202
+ #### Ruby Side
203
+
204
+ 1. **Add `layout_id` parameter** to `Layout` and `Block` (and optionally other container widgets like `Center`, `Overlay`).
205
+ 2. **Update `.rbs` type signatures** to document the new optional parameter.
206
+ 3. **Update `RatatuiRuby.draw()` signature** to return `Hash[Symbol | String, Rect] | nil` (or return both render status and layout info as needed).
207
+
208
+ ```ruby
209
+ # sig/ratatui_ruby/ratatui_ruby.rbs
210
+ def self.draw: (widget, ?return_layout: bool) -> (nil | Hash[Symbol | String, Rect])
211
+ ```
212
+
213
+ #### Rust Side
214
+
215
+ 1. **Track layout IDs during render:** When the Rust renderer encounters a widget with a `layout_id`, record its rendered rectangle.
216
+ 2. **Return layout info as a Ruby Hash:** Construct a Ruby Hash mapping `layout_id` (String or Symbol) to `Rect` objects.
217
+ 3. **Wire into `lib.rs`:** Modify the `draw` function to return this Hash instead of `nil`.
218
+
219
+ **Pseudo-code for `rendering.rs`:**
220
+
221
+ ```rust
222
+ pub fn render_node(
223
+ frame: &mut Frame,
224
+ area: Rect,
225
+ node: Value,
226
+ layout_map: &mut HashMap<Value, Rect>, // Collect rects as we go
227
+ ) -> Result<(), Error> {
228
+ // Extract layout_id if present
229
+ let layout_id: Option<Value> = node.funcall("layout_id", ()).ok();
230
+
231
+ if let Some(id) = layout_id {
232
+ layout_map.insert(id.clone(), area);
233
+ }
234
+
235
+ // ... render the widget ...
236
+ }
237
+
238
+ // In lib.rs, wrap the result:
239
+ pub fn draw(node: Value) -> Result<Value, Error> {
240
+ let mut layout_map = HashMap::new();
241
+ render_node(&mut frame, full_area, node, &mut layout_map)?;
242
+
243
+ // Convert HashMap to Ruby Hash
244
+ let result_hash = RHash::new();
245
+ for (key, rect) in layout_map {
246
+ result_hash.aset(key, rect)?;
247
+ }
248
+
249
+ Ok(result_hash.into())
250
+ }
251
+ ```
252
+
253
+ ### Backward Compatibility
254
+
255
+ **No breaking changes:**
256
+ - `layout_id` is optional on all widgets.
257
+ - `RatatuiRuby.draw()` continues to render correctly.
258
+ - **Behavior**: If `layout_id` is omitted, that region is simply not included in the returned Hash.
259
+ - **Return value**: If no widgets have `layout_id`, returns an empty Hash (or `nil` if we want to preserve existing return type).
260
+
261
+ **Recommendation**: Return `nil` if `layout_id` is not used anywhere in the tree (preserves current behavior of returning nothing). Return a Hash if any widget has a `layout_id`.
262
+
263
+ ## Example: Before and After
264
+
265
+ ### Before (Current)
266
+
267
+ ```ruby
268
+ class ColorPickerApp
269
+ def initialize
270
+ @input = "#F96302"
271
+ @current_color = parse_color(@input)
272
+ @error_message = ""
273
+ end
274
+
275
+ def run
276
+ RatatuiRuby.run do
277
+ loop do
278
+ calculate_layout # Manual layout calculation
279
+ render
280
+ result = handle_input
281
+ break if result == :quit
282
+ end
283
+ end
284
+ end
285
+
286
+ def calculate_layout
287
+ terminal_size = RatatuiRuby.terminal_size
288
+ width, height = terminal_size
289
+
290
+ full_area = RatatuiRuby::Rect.new(x: 0, y: 0, width: width, height: height)
291
+
292
+ input_area, rest = RatatuiRuby::Layout.split(full_area,
293
+ direction: :vertical,
294
+ constraints: [
295
+ RatatuiRuby::Constraint.length(3),
296
+ RatatuiRuby::Constraint.fill(1)
297
+ ]
298
+ )
299
+
300
+ color_area, control_area = RatatuiRuby::Layout.split(rest,
301
+ direction: :vertical,
302
+ constraints: [
303
+ RatatuiRuby::Constraint.length(14),
304
+ RatatuiRuby::Constraint.fill(1)
305
+ ]
306
+ )
307
+
308
+ harmony_area, @export_area_rect = RatatuiRuby::Layout.split(color_area,
309
+ direction: :vertical,
310
+ constraints: [
311
+ RatatuiRuby::Constraint.length(7),
312
+ RatatuiRuby::Constraint.fill(1)
313
+ ]
314
+ )
315
+ end
316
+
317
+ def render
318
+ main_ui = RatatuiRuby::Layout.new(
319
+ direction: :vertical,
320
+ constraints: [
321
+ RatatuiRuby::Constraint.length(3),
322
+ RatatuiRuby::Constraint.length(14),
323
+ RatatuiRuby::Constraint.fill(1)
324
+ ],
325
+ children: [
326
+ build_input_section,
327
+ build_color_section,
328
+ build_controls_section
329
+ ]
330
+ )
331
+ RatatuiRuby.draw(main_ui)
332
+ end
333
+
334
+ def handle_input
335
+ event = RatatuiRuby.poll_event
336
+ case event
337
+ in {type: :mouse, kind: "down", button: "left", x:, y:}
338
+ if @export_area_rect&.contains?(x, y) # Using cached rect
339
+ @copy_dialog_text = @current_color.to_hex.upcase
340
+ @copy_dialog_active = true
341
+ end
342
+ # ...
343
+ end
344
+ end
345
+ end
346
+ ```
347
+
348
+ **Problems:**
349
+ - `calculate_layout` duplicates the exact same layout structure as `render`.
350
+ - Changes to layout in `render` require manual updates to `calculate_layout`.
351
+ - Fragile: rect caching is manual and easy to forget.
352
+
353
+ ### After (With `layout_id`)
354
+
355
+ ```ruby
356
+ class ColorPickerApp
357
+ def initialize
358
+ @input = "#F96302"
359
+ @current_color = parse_color(@input)
360
+ @error_message = ""
361
+ @layout_info = {} # Will be populated by draw()
362
+ end
363
+
364
+ def run
365
+ RatatuiRuby.run do
366
+ loop do
367
+ render
368
+ result = handle_input
369
+ break if result == :quit
370
+ end
371
+ end
372
+ end
373
+
374
+ def render
375
+ main_ui = RatatuiRuby::Layout.new(
376
+ direction: :vertical,
377
+ layout_id: :main, # Tag the main layout
378
+ constraints: [
379
+ RatatuiRuby::Constraint.length(3),
380
+ RatatuiRuby::Constraint.length(14),
381
+ RatatuiRuby::Constraint.fill(1)
382
+ ],
383
+ children: [
384
+ build_input_section,
385
+ build_color_section(layout_id: :color_section), # Tag child layouts
386
+ build_controls_section
387
+ ]
388
+ )
389
+ @layout_info = RatatuiRuby.draw(main_ui) || {} # Capture layout info
390
+ end
391
+
392
+ def build_color_section(layout_id: nil)
393
+ RatatuiRuby::Layout.new(
394
+ direction: :vertical,
395
+ layout_id: layout_id,
396
+ constraints: [
397
+ RatatuiRuby::Constraint.length(7),
398
+ RatatuiRuby::Constraint.fill(1)
399
+ ],
400
+ children: [
401
+ build_harmonies,
402
+ RatatuiRuby::Block.new(
403
+ title: "Export Formats",
404
+ layout_id: :export_formats, # Tag the export block
405
+ borders: [:all],
406
+ children: [
407
+ build_export_content
408
+ ]
409
+ )
410
+ ]
411
+ )
412
+ end
413
+
414
+ def handle_input
415
+ event = RatatuiRuby.poll_event
416
+ case event
417
+ in {type: :mouse, kind: "down", button: "left", x:, y:}
418
+ if @layout_info[:export_formats]&.contains?(x, y) # No manual caching!
419
+ @copy_dialog_text = @current_color.to_hex.upcase
420
+ @copy_dialog_active = true
421
+ end
422
+ # ...
423
+ end
424
+ end
425
+ end
426
+ ```
427
+
428
+ **Benefits:**
429
+ - **Single source of truth**: Layout is defined once in `render`, not duplicated in `calculate_layout`.
430
+ - **Automatic tracking**: As you modify the UI tree, rects are automatically updated by the same render pass.
431
+ - **No manual caching**: Use `@layout_info` directly from `draw()`.
432
+ - **Declarative**: Tag regions with semantic IDs, making hit testing code self-documenting.
433
+
434
+ ## Design Alignment
435
+
436
+ ### Immediate-Mode Rendering
437
+
438
+ This proposal **preserves** immediate-mode principles:
439
+
440
+ - **Every frame**, the app constructs a fresh UI tree from current state.
441
+ - **Every frame**, `draw()` consumes that tree and renders it.
442
+ - **Returns**: Layout metadata computed during the same render pass.
443
+
444
+ The key insight: Returning layout info is **not** retained state; it's a **by-product** of the render, computed fresh each frame.
445
+
446
+ ### Data-Driven UI
447
+
448
+ Widgets remain immutable data objects; adding `layout_id` is just an optional annotation:
449
+
450
+ ```ruby
451
+ # Still pure data:
452
+ widget = RatatuiRuby::Block.new(
453
+ title: "Foo",
454
+ layout_id: :my_widget, # Just metadata, not behavior
455
+ borders: [:all]
456
+ )
457
+ ```
458
+
459
+ No rendering logic moves to Ruby.
460
+
461
+ ### Rust Backend Alignment
462
+
463
+ In Rust Ratatui, the `Frame` tracks where widgets are rendered:
464
+
465
+ ```rust
466
+ let mut frame = Terminal::new(backend)?;
467
+ frame.render_widget(widget, area); // Frame knows where this widget is now
468
+ ```
469
+
470
+ Returning layout info from `draw()` mirrors this: the Rust backend knows where things ended up, and returns that information to Ruby.
471
+
472
+ ## Alternatives Considered
473
+
474
+ ### Alternative 1: Widgets Maintain Their Own State
475
+
476
+ Store rects on mutable widget objects. **Rejected** because:
477
+ - Violates immediate-mode and data-driven design.
478
+ - Requires mutable state tracking in Rust.
479
+ - Complicates the simplicity of immutable data objects.
480
+
481
+ ### Alternative 2: Full Frame Object
482
+
483
+ Return a `Frame` object (similar to Ratatui's Frame) that tracks all rendering details. **Rejected** because:
484
+ - Over-engineered for the current need.
485
+ - RatatuiRuby is intentionally minimal.
486
+ - Overkill if the app only cares about hit testing a few regions.
487
+
488
+ ### Alternative 3: Callback-Based Rendering
489
+
490
+ Allow widgets to register callbacks when rendered. **Rejected** because:
491
+ - Adds complexity and statefulness.
492
+ - Less idiomatic for Ruby.
493
+ - Harder to reason about in immediate-mode loop.
494
+
495
+ ### Alternative 4: Hit Testing DSL
496
+
497
+ Provide a declarative hit testing layer separate from rendering. **Rejected** because:
498
+ - Duplicates layout info (still two sources of truth).
499
+ - Unnecessary indirection.
500
+
501
+ ## Impact Assessment
502
+
503
+ ### User-Facing Changes
504
+
505
+ - **New optional parameter**: `layout_id` on `Layout`, `Block`, and similar container widgets.
506
+ - **New return value**: `RatatuiRuby.draw()` optionally returns a Hash of rects.
507
+ - **Zero breaking changes**: Existing apps without `layout_id` work unchanged.
508
+
509
+ ### Documentation Updates
510
+
511
+ - **Update RDoc** for `Layout` and `Block` to document `layout_id`.
512
+ - **Add example**: `examples/hit_test/app.rb` (or new example) showing the pattern.
513
+ - **Update `doc/interactive_design.md`** with the new approach.
514
+
515
+ ### Testing
516
+
517
+ - **Unit tests (Rust)**: Verify that rects are collected and returned correctly.
518
+ - **Integration tests (Ruby)**: Verify hit testing works with returned layout info.
519
+ - **Example app**: Ensure the color picker and hit test examples demonstrate the pattern.
520
+
521
+ ## Timeline & Scope
522
+
523
+ **Scope**: Pre-1.0 feature. Fits RatatuiRuby's design philosophy and solves a real pain point.
524
+
525
+ **Estimated effort**:
526
+ - Rust backend: 4–6 hours (add `layout_id` extraction, rect collection, Hash construction)
527
+ - Ruby side: 2–3 hours (add parameter to widget classes, update `.rbs`, docs)
528
+ - Testing & examples: 2–3 hours
529
+ - **Total**: ~10 hours
530
+
531
+ **Risk**: Low. The change is additive (optional parameter, new return value). Backward compatible.
532
+
533
+ ## Recommendation
534
+
535
+ **Approve**. This proposal:
536
+
537
+ 1. Eliminates a real pain point (layout duplication).
538
+ 2. Aligns with immediate-mode and data-driven design.
539
+ 3. Mirrors how Rust Ratatui works (Frame tracks layout).
540
+ 4. Requires no breaking changes.
541
+ 5. Is low-risk and achievable pre-1.0.
542
+
543
+ Implement as **Option A (Plain Hash)** first. It's simpler and sufficient for hit testing. Evolve to `LayoutInfo` class if more complex queries are needed later.
@@ -41,7 +41,7 @@ The application loop typically looks like this:
41
41
  loop do
42
42
  # 1. & 2. Handle events and update state
43
43
  event = RatatuiRuby.poll_event
44
- break if event[:type] == :key && event[:code] == "esc"
44
+ break if event == :esc
45
45
 
46
46
  # 3. Construct View Tree
47
47
  ui = RatatuiRuby::Paragraph.new(text: "Time: #{Time.now}")