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
@@ -190,6 +190,8 @@ module RatatuiRuby
190
190
  # SPDX-SnippetEnd
191
191
  #++
192
192
  class Canvas < Data.define(:shapes, :x_bounds, :y_bounds, :marker, :block, :background_color)
193
+ include CoerceableWidget
194
+
193
195
  ##
194
196
  # :attr_reader: shapes
195
197
  # Array of shapes to render.
@@ -236,6 +238,60 @@ module RatatuiRuby
236
238
  background_color:
237
239
  )
238
240
  end
241
+
242
+ # Converts canvas coordinates to normalized grid coordinates.
243
+ #
244
+ # Hit testing and layout decisions need to know where a canvas point
245
+ # falls within the drawing surface. This method maps from the canvas
246
+ # coordinate system to normalized [0.0, 1.0] coordinates.
247
+ #
248
+ # Use it to determine if a click or touch event lands within the
249
+ # canvas bounds, and where proportionally.
250
+ #
251
+ # [x] X coordinate in canvas coordinate system.
252
+ # [y] Y coordinate in canvas coordinate system.
253
+ #
254
+ # Returns an Array <tt>[normalized_x, normalized_y]</tt> where each
255
+ # value is between 0.0 and 1.0, or <tt>nil</tt> if the point is
256
+ # outside the canvas bounds.
257
+ #
258
+ # === Example
259
+ #
260
+ #--
261
+ # SPDX-SnippetBegin
262
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
263
+ # SPDX-License-Identifier: MIT-0
264
+ #++
265
+ # canvas = Canvas.new(x_bounds: [0.0, 100.0], y_bounds: [0.0, 50.0])
266
+ # canvas.get_point(50.0, 25.0) # => [0.5, 0.5] (center)
267
+ # canvas.get_point(0.0, 0.0) # => [0.0, 1.0] (bottom-left)
268
+ # canvas.get_point(101.0, 0.0) # => nil (out of bounds)
269
+ #--
270
+ # SPDX-SnippetEnd
271
+ #++
272
+ def get_point(x, y)
273
+ left, right = x_bounds
274
+ bottom, top = y_bounds
275
+
276
+ # Check bounds
277
+ return nil if x < left || x > right || y < bottom || y > top
278
+
279
+ width = right - left
280
+ height = top - bottom
281
+
282
+ # Avoid division by zero
283
+ return nil if width <= 0.0 || height <= 0.0
284
+
285
+ # Normalize to [0.0, 1.0] range
286
+ normalized_x = (x - left) / width
287
+ normalized_y = (top - y) / height # Y inverted: top is 0, bottom is 1
288
+
289
+ [normalized_x, normalized_y]
290
+ end
291
+
292
+ # Ruby-idiomatic aliases (TIMTOWTDI)
293
+ alias point get_point
294
+ alias [] get_point
239
295
  end
240
296
  end
241
297
  end
@@ -37,6 +37,8 @@ module RatatuiRuby
37
37
  # SPDX-SnippetEnd
38
38
  #++
39
39
  class Cell < Data.define(:content, :style)
40
+ include CoerceableWidget
41
+
40
42
  ##
41
43
  # :attr_reader: content
42
44
  # The content to display (String, Text::Span, or Text::Line).
@@ -33,6 +33,8 @@ module RatatuiRuby
33
33
  # SPDX-SnippetEnd
34
34
  #++
35
35
  class Center < Data.define(:child, :width_percent, :height_percent)
36
+ include CoerceableWidget
37
+
36
38
  ##
37
39
  # :attr_reader: child
38
40
  # The widget to be centered.
@@ -14,6 +14,8 @@ module RatatuiRuby
14
14
  # [style] Style
15
15
  # [labels_alignment] Symbol (<tt>:left</tt>, <tt>:center</tt>, <tt>:right</tt>)
16
16
  class Axis < Data.define(:title, :bounds, :labels, :style, :labels_alignment)
17
+ include CoerceableWidget
18
+
17
19
  ##
18
20
  # :attr_reader: title
19
21
  # Label for the axis (String).
@@ -59,6 +61,8 @@ module RatatuiRuby
59
61
  # [marker] Symbol (<tt>:dot</tt>, <tt>:braille</tt>, <tt>:block</tt>, <tt>:bar</tt>, <tt>:half_block</tt>)
60
62
  # [graph_type] Symbol (<tt>:line</tt>, <tt>:scatter</tt>)
