ratatui_ruby 0.9.0 → 0.10.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 (268) 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 +2 -1
  7. data/CHANGELOG.md +122 -0
  8. data/REUSE.toml +5 -0
  9. data/Rakefile +1 -1
  10. data/Steepfile +49 -0
  11. data/doc/concepts/debugging.md +401 -0
  12. data/doc/getting_started/quickstart.md +8 -3
  13. data/doc/images/app_all_events.png +0 -0
  14. data/doc/images/app_color_picker.png +0 -0
  15. data/doc/images/app_debugging_showcase.gif +0 -0
  16. data/doc/images/app_debugging_showcase.png +0 -0
  17. data/doc/images/app_login_form.png +0 -0
  18. data/doc/images/app_stateful_interaction.png +0 -0
  19. data/doc/images/verify_quickstart_dsl.png +0 -0
  20. data/doc/images/verify_quickstart_layout.png +0 -0
  21. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  22. data/doc/images/verify_readme_usage.png +0 -0
  23. data/doc/images/widget_barchart.png +0 -0
  24. data/doc/images/widget_block.png +0 -0
  25. data/doc/images/widget_box.png +0 -0
  26. data/doc/images/widget_calendar.png +0 -0
  27. data/doc/images/widget_canvas.png +0 -0
  28. data/doc/images/widget_cell.png +0 -0
  29. data/doc/images/widget_center.png +0 -0
  30. data/doc/images/widget_chart.png +0 -0
  31. data/doc/images/widget_gauge.png +0 -0
  32. data/doc/images/widget_layout_split.png +0 -0
  33. data/doc/images/widget_line_gauge.png +0 -0
  34. data/doc/images/widget_list.png +0 -0
  35. data/doc/images/widget_map.png +0 -0
  36. data/doc/images/widget_overlay.png +0 -0
  37. data/doc/images/widget_popup.png +0 -0
  38. data/doc/images/widget_ratatui_logo.png +0 -0
  39. data/doc/images/widget_ratatui_mascot.png +0 -0
  40. data/doc/images/widget_rect.png +0 -0
  41. data/doc/images/widget_render.png +0 -0
  42. data/doc/images/widget_rich_text.png +0 -0
  43. data/doc/images/widget_scroll_text.png +0 -0
  44. data/doc/images/widget_scrollbar.png +0 -0
  45. data/doc/images/widget_sparkline.png +0 -0
  46. data/doc/images/widget_style_colors.png +0 -0
  47. data/doc/images/widget_table.png +0 -0
  48. data/doc/images/widget_tabs.png +0 -0
  49. data/doc/images/widget_text_width.png +0 -0
  50. data/doc/troubleshooting/async.md +4 -0
  51. data/examples/app_debugging_showcase/README.md +119 -0
  52. data/examples/app_debugging_showcase/app.rb +318 -0
  53. data/examples/widget_canvas/app.rb +19 -14
  54. data/examples/widget_gauge/app.rb +18 -3
  55. data/examples/widget_layout_split/app.rb +16 -4
  56. data/examples/widget_list/app.rb +22 -6
  57. data/examples/widget_rect/app.rb +7 -6
  58. data/examples/widget_rich_text/app.rb +62 -37
  59. data/examples/widget_style_colors/app.rb +26 -47
  60. data/examples/widget_table/app.rb +28 -5
  61. data/examples/widget_text_width/app.rb +6 -4
  62. data/ext/ratatui_ruby/Cargo.lock +48 -1
  63. data/ext/ratatui_ruby/Cargo.toml +6 -2
  64. data/ext/ratatui_ruby/src/color.rs +82 -0
  65. data/ext/ratatui_ruby/src/errors.rs +28 -0
  66. data/ext/ratatui_ruby/src/events.rs +16 -14
  67. data/ext/ratatui_ruby/src/lib.rs +56 -0
  68. data/ext/ratatui_ruby/src/rendering.rs +3 -1
  69. data/ext/ratatui_ruby/src/style.rs +48 -21
  70. data/ext/ratatui_ruby/src/terminal.rs +40 -9
  71. data/ext/ratatui_ruby/src/text.rs +21 -9
  72. data/ext/ratatui_ruby/src/widgets/chart.rs +2 -1
  73. data/ext/ratatui_ruby/src/widgets/layout.rs +90 -2
  74. data/ext/ratatui_ruby/src/widgets/list.rs +6 -5
  75. data/ext/ratatui_ruby/src/widgets/overlay.rs +2 -1
  76. data/ext/ratatui_ruby/src/widgets/table.rs +7 -6
  77. data/ext/ratatui_ruby/src/widgets/table_state.rs +55 -0
  78. data/ext/ratatui_ruby/src/widgets/tabs.rs +3 -2
  79. data/lib/ratatui_ruby/buffer/cell.rb +25 -15
  80. data/lib/ratatui_ruby/buffer.rb +134 -2
  81. data/lib/ratatui_ruby/cell.rb +13 -5
  82. data/lib/ratatui_ruby/debug.rb +215 -0
  83. data/lib/ratatui_ruby/event/key.rb +3 -2
  84. data/lib/ratatui_ruby/event/sync.rb +52 -0
  85. data/lib/ratatui_ruby/event.rb +7 -1
  86. data/lib/ratatui_ruby/layout/constraint.rb +184 -0
  87. data/lib/ratatui_ruby/layout/layout.rb +119 -13
  88. data/lib/ratatui_ruby/layout/position.rb +55 -0
  89. data/lib/ratatui_ruby/layout/rect.rb +188 -0
  90. data/lib/ratatui_ruby/layout/size.rb +55 -0
  91. data/lib/ratatui_ruby/layout.rb +4 -0
  92. data/lib/ratatui_ruby/style/color.rb +149 -0
  93. data/lib/ratatui_ruby/style/style.rb +51 -4
  94. data/lib/ratatui_ruby/style.rb +2 -0
  95. data/lib/ratatui_ruby/symbols.rb +435 -0
  96. data/lib/ratatui_ruby/synthetic_events.rb +86 -0
  97. data/lib/ratatui_ruby/table_state.rb +51 -0
  98. data/lib/ratatui_ruby/terminal_lifecycle.rb +2 -1
  99. data/lib/ratatui_ruby/test_helper/event_injection.rb +34 -1
  100. data/lib/ratatui_ruby/test_helper.rb +9 -0
  101. data/lib/ratatui_ruby/text/line.rb +245 -0
  102. data/lib/ratatui_ruby/text/span.rb +158 -0
  103. data/lib/ratatui_ruby/text.rb +99 -0
  104. data/lib/ratatui_ruby/tui/canvas_factories.rb +103 -0
  105. data/lib/ratatui_ruby/tui/core.rb +13 -2
  106. data/lib/ratatui_ruby/tui/layout_factories.rb +50 -3
  107. data/lib/ratatui_ruby/tui/state_factories.rb +42 -0
  108. data/lib/ratatui_ruby/tui/text_factories.rb +40 -0
  109. data/lib/ratatui_ruby/tui/widget_factories.rb +135 -60
  110. data/lib/ratatui_ruby/tui.rb +22 -1
  111. data/lib/ratatui_ruby/version.rb +1 -1
  112. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +2 -0
  113. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +2 -0
  114. data/lib/ratatui_ruby/widgets/bar_chart.rb +30 -20
  115. data/lib/ratatui_ruby/widgets/block.rb +14 -6
  116. data/lib/ratatui_ruby/widgets/calendar.rb +2 -0
  117. data/lib/ratatui_ruby/widgets/canvas.rb +56 -0
  118. data/lib/ratatui_ruby/widgets/cell.rb +2 -0
  119. data/lib/ratatui_ruby/widgets/center.rb +2 -0
  120. data/lib/ratatui_ruby/widgets/chart.rb +6 -0
  121. data/lib/ratatui_ruby/widgets/clear.rb +2 -0
  122. data/lib/ratatui_ruby/widgets/coerceable_widget.rb +77 -0
  123. data/lib/ratatui_ruby/widgets/cursor.rb +2 -0
  124. data/lib/ratatui_ruby/widgets/gauge.rb +61 -3
  125. data/lib/ratatui_ruby/widgets/line_gauge.rb +66 -4
  126. data/lib/ratatui_ruby/widgets/list.rb +87 -3
  127. data/lib/ratatui_ruby/widgets/list_item.rb +2 -0
  128. data/lib/ratatui_ruby/widgets/overlay.rb +2 -0
  129. data/lib/ratatui_ruby/widgets/paragraph.rb +4 -0
  130. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +2 -0
  131. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +2 -0
  132. data/lib/ratatui_ruby/widgets/row.rb +45 -0
  133. data/lib/ratatui_ruby/widgets/scrollbar.rb +2 -0
  134. data/lib/ratatui_ruby/widgets/shape/label.rb +2 -0
  135. data/lib/ratatui_ruby/widgets/sparkline.rb +21 -13
  136. data/lib/ratatui_ruby/widgets/table.rb +13 -3
  137. data/lib/ratatui_ruby/widgets/tabs.rb +6 -4
  138. data/lib/ratatui_ruby/widgets.rb +1 -0
  139. data/lib/ratatui_ruby.rb +51 -16
  140. data/sig/examples/app_all_events/model/app_model.rbs +23 -0
  141. data/sig/examples/app_all_events/model/event_entry.rbs +15 -8
  142. data/sig/examples/app_all_events/model/timestamp.rbs +1 -1
  143. data/sig/examples/app_all_events/view.rbs +1 -1
  144. data/sig/examples/app_stateful_interaction/app.rbs +5 -5
  145. data/sig/examples/widget_block_demo/app.rbs +6 -6
  146. data/sig/manifest.yaml +5 -0
  147. data/sig/patches/data.rbs +26 -0
  148. data/sig/patches/debugger__.rbs +8 -0
  149. data/sig/ratatui_ruby/buffer/cell.rbs +46 -0
  150. data/sig/ratatui_ruby/buffer.rbs +18 -0
  151. data/sig/ratatui_ruby/cell.rbs +44 -0
  152. data/sig/ratatui_ruby/clear.rbs +18 -0
  153. data/sig/ratatui_ruby/constraint.rbs +26 -0
  154. data/sig/ratatui_ruby/debug.rbs +45 -0
  155. data/sig/ratatui_ruby/draw.rbs +30 -0
  156. data/sig/ratatui_ruby/event.rbs +68 -8
  157. data/sig/ratatui_ruby/frame.rbs +4 -4
  158. data/sig/ratatui_ruby/interfaces.rbs +25 -0
  159. data/sig/ratatui_ruby/layout/constraint.rbs +39 -0
  160. data/sig/ratatui_ruby/layout/layout.rbs +45 -0
  161. data/sig/ratatui_ruby/layout/position.rbs +18 -0
  162. data/sig/ratatui_ruby/layout/rect.rbs +64 -0
  163. data/sig/ratatui_ruby/layout/size.rbs +18 -0
  164. data/sig/ratatui_ruby/output_guard.rbs +23 -0
  165. data/sig/ratatui_ruby/ratatui_ruby.rbs +83 -4
  166. data/sig/ratatui_ruby/rect.rbs +17 -0
  167. data/sig/ratatui_ruby/style/color.rbs +22 -0
  168. data/sig/ratatui_ruby/style/style.rbs +29 -0
  169. data/sig/ratatui_ruby/symbols.rbs +141 -0
  170. data/sig/ratatui_ruby/synthetic_events.rbs +21 -0
  171. data/sig/ratatui_ruby/table_state.rbs +6 -0
  172. data/sig/ratatui_ruby/terminal_lifecycle.rbs +31 -0
  173. data/sig/ratatui_ruby/test_helper/event_injection.rbs +2 -2
  174. data/sig/ratatui_ruby/test_helper/snapshot.rbs +22 -3
  175. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +8 -1
  176. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +7 -3
  177. data/sig/ratatui_ruby/text/line.rbs +27 -0
  178. data/sig/ratatui_ruby/text/span.rbs +23 -0
  179. data/sig/ratatui_ruby/text.rbs +12 -0
  180. data/sig/ratatui_ruby/tui/buffer_factories.rbs +1 -1
  181. data/sig/ratatui_ruby/tui/canvas_factories.rbs +23 -5
  182. data/sig/ratatui_ruby/tui/core.rbs +2 -2
  183. data/sig/ratatui_ruby/tui/layout_factories.rbs +16 -2
  184. data/sig/ratatui_ruby/tui/state_factories.rbs +8 -3
  185. data/sig/ratatui_ruby/tui/style_factories.rbs +3 -1
  186. data/sig/ratatui_ruby/tui/text_factories.rbs +7 -4
  187. data/sig/ratatui_ruby/tui/widget_factories.rbs +123 -30
  188. data/sig/ratatui_ruby/widgets/bar_chart.rbs +95 -0
  189. data/sig/ratatui_ruby/widgets/block.rbs +51 -0
  190. data/sig/ratatui_ruby/widgets/calendar.rbs +45 -0
  191. data/sig/ratatui_ruby/widgets/canvas.rbs +95 -0
  192. data/sig/ratatui_ruby/widgets/chart.rbs +91 -0
  193. data/sig/ratatui_ruby/widgets/coerceable_widget.rbs +26 -0
  194. data/sig/ratatui_ruby/widgets/gauge.rbs +44 -0
  195. data/sig/ratatui_ruby/widgets/line_gauge.rbs +48 -0
  196. data/sig/ratatui_ruby/widgets/list.rbs +63 -0
  197. data/sig/ratatui_ruby/widgets/misc.rbs +158 -0
  198. data/sig/ratatui_ruby/widgets/paragraph.rbs +45 -0
  199. data/sig/ratatui_ruby/widgets/row.rbs +43 -0
  200. data/sig/ratatui_ruby/widgets/scrollbar.rbs +53 -0
  201. data/sig/ratatui_ruby/widgets/shape/label.rbs +37 -0
  202. data/sig/ratatui_ruby/widgets/sparkline.rbs +45 -0
  203. data/sig/ratatui_ruby/widgets/table.rbs +78 -0
  204. data/sig/ratatui_ruby/widgets/tabs.rbs +44 -0
  205. data/sig/ratatui_ruby/{schema/list_item.rbs → widgets.rbs} +4 -4
  206. data/tasks/steep.rake +11 -0
  207. metadata +82 -63
  208. data/doc/contributors/v1.0.0_blockers.md +0 -876
  209. data/doc/troubleshooting/debugging.md +0 -101
  210. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +0 -47
  211. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +0 -25
  212. data/lib/ratatui_ruby/schema/bar_chart.rb +0 -287
  213. data/lib/ratatui_ruby/schema/block.rb +0 -198
  214. data/lib/ratatui_ruby/schema/calendar.rb +0 -84
  215. data/lib/ratatui_ruby/schema/canvas.rb +0 -239
  216. data/lib/ratatui_ruby/schema/center.rb +0 -67
  217. data/lib/ratatui_ruby/schema/chart.rb +0 -159
  218. data/lib/ratatui_ruby/schema/clear.rb +0 -62
  219. data/lib/ratatui_ruby/schema/constraint.rb +0 -151
  220. data/lib/ratatui_ruby/schema/cursor.rb +0 -50
  221. data/lib/ratatui_ruby/schema/gauge.rb +0 -72
  222. data/lib/ratatui_ruby/schema/layout.rb +0 -122
  223. data/lib/ratatui_ruby/schema/line_gauge.rb +0 -80
  224. data/lib/ratatui_ruby/schema/list.rb +0 -135
  225. data/lib/ratatui_ruby/schema/list_item.rb +0 -51
  226. data/lib/ratatui_ruby/schema/overlay.rb +0 -51
  227. data/lib/ratatui_ruby/schema/paragraph.rb +0 -107
  228. data/lib/ratatui_ruby/schema/ratatui_logo.rb +0 -31
  229. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +0 -36
  230. data/lib/ratatui_ruby/schema/rect.rb +0 -174
  231. data/lib/ratatui_ruby/schema/row.rb +0 -76
  232. data/lib/ratatui_ruby/schema/scrollbar.rb +0 -143
  233. data/lib/ratatui_ruby/schema/shape/label.rb +0 -76
  234. data/lib/ratatui_ruby/schema/sparkline.rb +0 -142
  235. data/lib/ratatui_ruby/schema/style.rb +0 -97
  236. data/lib/ratatui_ruby/schema/table.rb +0 -141
  237. data/lib/ratatui_ruby/schema/tabs.rb +0 -85
  238. data/lib/ratatui_ruby/schema/text.rb +0 -217
  239. data/sig/examples/app_all_events/model/events.rbs +0 -15
  240. data/sig/examples/app_all_events/view_state.rbs +0 -21
  241. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +0 -22
  242. data/sig/ratatui_ruby/schema/bar_chart/bar_group.rbs +0 -19
  243. data/sig/ratatui_ruby/schema/bar_chart.rbs +0 -38
  244. data/sig/ratatui_ruby/schema/block.rbs +0 -18
  245. data/sig/ratatui_ruby/schema/calendar.rbs +0 -23
  246. data/sig/ratatui_ruby/schema/canvas.rbs +0 -81
  247. data/sig/ratatui_ruby/schema/center.rbs +0 -17
  248. data/sig/ratatui_ruby/schema/chart.rbs +0 -39
  249. data/sig/ratatui_ruby/schema/constraint.rbs +0 -22
  250. data/sig/ratatui_ruby/schema/cursor.rbs +0 -16
  251. data/sig/ratatui_ruby/schema/draw.rbs +0 -33
  252. data/sig/ratatui_ruby/schema/gauge.rbs +0 -23
  253. data/sig/ratatui_ruby/schema/layout.rbs +0 -27
  254. data/sig/ratatui_ruby/schema/line_gauge.rbs +0 -24
  255. data/sig/ratatui_ruby/schema/list.rbs +0 -28
  256. data/sig/ratatui_ruby/schema/overlay.rbs +0 -15
  257. data/sig/ratatui_ruby/schema/paragraph.rbs +0 -20
  258. data/sig/ratatui_ruby/schema/ratatui_logo.rbs +0 -14
  259. data/sig/ratatui_ruby/schema/ratatui_mascot.rbs +0 -17
  260. data/sig/ratatui_ruby/schema/rect.rbs +0 -48
  261. data/sig/ratatui_ruby/schema/row.rbs +0 -28
  262. data/sig/ratatui_ruby/schema/scrollbar.rbs +0 -42
  263. data/sig/ratatui_ruby/schema/sparkline.rbs +0 -22
  264. data/sig/ratatui_ruby/schema/style.rbs +0 -19
  265. data/sig/ratatui_ruby/schema/table.rbs +0 -32
  266. data/sig/ratatui_ruby/schema/tabs.rbs +0 -21
  267. data/sig/ratatui_ruby/schema/text.rbs +0 -31
  268. /data/lib/ratatui_ruby/{schema/draw.rb → draw.rb} +0 -0
