ratatui_ruby 0.9.1 → 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 (267) 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 +98 -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 +10 -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 +15 -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.rb +1 -1
  85. data/lib/ratatui_ruby/layout/constraint.rb +49 -0
  86. data/lib/ratatui_ruby/layout/layout.rb +119 -13
  87. data/lib/ratatui_ruby/layout/position.rb +55 -0
  88. data/lib/ratatui_ruby/layout/rect.rb +188 -0
  89. data/lib/ratatui_ruby/layout/size.rb +55 -0
  90. data/lib/ratatui_ruby/layout.rb +4 -0
  91. data/lib/ratatui_ruby/style/color.rb +149 -0
  92. data/lib/ratatui_ruby/style/style.rb +51 -4
  93. data/lib/ratatui_ruby/style.rb +2 -0
  94. data/lib/ratatui_ruby/symbols.rb +435 -0
  95. data/lib/ratatui_ruby/synthetic_events.rb +1 -1
  96. data/lib/ratatui_ruby/table_state.rb +51 -0
  97. data/lib/ratatui_ruby/terminal_lifecycle.rb +2 -1
  98. data/lib/ratatui_ruby/test_helper/event_injection.rb +6 -1
  99. data/lib/ratatui_ruby/test_helper.rb +9 -0
  100. data/lib/ratatui_ruby/text/line.rb +245 -0
  101. data/lib/ratatui_ruby/text/span.rb +158 -0
  102. data/lib/ratatui_ruby/text.rb +99 -0
  103. data/lib/ratatui_ruby/tui/canvas_factories.rb +103 -0
  104. data/lib/ratatui_ruby/tui/core.rb +13 -2
  105. data/lib/ratatui_ruby/tui/layout_factories.rb +50 -3
  106. data/lib/ratatui_ruby/tui/state_factories.rb +42 -0
  107. data/lib/ratatui_ruby/tui/text_factories.rb +40 -0
  108. data/lib/ratatui_ruby/tui/widget_factories.rb +135 -60
  109. data/lib/ratatui_ruby/tui.rb +22 -1
  110. data/lib/ratatui_ruby/version.rb +1 -1
  111. data/lib/ratatui_ruby/widgets/bar_chart/bar.rb +2 -0
  112. data/lib/ratatui_ruby/widgets/bar_chart/bar_group.rb +2 -0
  113. data/lib/ratatui_ruby/widgets/bar_chart.rb +30 -20
  114. data/lib/ratatui_ruby/widgets/block.rb +14 -6
  115. data/lib/ratatui_ruby/widgets/calendar.rb +2 -0
  116. data/lib/ratatui_ruby/widgets/canvas.rb +56 -0
  117. data/lib/ratatui_ruby/widgets/cell.rb +2 -0
  118. data/lib/ratatui_ruby/widgets/center.rb +2 -0
  119. data/lib/ratatui_ruby/widgets/chart.rb +6 -0
  120. data/lib/ratatui_ruby/widgets/clear.rb +2 -0
  121. data/lib/ratatui_ruby/widgets/coerceable_widget.rb +77 -0
  122. data/lib/ratatui_ruby/widgets/cursor.rb +2 -0
  123. data/lib/ratatui_ruby/widgets/gauge.rb +61 -3
  124. data/lib/ratatui_ruby/widgets/line_gauge.rb +66 -4
  125. data/lib/ratatui_ruby/widgets/list.rb +87 -3
  126. data/lib/ratatui_ruby/widgets/list_item.rb +2 -0
  127. data/lib/ratatui_ruby/widgets/overlay.rb +2 -0
  128. data/lib/ratatui_ruby/widgets/paragraph.rb +4 -0
  129. data/lib/ratatui_ruby/widgets/ratatui_logo.rb +2 -0
  130. data/lib/ratatui_ruby/widgets/ratatui_mascot.rb +2 -0
  131. data/lib/ratatui_ruby/widgets/row.rb +45 -0
  132. data/lib/ratatui_ruby/widgets/scrollbar.rb +2 -0
  133. data/lib/ratatui_ruby/widgets/shape/label.rb +2 -0
  134. data/lib/ratatui_ruby/widgets/sparkline.rb +21 -13
  135. data/lib/ratatui_ruby/widgets/table.rb +13 -3
  136. data/lib/ratatui_ruby/widgets/tabs.rb +6 -4
  137. data/lib/ratatui_ruby/widgets.rb +1 -0
  138. data/lib/ratatui_ruby.rb +40 -9
  139. data/sig/examples/app_all_events/model/app_model.rbs +23 -0
  140. data/sig/examples/app_all_events/model/event_entry.rbs +15 -8
  141. data/sig/examples/app_all_events/model/timestamp.rbs +1 -1
  142. data/sig/examples/app_all_events/view.rbs +1 -1
  143. data/sig/examples/app_stateful_interaction/app.rbs +5 -5
  144. data/sig/examples/widget_block_demo/app.rbs +6 -6
  145. data/sig/manifest.yaml +5 -0
  146. data/sig/patches/data.rbs +26 -0
  147. data/sig/patches/debugger__.rbs +8 -0
  148. data/sig/ratatui_ruby/buffer/cell.rbs +46 -0
  149. data/sig/ratatui_ruby/buffer.rbs +18 -0
  150. data/sig/ratatui_ruby/cell.rbs +44 -0
  151. data/sig/ratatui_ruby/clear.rbs +18 -0
  152. data/sig/ratatui_ruby/constraint.rbs +26 -0
  153. data/sig/ratatui_ruby/debug.rbs +45 -0
  154. data/sig/ratatui_ruby/draw.rbs +30 -0
  155. data/sig/ratatui_ruby/event.rbs +68 -8
  156. data/sig/ratatui_ruby/frame.rbs +4 -4
  157. data/sig/ratatui_ruby/interfaces.rbs +25 -0
  158. data/sig/ratatui_ruby/layout/constraint.rbs +39 -0
  159. data/sig/ratatui_ruby/layout/layout.rbs +45 -0
  160. data/sig/ratatui_ruby/layout/position.rbs +18 -0
  161. data/sig/ratatui_ruby/layout/rect.rbs +64 -0
  162. data/sig/ratatui_ruby/layout/size.rbs +18 -0
  163. data/sig/ratatui_ruby/output_guard.rbs +23 -0
  164. data/sig/ratatui_ruby/ratatui_ruby.rbs +83 -4
  165. data/sig/ratatui_ruby/rect.rbs +17 -0
  166. data/sig/ratatui_ruby/style/color.rbs +22 -0
  167. data/sig/ratatui_ruby/style/style.rbs +29 -0
  168. data/sig/ratatui_ruby/symbols.rbs +141 -0
  169. data/sig/ratatui_ruby/synthetic_events.rbs +21 -0
  170. data/sig/ratatui_ruby/table_state.rbs +6 -0
  171. data/sig/ratatui_ruby/terminal_lifecycle.rbs +31 -0
  172. data/sig/ratatui_ruby/test_helper/event_injection.rbs +2 -2
  173. data/sig/ratatui_ruby/test_helper/snapshot.rbs +22 -3
  174. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +8 -1
  175. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +7 -3
  176. data/sig/ratatui_ruby/text/line.rbs +27 -0
  177. data/sig/ratatui_ruby/text/span.rbs +23 -0
  178. data/sig/ratatui_ruby/text.rbs +12 -0
  179. data/sig/ratatui_ruby/tui/buffer_factories.rbs +1 -1
  180. data/sig/ratatui_ruby/tui/canvas_factories.rbs +23 -5
  181. data/sig/ratatui_ruby/tui/core.rbs +2 -2
  182. data/sig/ratatui_ruby/tui/layout_factories.rbs +16 -2
  183. data/sig/ratatui_ruby/tui/state_factories.rbs +8 -3
  184. data/sig/ratatui_ruby/tui/style_factories.rbs +3 -1
  185. data/sig/ratatui_ruby/tui/text_factories.rbs +7 -4
  186. data/sig/ratatui_ruby/tui/widget_factories.rbs +123 -30
  187. data/sig/ratatui_ruby/widgets/bar_chart.rbs +95 -0
  188. data/sig/ratatui_ruby/widgets/block.rbs +51 -0
  189. data/sig/ratatui_ruby/widgets/calendar.rbs +45 -0
  190. data/sig/ratatui_ruby/widgets/canvas.rbs +95 -0
  191. data/sig/ratatui_ruby/widgets/chart.rbs +91 -0
  192. data/sig/ratatui_ruby/widgets/coerceable_widget.rbs +26 -0
  193. data/sig/ratatui_ruby/widgets/gauge.rbs +44 -0
  194. data/sig/ratatui_ruby/widgets/line_gauge.rbs +48 -0
  195. data/sig/ratatui_ruby/widgets/list.rbs +63 -0
  196. data/sig/ratatui_ruby/widgets/misc.rbs +158 -0
  197. data/sig/ratatui_ruby/widgets/paragraph.rbs +45 -0
  198. data/sig/ratatui_ruby/widgets/row.rbs +43 -0
  199. data/sig/ratatui_ruby/widgets/scrollbar.rbs +53 -0
  200. data/sig/ratatui_ruby/widgets/shape/label.rbs +37 -0
  201. data/sig/ratatui_ruby/widgets/sparkline.rbs +45 -0
  202. data/sig/ratatui_ruby/widgets/table.rbs +78 -0
  203. data/sig/ratatui_ruby/widgets/tabs.rbs +44 -0
  204. data/sig/ratatui_ruby/{schema/list_item.rbs → widgets.rbs} +4 -4
  205. data/tasks/steep.rake +11 -0
  206. metadata +80 -63
  207. data/doc/contributors/v1.0.0_blockers.md +0 -870
  208. data/doc/troubleshooting/debugging.md +0 -101
  209. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +0 -47
  210. data/lib/ratatui_ruby/schema/bar_chart/bar_group.rb +0 -25
  211. data/lib/ratatui_ruby/schema/bar_chart.rb +0 -287
  212. data/lib/ratatui_ruby/schema/block.rb +0 -198
  213. data/lib/ratatui_ruby/schema/calendar.rb +0 -84
  214. data/lib/ratatui_ruby/schema/canvas.rb +0 -239
  215. data/lib/ratatui_ruby/schema/center.rb +0 -67
  216. data/lib/ratatui_ruby/schema/chart.rb +0 -159
  217. data/lib/ratatui_ruby/schema/clear.rb +0 -62
  218. data/lib/ratatui_ruby/schema/constraint.rb +0 -151
  219. data/lib/ratatui_ruby/schema/cursor.rb +0 -50
  220. data/lib/ratatui_ruby/schema/gauge.rb +0 -72
  221. data/lib/ratatui_ruby/schema/layout.rb +0 -122
  222. data/lib/ratatui_ruby/schema/line_gauge.rb +0 -80
  223. data/lib/ratatui_ruby/schema/list.rb +0 -135
  224. data/lib/ratatui_ruby/schema/list_item.rb +0 -51
  225. data/lib/ratatui_ruby/schema/overlay.rb +0 -51
  226. data/lib/ratatui_ruby/schema/paragraph.rb +0 -107
  227. data/lib/ratatui_ruby/schema/ratatui_logo.rb +0 -31
  228. data/lib/ratatui_ruby/schema/ratatui_mascot.rb +0 -36
  229. data/lib/ratatui_ruby/schema/rect.rb +0 -174
  230. data/lib/ratatui_ruby/schema/row.rb +0 -76
  231. data/lib/ratatui_ruby/schema/scrollbar.rb +0 -143
  232. data/lib/ratatui_ruby/schema/shape/label.rb +0 -76
  233. data/lib/ratatui_ruby/schema/sparkline.rb +0 -142
  234. data/lib/ratatui_ruby/schema/style.rb +0 -97
  235. data/lib/ratatui_ruby/schema/table.rb +0 -141
  236. data/lib/ratatui_ruby/schema/tabs.rb +0 -85
  237. data/lib/ratatui_ruby/schema/text.rb +0 -217
  238. data/sig/examples/app_all_events/model/events.rbs +0 -15
  239. data/sig/examples/app_all_events/view_state.rbs +0 -21
  240. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +0 -22
  241. data/sig/ratatui_ruby/schema/bar_chart/bar_group.rbs +0 -19
  242. data/sig/ratatui_ruby/schema/bar_chart.rbs +0 -38
  243. data/sig/ratatui_ruby/schema/block.rbs +0 -18
  244. data/sig/ratatui_ruby/schema/calendar.rbs +0 -23
  245. data/sig/ratatui_ruby/schema/canvas.rbs +0 -81
  246. data/sig/ratatui_ruby/schema/center.rbs +0 -17
  247. data/sig/ratatui_ruby/schema/chart.rbs +0 -39
  248. data/sig/ratatui_ruby/schema/constraint.rbs +0 -30
  249. data/sig/ratatui_ruby/schema/cursor.rbs +0 -16
  250. data/sig/ratatui_ruby/schema/draw.rbs +0 -33
  251. data/sig/ratatui_ruby/schema/gauge.rbs +0 -23
  252. data/sig/ratatui_ruby/schema/layout.rbs +0 -27
  253. data/sig/ratatui_ruby/schema/line_gauge.rbs +0 -24
  254. data/sig/ratatui_ruby/schema/list.rbs +0 -28
  255. data/sig/ratatui_ruby/schema/overlay.rbs +0 -15
  256. data/sig/ratatui_ruby/schema/paragraph.rbs +0 -20
  257. data/sig/ratatui_ruby/schema/ratatui_logo.rbs +0 -14
  258. data/sig/ratatui_ruby/schema/ratatui_mascot.rbs +0 -17
  259. data/sig/ratatui_ruby/schema/rect.rbs +0 -48
  260. data/sig/ratatui_ruby/schema/row.rbs +0 -28
  261. data/sig/ratatui_ruby/schema/scrollbar.rbs +0 -42
  262. data/sig/ratatui_ruby/schema/sparkline.rbs +0 -22
  263. data/sig/ratatui_ruby/schema/style.rbs +0 -19
  264. data/sig/ratatui_ruby/schema/table.rbs +0 -32
  265. data/sig/ratatui_ruby/schema/tabs.rbs +0 -21
  266. data/sig/ratatui_ruby/schema/text.rbs +0 -31
  267. /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)