61
63
  class Dataset < Data.define(:name, :data, :style, :marker, :graph_type)
64
+ include CoerceableWidget
65
+
62
66
  ##
63
67
  # :attr_reader: name
64
68
  # Name for logical identification or legend.
@@ -118,6 +122,8 @@ module RatatuiRuby
118
122
  #
119
123
  # ruby examples/widget_chart/app.rb
120
124
  class Chart < Data.define(:datasets, :x_axis, :y_axis, :block, :style, :legend_position, :hidden_legend_constraints)
125
+ include CoerceableWidget
126
+
121
127
  ##
122
128
  # :attr_reader: datasets
123
129
  # Array of Dataset objects to plot.
@@ -46,6 +46,8 @@ module RatatuiRuby
46
46
  # SPDX-SnippetEnd
47
47
  #++
48
48
  class Clear < Data.define(:block)
49
+ include CoerceableWidget
50
+
49
51
  ##
50
52
  # :attr_reader: block
51
53
  # Optional Block to render after clearing.
@@ -0,0 +1,77 @@
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 Widgets
10
+ # Mixin that provides DWIM hash coercion for widget classes.
11
+ #
12
+ # When users call `tui.table(hash)` instead of `tui.table(**hash)`,
13
+ # Ruby's `...` forwarding passes the Hash as a positional argument,
14
+ # causing cryptic TypeErrors at the Rust FFI boundary.
15
+ #
16
+ # This mixin provides a `coerce_args` class method that detects
17
+ # this pattern and automatically splats the hash into keyword arguments.
18
+ #
19
+ # === Behavior
20
+ #
21
+ # - **Production mode**: Unknown keys are silently ignored
22
+ # - **Debug mode (RR_DEBUG=1)**: Raises ArgumentError to catch typos early
23
+ #
24
+ # === Usage
25
+ #
26
+ #--
27
+ # SPDX-SnippetBegin
28
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
29
+ # SPDX-License-Identifier: MIT-0
30
+ #++
31
+ # class Table < Data.define(:rows, :widths, ...)
32
+ # include CoerceableWidget
33
+ # end
34
+ #
35
+ # # In WidgetFactories:
36
+ # def table(first = nil, **kwargs)
37
+ # Widgets::Table.coerce_args(first, kwargs)
38
+ # end
39
+ #--
40
+ # SPDX-SnippetEnd
41
+ #++
42
+ module CoerceableWidget
43
+ ##
44
+ # Hook called when this module is included in a widget class.
45
+ #
46
+ # Extends the class with ClassMethods and defines KNOWN_KEYS constant
47
+ # from the Data.define members for validation.
48
+ #
49
+ # [base] The class including this module.
50
+ def self.included(base)
51
+ base.extend(ClassMethods)
52
+ base.const_set(:KNOWN_KEYS, base.members.freeze) unless base.const_defined?(:KNOWN_KEYS)
53
+ end
54
+
55
+ # Class methods extended onto widget classes.
56
+ module ClassMethods
57
+ # Coerces a bare Hash argument into keyword arguments.
58
+ #
59
+ # @param first [Hash, nil] First positional argument (bare hash case)
60
+ # @param kwargs [Hash] Keyword arguments (normal splatted case)
61
+ # @return [Object] New instance of the widget class
62
+ # @raise [ArgumentError] In debug mode, if unknown keys are present
63
+ def coerce_args(first, kwargs)
64
+ if first.is_a?(Hash) && kwargs.empty?
65
+ unknown = first.keys - self::KNOWN_KEYS
66
+ if unknown.any? && RatatuiRuby::Debug.enabled?
67
+ raise ArgumentError, "#{name}: unknown keys #{unknown.inspect}"
68
+ end
69
+ new(**first.slice(*self::KNOWN_KEYS))
70
+ else
71
+ new(**kwargs)
72
+ end
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -32,6 +32,8 @@ module RatatuiRuby
32
32
  # - {Component-based implementation using Frame API}[link:/examples/app_color_picker/app_rb.html]
33
33
  # - RatatuiRuby::Frame#set_cursor_position (Frame API alternative)
34
34
  class Cursor < Data.define(:x, :y)
35
+ include CoerceableWidget
36
+
35
37
  ##
36
38
  # :attr_reader: x
37
39
  # X coordinate (column).
@@ -23,6 +23,8 @@ module RatatuiRuby
23
23
  #