@@ -0,0 +1,215 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ ##
10
+ # Debug mode control for RatatuiRuby.
11
+ #
12
+ # TUI applications are hard to debug. Rust panics show cryptic stack traces.
13
+ # Ruby exceptions lack Rust context.
14
+ #
15
+ # This module controls debug visibility. Enable Rust backtraces only, or
16
+ # enable full debug mode for both Rust and Ruby-side features.
17
+ #
18
+ # == Activation Methods
19
+ #
20
+ # Three ways to enable debug features:
21
+ #
22
+ # [<tt>RUST_BACKTRACE=1</tt>] Rust backtraces only (no Ruby-side debug).
23
+ # [<tt>RR_DEBUG=1</tt>] Full debug mode (backtraces + Ruby features).
24
+ # [<tt>include RatatuiRuby::TestHelper</tt>] Auto-enables debug mode.
25
+ #
26
+ # === Example
27
+ #
28
+ #--
29
+ # SPDX-SnippetBegin
30
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
31
+ # SPDX-License-Identifier: MIT-0
32
+ #++
33
+ # # Programmatic activation
34
+ # RatatuiRuby::Debug.enable!
35
+ #
36
+ # # Or use the convenience alias
37
+ # RatatuiRuby.debug_mode!
38
+ #
39
+ #--
40
+ # SPDX-SnippetEnd
41
+ #++
42
+ module Debug
43
+ @rust_backtrace_enabled = false
44
+ @debug_mode_enabled = false
45
+
46
+ class << self
47
+ ##
48
+ # Enables Rust backtraces only.
49
+ #
50
+ # Call this to get meaningful stack traces when Rust panics.
51
+ # Does not enable Ruby-side debug features.
52
+ #
53
+ # Safe to call multiple times; subsequent calls are no-ops.
54
+ def enable_rust_backtrace!
55
+ return if @rust_backtrace_enabled
56
+
57
+ @rust_backtrace_enabled = true
58
+ RatatuiRuby.__send__(:_enable_rust_backtrace)
59
+ end
60
+
61
+ ##
62
+ # Enables full debug mode.
63
+ #
64
+ # Activates Rust backtraces plus any Ruby-side debug features.
65
+ # Optionally enables remote debugging via the debug gem.
66
+ #
67
+ # Safe to call multiple times; subsequent calls are no-ops.
68
+ #
69
+ # [source] <tt>:env</tt> if called from RR_DEBUG env var,
70
+ #--
71
+ # SPDX-SnippetBegin
72
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
73
+ # SPDX-License-Identifier: MIT-0
74
+ #++
75
+ # <tt>:test</tt> from TestHelper (skips remote debugging),
76
+ # <tt>:programmatic</tt> otherwise.
77
+ #--
78
+ # SPDX-SnippetEnd
79
+ #++
80
+ def enable!(source: :programmatic)
81
+ return @socket_path if @debug_mode_enabled
82
+
83
+ @debug_mode_enabled = true
84
+ enable_rust_backtrace!
85
+
86
+ # Tests don't need remote debugging — it would cause hangs
87
+ return if source == :test
88
+
89
+ @remote_debugging_mode = (source == :env) ? :open : :open_nonstop
90
+ @socket_path = enable_remote_debugging!
91
+ end
92
+
93
+ # rubocop:disable Lint/Debugger -- intentional debug gem integration
94
+ private def enable_remote_debugging!
95
+ # Suppress the "Debugger can attach via..." message that corrupts TUI displays
96
+ # Only suppress for programmatic activation; RR_DEBUG=1 users need to see it
97
+ old_log_level = ENV["RUBY_DEBUG_LOG_LEVEL"]
98
+ ENV["RUBY_DEBUG_LOG_LEVEL"] = "ERROR" if @remote_debugging_mode == :open_nonstop
99
+
100
+ case @remote_debugging_mode
101
+ when :open
102
+ # Stop at load so user can read socket path before TUI enters raw mode
103
+ ENV["RUBY_DEBUG_STOP_AT_LOAD"] = "1"
104
+ require "debug/open"
105
+ when :open_nonstop
106
+ require "debug/open_nonstop"
107
+ end
108
+
109
+ # Restore log level after require (the require is what prints the message)
110
+ ENV["RUBY_DEBUG_LOG_LEVEL"] = old_log_level if @remote_debugging_mode == :open_nonstop
111
+
112
+ # Return the socket path so apps can display it
113
+ ::DEBUGGER__.create_unix_domain_socket_name
114
+ rescue NameError
115
+ # Windows uses TCP/IP, not Unix sockets — DEBUGGER__ might not have this method
116
+ nil
117
+ # rubocop:enable Lint/Debugger
118
+ rescue LoadError
119
+ return unless @remote_debugging_mode == :open
120
+
121
+ raise LoadError,
122
+ "RR_DEBUG=1 requires the 'debug' gem for remote debugging. " \
123
+ "Add `gem 'debug'` to your Gemfile or install it with `gem install debug`."
124
+ end
125
+
126
+ ##
127
+ # Returns whether full debug mode is enabled.
128
+ public def enabled?
129
+ @debug_mode_enabled
130
+ end
131
+
132
+ ##
133
+ # Returns whether Rust backtraces are enabled.
134
+ public def rust_backtrace_enabled?
135
+ @rust_backtrace_enabled
136
+ end
137
+
138
+ ##
139
+ # Returns the remote debugging mode for debug gem integration.
140
+ #
141
+ # TUI apps run in raw terminal mode, making interactive debugging
142
+ # impossible. The debug gem's remote debugging feature lets you
143
+ # attach from another terminal via UNIX socket.
144
+ #
145
+ # Returns one of:
146
+ # <tt>:open</tt> Stop at program start, wait for debugger attach.
147
+ # Activated when <tt>RR_DEBUG=1</tt> is set at startup.
148
+ # <tt>:open_nonstop</tt> Continue running, attach whenever ready.
149
+ # Activated when <tt>enable!</tt> is called programmatically.
150
+ # <tt>nil</tt> No remote debugging configured.
151
+ public def remote_debugging_mode
152
+ @remote_debugging_mode
153
+ end
154
+
155
+ ##
156
+ # Triggers a Rust panic for backtrace verification.
157
+ #
158
+ # Debugging TUI apps is hard. Rust errors lack context. You want to
159
+ # confirm <tt>RUST_BACKTRACE=1</tt> actually shows stack traces before
160
+ # hitting a real bug.
161
+ #
162
+ # This method deliberately panics. The panic hook catches it and prints
163
+ # the Rust backtrace to stderr. If you see stack frames, your setup works.
164
+ #
165
+ # <b>WARNING</b>: Crashes your process. Use only for debugging.
166
+ #
167
+ # === Example
168
+ #
169
+ #--
170
+ # SPDX-SnippetBegin
171
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
172
+ # SPDX-License-Identifier: MIT-0
173
+ #++
174
+ # RUST_BACKTRACE=1 ruby -e 'require "ratatui_ruby"; RatatuiRuby::Debug.test_panic!'
175
+ #--
176
+ # SPDX-SnippetEnd
177
+ #++
178
+ public def test_panic!
179
+ RatatuiRuby.__send__(:_test_panic)
180
+ end
181
+
182
+ ##
183
+ # Temporarily suppresses Ruby-side debug mode checks.
184
+ #
185
+ # Rust backtraces remain enabled if previously activated; only
186
+ # Ruby-side features (like unknown-key errors) are suppressed
187
+ # within the block.
188
+ #
189
+ # === Example
190
+ #
191
+ #--
192
+ # SPDX-SnippetBegin
193
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
194
+ # SPDX-License-Identifier: MIT-0
195
+ #++
196
+ # RatatuiRuby::Debug.suppress_debug_mode do
197
+ # tui.table({ unknown_key: 1 }) # Does not raise
198
+ # end
199
+ #--
200
+ # SPDX-SnippetEnd
201
+ #++
202
+ public def suppress_debug_mode
203
+ old_value = @debug_mode_enabled
204
+ @debug_mode_enabled = false
205
+ yield
206
+ ensure
207
+ @debug_mode_enabled = old_value
208
+ end
209
+ end
210
+ end
211
+ end
212
+
213
+ # Auto-enable based on environment variables
214
+ RatatuiRuby::Debug.enable_rust_backtrace! if ENV["RUST_BACKTRACE"]
215
+ RatatuiRuby::Debug.enable!(source: :env) if ENV["RR_DEBUG"]
@@ -330,9 +330,10 @@ module RatatuiRuby
330
330
  #--
