ratatui_ruby 0.5.0 → 0.6.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 (234) 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 +6 -0
  7. data/CHANGELOG.md +44 -7
  8. data/README.md +11 -4
  9. data/REUSE.toml +2 -7
  10. data/doc/application_architecture.md +84 -10
  11. data/doc/application_testing.md +75 -29
  12. data/doc/contributors/design/ruby_frontend.md +39 -3
  13. data/doc/contributors/design/rust_backend.md +1 -0
  14. data/doc/contributors/developing_examples.md +129 -44
  15. data/doc/contributors/examples_audit/p1_high.md +21 -0
  16. data/doc/contributors/examples_audit/p2_moderate.md +81 -0
  17. data/doc/contributors/examples_audit.md +41 -0
  18. data/doc/event_handling.md +11 -3
  19. data/doc/images/app_all_events.png +0 -0
  20. data/doc/images/app_color_picker.png +0 -0
  21. data/doc/images/app_login_form.png +0 -0
  22. data/doc/images/app_stateful_interaction.png +0 -0
  23. data/doc/images/verify_quickstart_dsl.png +0 -0
  24. data/doc/images/verify_quickstart_layout.png +0 -0
  25. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  26. data/doc/images/verify_readme_usage.png +0 -0
  27. data/doc/images/widget_barchart_demo.png +0 -0
  28. data/doc/images/widget_block_demo.png +0 -0
  29. data/doc/images/widget_canvas_demo.png +0 -0
  30. data/doc/images/widget_cell_demo.png +0 -0
  31. data/doc/images/widget_center_demo.png +0 -0
  32. data/doc/images/widget_chart_demo.png +0 -0
  33. data/doc/images/widget_list_demo.png +0 -0
  34. data/doc/images/widget_overlay_demo.png +0 -0
  35. data/doc/images/widget_render.png +0 -0
  36. data/doc/images/widget_rich_text.png +0 -0
  37. data/doc/images/widget_scroll_text.png +0 -0
  38. data/doc/images/widget_sparkline_demo.png +0 -0
  39. data/doc/images/widget_table_demo.png +0 -0
  40. data/doc/images/widget_tabs_demo.png +0 -0
  41. data/doc/images/widget_text_width.png +0 -0
  42. data/doc/quickstart.md +69 -76
  43. data/doc/terminal_limitations.md +92 -0
  44. data/examples/app_all_events/README.md +45 -27
  45. data/examples/app_all_events/app.rb +38 -35
  46. data/examples/app_all_events/model/app_model.rb +157 -0
  47. data/examples/app_all_events/model/event_entry.rb +17 -0
  48. data/examples/app_all_events/model/msg.rb +37 -0
  49. data/examples/app_all_events/update.rb +73 -0
  50. data/examples/app_all_events/view/app_view.rb +8 -8
  51. data/examples/app_all_events/view/controls_view.rb +8 -6
  52. data/examples/app_all_events/view/counts_view.rb +12 -8
  53. data/examples/app_all_events/view/live_view.rb +8 -7
  54. data/examples/app_all_events/view/log_view.rb +10 -15
  55. data/examples/app_color_picker/README.md +84 -44
  56. data/examples/app_color_picker/app.rb +24 -62
  57. data/examples/app_color_picker/controls.rb +90 -0
  58. data/examples/app_color_picker/copy_dialog.rb +45 -49
  59. data/examples/app_color_picker/export_pane.rb +126 -0
  60. data/examples/app_color_picker/input.rb +99 -67
  61. data/examples/app_color_picker/main_container.rb +178 -0
  62. data/examples/app_color_picker/palette.rb +55 -26
  63. data/examples/app_login_form/README.md +47 -0
  64. data/examples/app_login_form/app.rb +2 -3
  65. data/examples/app_stateful_interaction/README.md +31 -0
  66. data/examples/app_stateful_interaction/app.rb +272 -0
  67. data/examples/timeout_demo.rb +43 -0
  68. data/examples/verify_quickstart_dsl/README.md +48 -0
  69. data/examples/verify_quickstart_dsl/app.rb +2 -0
  70. data/examples/verify_quickstart_layout/README.md +71 -0
  71. data/examples/verify_quickstart_layout/app.rb +2 -0
  72. data/examples/verify_quickstart_lifecycle/README.md +56 -0
  73. data/examples/verify_quickstart_lifecycle/app.rb +8 -2
  74. data/examples/verify_readme_usage/README.md +43 -0
  75. data/examples/verify_readme_usage/app.rb +8 -2
  76. data/examples/widget_barchart_demo/README.md +49 -0
  77. data/examples/widget_barchart_demo/app.rb +5 -5
  78. data/examples/widget_block_demo/README.md +34 -0
  79. data/examples/widget_block_demo/app.rb +256 -0
  80. data/examples/widget_box_demo/README.md +45 -0
  81. data/examples/widget_calendar_demo/README.md +39 -0
  82. data/examples/widget_canvas_demo/README.md +27 -0
  83. data/examples/widget_canvas_demo/app.rb +123 -0
  84. data/examples/widget_cell_demo/README.md +36 -0
  85. data/examples/widget_cell_demo/app.rb +31 -24
  86. data/examples/widget_center_demo/README.md +29 -0
  87. data/examples/widget_center_demo/app.rb +116 -0
  88. data/examples/widget_chart_demo/README.md +41 -0
  89. data/examples/widget_chart_demo/app.rb +7 -2
  90. data/examples/widget_gauge_demo/README.md +41 -0
  91. data/examples/widget_layout_split/README.md +44 -0
  92. data/examples/widget_line_gauge_demo/README.md +41 -0
  93. data/examples/widget_list_demo/README.md +49 -0
  94. data/examples/widget_list_demo/app.rb +91 -107
  95. data/examples/widget_map_demo/README.md +39 -0
  96. data/examples/{app_map_demo → widget_map_demo}/app.rb +2 -2
  97. data/examples/widget_overlay_demo/app.rb +248 -0
  98. data/examples/widget_popup_demo/README.md +36 -0
  99. data/examples/widget_ratatui_logo_demo/README.md +34 -0
  100. data/examples/widget_ratatui_mascot_demo/README.md +34 -0
  101. data/examples/widget_rect/README.md +38 -0
  102. data/examples/widget_render/README.md +37 -0
  103. data/examples/widget_rich_text/README.md +35 -0
  104. data/examples/widget_rich_text/app.rb +62 -33
  105. data/examples/widget_scroll_text/README.md +37 -0
  106. data/examples/widget_scroll_text/app.rb +0 -1
  107. data/examples/widget_scrollbar_demo/README.md +37 -0
  108. data/examples/widget_sparkline_demo/README.md +42 -0
  109. data/examples/widget_sparkline_demo/app.rb +4 -3
  110. data/examples/widget_style_colors/README.md +34 -0
  111. data/examples/widget_table_demo/README.md +48 -0
  112. data/examples/{app_table_select → widget_table_demo}/app.rb +46 -8
  113. data/examples/widget_tabs_demo/README.md +41 -0
  114. data/examples/widget_tabs_demo/app.rb +15 -1
  115. data/examples/widget_text_width/README.md +35 -0
  116. data/examples/widget_text_width/app.rb +106 -0
  117. data/exe/.gitkeep +0 -0
  118. data/ext/ratatui_ruby/Cargo.lock +11 -4
  119. data/ext/ratatui_ruby/Cargo.toml +2 -1
  120. data/ext/ratatui_ruby/src/events.rs +238 -26
  121. data/ext/ratatui_ruby/src/frame.rs +113 -1
  122. data/ext/ratatui_ruby/src/lib.rs +34 -4
  123. data/ext/ratatui_ruby/src/string_width.rs +101 -0
  124. data/ext/ratatui_ruby/src/terminal.rs +39 -15
  125. data/ext/ratatui_ruby/src/text.rs +1 -1
  126. data/ext/ratatui_ruby/src/widgets/barchart.rs +24 -6
  127. data/ext/ratatui_ruby/src/widgets/gauge.rs +9 -2
  128. data/ext/ratatui_ruby/src/widgets/line_gauge.rs +9 -2
  129. data/ext/ratatui_ruby/src/widgets/list.rs +179 -3
  130. data/ext/ratatui_ruby/src/widgets/list_state.rs +137 -0
  131. data/ext/ratatui_ruby/src/widgets/mod.rs +3 -0
  132. data/ext/ratatui_ruby/src/widgets/scrollbar.rs +93 -1
  133. data/ext/ratatui_ruby/src/widgets/scrollbar_state.rs +169 -0
  134. data/ext/ratatui_ruby/src/widgets/table.rs +113 -1
  135. data/ext/ratatui_ruby/src/widgets/table_state.rs +121 -0
  136. data/lib/ratatui_ruby/cell.rb +4 -4
  137. data/lib/ratatui_ruby/event/key/character.rb +35 -0
  138. data/lib/ratatui_ruby/event/key/media.rb +44 -0
  139. data/lib/ratatui_ruby/event/key/modifier.rb +95 -0
  140. data/lib/ratatui_ruby/event/key/navigation.rb +55 -0
  141. data/lib/ratatui_ruby/event/key/system.rb +45 -0
  142. data/lib/ratatui_ruby/event/key.rb +111 -51
  143. data/lib/ratatui_ruby/event/mouse.rb +3 -3
  144. data/lib/ratatui_ruby/event/paste.rb +1 -1
  145. data/lib/ratatui_ruby/frame.rb +96 -0
  146. data/lib/ratatui_ruby/list_state.rb +88 -0
  147. data/lib/ratatui_ruby/schema/bar_chart/bar.rb +2 -2
  148. data/lib/ratatui_ruby/schema/cursor.rb +5 -0
  149. data/lib/ratatui_ruby/schema/gauge.rb +3 -1
  150. data/lib/ratatui_ruby/schema/line_gauge.rb +2 -2
  151. data/lib/ratatui_ruby/schema/list.rb +25 -4
  152. data/lib/ratatui_ruby/schema/list_item.rb +41 -0
  153. data/lib/ratatui_ruby/schema/rect.rb +43 -0
  154. data/lib/ratatui_ruby/schema/style.rb +24 -4
  155. data/lib/ratatui_ruby/schema/table.rb +21 -3
  156. data/lib/ratatui_ruby/schema/text.rb +69 -1
  157. data/lib/ratatui_ruby/scrollbar_state.rb +112 -0
  158. data/lib/ratatui_ruby/session/autodoc.rb +65 -0
  159. data/lib/ratatui_ruby/session.rb +22 -7
  160. data/lib/ratatui_ruby/table_state.rb +90 -0
  161. data/lib/ratatui_ruby/test_helper/event_injection.rb +169 -0
  162. data/lib/ratatui_ruby/test_helper/snapshot.rb +390 -0
  163. data/lib/ratatui_ruby/test_helper/style_assertions.rb +351 -0
  164. data/lib/ratatui_ruby/test_helper/terminal.rb +127 -0
  165. data/lib/ratatui_ruby/test_helper/test_doubles.rb +68 -0
  166. data/lib/ratatui_ruby/test_helper.rb +65 -358
  167. data/lib/ratatui_ruby/version.rb +1 -1
  168. data/lib/ratatui_ruby.rb +42 -19
  169. data/sig/examples/app_stateful_interaction/app.rbs +33 -0
  170. data/sig/examples/widget_block_demo/app.rbs +32 -0
  171. data/sig/examples/{app_map_demo → widget_map_demo}/app.rbs +2 -2
  172. data/sig/examples/{app_table_select → widget_table_demo}/app.rbs +2 -2
  173. data/sig/examples/{widget_table_flex → widget_text_width}/app.rbs +2 -3
  174. data/sig/ratatui_ruby/event.rbs +11 -1
  175. data/sig/ratatui_ruby/frame.rbs +2 -0
  176. data/sig/ratatui_ruby/list_state.rbs +13 -0
  177. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -2
  178. data/sig/ratatui_ruby/schema/bar_chart/bar.rbs +3 -3
  179. data/sig/ratatui_ruby/schema/gauge.rbs +2 -2
  180. data/sig/ratatui_ruby/schema/line_gauge.rbs +2 -2
  181. data/sig/ratatui_ruby/schema/list.rbs +4 -2
  182. data/sig/ratatui_ruby/schema/list_item.rbs +10 -0
  183. data/sig/ratatui_ruby/schema/rect.rbs +3 -0
  184. data/sig/ratatui_ruby/schema/style.rbs +3 -3
  185. data/sig/ratatui_ruby/schema/table.rbs +3 -1
  186. data/sig/ratatui_ruby/schema/text.rbs +8 -6
  187. data/sig/ratatui_ruby/scrollbar_state.rbs +18 -0
  188. data/sig/ratatui_ruby/session.rbs +13 -0
  189. data/sig/ratatui_ruby/table_state.rbs +15 -0
  190. data/sig/ratatui_ruby/test_helper/event_injection.rbs +16 -0
  191. data/sig/ratatui_ruby/test_helper/snapshot.rbs +12 -0
  192. data/sig/ratatui_ruby/test_helper/style_assertions.rbs +64 -0
  193. data/sig/ratatui_ruby/test_helper/terminal.rbs +14 -0
  194. data/sig/ratatui_ruby/test_helper/test_doubles.rbs +22 -0
  195. data/sig/ratatui_ruby/test_helper.rbs +5 -4
  196. data/tasks/autodoc/examples.rb +79 -0
  197. data/tasks/autodoc/inventory.rb +9 -7
  198. data/tasks/autodoc.rake +11 -5
  199. data/tasks/bump/changelog.rb +3 -3
  200. data/tasks/bump/links.rb +67 -0
  201. data/tasks/sourcehut.rake +61 -21
  202. data/tasks/terminal_preview/app_screenshot.rb +13 -3
  203. data/tasks/terminal_preview/saved_screenshot.rb +4 -3
  204. metadata +111 -37
  205. data/doc/images/app_table_select.png +0 -0
  206. data/doc/images/widget_block_padding.png +0 -0
  207. data/doc/images/widget_block_titles.png +0 -0
  208. data/doc/images/widget_list_styles.png +0 -0
  209. data/examples/app_all_events/model/events.rb +0 -180
  210. data/examples/app_all_events/model/highlight.rb +0 -57
  211. data/examples/app_all_events/test/snapshots/after_focus_lost.txt +0 -24
  212. data/examples/app_all_events/test/snapshots/after_focus_regained.txt +0 -24
  213. data/examples/app_all_events/test/snapshots/after_horizontal_resize.txt +0 -24
  214. data/examples/app_all_events/test/snapshots/after_key_a.txt +0 -24
  215. data/examples/app_all_events/test/snapshots/after_key_ctrl_x.txt +0 -24
  216. data/examples/app_all_events/test/snapshots/after_mouse_click.txt +0 -24
  217. data/examples/app_all_events/test/snapshots/after_mouse_drag.txt +0 -24
  218. data/examples/app_all_events/test/snapshots/after_multiple_events.txt +0 -24
  219. data/examples/app_all_events/test/snapshots/after_paste.txt +0 -24
  220. data/examples/app_all_events/test/snapshots/after_resize.txt +0 -24
  221. data/examples/app_all_events/test/snapshots/after_right_click.txt +0 -24
  222. data/examples/app_all_events/test/snapshots/after_vertical_resize.txt +0 -24
  223. data/examples/app_all_events/test/snapshots/initial_state.txt +0 -24
  224. data/examples/app_all_events/view_state.rb +0 -42
  225. data/examples/app_color_picker/scene.rb +0 -201
  226. data/examples/widget_block_padding/app.rb +0 -67
  227. data/examples/widget_block_titles/app.rb +0 -69
  228. data/examples/widget_list_styles/app.rb +0 -141
  229. data/examples/widget_table_flex/app.rb +0 -95
  230. data/sig/examples/widget_block_padding/app.rbs +0 -11
  231. data/sig/examples/widget_block_titles/app.rbs +0 -11
  232. data/sig/examples/widget_list_styles/app.rbs +0 -11
  233. data/tasks/bump/comparison_links.rb +0 -41
  234. /data/doc/images/{app_map_demo.png → widget_map_demo.png} +0 -0