@@ -118,7 +118,7 @@ module RatatuiRuby
118
118
 
119
119
  # Responds to dynamic predicate methods for key checks.
120
120
  # All non-Key events return false for any key predicate.
121
- def method_missing(name, *args, &block)
121
+ def method_missing(name, *args, **kwargs, &block)
122
122
  if name.to_s.end_with?("?")
123
123
  false
124
124
  else
@@ -283,6 +283,55 @@ module RatatuiRuby
283
283
  def self.from_ratios(pairs)
284
284
  pairs.map { |n, d| ratio(n, d) }
285
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
286
335
  end
287
336
  end
288
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
@@ -0,0 +1,55 @@
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
+ module Layout
10
+ # A position in terminal coordinates.
11
+ #
12
+ # Layout code passes x/y pairs between functions. Bundling them
13
+ # into separate variables is verbose and prone to ordering mistakes.
14
+ #
15
+ # This class wraps column and row into a single immutable object.
16
+ # Pass it around, destructure it, or convert from a Rect.
17
+ #
18
+ # Use it for cursor positioning, mouse coordinates, or anywhere
19
+ # you need to represent a single point on the terminal grid.
20
+ #
21
+ # === Example
22
+ #
23
+ #--
24
+ # SPDX-SnippetBegin
25
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
26
+ # SPDX-License-Identifier: MIT-0
27
+ #++
28
+ # pos = Layout::Position.new(x: 10, y: 5)
29
+ # puts "Cursor at column #{pos.x}, row #{pos.y}"
30
+ #
31
+ # # Extract from a Rect
32
+ # rect = Layout::Rect.new(x: 10, y: 5, width: 80, height: 24)
33
+ # pos = rect.as_position # => Position(x: 10, y: 5)
34
+ #--
35
+ # SPDX-SnippetEnd
36
+ #++
37
+ class Position < Data.define(:x, :y)
38
+ ##
39
+ # :attr_reader: x
40
+ # Column index (0-indexed from left edge).
41
+
42
+ ##
43
+ # :attr_reader: y
44
+ # Row index (0-indexed from top edge).
45
+
46
+ # Creates a new Position.
47
+ #
48
+ # [x] Column index (Integer, coerced via +Integer()+).
49
+ # [y] Row index (Integer, coerced via +Integer()+).
50
+ def initialize(x: 0, y: 0)
51
+ super(x: Integer(x), y: Integer(y))
52
+ end
53
+ end
54
+ end
55
+ end