ratatui_ruby 0.9.1 → 0.10.1

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 +113 -0
  8. data/README.md +17 -0
  9. data/REUSE.toml +5 -0
  10. data/Rakefile +1 -1
  11. data/Steepfile +49 -0
  12. data/doc/concepts/debugging.md +401 -0
  13. data/doc/getting_started/quickstart.md +8 -3
  14. data/doc/images/app_all_events.png +0 -0
  15. data/doc/images/app_color_picker.png +0 -0
  16. data/doc/images/app_debugging_showcase.gif +0 -0
  17. data/doc/images/app_debugging_showcase.png +0 -0
  18. data/doc/images/app_login_form.png +0 -0
  19. data/doc/images/app_stateful_interaction.png +0 -0
  20. data/doc/images/verify_quickstart_dsl.png +0 -0
  21. data/doc/images/verify_quickstart_layout.png +0 -0
  22. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  23. data/doc/images/verify_readme_usage.png +0 -0
  24. data/doc/images/widget_barchart.png +0 -0
  25. data/doc/images/widget_block.png +0 -0
  26. data/doc/images/widget_box.png +0 -0
  27. data/doc/images/widget_calendar.png +0 -0
  28. data/doc/images/widget_canvas.png +0 -0
  29. data/doc/images/widget_cell.png +0 -0
  30. data/doc/images/widget_center.png +0 -0
  31. data/doc/images/widget_chart.png +0 -0
  32. data/doc/images/widget_gauge.png +0 -0
  33. data/doc/images/widget_layout_split.png +0 -0
  34. data/doc/images/widget_line_gauge.png +0 -0
  35. data/doc/images/widget_list.png +0 -0
  36. data/doc/images/widget_map.png +0 -0
  37. data/doc/images/widget_overlay.png +0 -0
  38. data/doc/images/widget_popup.png +0 -0
  39. data/doc/images/widget_ratatui_logo.png +0 -0
  40. data/doc/images/widget_ratatui_mascot.png +0 -0
  41. data/doc/images/widget_rect.png +0 -0
  42. data/doc/images/widget_render.png +0 -0
  43. data/doc/images/widget_rich_text.png +0 -0
  44. data/doc/images/widget_scroll_text.png +0 -0
  45. data/doc/images/widget_scrollbar.png +0 -0
  46. data/doc/images/widget_sparkline.png +0 -0
  47. data/doc/images/widget_style_colors.png +0 -0
  48. data/doc/images/widget_table.png +0 -0
  49. data/doc/images/widget_tabs.png +0 -0
  50. data/doc/images/widget_text_width.png +0 -0
  51. data/doc/troubleshooting/async.md +4 -0
  52. data/examples/app_debugging_showcase/README.md +119 -0
  53. data/examples/app_debugging_showcase/app.rb +318 -0
  54. data/examples/widget_canvas/app.rb +19 -14
  55. data/examples/widget_gauge/app.rb +18 -3
  56. data/examples/widget_layout_split/app.rb +10 -4
  57. data/examples/widget_list/app.rb +22 -6
  58. data/examples/widget_rect/app.rb +7 -6
  59. data/examples/widget_rich_text/app.rb +62 -37
  60. data/examples/widget_style_colors/app.rb +26 -47
  61. data/examples/widget_table/app.rb +28 -5
  62. data/examples/widget_text_width/app.rb +6 -4
  63. data/ext/ratatui_ruby/Cargo.lock +48 -1
  64. data/ext/ratatui_ruby/Cargo.toml +6 -2
  65. data/ext/ratatui_ruby/src/color.rs +82 -0
  66. data/ext/ratatui_ruby/src/errors.rs +28 -0
  67. data/ext/ratatui_ruby/src/events.rs +15 -14
  68. data/ext/ratatui_ruby/src/lib.rs +56 -0
  69. data/ext/ratatui_ruby/src/rendering.rs +3 -1
  70. data/ext/ratatui_ruby/src/style.rs +48 -21
  71. data/ext/ratatui_ruby/src/terminal.rs +40 -9
  72. data/ext/ratatui_ruby/src/text.rs +21 -9
  73. data/ext/ratatui_ruby/src/widgets/chart.rs +2 -1
  74. data/ext/ratatui_ruby/src/widgets/layout.rs +90 -2
  75. data/ext/ratatui_ruby/src/widgets/list.rs +6 -5
  76. data/ext/ratatui_ruby/src/widgets/overlay.rs +2 -1
  77. data/ext/ratatui_ruby/src/widgets/table.rs +7 -6
  78. data/ext/ratatui_ruby/src/widgets/table_state.rs +55 -0
  79. data/ext/ratatui_ruby/src/widgets/tabs.rs +3 -2
  80. data/lib/ratatui_ruby/buffer/cell.rb +25 -15
  81. data/lib/ratatui_ruby/buffer.rb +134 -2
  82. data/lib/ratatui_ruby/cell.rb +13 -5
  83. data/lib/ratatui_ruby/debug.rb +215 -0
  84. data/lib/ratatui_ruby/event/key.rb +3 -2
  85. data/lib/ratatui_ruby/event.rb +1 -1
  86. data/lib/ratatui_ruby/layout/constraint.rb +49 -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 +1 -1
  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 +6 -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 +42 -11
  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 +84 -5
  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 +80 -63
  208. data/doc/contributors/v1.0.0_blockers.md +0 -870
  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 -30
  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