@@ -28,6 +28,8 @@ module RatatuiRuby
28
28
  # :attr_reader: label
29
29
  # Text label to display (optional).
30
30
  #
31
+ # Accepts String or Text::Span for rich styling.
32
+ #
31
33
  # If nil, it often displays the percentage automatically depending on renderer logic,
32
34
  # but explicit labels are preferred.
33
35
 
@@ -52,7 +54,7 @@ module RatatuiRuby
52
54
  #
53
55
  # [ratio] Float (0.0 - 1.0).
54
56
  # [percent] Integer (0 - 100), alternative to ratio.
55
- # [label] String (optional).
57
+ # [label] String or Text::Span (optional).
56
58
  # [style] Style object for the background (optional).
57
59
  # [gauge_style] Style object for the filled bar (optional).
58
60
  # [block] Block widget (optional).
@@ -26,7 +26,7 @@ module RatatuiRuby
26
26
 
27
27
  ##
28
28
  # :attr_reader: label
29
- # Optional label.
29
+ # Optional label (String or Text::Span for rich styling).
30
30
 
31
31
  ##
32
32
  # :attr_reader: style
@@ -55,7 +55,7 @@ module RatatuiRuby
55
55
  # Creates a new LineGauge.
56
56
  #
57
57
  # [ratio] Float (0.0 - 1.0).