24
24
  # ruby examples/widget_gauge/app.rb
25
25
  class Gauge < Data.define(:ratio, :label, :style, :gauge_style, :block, :use_unicode)
26
+ include CoerceableWidget
27
+
26
28
  ##
27
29
  # :attr_reader: ratio
28
30
  # Progress ratio from 0.0 to 1.0.
@@ -62,9 +64,16 @@ module RatatuiRuby
62
64
  # [gauge_style] Style object for the filled bar (optional).
63
65
  # [block] Block widget (optional).
64
66
  # [use_unicode] Boolean (default: true).
67
+ #
68
+ # Raises ArgumentError if percent is not 0..100.
65
69
  def initialize(ratio: nil, percent: nil, label: nil, style: nil, gauge_style: nil, block: nil, use_unicode: true)
66
70
  if percent
67
- ratio = Float(percent) / 100.0
71
+ float_percent = Float(percent)
72
+ unless float_percent.between?(0, 100)
73
+ raise ArgumentError, "percent must be between 0 and 100 (got #{percent.inspect})"
74
+ end
75
+ # Float(Numeric) incorrectly returns Float? -- https://github.com/ruby/rbs/issues/2793
76
+ ratio = float_percent / 100.0 #: Float
68
77
  end
69
78
  ratio = Float(ratio || 0.0)
70
79
  super(ratio:, label:, style:, gauge_style:, block:, use_unicode:)
@@ -72,17 +81,66 @@ module RatatuiRuby
72
81
 
73
82
  # Returns true if the gauge has any fill (ratio > 0).
74
83
  #
75
- # @return [Boolean]
84
+ # === Example
85
+ #
86
+ #--
87
+ # SPDX-SnippetBegin
88
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
89
+ # SPDX-License-Identifier: MIT-0
90
+ #++
91
+ # Widgets::Gauge.new(ratio: 0.0).filled? # => false
92
+ # Widgets::Gauge.new(ratio: 0.5).filled? # => true
93
+ #--
94
+ # SPDX-SnippetEnd
95
+ #++
76
96
  def filled?
77
97
  ratio > 0
78
98
  end
79
99
 
80
100
  # Returns true if the gauge is at 100% or more (ratio >= 1.0).
81
101
  #
82
- # @return [Boolean]
102
+ # === Example
103
+ #
104
+ #--
105
+ # SPDX-SnippetBegin
106
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
107
+ # SPDX-License-Identifier: MIT-0
108
+ #++
109
+ # Widgets::Gauge.new(ratio: 0.99).complete? # => false
110
+ # Widgets::Gauge.new(ratio: 1.0).complete? # => true
111
+ #--
112
+ # SPDX-SnippetEnd
113
+ #++
83
114
  def complete?
84
115
  ratio >= 1.0
85
116
  end
117
+
118
+ # Returns the progress as an integer percentage (0-100).
119
+ #
120
+ # Gauge stores progress as a ratio (0.0 to 1.0). User-facing code often
121
+ # displays percentages. Converting manually is tedious.
122
+ #
123
+ # This is the inverse of passing <tt>percent:</tt> to the constructor.
124
+ # Rounds down to the nearest integer.
125
+ #
126
+ # === Example
127
+ #
128
+ #--
129
+ # SPDX-SnippetBegin
130
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
131
+ # SPDX-License-Identifier: MIT-0
132
+ #++
133
+ # gauge = Widgets::Gauge.new(percent: 75)
134
+ # gauge.percent # => 75
135
+ #
136
+ # gauge = Widgets::Gauge.new(ratio: 0.456)
137
+ # gauge.percent # => 45
138
+ #--
139
+ # SPDX-SnippetEnd
140
+ #++
141
+ def percent
142
+ (ratio * 100).to_i
143
+ end
86
144
  end
87
145
  end
88
146
  end
@@ -23,6 +23,8 @@ module RatatuiRuby
23
23
  #
24
24
  # ruby examples/widget_line_gauge/app.rb
25
25
  class LineGauge < Data.define(:ratio, :label, :style, :filled_style, :unfilled_style, :block, :filled_symbol, :unfilled_symbol)
26
+ include CoerceableWidget
27
+
26
28
  ##
27
29
  # :attr_reader: ratio
28
30
  # Progress ratio from 0.0 to 1.0.
@@ -58,6 +60,7 @@ module RatatuiRuby
58
60
  # Creates a new LineGauge.