@@ -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
@@ -379,6 +379,37 @@ module RatatuiRuby
379
379
  )
380
380
  end
381
381
 
382
+ # Expands the rect by a uniform margin on all sides.
383
+ #
384
+ # Containers wrap content with decorations. Adding margin to all four edges inline is verbose.
385
+ # Off-by-one errors happen when you forget to double the margin.
386
+ #
387
+ # This method computes the outer area. Saturates x/y at 0 when margin exceeds position.
388
+ # Use Rect#clamp to constrain the result if it may exceed screen bounds.
389
+ #
390
+ # [margin] Integer expansion on all sides.
391
+ #
392
+ # === Example
393
+ #
394
+ #--
395
+ # SPDX-SnippetBegin
396
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
397
+ # SPDX-License-Identifier: MIT-0
398
+ #++
399
+ # rect = Layout::Rect.new(x: 10, y: 10, width: 20, height: 10)
400
+ # rect.outer(5) # => Rect(x: 5, y: 5, width: 30, height: 20)
401
+ #--
402
+ # SPDX-SnippetEnd
403
+ #++
404
+ def outer(margin)
405
+ new_x = [x - margin, 0].max
406
+ new_y = [y - margin, 0].max
407
+ new_width = right + margin - new_x
408
+ new_height = bottom + margin - new_y
409
+
410
+ Rect.new(x: new_x, y: new_y, width: new_width, height: new_height)
411
+ end
412
+
382
413
  # Moves the rect without changing size.
383
414
  #
384
415
  # Animations and drag-and-drop shift widgets.
@@ -405,6 +436,32 @@ module RatatuiRuby
405
436
  Rect.new(x: x + dx, y: y + dy, width:, height:)
406
437
  end
407
438
 
439
+ # Changes dimensions while preserving position.
440
+ #
441
+ # Window resizing and responsive layouts adjust size mid-session.
442
+ # Creating a new rect with the same position but different size is common.
443
+ #
444
+ # This method returns a resized copy. Position unchanged.
445
+ #
446
+ # [new_size] Size object with new dimensions.
447
+ #
448
+ # === Example
449
+ #
450
+ #--
451
+ # SPDX-SnippetBegin
452
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
453
+ # SPDX-License-Identifier: MIT-0
454
+ #++
455
+ # rect = Layout::Rect.new(x: 10, y: 5, width: 20, height: 10)
456
+ # rect.resize(Size.new(width: 40, height: 20))
457
+ # # => Rect(x: 10, y: 5, width: 40, height: 20)
458
+ #--
459
+ # SPDX-SnippetEnd
460
+ #++
461
+ def resize(new_size)
462
+ Rect.new(x:, y:, width: new_size.width, height: new_size.height)
463
+ end
464
+
408
465
  # Constrains the rect to fit inside bounds.