58
- # [label] String (optional).
58
+ # [label] String or Text::Span (optional).
59
59
  # [style] Style (optional, base style for the gauge).
60
60
  # [filled_style] Style.
61
61
  # [unfilled_style] Style.
@@ -27,15 +27,34 @@ module RatatuiRuby
27
27
  # highlight_style: Style.new(bg: :blue),
28
28
  # highlight_symbol: ">> "
29
29
  # )
30
- class List < Data.define(:items, :selected_index, :style, :highlight_style, :highlight_symbol, :repeat_highlight_symbol, :highlight_spacing, :direction, :scroll_padding, :block)
30
+ class List < Data.define(:items, :selected_index, :offset, :style, :highlight_style, :highlight_symbol, :repeat_highlight_symbol, :highlight_spacing, :direction, :scroll_padding, :block)
31
31
  ##
32
32
  # :attr_reader: items
33
- # The items to display (Array of Strings).
33
+ # The items to display.
34
+ #
35
+ # Accepts Array of Strings, Text::Spans, Text::Lines, or ListItem objects.
36
+ # For styled individual rows, use ListItem with a style.
34
37
 
35
38
  ##
36
39
  # :attr_reader: selected_index
37
40
  # Index of the active selection (Integer or nil).
38
41
 
42
+ ##
43
+ # :attr_reader: offset
44
+ # Scroll offset (Integer or nil).
45
+ #
46
+ # Controls the viewport's starting position in the list.
47
+ #
48
+ # When +nil+ (default), Ratatui auto-scrolls to keep the selection visible ("natural scrolling").
49
+ #
50
+ # When set, forces the viewport to start at this item index. Use this for:
51
+ # - **Passive scrolling**: Scroll through a log viewer without selecting items.
52
+ # - **Click-to-select math**: Calculate which item index corresponds to a click coordinate.
53
+ #
54
+ # *Important*: When both +offset+ and +selected_index+ are set, Ratatui may still adjust
55
+ # the viewport during rendering to ensure the selection stays visible. Set +selected_index+
56
+ # to +nil+ for fully manual scroll control.
57
+
39
58
  ##