331
331
  # SPDX-SnippetEnd
332
332
  #++
333
- def method_missing(name, *args, &block)
333
+ def method_missing(name, *args, **kwargs, &block)
334
334
  if name.to_s.end_with?("?")
335
- key_name = name.to_s[0...-1]
335
+ name_str = name.to_s
336
+ key_name = name_str.chop # Returns String, never nil for non-empty string
336
337
  key_sym = key_name.to_sym
337
338
 
338
339
  # Fast path: Exact match (e.g., media_pause? for media_pause)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ #--
4
+ # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
5
+ # SPDX-License-Identifier: LGPL-3.0-or-later
6
+ #++
7
+
8
+ module RatatuiRuby
9
+ class Event
10
+ # Synthetic event for synchronizing async operations in tests.
11
+ #
12
+ # Testing async behavior is tricky. You inject an event, but results arrive
13
+ # later. By the time you assert, the async work may not have completed.
14
+ #
15
+ # When a runtime (Tea, Kit) encounters this event, it should wait for all
16
+ # pending async operations to complete before processing the next event.
17
+ # This enables deterministic testing without changing production code paths.
18
+ #
19
+ # Inject this event between user actions and assertions to ensure async
20
+ # results have been processed:
21
+ #
22
+ # === Example
23
+ #
24
+ #--
25
+ # SPDX-SnippetBegin
26
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
27
+ # SPDX-License-Identifier: MIT-0
28
+ #++
29
+ # inject_key("s") # Triggers async command
30
+ # inject_sync # Wait for command to complete
31
+ # inject_key(:q) # Quit after seeing results
32
+ # Tea.run(...)
33
+ # assert_snapshots("after_s_with_results")
34
+ #
35
+ #--
36
+ # SPDX-SnippetEnd
37
+ #++
38
+ # This is not "test mode"—it's a real event that runtimes handle.
39
+ # Production apps could use it too (e.g., "ensure saves complete before quit").
40
+ class Sync < Event
41
+ # Returns true for Sync events.
42
+ def sync?
43
+ true
44
+ end
45
+
46
+ # Deconstructs the event for pattern matching.
47
+ def deconstruct_keys(keys)
48
+ { type: :sync }
49
+ end
50
+ end
51
+ end
52
+ end
@@ -111,9 +111,14 @@ module RatatuiRuby
111
111
  false
