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
@@ -96,5 +96,56 @@ module RatatuiRuby
96
96
  # Scrolls up by +n+ rows.
97
97
  #
98
98
  # (Native method implemented in Rust)
99
+
100
+ ##
101
+ # :method: selected_cell
102
+ # :call-seq: selected_cell() -> Array or nil
103
+ #
104
+ # Returns the currently selected cell as <tt>[row, column]</tt>.
105
+ # Returns +nil+ if either row or column is not selected.
106
+ #
107
+ # (Native method implemented in Rust)
108
+
109
+ ##
110
+ # :method: select_next_column
111
+ # :call-seq: select_next_column() -> nil
112
+ #
113
+ # Selects the next column, or column 0 if none selected.
114
+ #
115
+ # (Native method implemented in Rust)
116
+
117
+ ##
118
+ # :method: select_previous_column
119
+ # :call-seq: select_previous_column() -> nil
120
+ #
121
+ # Selects the previous column. Saturates at 0.
122
+ #
123
+ # (Native method implemented in Rust)
124
+
125
+ ##
126
+ # :method: select_first_column
127
+ # :call-seq: select_first_column() -> nil
128
+ #
129
+ # Selects column 0.
130
+ #
131
+ # (Native method implemented in Rust)
132
+
133
+ ##
134
+ # :method: select_last_column
135
+ # :call-seq: select_last_column() -> nil
136
+ #
137
+ # Selects the last column. The index is clamped during rendering.
138
+ #
139
+ # (Native method implemented in Rust)
140
+
141
+ ##
142
+ # :singleton-method: with_selected_cell
143
+ # :call-seq: with_selected_cell(cell) -> TableState
144
+ #
145
+ # Creates a new TableState with both row and column selected.
146
+ #
147
+ # [cell] <tt>[row, column]</tt> array, or +nil+.
148
+ #
149
+ # (Native method implemented in Rust)
99
150
  end
100
151
  end
@@ -90,7 +90,7 @@ module RatatuiRuby
90
90
  ##
91
91
  # Restores the terminal to its original state.
92
92
  # Leaves alternate screen and disables raw mode.
93
- # Also flushes any deferred warnings that were queued during the session.
93
+ # Also flushes any deferred warnings and panic info that were queued during the session.
94
94
  #
95
95
  # In headless mode ({headless!}), this method is a silent no-op since
96
96
  # no terminal was ever initialized.
@@ -103,6 +103,7 @@ module RatatuiRuby
103
103
  ensure
104
104
  @tui_session_active = false
105
105
  flush_warnings
106
+ flush_panic_info
106
107
  end
107
108
 
108
109
  ##
@@ -64,7 +64,7 @@ module RatatuiRuby
64
64
  # [event] A <tt>RatatuiRuby::Event</tt> object.
65
65
  def inject_event(event)
66
66
  unless @_ratatui_test_terminal_active
67
- raise "Events must be injected inside a `with_test_terminal` block. " \
67
+ raise RatatuiRuby::Error::Invariant, "Events must be injected inside a `with_test_terminal` block. " \
68
68
  "Calling this method outside the block causes a race condition where the event " \
69
69
  "is flushed before the application starts."
70
70
  end
@@ -209,6 +209,11 @@ module RatatuiRuby
209
209
  # pending async operations to complete before processing the next event.
210
210
  # This enables deterministic testing of async behavior.
211
211
  #
212
+ # *Important*: Sync waits for commands to _complete_. Do not use it
213
+ # with long-running commands that wait indefinitely (e.g., for
214
+ # cancellation). Those commands will block forever, causing a timeout.
215
+ # For cancellation tests, dispatch the cancel command without Sync.
216
+ #
212
217
  # === Example
213
218
  #
214
219
  #--
@@ -92,6 +92,15 @@ module RatatuiRuby
92
92
  # SPDX-SnippetEnd
93
93
  #++
94
94
  module TestHelper
95
+ ##
96
+ # Auto-enables debug mode when TestHelper is included.
97
+ #
98
+ # This ensures Rust backtraces are available in tests.
99
+ # Skips remote debugging since tests don't need it.
100
+ def self.included(base)
101
+ RatatuiRuby::Debug.enable!(source: :test)
102
+ end
103
+
95
104
  include Terminal
96
105
  include Snapshot