40
59
  # :attr_reader: style
41
60
  # Base style for unselected items.
@@ -76,8 +95,9 @@ module RatatuiRuby
76
95
  #
77
96
  # Integer parameters accept any object responding to +to_int+ or +to_i+ (duck-typed).
78
97
  #
79
- # [items] Array of Strings.
98
+ # [items] Array of Strings, Text::Spans, Text::Lines, or ListItem objects.
80
99
  # [selected_index] Numeric (nullable, coerced to Integer).
100
+ # [offset] Numeric (nullable, coerced to Integer). Forces scroll position when set.
81
101
  # [style] Style object.
82
102
  # [highlight_style] Style object.
83
103
  # [highlight_symbol] String (default: <tt>"> "</tt>).
@@ -86,10 +106,11 @@ module RatatuiRuby
86
106
  # [direction] Symbol (default: <tt>:top_to_bottom</tt>).
87
107
  # [scroll_padding] Numeric (nullable, coerced to Integer, default: <tt>nil</tt>).
88
108
  # [block] Block (optional).
89
- def initialize(items: [], selected_index: nil, style: nil, highlight_style: nil, highlight_symbol: "> ", repeat_highlight_symbol: false, highlight_spacing: :when_selected, direction: :top_to_bottom, scroll_padding: nil, block: nil)
109
+ def initialize(items: [], selected_index: nil, offset: nil, style: nil, highlight_style: nil, highlight_symbol: "> ", repeat_highlight_symbol: false, highlight_spacing: :when_selected, direction: :top_to_bottom, scroll_padding: nil, block: nil)
90
110
  super(
91
111
  items:,
92
112
  selected_index: selected_index.nil? ? nil : Integer(selected_index),
113
+ offset: offset.nil? ? nil : Integer(offset),
93
114
  style:,
94
115
  highlight_style:,
95
116
  highlight_symbol:,
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ module RatatuiRuby
7
+ # A styled list item combining content with optional style.
8
+ #
9
+ # By default, List items are strings. For more control over styling individual rows,
10
+ # wrap the content in a ListItem to apply a style specific to that item.
11
+ #
12
+ # The content can be a String, Text::Span, or Text::Line. The style applies to the
13
+ # entire row background.
14
+ #
15
+ # === Examples
16
+ #
17
+ # # Item with red background
18
+ # ListItem.new(content: "Error", style: Style.new(bg: :red))
19
+ #
20
+ # # Item with styled content
21
+ # ListItem.new(
22
+ # content: Text::Span.new(content: "Status: OK", style: Style.new(fg: :green, modifiers: [:bold]))
23
+ # )
24
+ class ListItem < Data.define(:content, :style)
25
+ ##
26
+ # :attr_reader: content
27
+ # The content to display (String, Text::Span, or Text::Line).
28
+
29
+ ##
30
+ # :attr_reader: style
31
+ # The style to apply to the item (optional Style).
32
+
33
+ # Creates a new ListItem.
34
+ #
35
+ # [content] String, Text::Span, or Text::Line.
36
+ # [style] Style object (optional).
37
+ def initialize(content:, style: nil)
38
+ super
39
+ end
40
+ end
41
+ end
@@ -69,5 +69,48 @@ module RatatuiRuby
69
69
  def contains?(px, py)
70
70
  px >= x && px < x + width && py >= y && py < y + height
71
71
  end
72
+
73
+ # Tests whether this rectangle overlaps with another.
74
+ #
75
+ # Essential for determining if a widget is visible within a viewport or clipping area.
76
+ #
77
+ # viewport = Rect.new(x: 0, y: 0, width: 80, height: 24)
78
+ # widget = Rect.new(x: 70, y: 20, width: 20, height: 10)
79
+ # viewport.intersects?(widget) # => true (partial overlap)
80
+ #
81
+ # [other]
82
+ # Another Rect to test against.
83
+ #
84
+ # Returns true if the rectangles overlap.
85
+ def intersects?(other)
86
+ x < other.x + other.width &&
87
+ x + width > other.x &&
88
+ y < other.y + other.height &&
89
+ y + height > other.y
90
+ end
91
+
92
+ # Returns the overlapping area between this rectangle and another.
93
+ #
94
+ # Essential for calculating visible portions of widgets inside scroll views.
95
+ #
96
+ # viewport = Rect.new(x: 0, y: 0, width: 80, height: 24)
97
+ # widget = Rect.new(x: 70, y: 20, width: 20, height: 10)
98
+ # visible = viewport.intersection(widget)
99
+ # # => Rect(x: 70, y: 20, width: 10, height: 4)
100
+ #
101
+ # [other]
102
+ # Another Rect to intersect with.
103
+ #
104
+ # Returns a new Rect representing the intersection, or +nil+ if no overlap.
105
+ def intersection(other)
106
+ return nil unless intersects?(other)
107
+
108
+ new_x = [x, other.x].max
109
+ new_y = [y, other.y].max
110
+ new_right = [x + width, other.x + other.width].min
111
+ new_bottom = [y + height, other.y + other.height].min
112
+
113
+ Rect.new(x: new_x, y: new_y, width: new_right - new_x, height: new_bottom - new_y)
114
+ end
72
115
  end
73
116
  end
@@ -20,18 +20,38 @@ module RatatuiRuby
20
20
  #
21
21
  # # Hex colors
22
22
  # Style.new(fg: "#ff00ff")
23
+ #
24
+ # === Supported Colors
25
+ #
26
+ # ==== Integer
27
+ # Represents an indexed color from the Xterm 256-color palette (0-255).
28
+ # * <tt>0</tt>–<tt>15</tt>: Standard and bright ANSI colors.
29
+ # * <tt>16</tt>–<tt>231</tt>: {6x6x6 Color Cube}[https://en.wikipedia.org/wiki/ANSI_escape_code#8-bit].
30
+ # * <tt>232</tt>–<tt>255</tt>: Grayscale ramp.
31
+ #
32
+ # ==== Symbol
33
+ # Represents a named color from the standard ANSI palette. Supported values:
34
+ # * <tt>:black</tt>, <tt>:red</tt>, <tt>:green</tt>, <tt>:yellow</tt>,
35
+ # <tt>:blue</tt>, <tt>:magenta</tt>, <tt>:cyan</tt>, <tt>:gray</tt>
36
+ # * <tt>:dark_gray</tt>, <tt>:light_red</tt>, <tt>:light_green</tt>,
37
+ # <tt>:light_yellow</tt>, <tt>:light_blue</tt>, <tt>:light_magenta</tt>,
38
+ # <tt>:light_cyan</tt>, <tt>:white</tt>
39
+ #
40
+ # ==== String
41
+ # Represents a specific RGB color using a Hex code (<tt>"#RRGGBB"</tt>).
42
+ # Requires a terminal emulator with "True Color" (24-bit color) support.
23
43
  class Style < Data.define(:fg, :bg, :modifiers)
24
44
  ##
25
45
  # :attr_reader: fg
26
46
  # Foreground color.
27
47
  #
28
- # Symbol (<tt>:red</tt>) or Hex String (<tt>"#ffffff"</tt>).
48
+ # Symbol (<tt>:red</tt>), Hex String (<tt>"#ffffff"</tt>), or Integer (0-255).
29
49
 
30
50
  ##
31
51
  # :attr_reader: bg
32
52
  # Background color.
33
53
  #
34
- # Symbol (<tt>:black</tt>) or Hex String (<tt>"#000000"</tt>).
54
+ # Symbol (<tt>:black</tt>), Hex String (<tt>"#000000"</tt>), or Integer (0-255).
35
55
 
36
56
  ##
37
57
  # :attr_reader: modifiers
@@ -42,8 +62,8 @@ module RatatuiRuby
42
62
 
43
63
  # Creates a new Style.
44
64
  #
45
- # [fg] Color (Symbol/String).
46
- # [bg] Color (Symbol/String).
65
+ # [fg] Color (Symbol/String/Integer).
66
+ # [bg] Color (Symbol/String/Integer).
47
67
  # [modifiers] Array of Symbols.
48
68
  def initialize(fg: nil, bg: nil, modifiers: [])
49
69
  super
@@ -20,7 +20,7 @@ module RatatuiRuby
20
20
  # Run the interactive demo from the terminal:
21
21
  #
22
22
  # ruby examples/widget_table_flex/app.rb
23
- class Table < Data.define(:header, :rows, :widths, :highlight_style, :highlight_symbol, :highlight_spacing, :column_highlight_style, :cell_highlight_style, :selected_row, :selected_column, :block, :footer, :flex, :style, :column_spacing)
23
+ class Table < Data.define(:header, :rows, :widths, :highlight_style, :highlight_symbol, :highlight_spacing, :column_highlight_style, :cell_highlight_style, :selected_row, :selected_column, :offset, :block, :footer, :flex, :style, :column_spacing)
24
24
  ##
25
25
  # :attr_reader: header
26
26
  # Header row content (Array of Strings).
@@ -43,7 +43,7 @@ module RatatuiRuby
43
43
 
44
44
  ##
45
45
  # :attr_reader: highlight_spacing
46
- # When to show the highlight symbol column (:always, :when_selected, :never).
46
+ # When to show the highlight symbol column (<tt>:always</tt>, <tt>:when_selected</tt>, <tt>:never</tt>).
47
47
 
48
48
  ##
49
49
  # :attr_reader: column_highlight_style
@@ -61,6 +61,22 @@ module RatatuiRuby
61
61
  # :attr_reader: selected_column
62
62
  # Index of the selected column (Integer or nil).
63
63
 
64
+ ##
65
+ # :attr_reader: offset
66
+ # Scroll offset (Integer or nil).
67
+ #
68
+ # Controls the viewport's starting row position in the table.
69
+ #
70
+ # When +nil+ (default), Ratatui auto-scrolls to keep the selection visible ("natural scrolling").
71
+ #
72
+ # When set, forces the viewport to start at this row index. Use this for:
73
+ # - **Passive scrolling**: Scroll through a log table without selecting rows.
74
+ # - **Click-to-select math**: Calculate which row index corresponds to a click coordinate.
75
+ #
76
+ # *Important*: When both +offset+ and +selected_row+ are set, Ratatui may still adjust
77
+ # the viewport during rendering to ensure the selection stays visible. Set +selected_row+
78
+ # to +nil+ for fully manual scroll control.
79
+
64
80
  ##
65
81
  # :attr_reader: block
66
82
  # Optional wrapping block.
@@ -93,12 +109,13 @@ module RatatuiRuby
93
109
  # [cell_highlight_style] Style object.
94
110
  # [selected_row] Integer (nullable).
95
111
  # [selected_column] Integer (nullable).
112
+ # [offset] Numeric (nullable, coerced to Integer). Forces scroll position when set.
96
113
  # [block] Block (optional).
97
114
  # [footer] Array of strings/paragraphs (optional).
98
115
  # [flex] Symbol (optional, default: <tt>:legacy</tt>).
99
116
  # [style] Style object or Hash (optional).
100
117
  # [column_spacing] Integer (optional, default: 1).
101
- def initialize(header: nil, rows: [], widths: [], highlight_style: nil, highlight_symbol: "> ", highlight_spacing: :when_selected, column_highlight_style: nil, cell_highlight_style: nil, selected_row: nil, selected_column: nil, block: nil, footer: nil, flex: :legacy, style: nil, column_spacing: 1)
118
+ def initialize(header: nil, rows: [], widths: [], highlight_style: nil, highlight_symbol: "> ", highlight_spacing: :when_selected, column_highlight_style: nil, cell_highlight_style: nil, selected_row: nil, selected_column: nil, offset: nil, block: nil, footer: nil, flex: :legacy, style: nil, column_spacing: 1)
102
119
  super(
103
120
  header:,
104
121
  rows:,
@@ -110,6 +127,7 @@ module RatatuiRuby
110
127
  cell_highlight_style:,
111
128
  selected_row: selected_row.nil? ? nil : Integer(selected_row),
112
129
  selected_column: selected_column.nil? ? nil : Integer(selected_column),
130
+ offset: offset.nil? ? nil : Integer(offset),
113
131
  block:,
114
132
  footer:,
115
133
  flex:,
@@ -4,8 +4,42 @@
4
4
  # SPDX-License-Identifier: AGPL-3.0-or-later
5
5
 
6
6
  module RatatuiRuby
7
- # Namespace for rich text components (Span and Line).
7
+ # Namespace for rich text components (Span, Line) and text utilities.
8
8
  # Distinct from canvas shapes and other Line usages.
9
+ #
10
+ # == Text Measurement
11
+ #
12
+ # The Text module provides a utility method for calculating the display width
13
+ # of strings in terminal cells. This accounts for unicode complexity:
14
+ #
15
+ # - ASCII characters: 1 cell each
16
+ # - CJK (Chinese, Japanese, Korean) characters: 2 cells each (full-width)
17
+ # - Emoji: typically 2 cells each (varies by terminal)
18
+ # - Combining marks: 0 cells (zero-width)
19
+ #
20
+ # This is essential for layout calculations in TUI applications, where you need to know
21
+ # how much space a string will occupy on the screen, not just its byte or character length.
22
+ #
23
+ # === Use Cases
24
+ #
25
+ # - Auto-sizing widgets (Button, Badge) that fit their content
26
+ # - Calculating padding or centering for text alignment
27
+ # - Building responsive layouts that adapt to content width
28
+ # - Measuring text for scrolling or truncation logic
29
+ #
30
+ # === Examples
31
+ #
32
+ # # Simple ASCII text
33
+ # RatatuiRuby::Text.width("Hello") # => 5
34
+ #
35
+ # # With emoji
36
+ # RatatuiRuby::Text.width("Hello 👍") # => 8 (5 + space + 2-width emoji)
37
+ #
38
+ # # With CJK characters
39
+ # RatatuiRuby::Text.width("你好") # => 4 (each CJK char is 2 cells)
40
+ #
41
+ # # Mixed content
42
+ # RatatuiRuby::Text.width("Hi 你好 👍") # => 11 (2 + space + 4 + space + 2)
9
43
  module Text
10
44
  # A styled string fragment.
11
45
  #
@@ -86,5 +120,39 @@ module RatatuiRuby
86
120
  new(spans: [Span.new(content:, style: nil)], alignment:)
87
121
  end
88
122
  end
123
+
124
+ ##
125
+ # :method: width
126
+ # :call-seq: width(string) -> Integer
127
+ #
128
+ # Calculates the display width of a string in terminal cells.
129
+ #
130
+ # Layout demands precision. Terminals measure space in cells, not characters. An ASCII letter occupies one cell. A Chinese character occupies two. An emoji occupies two. Combining marks occupy zero.
131
+ #
132
+ # Measuring width manually is error-prone. You can count <tt>string.length</tt>, but that counts characters, not cells. A string with one emoji counts as 1 character but occupies 2 cells.
133
+ #
134
+ # This method returns the true display width. Use it to auto-size widgets, calculate padding, center text, or build responsive layouts.
135
+ #
136
+ # === Examples
137
+ #
138
+ # RatatuiRuby::Text.width("Hello") # => 5 (5 ASCII chars × 1 cell)
139
+ #
140
+ # RatatuiRuby::Text.width("你好") # => 4 (2 CJK chars × 2 cells)
141
+ #
142
+ # RatatuiRuby::Text.width("Hello 👍") # => 8 (5 ASCII + 1 space + 1 emoji × 2)
143
+ #
144
+ # # In the Session DSL (easier)
145
+ # RatatuiRuby.run do |tui|
146
+ # width = tui.text_width("Hello 👍")
147
+ # end
148
+ #
149
+ # [string] String to measure (String or object convertible to String)
150
+ # Returns: Integer (number of terminal cells the string occupies)
151
+ # Raises: TypeError if the argument is not a String
152
+ #
153
+ # (Native method implemented in Rust)
154
+ def self.width(string)
155
+ RatatuiRuby._text_width(string)
156
+ end
89
157
  end
90
158
  end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ # SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
4
+ # SPDX-License-Identifier: AGPL-3.0-or-later
5
+
6
+ module RatatuiRuby
7
+ # Mutable state object for Scrollbar widgets.
8
+ #
9
+ # When using {Frame#render_stateful_widget}, the State object is the
10
+ # *single source of truth* for position and content length. Widget
11
+ # properties (+position+, +content_length+) are *ignored* in stateful mode.
12
+ #
13
+ # == Example
14
+ #
15
+ # @scrollbar_state = RatatuiRuby::ScrollbarState.new(100)
16
+ # @scrollbar_state.position = 25
17
+ #
18
+ # RatatuiRuby.draw do |frame|
19
+ # scrollbar = RatatuiRuby::Scrollbar.new(orientation: :vertical_right)
20
+ # frame.render_stateful_widget(scrollbar, frame.area, @scrollbar_state)
21
+ # end
22
+ #
23
+ class ScrollbarState
24
+ ##
25
+ # :method: new
26
+ # :call-seq: new(content_length) -> ScrollbarState
27
+ #
28
+ # Creates a new ScrollbarState with the given content length.
29
+ #
30
+ # (Native method implemented in Rust)
31
+
32
+ ##
33
+ # :method: position
34
+ # :call-seq: position() -> Integer
35
+ #
36
+ # Returns the current scroll position.
37
+ #
38
+ # (Native method implemented in Rust)
39
+
40
+ ##
41
+ # :method: position=
42
+ # :call-seq: position=(value) -> Integer
43
+ #
44
+ # Sets the current scroll position.
45
+ #
46
+ # (Native method implemented in Rust)
47
+
48
+ ##
49
+ # :method: content_length
50
+ # :call-seq: content_length() -> Integer
51
+ #
52
+ # Returns the total content length.
53
+ #
54
+ # (Native method implemented in Rust)
55
+
56
+ ##
57
+ # :method: content_length=
58
+ # :call-seq: content_length=(value) -> Integer
59
+ #
60
+ # Sets the total content length.
61
+ #
62
+ # (Native method implemented in Rust)
63
+
64
+ ##
65
+ # :method: viewport_content_length
66
+ # :call-seq: viewport_content_length() -> Integer
67
+ #
68
+ # Returns the viewport content length.
69
+ #
70
+ # (Native method implemented in Rust)
71
+
72
+ ##
73
+ # :method: viewport_content_length=
74
+ # :call-seq: viewport_content_length=(value) -> Integer
75
+ #
76
+ # Sets the viewport content length.
77
+ #
78
+ # (Native method implemented in Rust)
79
+
80
+ ##
81
+ # :method: first
82
+ # :call-seq: first() -> nil
83
+ #
84
+ # Scrolls to the first position.
85
+ #
86
+ # (Native method implemented in Rust)
87
+
88
+ ##
89
+ # :method: last
90
+ # :call-seq: last() -> nil
91
+ #
92
+ # Scrolls to the last position.
93
+ #
94
+ # (Native method implemented in Rust)
95
+
96
+ ##
97
+ # :method: next
98
+ # :call-seq: next() -> nil
99
+ #
100
+ # Scrolls to the next position.
101
+ #
102
+ # (Native method implemented in Rust)
103
+
104
+ ##
105
+ # :method: prev
106
+ # :call-seq: prev() -> nil
107
+ #
108
+ # Scrolls to the previous position.
109
+ #
110
+ # (Native method implemented in Rust)
111
+ end
112
+ end
@@ -26,6 +26,11 @@ module RatatuiRuby
26
26
  #
27
27
  # Delegates to RatatuiRuby._tabs_width.
28
28
  #
29
+ # :method: _text_width
30
+ # :call-seq: _text_width(*args, **kwargs, &block)
31
+ #
32
+ # Delegates to RatatuiRuby._text_width.
33
+ #
29
34
  # :method: clear_events
30
35
  # :call-seq: clear_events(*args, **kwargs, &block)
31
36
  #
@@ -223,6 +228,16 @@ module RatatuiRuby
223
228
  #
224
229
  # Factory for RatatuiRuby::Dataset.new.
225
230
  #
231
+ # :method: draw_cell
232
+ # :call-seq: draw_cell(*args, **kwargs, &block)
233
+ #
234
+ # Helper for RatatuiRuby::Draw.cell.
235
+ #
236
+ # :method: draw_string
237
+ # :call-seq: draw_string(*args, **kwargs, &block)
238
+ #
239
+ # Helper for RatatuiRuby::Draw.string.
240
+ #
226
241
  # :method: draw_cell_cmd
227
242
  # :call-seq: draw_cell_cmd(*args, **kwargs, &block)
228
243
  #
@@ -238,6 +253,16 @@ module RatatuiRuby
238
253
  #
239
254
  # Factory for RatatuiRuby::Error.new.
240
255
  #
256
+ # :method: error_safety
257
+ # :call-seq: error_safety(*args, **kwargs, &block)
258
+ #
259
+ # Factory for RatatuiRuby::Error::Safety.new.
260
+ #
261
+ # :method: error_terminal
262
+ # :call-seq: error_terminal(*args, **kwargs, &block)
263
+ #
264
+ # Factory for RatatuiRuby::Error::Terminal.new.
265
+ #
241
266
  # :method: event
242
267
  # :call-seq: event(*args, **kwargs, &block)
243
268
  #
@@ -313,6 +338,21 @@ module RatatuiRuby
313
338
  #
314
339
  # Factory for RatatuiRuby::List.new.
315
340
  #
341
+ # :method: list_item
342
+ # :call-seq: list_item(*args, **kwargs, &block)
343
+ #
344
+ # Factory for RatatuiRuby::ListItem.new.
345
+ #
346
+ # :method: list_state
347
+ # :call-seq: list_state(*args, **kwargs, &block)
348
+ #
349
+ # Factory for RatatuiRuby::ListState.new.
350
+ #
351
+ # :method: list_state_new
352
+ # :call-seq: list_state_new(*args, **kwargs, &block)
353
+ #
354
+ # Helper for RatatuiRuby::ListState.new.
355
+ #
316
356
  # :method: overlay
317
357
  # :call-seq: overlay(*args, **kwargs, &block)
318
358
  #
@@ -348,6 +388,16 @@ module RatatuiRuby
348
388
  #
349
389
  # Factory for RatatuiRuby::Scrollbar.new.
350
390
  #
391
+ # :method: scrollbar_state
392
+ # :call-seq: scrollbar_state(*args, **kwargs, &block)
393
+ #
394
+ # Factory for RatatuiRuby::ScrollbarState.new.
395
+ #
396
+ # :method: scrollbar_state_new
397
+ # :call-seq: scrollbar_state_new(*args, **kwargs, &block)
398
+ #
399
+ # Helper for RatatuiRuby::ScrollbarState.new.
400
+ #
351
401
  # :method: shape_circle
352
402
  # :call-seq: shape_circle(*args, **kwargs, &block)
353
403
  #
@@ -398,11 +448,26 @@ module RatatuiRuby
398
448
  #
399
449
  # Factory for RatatuiRuby::Table.new.
400
450
  #
451
+ # :method: table_state
452
+ # :call-seq: table_state(*args, **kwargs, &block)
453
+ #
454
+ # Factory for RatatuiRuby::TableState.new.
455
+ #
456
+ # :method: table_state_new
457
+ # :call-seq: table_state_new(*args, **kwargs, &block)
458
+ #
459
+ # Helper for RatatuiRuby::TableState.new.
460
+ #
401
461
  # :method: tabs
402
462
  # :call-seq: tabs(*args, **kwargs, &block)
403
463
  #
404
464
  # Factory for RatatuiRuby::Tabs.new.
405
465
  #
466
+ # :method: text_width
467
+ # :call-seq: text_width(*args, **kwargs, &block)
468
+ #
469
+ # Helper for RatatuiRuby::Text.width.
470
+ #
406
471
  # :method: text_line
407
472
  # :call-seq: text_line(*args, **kwargs, &block)
408
473
  #
@@ -12,6 +12,14 @@ module RatatuiRuby
12
12
  #
13
13
  # Use it within <tt>RatatuiRuby.run</tt> to build your interface cleanly.
14
14
  #
15
+ # == Thread/Ractor Safety
16
+ #
17
+ # Session is an *I/O handle*, not a data object. It has side effects (draw,
18
+ # poll_event) and is intentionally *not* Ractor-shareable. Caching it in
19
+ # instance variables (<tt>@tui = tui</tt>) during your application's run loop
20
+ # is fine. However, do not include it in immutable TEA Models/Messages or
21
+ # pass it to other Ractors.
22
+ #
15
23
  # == Available Methods
16
24
  #
17
25
  # The session dynamically defines factory methods for all RatatuiRuby constants.
@@ -40,6 +48,7 @@ module RatatuiRuby
40
48
  #
41
49
  # * <tt>text_span(...)</tt> -> <tt>RatatuiRuby::Text::Span.new(...)</tt>
42
50
  # * <tt>text_line(...)</tt> -> <tt>RatatuiRuby::Text::Line.new(...)</tt>
51
+ # * <tt>text_width(string)</tt> -> <tt>RatatuiRuby::Text.width(string)</tt>
43
52
  #
44
53
  # === Examples
45
54
  #
@@ -123,15 +132,21 @@ module RatatuiRuby
123
132
  define_method(method_name) do |*args, **kwargs, &block|
124
133
  const.new(*args, **kwargs, &block)
125
134
  end
135
+ end
136
+
137
+ # 2. Singleton Method Helpers (for both Classes and Modules)
138
+ # e.g. Layout.split -> layout_split
139
+ # e.g. Text.width -> text_width
140
+ const.singleton_methods(false).each do |class_method|
141
+ parent_prefix = const_name.to_s
142
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
143
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
144
+ .downcase
126
145
 
127
- # 2. Class Method Helpers (for Classes only)
128
- # e.g. Layout.split -> layout_split
129
- const.singleton_methods(false).each do |class_method|
130
- session_method_name = "#{method_name}_#{class_method}"
146
+ session_method_name = "#{parent_prefix}_#{class_method}"
131
147
 
132
- define_method(session_method_name) do |*args, **kwargs, &block|
133
- const.public_send(class_method, *args, **kwargs, &block)
134
- end
148
+ define_method(session_method_name) do |*args, **kwargs, &block|
149
+ const.public_send(class_method, *args, **kwargs, &block)
135
150
  end
136
151
  end
137
152