112
112
  end
113
113
 
114
+ # Returns true if this is a Sync event.
115
+ def sync?
116
+ false
117
+ end
118
+
114
119
  # Responds to dynamic predicate methods for key checks.
115
120
  # All non-Key events return false for any key predicate.
116
- def method_missing(name, *args, &block)
121
+ def method_missing(name, *args, **kwargs, &block)
117
122
  if name.to_s.end_with?("?")
118
123
  false
119
124
  else
@@ -155,3 +160,4 @@ require_relative "event/resize"
155
160
  require_relative "event/paste"
156
161
  require_relative "event/focus_gained"
157
162
  require_relative "event/focus_lost"
163
+ require_relative "event/sync"
@@ -148,6 +148,190 @@ module RatatuiRuby
148
148
  def self.ratio(numerator, denominator)
149
149
  new(type: :ratio, value: [Integer(numerator), Integer(denominator)])
150
150
  end
151
+
152
+ # Converts an array of lengths into an array of Length constraints.
153
+ #
154
+ # Complex layouts often use multiple fixed-size sections. Manually creating each constraint
155
+ # clutters the code.
156
+ #
157
+ # This method maps over the input, returning a constraint array in one call.
158
+ #
159
+ # === Example
160
+ #
161
+ #--
162
+ # SPDX-SnippetBegin
163
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
164
+ # SPDX-License-Identifier: MIT-0
165
+ #++
166
+ # Constraint.from_lengths([10, 20, 10])
167
+ # # => [Constraint.length(10), Constraint.length(20), Constraint.length(10)]
168
+ #
169
+ #--
170
+ # SPDX-SnippetEnd
171
+ #++
172
+ # [values] Enumerable of Integers.
173
+ def self.from_lengths(values)
174
+ values.map { |v| length(v) }
175
+ end
176
+
177
+ # Converts an array of percentages into an array of Percentage constraints.
178
+ #
179
+ # Percentage-based layouts distribute space proportionally. This method batches the creation.
180
+ #
181
+ # === Example
182
+ #
183
+ #--
184
+ # SPDX-SnippetBegin
185
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
186
+ # SPDX-License-Identifier: MIT-0
187
+ #++
188
+ # Constraint.from_percentages([25, 50, 25])
189
+ # # => [Constraint.percentage(25), Constraint.percentage(50), Constraint.percentage(25)]
190
+ #
191
+ #--
192
+ # SPDX-SnippetEnd
193
+ #++
194
+ # [values] Enumerable of Integers (0-100).
195
+ def self.from_percentages(values)
196
+ values.map { |v| percentage(v) }
197
+ end
198
+
199
+ # Converts an array of minimums into an array of Min constraints.
200
+ #
201
+ # Minimum constraints ensure sections never shrink below a threshold. Batch them here.
202
+ #
203
+ # === Example
204
+ #
205
+ #--
206
+ # SPDX-SnippetBegin
207
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
208
+ # SPDX-License-Identifier: MIT-0
209
+ #++
210
+ # Constraint.from_mins([5, 10, 5])
211
+ # # => [Constraint.min(5), Constraint.min(10), Constraint.min(5)]
212
+ #
213
+ #--
214
+ # SPDX-SnippetEnd
215
+ #++
216
+ # [values] Enumerable of Integers.
217
+ def self.from_mins(values)
218
+ values.map { |v| min(v) }
219
+ end
220
+
221
+ # Converts an array of maximums into an array of Max constraints.
222
+ #
223
+ # Maximum constraints cap section sizes. Batch them here.
224
+ #
225
+ # === Example
226
+ #
227
+ #--
228
+ # SPDX-SnippetBegin
229
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
230
+ # SPDX-License-Identifier: MIT-0
231
+ #++
232
+ # Constraint.from_maxes([20, 30, 40])
233
+ # # => [Constraint.max(20), Constraint.max(30), Constraint.max(40)]
234
+ #
235
+ #--
236
+ # SPDX-SnippetEnd
237
+ #++
238
+ # [values] Enumerable of Integers.
239
+ def self.from_maxes(values)
240
+ values.map { |v| max(v) }
241
+ end
242
+
243
+ # Converts an array of weights into an array of Fill constraints.
244
+ #
245
+ # Fill constraints distribute remaining space by weight. Batch them here.
246
+ #
247
+ # === Example
248
+ #
249
+ #--
250
+ # SPDX-SnippetBegin
251
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
252
+ # SPDX-License-Identifier: MIT-0
253
+ #++
254
+ # Constraint.from_fills([1, 2, 1])
255
+ # # => [Constraint.fill(1), Constraint.fill(2), Constraint.fill(1)]
256
+ #
257
+ #--
258
+ # SPDX-SnippetEnd
259
+ #++
260
+ # [values] Enumerable of Integers.
261
+ def self.from_fills(values)
262
+ values.map { |v| fill(v) }
263
+ end
264
+
265
+ # Converts an array of ratio pairs into an array of Ratio constraints.
266
+ #
267
+ # Ratio constraints define exact fractions of space. Batch them here.
268
+ #
269
+ # === Example
270
+ #
271
+ #--
272
+ # SPDX-SnippetBegin
273
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
274
+ # SPDX-License-Identifier: MIT-0
275
+ #++
276
+ # Constraint.from_ratios([[1, 4], [2, 4], [1, 4]])
277
+ # # => [Constraint.ratio(1, 4), Constraint.ratio(2, 4), Constraint.ratio(1, 4)]
278
+ #
279
+ #--
280
+ # SPDX-SnippetEnd
281
+ #++
282
+ # [pairs] Enumerable of <tt>[numerator, denominator]</tt> arrays.
283
+ def self.from_ratios(pairs)
284
+ pairs.map { |n, d| ratio(n, d) }
285
+ end
286
+
287
+ # Computes the size this constraint would produce given available space.
288
+ #
289
+ # Layout engines use constraints to compute actual dimensions.
290
+ # Calling apply lets you preview the result without rendering.
291
+ #
292
+ # === Example
293
+ #
294
+ #--
295
+ # SPDX-SnippetBegin
296
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
297
+ # SPDX-License-Identifier: MIT-0
298
+ #++
299
+ # Constraint.percentage(50).apply(100) # => 50
300
+ # Constraint.length(10).apply(100) # => 10
301
+ # Constraint.min(10).apply(5) # => 10
302
+ # Constraint.max(10).apply(15) # => 10
303
+ # Constraint.ratio(1, 4).apply(100) # => 25
304
+ #--
305
+ # SPDX-SnippetEnd
306
+ #++
307
+ #
308
+ # [length] Available space (Integer).
309
+ #
310
+ # Returns the computed size (Integer).
311
+ def apply(length)
312
+ length = Integer(length)
313
+ case type
314
+ when :length
315
+ value
316
+ when :percentage
317
+ (length * value) / 100
318
+ when :min
319
+ [value, length].max
320
+ when :max
321
+ [value, length].min
322
+ when :fill
323
+ length
324
+ when :ratio
325
+ numerator, denominator = value
326
+ denominator.zero? ? 0 : (length * numerator) / denominator
327
+ else
328
+ length
329
+ end
330
+ end
331
+
332
+ # Ruby-idiomatic alias (TIMTOWTDI)
333
+ # Allows proc-like invocation: constraint.(100)
334
+ alias call apply
151
335
  end