97
106
  include EventInjection
@@ -0,0 +1,245 @@
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 Text
10
+ # A sequence of styled spans.
11
+ #
12
+ # Words form sentences. Spans form lines.
13
+ #
14
+ # This class composes multiple {Span} objects into a single horizontal row of text.
15
+ # It handles the layout of rich text fragments within the flow of a paragraph.
16
+ #
17
+ # Use it to build multi-colored headers, status messages, or log entries.
18
+ #
19
+ # === Examples
20
+ #
21
+ #--
22
+ # SPDX-SnippetBegin
23
+ # SPDX-FileCopyrightText: 2025 Kerrick Long
24
+ # SPDX-License-Identifier: MIT-0
25
+ #++
26
+ # Text::Line.new(
27
+ # spans: [
28
+ # Text::Span.styled("User: ", Style.new(modifiers: [:bold])),
29
+ # Text::Span.styled("kerrick", Style.new(fg: :blue))
30
+ # ]
31
+ # )
32
+ #--
33
+ # SPDX-SnippetEnd
34
+ #++
35
+ class Line < Data.define(:spans, :alignment, :style)
36
+ ##
37
+ # :attr_reader: spans
38
+ # Array of Span objects.
39
+
40
+ ##
41
+ # :attr_reader: alignment
42
+ # Alignment within the container.
43
+ #
44
+ # <tt>:left</tt>, <tt>:center</tt>, or <tt>:right</tt>.
45
+
46
+ ##
47
+ # :attr_reader: style
48
+ # Line-level style applied to all spans.
49
+ #
50
+ # A Style object that sets colors/modifiers for the entire line.
51
+
52
+ # Creates a new Line.
53
+ #
54
+ # [spans] Array of Span objects (or Strings).
55
+ # [alignment] Symbol (optional).
56
+ # [style] Style object (optional).
57
+ def initialize(spans: [], alignment: nil, style: nil)
58
+ super
59
+ end
60
+
61
+ # Creates a simple line from a string.
62
+ #
63
+ # Text::Line.from_string("Hello")
64
+ def self.from_string(content, alignment: nil)
65
+ new(spans: [Span.new(content:, style: nil)], alignment:)
66
+ end
67
+
68
+ # Calculates the display width of this line in terminal cells.
69
+ #
70
+ # Sums the widths of all span contents using the same unicode-aware
71
+ # algorithm as Text.width. Useful for layout calculations.
72
+ #
73
+ # === Examples
74
+ #
75
+ #--
76
+ # SPDX-SnippetBegin
77
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
78
+ # SPDX-License-Identifier: MIT-0
79
+ #++
80
+ # line = Text::Line.new(spans: [
81
+ # Text::Span.new(content: "Hello "),
82
+ # Text::Span.new(content: "世界")
83
+ # ])
84
+ # line.width # => 10 (6 ASCII + 4 CJK)
85
+ #
86
+ #--
87
+ # SPDX-SnippetEnd
88
+ #++
89
+ # Returns: Integer (number of terminal cells).
90
+ def width
91
+ RatatuiRuby::Text.width(spans.map { |s| s.content.to_s }.join)
92
+ end
93
+
94
+ # Left-aligns this line of text.
95
+ #
96
+ # Convenience shortcut for <tt>alignment: :left</tt>. Setting the alignment of a Line
97
+ # overrides the alignment of its parent Text or Widget.
98
+ #
99
+ # === Example
100
+ #
101
+ #--
102
+ # SPDX-SnippetBegin
103
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
104
+ # SPDX-License-Identifier: MIT-0
105
+ #++
106
+ # line = Text::Line.new(spans: [Text::Span.new(content: "Hello")])
107
+ # aligned = line.left_aligned
108
+ # aligned.alignment # => :left
109
+ #
110
+ #--
111
+ # SPDX-SnippetEnd
112
+ #++
113
+ # Returns: Line.
114
+ def left_aligned
115
+ with(alignment: :left)
116
+ end
117
+
118
+ # Center-aligns this line of text.
119
+ #
120
+ # Convenience shortcut for <tt>alignment: :center</tt>. Setting the alignment of a Line
121
+ # overrides the alignment of its parent Text or Widget.
122
+ #
123
+ # === Example
124
+ #
125
+ #--
126
+ # SPDX-SnippetBegin
127
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
128
+ # SPDX-License-Identifier: MIT-0
129
+ #++
130
+ # line = Text::Line.new(spans: [Text::Span.new(content: "Hello")])
131
+ # centered = line.centered
132
+ # centered.alignment # => :center
133
+ #
134
+ #--
135
+ # SPDX-SnippetEnd
136
+ #++
137
+ # Returns: Line.
138
+ def centered
139
+ with(alignment: :center)
140
+ end
141
+
142
+ # Right-aligns this line of text.
143
+ #
144
+ # Convenience shortcut for <tt>alignment: :right</tt>. Setting the alignment of a Line
145
+ # overrides the alignment of its parent Text or Widget.
146
+ #
147
+ # === Example
148
+ #
149
+ #--
150
+ # SPDX-SnippetBegin
151
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
152
+ # SPDX-License-Identifier: MIT-0
153
+ #++
154
+ # line = Text::Line.new(spans: [Text::Span.new(content: "Hello")])
155
+ # aligned = line.right_aligned
156
+ # aligned.alignment # => :right
157
+ #
158
+ #--
159
+ # SPDX-SnippetEnd
160
+ #++
161
+ # Returns: Line.
162
+ def right_aligned
163
+ with(alignment: :right)
164
+ end
165
+
166
+ # Adds a span to the line.
167
+ #
168
+ # Since Line is immutable (a Data subclass), this returns a new Line with the span appended.
169
+ # The original line remains unchanged.
170
+ #
171
+ # === Example
172
+ #
173
+ #--
174
+ # SPDX-SnippetBegin
175
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
176
+ # SPDX-License-Identifier: MIT-0
177
+ #++
178
+ # line = Text::Line.new(spans: [Text::Span.new(content: "Hello, ")])
179
+ # extended = line.push_span(Text::Span.new(content: "world!"))
180
+ # extended.spans.size # => 2
181
+ # line.spans.size # => 1 (original unchanged)
182
+ #
183
+ #--
184
+ # SPDX-SnippetEnd
185
+ #++
186
+ # [span] Span to append.
187
+ #
188
+ # Returns: Line.
189
+ def push_span(span)
190
+ with(spans: spans + [span])
191
+ end
192
+
193
+ # Patches the style of this line, adding modifiers from the given style.
194
+ #
195
+ # Applies <tt>patch_style</tt> to each span in the line. Use this when you want to layer
196
+ # styles on all spans without replacing their existing styles.
197
+ #
198
+ # === Example
199
+ #
200
+ #--
201
+ # SPDX-SnippetBegin
202
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
203
+ # SPDX-License-Identifier: MIT-0
204
+ #++
205
+ # line = Text::Line.new(spans: [Text::Span.new(content: "Hello")])
206
+ # styled = line.patch_style(Style::Style.new(fg: :red))
207
+ # styled.spans.first.style.fg # => :red
208
+ #
209
+ #--
210
+ # SPDX-SnippetEnd
211
+ #++
212
+ # [patch] Style::Style to merge onto each span.
213
+ #
214
+ # Returns: Line.
215
+ def patch_style(patch)
216
+ with(spans: spans.map { |s| s.patch_style(patch) })
217
+ end
218
+
219
+ # Resets the style of this line.
220
+ #
221
+ # Applies <tt>reset_style</tt> to each span in the line, clearing all styling.
222
+ #
223
+ # === Example
224
+ #
225
+ #--
226
+ # SPDX-SnippetBegin
227
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
228
+ # SPDX-License-Identifier: MIT-0
229
+ #++
230
+ # line = Text::Line.new(spans: [
231
+ # Text::Span.new(content: "styled", style: Style::Style.new(fg: :red))
232
+ # ])
233
+ # reset = line.reset_style
234
+ # reset.spans.first.style # => nil
235
+ #
236
+ #--
237
+ # SPDX-SnippetEnd
238
+ #++
239
+ # Returns: Line.
240
+ def reset_style
241
+ with(spans: spans.map(&:reset_style))
242
+ end
243
+ end
244
+ end
245
+ end
@@ -0,0 +1,158 @@
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 Text
10
+ # A styled string fragment.
11
+ #
12
+ # Text is rarely uniform. You need to bold a keyword, colorize an error, or dim a timestamp.
13
+ #
14
+ # This class attaches style to content. It pairs a string with visual attributes.
15
+ #
16
+ # combine spans into a {Line} to create rich text.
17
+ #
18
+ # === Examples
19
+ #
20
+ # Text::Span.new(content: "Error", style: Style.new(fg: :red, modifiers: [:bold]))
21
+ class Span < Data.define(:content, :style)
22
+ ##
23
+ # :attr_reader: content
24
+ # The text content.
25
+
26
+ ##
27
+ # :attr_reader: style
28
+ # The style to apply.
29
+
30
+ # Creates a new Span.
31
+ #
32
+ # [content] String.
33
+ # [style] Style object (optional).
34
+ def initialize(content:, style: nil)
35
+ super
36
+ end
37
+
38
+ # Concise helper for styling.
39
+ #
40
+ # === Example
41
+ #
42
+ # Text::Span.styled("Bold", Style::Style.new(modifiers: [:bold]))
43
+ def self.styled(content, style = nil)
44
+ new(content:, style:)
45
+ end
46
+
47
+ # Returns the unicode display width of the content in terminal cells.
48
+ #
49
+ # CJK characters and emoji count as 2 cells. ASCII characters count as 1 cell.
50
+ # Use this to measure how much horizontal space a span will occupy.
51
+ #
52
+ # === Example
53
+ #
54
+ #--
55
+ # SPDX-SnippetBegin
56
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
57
+ # SPDX-License-Identifier: MIT-0
58
+ #++
59
+ # span = Text::Span.new(content: "Hello")
60
+ # span.width # => 5
61
+ #
62
+ # span = Text::Span.new(content: "你好") # Chinese characters
63
+ # span.width # => 4
64
+ #
65
+ #--
66
+ # SPDX-SnippetEnd
67
+ #++
68
+ # Returns: Integer.
69
+ def width
70
+ RatatuiRuby::Text.width(content.to_s)
71
+ end
72
+
73
+ # Creates a span with the default style.
74
+ #
75
+ # Use this factory method when you want unstyled text. It mirrors the Ratatui API.
76
+ #
77
+ # === Example
78
+ #
79
+ #--
80
+ # SPDX-SnippetBegin
81
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
82
+ # SPDX-License-Identifier: MIT-0
83
+ #++
84
+ # span = Text::Span.raw("test content")
85
+ # span.content # => "test content"
86
+ # span.style # => nil
87
+ #
88
+ #--
89
+ # SPDX-SnippetEnd
90
+ #++
91
+ # [content] String.
92
+ #
93
+ # Returns: Span.
94
+ def self.raw(content)
95
+ new(content:)
96
+ end
97
+
98
+ # Patches the style of the span, merging modifiers from the given style.
99
+ #
100
+ # Non-nil values from the patch style override the existing style. Use this when you want to
101
+ # layer styles without replacing the entire style. Colors in the patch take precedence over
102
+ # existing colors. Modifiers are combined.
103
+ #
104
+ # === Example
105
+ #
106
+ #--
107
+ # SPDX-SnippetBegin
108
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
109
+ # SPDX-License-Identifier: MIT-0
110
+ #++
111
+ # span = Text::Span.new(content: "test", style: Style::Style.new(fg: :green))
112
+ # patched = span.patch_style(Style::Style.new(bg: :yellow, modifiers: [:bold]))
113
+ # patched.style.fg # => :green (preserved)
114
+ # patched.style.bg # => :yellow (added)
115
+ #
116
+ #--
117
+ # SPDX-SnippetEnd
118
+ #++
119
+ # [patch] Style::Style to merge.
120
+ #
121
+ # Returns: Span.
122
+ def patch_style(patch)
123
+ return self if patch.nil?
124
+ return with(style: patch) if style.nil?
125
+
126
+ merged = Style::Style.new(
127
+ fg: patch.fg.nil? ? style.fg : patch.fg,
128
+ bg: patch.bg.nil? ? style.bg : patch.bg,
129
+ modifiers: patch.modifiers.empty? ? style.modifiers : (style.modifiers + patch.modifiers).uniq
130
+ )
131
+ with(style: merged)
132
+ end
133
+
134
+ # Resets the style of the span.
135
+ #
136
+ # Returns a new span with no style applied. Use this to strip all styling.
137
+ #
138
+ # === Example
139
+ #
140
+ #--
141
+ # SPDX-SnippetBegin
142
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
143
+ # SPDX-License-Identifier: MIT-0
144
+ #++
145
+ # span = Text::Span.new(content: "styled", style: Style::Style.new(fg: :red))
146
+ # reset = span.reset_style
147
+ # reset.style # => nil
148
+ #
149
+ #--
150
+ # SPDX-SnippetEnd
151
+ #++
152
+ # Returns: Span.
153
+ def reset_style
154
+ with(style: nil)
155
+ end
156
+ end
157
+ end
158
+ end
@@ -0,0 +1,99 @@
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
+ # Namespace for rich text components (Span, Line) and text utilities.
10
+ # Distinct from canvas shapes and other Line usages.
11
+ #
12
+ # == Text Measurement
13
+ #
14
+ # The Text module provides a utility method for calculating the display width
15
+ # of strings in terminal cells. This accounts for unicode complexity:
16
+ #
17
+ # - ASCII characters: 1 cell each
18
+ # - CJK (Chinese, Japanese, Korean) characters: 2 cells each (full-width)
19
+ # - Emoji: typically 2 cells each (varies by terminal)
20
+ # - Combining marks: 0 cells (zero-width)
21
+ #
22
+ # This is essential for layout calculations in TUI applications, where you need to know
23
+ # how much space a string will occupy on the screen, not just its byte or character length.
24
+ #
25
+ # === Use Cases
26
+ #
27
+ # - Auto-sizing widgets (Button, Badge) that fit their content
28
+ # - Calculating padding or centering for text alignment
29
+ # - Building responsive layouts that adapt to content width
30
+ # - Measuring text for scrolling or truncation logic
31
+ #
32
+ # === Examples
33
+ #
34
+ #--
35
+ # SPDX-SnippetBegin
36
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
37
+ # SPDX-License-Identifier: MIT-0
38
+ #++
39
+ # # Simple ASCII text
40
+ # RatatuiRuby::Text.width("Hello") # => 5
41
+ #
42
+ # # With emoji
43
+ # RatatuiRuby::Text.width("Hello 👍") # => 8 (5 + space + 2-width emoji)
44
+ #
45
+ # # With CJK characters
46
+ # RatatuiRuby::Text.width("你好") # => 4 (each CJK char is 2 cells)
47
+ #
48
+ # # Mixed content
49
+ # RatatuiRuby::Text.width("Hi 你好 👍") # => 11 (2 + space + 4 + space + 2)
50
+ #--
51
+ # SPDX-SnippetEnd
52
+ #++
53
+ module Text
54
+ ##
55
+ # :method: width
56
+ # :call-seq: width(string) -> Integer
57
+ #
58
+ # Calculates the display width of a string in terminal cells.
59
+ #
60
+ # 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.
61
+ #
62
+ # 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.
63
+ #
64
+ # This method returns the true display width. Use it to auto-size widgets, calculate padding, center text, or build responsive layouts.
65
+ #
66
+ # === Examples
67
+ #
68
+ #--
69
+ # SPDX-SnippetBegin
70
+ # SPDX-FileCopyrightText: 2026 Kerrick Long
71
+ # SPDX-License-Identifier: MIT-0
72
+ #++
73
+ # RatatuiRuby::Text.width("Hello") # => 5 (5 ASCII chars × 1 cell)
74
+ #
75
+ # RatatuiRuby::Text.width("你好") # => 4 (2 CJK chars × 2 cells)
76
+ #
77
+ # RatatuiRuby::Text.width("Hello 👍") # => 8 (5 ASCII + 1 space + 1 emoji × 2)
78
+ #
79
+ # # In the Session DSL (easier)
80
+ # RatatuiRuby.run do |tui|
81
+ # width = tui.text_width("Hello 👍")
82
+ # end
83
+ #
84
+ #--
85
+ # SPDX-SnippetEnd
86
+ #++
87
+ # [string] String to measure (String or object convertible to String)
88
+ # Returns: Integer (number of terminal cells the string occupies)
89
+ # Raises: TypeError if the argument is not a String
90
+ #
91
+ # (Native method implemented in Rust)
92
+ def self.width(string)
93
+ RatatuiRuby._text_width(string)
94
+ end
95
+ end
96
+ end
97
+
98
+ require_relative "text/span"
99
+ require_relative "text/line"