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
@@ -41,6 +41,8 @@ module RatatuiRuby
41
41
  # SPDX-SnippetEnd
42
42
  #++
43
43
  class BarChart < Data.define(:data, :bar_width, :bar_gap, :group_gap, :max, :style, :block, :direction, :label_style, :value_style, :bar_set)
44
+ include CoerceableWidget
45
+
44
46
  ##
45
47
  ##
46
48
  ##
@@ -215,19 +217,25 @@ module RatatuiRuby
215
217
  # SPDX-SnippetEnd
216
218
  #++
217
219
  def initialize(data:, bar_width: 3, bar_gap: 1, group_gap: 0, max: nil, style: nil, block: nil, direction: :vertical, label_style: nil, value_style: nil, bar_set: nil)
218
- if bar_set && !bar_set.is_a?(Symbol)
219
- if bar_set.is_a?(Array) && bar_set.size == 9
220
- # Convert Array to Hash using BAR_KEYS order
221
- bar_set = BAR_KEYS.zip(bar_set).to_h
222
- else
223
- bar_set = bar_set.dup
224
- # Normalize numeric keys (0-8) to symbolic keys
225
- BAR_KEYS.each_with_index do |key, i|
226
- if (val = bar_set.delete(i) || bar_set.delete(i.to_s))
227
- bar_set[key] = val
228
- end
229
- end
230
- end
220
+ # Normalize bar_set to Hash[Symbol, String] if provided as Array or Hash
221
+ bar_set = case bar_set
222
+ when Symbol, nil
223
+ bar_set
224
+ when Array
225
+ # Convert Array to Hash using BAR_KEYS order
226
+ BAR_KEYS.zip(bar_set).to_h
227
+ when Hash
228
+ # @type var raw_hash: Hash[untyped, untyped]
229
+ raw_hash = bar_set.dup
230
+ normalized = {} #: Hash[Symbol, String]
231
+ # Normalize numeric keys (0-8) to symbolic keys
232
+ BAR_KEYS.each_with_index do |key, i|
233
+ val = raw_hash.delete(i) || raw_hash.delete(i.to_s) || raw_hash.delete(key)
234
+ normalized[key] = val.to_s if val
235
+ end
236
+ normalized
237
+ else
238
+ bar_set
231
239
  end
232
240
 
233
241
  # Normalize data to Array of BarGroup
@@ -248,12 +256,13 @@ module RatatuiRuby
248
256
  elsif data.first.is_a?(BarGroup)
249
257
  data
250
258
  elsif data.first.is_a?(Array)
251
- # Tuples
259
+ # Tuples - use type assertion for Steep
252
260
  if direction == :horizontal
253
261
  bars = data.map do |item|
254
- label = item[0].to_s
255
- value = item[1]
256
- style = item[2]
262
+ tuple = item #: Array[untyped]
263
+ label = tuple[0].to_s
264
+ value = tuple[1]
265
+ style = tuple[2]
257
266
 
258
267
  bar = Bar.new(value:, label:)
259
268
  bar = bar.with(style:) if style
@@ -262,9 +271,10 @@ module RatatuiRuby
262
271
  [BarGroup.new(label: "", bars:)]
263
272
  else
264
273
  data.map do |item|
265
- label = item[0].to_s
266
- value = item[1]
267
- style = item[2]
274
+ tuple = item #: Array[untyped]
275
+ label = tuple[0].to_s
276
+ value = tuple[1]
277
+ style = tuple[2]
268
278
 
269
279
  bar = Bar.new(value:)
270
280
  bar = bar.with(style:) if style
@@ -23,6 +23,8 @@ module RatatuiRuby
23
23
  #
24
24
  # ruby examples/widget_box/app.rb
25
25
  class Block < Data.define(:title, :titles, :title_alignment, :title_style, :borders, :border_style, :border_type, :border_set, :style, :padding, :children)
26
+ include CoerceableWidget
27
+
26
28
  ##
27
29
  # :attr_reader: title
28
30
  # The main title displayed on the top border.
@@ -170,8 +172,8 @@ module RatatuiRuby
170
172
  if border_set
171
173
  border_set = border_set.dup
172
174
  %i[top_left top_right bottom_left bottom_right vertical_left vertical_right horizontal_top horizontal_bottom].each do |long_key|
173
- short_key = long_key.to_s.split("_").map { |s| s[0] }.join
174
- if (val = border_set.delete(short_key.to_sym) || border_set.delete(short_key))
175
+ short_key = long_key.to_s.split("_").map { |s| s[0] }.join.to_sym
176
+ if (val = border_set.delete(short_key))
175
177
  border_set[long_key] = val
176
178
  end
177
179
  end
@@ -237,12 +239,18 @@ module RatatuiRuby
237
239
  top_border = has_border.call(:top) ? 1 : 0
238
240
  bottom_border = has_border.call(:bottom) ? 1 : 0
239
241
 
240
- # Calculate padding offsets
241
- if padding.is_a?(Array)
242
+ # Calculate padding offsets - ensure all are Integer
243
+ pad_left, pad_right, pad_top, pad_bottom = if padding.is_a?(Array)
242
244
  # [left, right, top, bottom]
243
- pad_left, pad_right, pad_top, pad_bottom = padding
245
+ [
246
+ Integer(padding[0] || 0),
247
+ Integer(padding[1] || 0),
248
+ Integer(padding[2] || 0),
249
+ Integer(padding[3] || 0),
250
+ ]
244
251
  else
245
- pad_left = pad_right = pad_top = pad_bottom = padding
252
+ p = Integer(padding)
253
+ [p, p, p, p]
246
254
  end
247
255
 
248
256
  # Compute inner area
@@ -24,6 +24,8 @@ module RatatuiRuby
24
24
  #
25
25
  # ruby examples/widget_calendar/app.rb
26
26
  class Calendar < Data.define(:year, :month, :events, :default_style, :header_style, :block, :show_weekdays_header, :show_surrounding, :show_month_header)
27
+ include CoerceableWidget
28
+
27
29
  ##
28
30
  # :attr_reader: year
29
31
  # The year to display (Integer).
@@ -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.