152
336
  end
153
337
  end
@@ -22,7 +22,7 @@ module RatatuiRuby
22
22
  # Run the interactive demo from the terminal:
23
23
  #
24
24
  # ruby examples/widget_layout_split/app.rb
25
- class Layout < Data.define(:direction, :constraints, :children, :flex)
25
+ class Layout < Data.define(:direction, :constraints, :children, :flex, :margin, :spacing)
26
26
  ##
27
27
  # :attr_reader: direction
28
28
  # Direction of the split.
@@ -81,6 +81,23 @@ module RatatuiRuby
81
81
  # Flex: space evenly between elements.
82
82
  FLEX_SPACE_EVENLY = :space_evenly
83
83
 
84
+ ##
85
+ # :attr_reader: margin
86
+ # Margin around the layout area.
87
+ #
88
+ # Either a single <tt>Integer</tt> for uniform margin on all sides, or a
89
+ # <tt>Hash</tt> with <tt>:horizontal</tt> and <tt>:vertical</tt> keys.
90
+ #
91
+ # layout.margin # => 2
92
+
93
+ ##
94
+ # :attr_reader: spacing
95
+ # Gap between segments (in cells).
96
+ #
97
+ # A positive integer that specifies the number of cells between each segment.
98
+ #
99
+ # layout.spacing # => 1
100
+
84
101
  # Creates a new Layout.