59
61
  #
60
62
  # [ratio] Float (0.0 - 1.0).
63
+ # [percent] Integer (0 - 100), alternative to ratio.
61
64
  # [label] String or Text::Span (optional).
62
65
  # [style] Style (optional, base style for the gauge).
63
66
  # [filled_style] Style.
@@ -65,9 +68,19 @@ module RatatuiRuby
65
68
  # [block] Block.
66
69
  # [filled_symbol] String (default: <tt>"█"</tt>).
67
70
  # [unfilled_symbol] String (default: <tt>"░"</tt>).
68
- def initialize(ratio: 0.0, label: nil, style: nil, filled_style: nil, unfilled_style: nil, block: nil, filled_symbol: "█", unfilled_symbol: "░")
71
+ #
72
+ # Raises ArgumentError if percent is not 0..100.
73
+ def initialize(ratio: nil, percent: nil, label: nil, style: nil, filled_style: nil, unfilled_style: nil, block: nil, filled_symbol: "█", unfilled_symbol: "░")
74
+ if percent
75
+ float_percent = Float(percent)
76
+ unless float_percent.between?(0, 100)
77
+ raise ArgumentError, "percent must be between 0 and 100 (got #{percent.inspect})"
78
+ end
79
+ ratio = float_percent / 100.0
80
+ end
81
+ ratio = Float(ratio || 0.0)
69
82
  super(
70
- ratio: Float(ratio),
83
+ ratio:,
71
84
  label:,
72
85
  style:,
73
86
  filled_style:,
@@ -80,17 +93,66 @@ module RatatuiRuby
80
93
 
81
94
  # Returns true if the gauge has any fill (ratio > 0).
82
95
  #
83
- # @return [Boolean]
96
+ # === Example
97
+ #
98
+ #--
99
+ # SPDX-SnippetBegin
100
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
101
+ # SPDX-License-Identifier: MIT-0
102
+ #++
103
+ # Widgets::LineGauge.new(ratio: 0.0).filled? # => false
104
+ # Widgets::LineGauge.new(ratio: 0.5).filled? # => true
105
+ #--
106
+ # SPDX-SnippetEnd
107
+ #++
84
108
  def filled?
85
109
  ratio > 0
86
110
  end
87
111
 
88
112
  # Returns true if the gauge is at 100% or more (ratio >= 1.0).
89
113
  #
90
- # @return [Boolean]
114
+ # === Example
115
+ #
116
+ #--
117
+ # SPDX-SnippetBegin
118
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
119
+ # SPDX-License-Identifier: MIT-0
120
+ #++
121
+ # Widgets::LineGauge.new(ratio: 0.99).complete? # => false
122
+ # Widgets::LineGauge.new(ratio: 1.0).complete? # => true
123
+ #--
124
+ # SPDX-SnippetEnd
125
+ #++
91
126
  def complete?
92
127
  ratio >= 1.0
93
128
  end
129
+
130
+ # Returns the progress as an integer percentage (0-100).
131
+ #
132
+ # LineGauge stores progress as a ratio (0.0 to 1.0). User-facing code often
133
+ # displays percentages. Converting manually is tedious.
134
+ #
135
+ # This is the inverse of passing <tt>percent:</tt> to the constructor.
136
+ # Rounds down to the nearest integer.
137
+ #
138
+ # === Example
139
+ #
140
+ #--
141
+ # SPDX-SnippetBegin
142
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
143
+ # SPDX-License-Identifier: MIT-0
144
+ #++
145
+ # lg = Widgets::LineGauge.new(percent: 75)
146
+ # lg.percent # => 75
147
+ #
148
+ # lg = Widgets::LineGauge.new(ratio: 0.456)
149
+ # lg.percent # => 45
150
+ #--
151
+ # SPDX-SnippetEnd
152
+ #++
153
+ def percent
154
+ (ratio * 100).to_i
155
+ end
94
156
  end
95
157
  end
96
158
  end
@@ -39,6 +39,8 @@ module RatatuiRuby
39
39
  # SPDX-SnippetEnd
40
40
  #++
41
41
  class List < Data.define(:items, :selected_index, :offset, :style, :highlight_style, :highlight_symbol, :repeat_highlight_symbol, :highlight_spacing, :direction, :scroll_padding, :block)
42
+ include CoerceableWidget
43
+
42
44
  ##
43
45
  # Highlight spacing: always show the spacing column.
44
46
  HIGHLIGHT_ALWAYS = :always
@@ -152,17 +154,99 @@ module RatatuiRuby
152
154
 
153
155
  # Returns true if an item is selected.
154
156
  #
155
- # @return [Boolean]
157
+ # === Example
158
+ #
159
+ #--
160
+ # SPDX-SnippetBegin
161
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
162
+ # SPDX-License-Identifier: MIT-0
163
+ #++
164
+ # list = Widgets::List.new(items: %w[a b c])
165
+ # list.selected? # => false
166
+ #
167
+ # list = Widgets::List.new(items: %w[a b c], selected_index: 1)
168
+ # list.selected? # => true
169
+ #
170
+ #--
171
+ # SPDX-SnippetEnd
172
+ #++
173
+ # Returns: Boolean.
156
174
  def selected?
157
175
  !selected_index.nil?
158
176
  end
159
177
 
160
- # Returns true if the list has no items.
178
+ # Returns true if the list contains no items.
179
+ #
180
+ # === Example
161
181
  #
162
- # @return [Boolean]
182
+ #--
183
+ # SPDX-SnippetBegin
184
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
185
+ # SPDX-License-Identifier: MIT-0
186
+ #++
187
+ # list = Widgets::List.new(items: [])
188
+ # list.empty? # => true
189
+ #
190
+ #--
191
+ # SPDX-SnippetEnd
192
+ #++
193
+ # Returns: Boolean.
163
194
  def empty?
164
195
  items.empty?
165
196
  end
197
+
198
+ # Returns the number of items in the list.
199
+ #
200
+ # === Example
201
+ #
202
+ #--
203
+ # SPDX-SnippetBegin
204
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
205
+ # SPDX-License-Identifier: MIT-0
206
+ #++
207
+ # list = Widgets::List.new(items: %w[alpha beta gamma])
208
+ # list.len # => 3
209
+ #
210
+ #--
211
+ # SPDX-SnippetEnd
212
+ #++
213
+ # Returns: Integer.
214
+ def len
215
+ items.length
216
+ end
217
+
218
+ alias length len
219
+ alias size len
220
+
221
+ # NOTE: No 'selection' alias - it's ambiguous whether it returns an item or index.
222
+ # Use selected_index for the index, selected_item for the item.
223
+
224
+ # Returns the currently selected item, or nil if nothing is selected.
225
+ #
226
+ # Accessing the selected item directly requires looking up +items[selected_index]+
227
+ # after checking that +selected_index+ is not nil. This is verbose.
228
+ #
229
+ # This method encapsulates that pattern.
230
+ #
231
+ # === Example
232
+ #
233
+ #--
234
+ # SPDX-SnippetBegin
235
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
236
+ # SPDX-License-Identifier: MIT-0
237
+ #++
238
+ # list = Widgets::List.new(items: %w[alpha beta gamma], selected_index: 1)
239
+ # list.selected_item # => "beta"
240
+ #
241
+ #--
242
+ # SPDX-SnippetEnd
243
+ #++
244
+ # Returns: The item at the selected index, or nil if no selection.
245
+ def selected_item
246
+ return nil if selected_index.nil?
247
+
248
+ items[selected_index]
249
+ end
166
250
  end
167
251
  end
168
252
  end
@@ -33,6 +33,8 @@ module RatatuiRuby
33
33
  # SPDX-SnippetEnd
34
34
  #++
35
35
  class ListItem < Data.define(:content, :style)
36
+ include CoerceableWidget
37
+
36
38
  ##
37
39
  # :attr_reader: content
38
40
  # The content to display (String, Text::Span, or Text::Line).
@@ -35,6 +35,8 @@ module RatatuiRuby
35
35
  # SPDX-SnippetEnd
36
36
  #++
37
37
  class Overlay < Data.define(:layers)
38
+ include CoerceableWidget
39
+
38
40
  ##
39
41
  # :attr_reader: layers
40
42
  # The stack of widgets to render.
@@ -15,6 +15,8 @@ module RatatuiRuby
15
15
  #
16
16
  # Use it for everything from simple labels to complex, multi-paragraph documents.
17
17
  #
18
+ # See also: <tt>examples/widget_scroll_text/app.rb</tt> for scrollable paragraphs.
19
+ #
18
20
  # === Examples
19
21
  #
20
22
  #--
@@ -39,6 +41,8 @@ module RatatuiRuby
39
41
  # SPDX-SnippetEnd
40
42
  #++
41
43
  class Paragraph < Data.define(:text, :style, :block, :wrap, :alignment, :scroll)
44
+ include CoerceableWidget
45
+
42
46
  ##
43
47
  # :attr_reader: text
44
48
  # The content to display.
@@ -23,6 +23,8 @@ module RatatuiRuby
23
23
  #
24
24
  # ruby examples/widget_ratatui_logo/app.rb
25
25
  class RatatuiLogo < Data.define
26
+ include CoerceableWidget
27
+
26
28
  ##
27
29
  # :method: new
28
30
  # :call-seq: new -> RatatuiLogo
@@ -23,6 +23,8 @@ module RatatuiRuby
23
23
  #
24
24
  # ruby examples/widget_ratatui_mascot/app.rb
25
25
  class RatatuiMascot < Data.define(:block)
26
+ include CoerceableWidget
27
+
26
28
  ##
27
29
  # :method: new
28
30
  # :call-seq: new(block: nil) -> RatatuiMascot
@@ -37,6 +37,8 @@ module RatatuiRuby
37
37
  # SPDX-SnippetEnd
38
38
  #++
39
39
  class Row < Data.define(:cells, :style, :height, :top_margin, :bottom_margin)
40
+ include CoerceableWidget
41
+
40
42
  ##
41
43
  # :attr_reader: cells
42
44
  # The cells to display (Array of Strings, Text::Spans, Text::Lines, Paragraphs, or Cells).
@@ -73,6 +75,49 @@ module RatatuiRuby
73
75
  bottom_margin: bottom_margin.nil? ? nil : Integer(bottom_margin)
74
76
  )