409
466
  #
410
467
  # Popups and tooltips may extend beyond screen edges.
@@ -516,6 +573,137 @@ module RatatuiRuby
516
573
  end
517
574
  end
518
575
  end
576
+
577
+ # Extracts the position (x, y) from this rect.
578
+ #
579
+ # Layout code sometimes separates position from size.
580
+ # Extracting x and y into multiple variables is verbose.
581
+ #
582
+ # This method returns a Position object containing just the coordinates.
583
+ #
584
+ # === Example
585
+ #
586
+ #--
587
+ # SPDX-SnippetBegin
588
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
589
+ # SPDX-License-Identifier: MIT-0
590
+ #++
591
+ # rect = Layout::Rect.new(x: 10, y: 5, width: 80, height: 24)
592
+ # rect.as_position # => Position(x: 10, y: 5)
593
+ #--
594
+ # SPDX-SnippetEnd
595
+ #++
596
+ def as_position
597
+ Position.new(x:, y:)
598
+ end
599
+
600
+ # Extracts the size (width, height) from this rect.
601
+ #
602
+ # Layout code sometimes separates size from position.
603
+ # Extracting width and height into multiple variables is verbose.
604
+ #
605
+ # This method returns a Size object containing just the dimensions.
606
+ #
607
+ # === Example
608
+ #
609
+ #--
610
+ # SPDX-SnippetBegin
611
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
612
+ # SPDX-License-Identifier: MIT-0
613
+ #++
614
+ # rect = Layout::Rect.new(x: 10, y: 5, width: 80, height: 24)
615
+ # rect.as_size # => Size(width: 80, height: 24)
616
+ #--
617
+ # SPDX-SnippetEnd
618
+ #++
619
+ def as_size
620
+ Size.new(width:, height:)
621
+ end
622
+
623
+ # Returns a new Rect, centered horizontally within this rect based on the constraint.
624
+ #
625
+ # Modal dialogs and centered content need horizontal centering.
626
+ # Computing the left offset manually is error-prone.
627
+ #
628
+ # This method uses Layout to compute the centered position.
629
+ #
630
+ # [constraint] Constraint defining the width of the centered area.
631
+ #
632
+ # === Example
633
+ #
634
+ #--
635
+ # SPDX-SnippetBegin
636
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
637
+ # SPDX-License-Identifier: MIT-0
638
+ #++
639
+ # rect = Layout::Rect.new(x: 0, y: 0, width: 100, height: 24)
640
+ # rect.centered_horizontally(Constraint.length(40))
641
+ # # => Rect(x: 30, y: 0, width: 40, height: 24)
642
+ #--
643
+ # SPDX-SnippetEnd
644
+ #++
645
+ def centered_horizontally(constraint)
646
+ areas = Layout.split(self, direction: :horizontal, constraints: [constraint], flex: :center)
647
+ areas.first
648
+ end
649
+
650
+ # Returns a new Rect, centered vertically within this rect based on the constraint.
651
+ #
652
+ # Modal dialogs and centered content need vertical centering.
653
+ # Computing the top offset manually is error-prone.
654
+ #
655
+ # This method uses Layout to compute the centered position.
656
+ #
657
+ # [constraint] Constraint defining the height of the centered area.
658
+ #
659
+ # === Example
660
+ #
661
+ #--
662
+ # SPDX-SnippetBegin
663
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
664
+ # SPDX-License-Identifier: MIT-0
665
+ #++
666
+ # rect = Layout::Rect.new(x: 0, y: 0, width: 80, height: 100)
667
+ # rect.centered_vertically(Constraint.length(20))
668
+ # # => Rect(x: 0, y: 40, width: 80, height: 20)
669
+ #--
670
+ # SPDX-SnippetEnd
671
+ #++
672
+ def centered_vertically(constraint)
673
+ areas = Layout.split(self, direction: :vertical, constraints: [constraint], flex: :center)
674
+ areas.first
675
+ end
676
+
677
+ # Returns a new Rect, centered both horizontally and vertically within this rect.
678
+ #
679
+ # Modal dialogs often need exact centering on both axes.
680
+ # Computing both offsets manually is tedious.
681
+ #
682
+ # This method chains centered_horizontally and centered_vertically.
683
+ #
684
+ # [horizontal_constraint] Constraint defining the width of the centered area.
685
+ # [vertical_constraint] Constraint defining the height of the centered area.
686
+ #
687
+ # === Example
688
+ #
689
+ #--
690
+ # SPDX-SnippetBegin
691
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
692
+ # SPDX-License-Identifier: MIT-0
693
+ #++
694
+ # rect = Layout::Rect.new(x: 0, y: 0, width: 100, height: 100)
695
+ # rect.centered(Constraint.length(40), Constraint.length(20))
696
+ # # => Rect(x: 30, y: 40, width: 40, height: 20)
697
+ #--
698
+ # SPDX-SnippetEnd
699
+ #++
700
+ def centered(horizontal_constraint, vertical_constraint)
701
+ centered_horizontally(horizontal_constraint).centered_vertically(vertical_constraint)
702
+ end
703
+
704
+ # Ruby-idiomatic aliases (TIMTOWTDI)
705
+ alias position as_position
706
+ alias size as_size
519
707
  end