85
102
  #
86
103
  # [direction]
@@ -91,7 +108,11 @@ module RatatuiRuby
91
108
  # List of widgets to render (optional).
92
109
  # [flex]
93
110
  # Flex mode for spacing (default: <tt>:legacy</tt>).
94
- def initialize(direction: :vertical, constraints: [], children: [], flex: :legacy)
111
+ # [margin]
112
+ # Edge margin in cells (default: <tt>0</tt>).
113
+ # [spacing]
114
+ # Gap between segments in cells (default: <tt>0</tt>).
115
+ def initialize(direction: :vertical, constraints: [], children: [], flex: :legacy, margin: 0, spacing: 0)
95
116
  super
96
117
  end
97
118
 
@@ -134,20 +155,105 @@ module RatatuiRuby
134
155
  #++
135
156
  # Returns an Array of <tt>Rect</tt> objects.
136
157
  def self.split(area, direction: :vertical, constraints:, flex: :legacy)
137
- # Duck-typing: If it lacks geometry methods but can be a Hash, convert it.
138
- if !area.respond_to?(:x) && area.respond_to?(:to_h)
139
- # Assume it's a Hash-like object with :x, :y, etc.
140
- hash = area.to_h
141
- area = Rect.new(
142
- x: hash.fetch(:x, 0),
143
- y: hash.fetch(:y, 0),
144
- width: hash.fetch(:width, 0),
145
- height: hash.fetch(:height, 0)
146
- )
158
+ # Coerce area to Rect for type safety (supports duck typing via _RectLike interface)
159
+ rect = case area
160
+ when Rect
161
+ area
162
+ when Hash
163
+ Rect.new(
164
+ x: Integer(area.fetch(:x, 0)),
165
+ y: Integer(area.fetch(:y, 0)),
166
+ width: Integer(area.fetch(:width, 0)),
167
+ height: Integer(area.fetch(:height, 0))
168
+ )
169
+ else
170
+ # Duck typing: accept any object responding to x, y, width, height
171
+ if area.respond_to?(:x) && area.respond_to?(:y) && area.respond_to?(:width) && area.respond_to?(:height)
172
+ # @type var rect_like: _RectLike
173
+ rect_like = area
174
+ Rect.new(x: rect_like.x, y: rect_like.y, width: rect_like.width, height: rect_like.height)
175
+ else
176
+ raise ArgumentError, "area must be a Rect, Hash, or respond to x/y/width/height, got #{area.class}"
177
+ end
147
178
  end