75
77
  end
78
+
79
+ # Returns a new Row with strikethrough styling enabled.
80
+ #
81
+ # Table rows sometimes need strikethrough styling to indicate
82
+ # deleted, deprecated, or cancelled items. Manually managing
83
+ # style modifiers is tedious.
84
+ #
85
+ # This method adds the <tt>:crossed_out</tt> modifier to the row's
86
+ # style. If the row has no existing style, a new style is created.
87
+ #
88
+ # Use it to visually mark rows as cancelled, completed, or invalid.
89
+ #
90
+ # *Terminal Compatibility:* Strikethrough (SGR 9) is not universally
91
+ # supported. macOS Terminal.app notably lacks support. Modern terminals
92
+ # like Kitty, iTerm2, Alacritty, and WezTerm render it correctly.
93
+ # Consider pairing with <tt>:dim</tt> as a fallback for visibility.
94
+ #
95
+ # Returns a new Row instance with strikethrough enabled.
96
+ #
97
+ # === Example
98
+ #
99
+ #--
100
+ # SPDX-SnippetBegin
101
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
102
+ # SPDX-License-Identifier: MIT-0
103
+ #++
104
+ # row = Row.new(cells: ["Cancelled Task", "2024-01-15"])
105
+ # strikethrough_row = row.enable_strikethrough
106
+ # # Row style now includes :crossed_out modifier
107
+ #--
108
+ # SPDX-SnippetEnd
109
+ #++
110
+ def enable_strikethrough
111
+ current_style = style || Style::Style.new
112
+ current_modifiers = current_style.modifiers || []
113
+ new_modifiers = current_modifiers.include?(:crossed_out) ? current_modifiers : current_modifiers + [:crossed_out]
114
+
115
+ new_style = current_style.with(modifiers: new_modifiers)
116
+ with(style: new_style)
117
+ end
118
+
119
+ # Ruby-idiomatic alias
120
+ alias strikethrough enable_strikethrough
76
121
  end
77
122
  end
78
123
  end
@@ -37,6 +37,8 @@ module RatatuiRuby
37
37
  :style,
38
38
  :block
39
39
  )
40
+ include CoerceableWidget
41
+
40
42
  ##
41
43
  # :attr_reader: content_length
42
44
  # Total items or lines in the content.
@@ -47,6 +47,8 @@ module RatatuiRuby
47
47
  # SPDX-SnippetEnd
48
48
  #++
49
49
  class Label < Data.define(:x, :y, :text, :style)
50
+ include CoerceableWidget
51
+
50
52
  ##
51
53
  # :attr_reader: x
52
54
  # X coordinate in canvas space (Float, duck-typed via +to_f+).