520
708
  end
521
709
  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
+ # Terminal dimensions as width and height.
11
+ #
12
+ # Layout calculations need sizes. Passing width and height
13
+ # as separate arguments is verbose and easy to swap by mistake.
14
+ #
15
+ # This class bundles dimensions into a single immutable object.
16
+ # Extract it from a Rect or create it directly for sizing operations.
17
+ #
18
+ # Use it for terminal dimensions, widget sizing constraints,
19
+ # or anywhere you need width/height without position.
20
+ #
21
+ # === Example
22
+ #
23
+ #--
24
+ # SPDX-SnippetBegin
25
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
26
+ # SPDX-License-Identifier: MIT-0
27
+ #++
28
+ # size = Layout::Size.new(width: 80, height: 24)
29
+ # puts "Terminal is #{size.width} columns by #{size.height} rows"
30
+ #
31
+ # # Extract from a Rect
32
+ # rect = Layout::Rect.new(x: 10, y: 5, width: 80, height: 24)
33
+ # size = rect.as_size # => Size(width: 80, height: 24)
34
+ #--
35
+ # SPDX-SnippetEnd
36
+ #++
37
+ class Size < Data.define(:width, :height)
38
+ ##
39
+ # :attr_reader: width
40
+ # Width in terminal columns.
41
+
42
+ ##
43
+ # :attr_reader: height
44
+ # Height in terminal rows.
45
+
46
+ # Creates a new Size.
47
+ #
48
+ # [width] Width in columns (Integer, coerced via +Integer()+).
49
+ # [height] Height in rows (Integer, coerced via +Integer()+).
50
+ def initialize(width: 0, height: 0)
51
+ super(width: Integer(width), height: Integer(height))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -10,6 +10,8 @@ module RatatuiRuby
10
10
  #
11
11
  # This module mirrors +ratatui::layout+ and contains:
12
12
  # - {Rect} — Rectangle geometry
13
+ # - {Position} — Terminal coordinates
14
+ # - {Size} — Terminal dimensions
13
15
  # - {Constraint} — Sizing rules
14
16
  # - {Layout} — Space distribution
15
17
  module Layout
@@ -17,5 +19,7 @@ module RatatuiRuby
17
19
  end
18
20
 
19
21
  require_relative "layout/rect"
22
+ require_relative "layout/position"
23
+ require_relative "layout/size"
20
24
  require_relative "layout/constraint"
21
25
  require_relative "layout/layout"