148
- raw_rects = _split(area, direction, constraints, flex)
179
+ raw_rects = _split(rect, direction, constraints, flex)
149
180
  raw_rects.map { |r| Rect.new(x: r[:x], y: r[:y], width: r[:width], height: r[:height]) }
150
181
  end
182
+
183
+ # Splits an area into multiple rectangles, returning both segments and spacers.
184
+ #
185
+ # Layout splitting returns only the content areas. But some designs need to
186
+ # render content in the gaps (dividers, separators, decorations).
187
+ #
188
+ # This method returns both the segments (content areas) and the spacers
189
+ # (gaps between segments) as separate arrays. The spacers are the Rects
190
+ # that represent the spacing between each segment.
191
+ #
192
+ # Use it to render custom separators or to calculate layout with spacing.
193
+ #
194
+ # [area]
195
+ # The area to split. Can be a <tt>Rect</tt> or a <tt>Hash</tt> containing <tt>:x</tt>, <tt>:y</tt>, <tt>:width</tt>, and <tt>:height</tt>.
196
+ # [direction]
197
+ # <tt>:vertical</tt> or <tt>:horizontal</tt> (default: <tt>:vertical</tt>).
198
+ # [constraints]
199
+ # Array of <tt>Constraint</tt> objects defining section sizes.
200
+ # [flex]
201
+ #--
202
+ # SPDX-SnippetBegin
203
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
204
+ # SPDX-License-Identifier: MIT-0
205
+ #++
206
+ # Flex mode for spacing (default: <tt>:legacy</tt>).
207
+ #
208
+ #--
209
+ # SPDX-SnippetEnd
210
+ #++
211
+ # Returns an Array of two Arrays: <tt>[segments, spacers]</tt>, each containing <tt>Rect</tt> objects.
212
+ #
213
+ # === Example
214
+ #
215
+ #--
216
+ # SPDX-SnippetBegin
217
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
218
+ # SPDX-License-Identifier: MIT-0
219
+ #++
220
+ # area = Rect.new(x: 0, y: 0, width: 100, height: 10)
221
+ # segments, spacers = Layout.split_with_spacers(
222
+ # area,
223
+ # direction: :horizontal,
224
+ # constraints: [Constraint.length(40), Constraint.length(40)],
225
+ # flex: :space_around
226
+ # )
227
+ # # segments: 2 Rects for content
228
+ # # spacers: Rects for gaps between/around segments
229
+ #--
230
+ # SPDX-SnippetEnd
231
+ #++
232
+ def self.split_with_spacers(area, direction: :vertical, constraints:, flex: :legacy)
233
+ # Coerce area to Rect for type safety
234
+ rect = case area
235
+ when Rect
236
+ area
237
+ when Hash
238
+ Rect.new(
239
+ x: Integer(area.fetch(:x, 0)),
240
+ y: Integer(area.fetch(:y, 0)),
241
+ width: Integer(area.fetch(:width, 0)),
242
+ height: Integer(area.fetch(:height, 0))
243
+ )
244
+ else
245
+ if area.respond_to?(:x) && area.respond_to?(:y) && area.respond_to?(:width) && area.respond_to?(:height)
246
+ rect_like = area
247
+ Rect.new(x: rect_like.x, y: rect_like.y, width: rect_like.width, height: rect_like.height)
248
+ else
249
+ raise ArgumentError, "area must be a Rect, Hash, or respond to x/y/width/height, got #{area.class}"
250
+ end
251
+ end
252
+ raw_segments, raw_spacers = _split_with_spacers(rect, direction, constraints, flex)
253
+ segments = raw_segments.map { |r| Rect.new(x: r[:x], y: r[:y], width: r[:width], height: r[:height]) }
254
+ spacers = raw_spacers.map { |r| Rect.new(x: r[:x], y: r[:y], width: r[:width], height: r[:height]) }
255
+ [segments, spacers]
256
+ end
151
257
  end
152
258
  end
153
259
  end