ratatui_ruby 1.1.0 → 1.1.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 (259) hide show
  1. checksums.yaml +4 -4
  2. data/ext/ratatui_ruby/Cargo.lock +1 -1
  3. data/ext/ratatui_ruby/Cargo.toml +1 -1
  4. data/lib/ratatui_ruby/version.rb +1 -1
  5. metadata +1 -255
  6. data/.builds/ruby-3.2.yml +0 -54
  7. data/.builds/ruby-3.3.yml +0 -54
  8. data/.builds/ruby-3.4.yml +0 -54
  9. data/.builds/ruby-4.0.0.yml +0 -54
  10. data/.pre-commit-config.yaml +0 -16
  11. data/.rubocop.yml +0 -10
  12. data/AGENTS.md +0 -147
  13. data/CHANGELOG.md +0 -736
  14. data/README.md +0 -187
  15. data/README.rdoc +0 -302
  16. data/Rakefile +0 -11
  17. data/Steepfile +0 -50
  18. data/doc/concepts/application_architecture.md +0 -321
  19. data/doc/concepts/application_testing.md +0 -193
  20. data/doc/concepts/async.md +0 -190
  21. data/doc/concepts/custom_widgets.md +0 -247
  22. data/doc/concepts/debugging.md +0 -401
  23. data/doc/concepts/event_handling.md +0 -162
  24. data/doc/concepts/interactive_design.md +0 -146
  25. data/doc/contributors/auditing/parity.md +0 -239
  26. data/doc/contributors/design/ruby_frontend.md +0 -448
  27. data/doc/contributors/design/rust_backend.md +0 -434
  28. data/doc/contributors/design.md +0 -11
  29. data/doc/contributors/developing_examples.md +0 -400
  30. data/doc/contributors/documentation_style.md +0 -121
  31. data/doc/contributors/index.md +0 -21
  32. data/doc/contributors/releasing.md +0 -215
  33. data/doc/contributors/todo/align/api_completeness_audit-finished.md +0 -381
  34. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +0 -200
  35. data/doc/contributors/todo/align/term.md +0 -351
  36. data/doc/contributors/todo/align/terminal.md +0 -647
  37. data/doc/contributors/todo/future_work.md +0 -169
  38. data/doc/contributors/upstream_requests/paragraph_span_rects.md +0 -259
  39. data/doc/contributors/upstream_requests/tab_rects.md +0 -173
  40. data/doc/contributors/upstream_requests/title_rects.md +0 -132
  41. data/doc/custom.css +0 -22
  42. data/doc/getting_started/quickstart.md +0 -291
  43. data/doc/getting_started/why.md +0 -93
  44. data/doc/images/app_all_events.png +0 -0
  45. data/doc/images/app_cli_rich_moments.gif +0 -0
  46. data/doc/images/app_color_picker.png +0 -0
  47. data/doc/images/app_debugging_showcase.gif +0 -0
  48. data/doc/images/app_debugging_showcase.png +0 -0
  49. data/doc/images/app_external_editor.gif +0 -0
  50. data/doc/images/app_login_form.png +0 -0
  51. data/doc/images/app_stateful_interaction.png +0 -0
  52. data/doc/images/verify_quickstart_dsl.png +0 -0
  53. data/doc/images/verify_quickstart_layout.png +0 -0
  54. data/doc/images/verify_quickstart_lifecycle.png +0 -0
  55. data/doc/images/verify_readme_usage.png +0 -0
  56. data/doc/images/widget_barchart.png +0 -0
  57. data/doc/images/widget_block.png +0 -0
  58. data/doc/images/widget_box.png +0 -0
  59. data/doc/images/widget_calendar.png +0 -0
  60. data/doc/images/widget_canvas.png +0 -0
  61. data/doc/images/widget_cell.png +0 -0
  62. data/doc/images/widget_center.png +0 -0
  63. data/doc/images/widget_chart.png +0 -0
  64. data/doc/images/widget_gauge.png +0 -0
  65. data/doc/images/widget_layout_split.png +0 -0
  66. data/doc/images/widget_line_gauge.png +0 -0
  67. data/doc/images/widget_list.png +0 -0
  68. data/doc/images/widget_map.png +0 -0
  69. data/doc/images/widget_overlay.png +0 -0
  70. data/doc/images/widget_popup.png +0 -0
  71. data/doc/images/widget_ratatui_logo.png +0 -0
  72. data/doc/images/widget_ratatui_mascot.png +0 -0
  73. data/doc/images/widget_rect.png +0 -0
  74. data/doc/images/widget_render.png +0 -0
  75. data/doc/images/widget_rich_text.png +0 -0
  76. data/doc/images/widget_scroll_text.png +0 -0
  77. data/doc/images/widget_scrollbar.png +0 -0
  78. data/doc/images/widget_sparkline.png +0 -0
  79. data/doc/images/widget_style_colors.png +0 -0
  80. data/doc/images/widget_table.png +0 -0
  81. data/doc/images/widget_tabs.png +0 -0
  82. data/doc/images/widget_text_width.png +0 -0
  83. data/doc/index.md +0 -34
  84. data/doc/troubleshooting/async.md +0 -4
  85. data/doc/troubleshooting/terminal_limitations.md +0 -131
  86. data/doc/troubleshooting/tui_output.md +0 -197
  87. data/examples/app_all_events/README.md +0 -114
  88. data/examples/app_all_events/app.rb +0 -98
  89. data/examples/app_all_events/model/app_model.rb +0 -159
  90. data/examples/app_all_events/model/event_color_cycle.rb +0 -43
  91. data/examples/app_all_events/model/event_entry.rb +0 -94
  92. data/examples/app_all_events/model/msg.rb +0 -39
  93. data/examples/app_all_events/model/timestamp.rb +0 -56
  94. data/examples/app_all_events/update.rb +0 -75
  95. data/examples/app_all_events/view/app_view.rb +0 -80
  96. data/examples/app_all_events/view/controls_view.rb +0 -54
  97. data/examples/app_all_events/view/counts_view.rb +0 -61
  98. data/examples/app_all_events/view/live_view.rb +0 -72
  99. data/examples/app_all_events/view/log_view.rb +0 -57
  100. data/examples/app_all_events/view.rb +0 -9
  101. data/examples/app_cli_rich_moments/README.md +0 -81
  102. data/examples/app_cli_rich_moments/app.rb +0 -189
  103. data/examples/app_color_picker/README.md +0 -156
  104. data/examples/app_color_picker/app.rb +0 -76
  105. data/examples/app_color_picker/clipboard.rb +0 -86
  106. data/examples/app_color_picker/color.rb +0 -193
  107. data/examples/app_color_picker/controls.rb +0 -92
  108. data/examples/app_color_picker/copy_dialog.rb +0 -168
  109. data/examples/app_color_picker/export_pane.rb +0 -128
  110. data/examples/app_color_picker/harmony.rb +0 -58
  111. data/examples/app_color_picker/input.rb +0 -176
  112. data/examples/app_color_picker/main_container.rb +0 -180
  113. data/examples/app_color_picker/palette.rb +0 -111
  114. data/examples/app_debugging_showcase/README.md +0 -119
  115. data/examples/app_debugging_showcase/app.rb +0 -318
  116. data/examples/app_external_editor/README.md +0 -62
  117. data/examples/app_external_editor/app.rb +0 -344
  118. data/examples/app_login_form/README.md +0 -58
  119. data/examples/app_login_form/app.rb +0 -109
  120. data/examples/app_stateful_interaction/README.md +0 -35
  121. data/examples/app_stateful_interaction/app.rb +0 -328
  122. data/examples/timeout_demo.rb +0 -45
  123. data/examples/verify_quickstart_dsl/README.md +0 -55
  124. data/examples/verify_quickstart_dsl/app.rb +0 -49
  125. data/examples/verify_quickstart_layout/README.md +0 -77
  126. data/examples/verify_quickstart_layout/app.rb +0 -73
  127. data/examples/verify_quickstart_lifecycle/README.md +0 -68
  128. data/examples/verify_quickstart_lifecycle/app.rb +0 -62
  129. data/examples/verify_readme_usage/README.md +0 -49
  130. data/examples/verify_readme_usage/app.rb +0 -42
  131. data/examples/verify_website_managed/README.md +0 -48
  132. data/examples/verify_website_managed/app.rb +0 -36
  133. data/examples/verify_website_menu/README.md +0 -60
  134. data/examples/verify_website_menu/app.rb +0 -84
  135. data/examples/verify_website_spinner/README.md +0 -44
  136. data/examples/verify_website_spinner/app.rb +0 -34
  137. data/examples/widget_barchart/README.md +0 -58
  138. data/examples/widget_barchart/app.rb +0 -240
  139. data/examples/widget_block/README.md +0 -44
  140. data/examples/widget_block/app.rb +0 -258
  141. data/examples/widget_box/README.md +0 -54
  142. data/examples/widget_box/app.rb +0 -255
  143. data/examples/widget_calendar/README.md +0 -48
  144. data/examples/widget_calendar/app.rb +0 -115
  145. data/examples/widget_canvas/README.md +0 -31
  146. data/examples/widget_canvas/app.rb +0 -130
  147. data/examples/widget_cell/README.md +0 -45
  148. data/examples/widget_cell/app.rb +0 -112
  149. data/examples/widget_center/README.md +0 -33
  150. data/examples/widget_center/app.rb +0 -118
  151. data/examples/widget_chart/README.md +0 -50
  152. data/examples/widget_chart/app.rb +0 -220
  153. data/examples/widget_gauge/README.md +0 -50
  154. data/examples/widget_gauge/app.rb +0 -229
  155. data/examples/widget_layout_split/README.md +0 -53
  156. data/examples/widget_layout_split/app.rb +0 -260
  157. data/examples/widget_line_gauge/README.md +0 -50
  158. data/examples/widget_line_gauge/app.rb +0 -219
  159. data/examples/widget_list/README.md +0 -58
  160. data/examples/widget_list/app.rb +0 -382
  161. data/examples/widget_map/README.md +0 -48
  162. data/examples/widget_map/app.rb +0 -95
  163. data/examples/widget_overlay/README.md +0 -45
  164. data/examples/widget_overlay/app.rb +0 -250
  165. data/examples/widget_popup/README.md +0 -45
  166. data/examples/widget_popup/app.rb +0 -106
  167. data/examples/widget_ratatui_logo/README.md +0 -43
  168. data/examples/widget_ratatui_logo/app.rb +0 -104
  169. data/examples/widget_ratatui_mascot/README.md +0 -43
  170. data/examples/widget_ratatui_mascot/app.rb +0 -95
  171. data/examples/widget_rect/README.md +0 -53
  172. data/examples/widget_rect/app.rb +0 -222
  173. data/examples/widget_render/README.md +0 -46
  174. data/examples/widget_render/app.rb +0 -186
  175. data/examples/widget_render/app.rbs +0 -41
  176. data/examples/widget_rich_text/README.md +0 -44
  177. data/examples/widget_rich_text/app.rb +0 -193
  178. data/examples/widget_scroll_text/README.md +0 -46
  179. data/examples/widget_scroll_text/app.rb +0 -109
  180. data/examples/widget_scrollbar/README.md +0 -46
  181. data/examples/widget_scrollbar/app.rb +0 -155
  182. data/examples/widget_sparkline/README.md +0 -51
  183. data/examples/widget_sparkline/app.rb +0 -277
  184. data/examples/widget_style_colors/README.md +0 -43
  185. data/examples/widget_style_colors/app.rb +0 -83
  186. data/examples/widget_table/README.md +0 -57
  187. data/examples/widget_table/app.rb +0 -285
  188. data/examples/widget_tabs/README.md +0 -50
  189. data/examples/widget_tabs/app.rb +0 -183
  190. data/examples/widget_text_width/README.md +0 -44
  191. data/examples/widget_text_width/app.rb +0 -117
  192. data/migrate_to_buffer.rb +0 -145
  193. data/mise.toml +0 -8
  194. data/tasks/autodoc/examples.rb +0 -87
  195. data/tasks/autodoc/member.rb +0 -58
  196. data/tasks/autodoc/name.rb +0 -21
  197. data/tasks/autodoc.rake +0 -21
  198. data/tasks/bump/bump_workflow.rb +0 -49
  199. data/tasks/bump/cargo_lockfile.rb +0 -21
  200. data/tasks/bump/changelog.rb +0 -104
  201. data/tasks/bump/header.rb +0 -32
  202. data/tasks/bump/history.rb +0 -32
  203. data/tasks/bump/links.rb +0 -69
  204. data/tasks/bump/manifest.rb +0 -33
  205. data/tasks/bump/patch_release.rb +0 -19
  206. data/tasks/bump/release_branch.rb +0 -17
  207. data/tasks/bump/release_from_trunk.rb +0 -49
  208. data/tasks/bump/repository.rb +0 -54
  209. data/tasks/bump/ruby_gem.rb +0 -29
  210. data/tasks/bump/sem_ver.rb +0 -44
  211. data/tasks/bump/unreleased_section.rb +0 -73
  212. data/tasks/bump.rake +0 -61
  213. data/tasks/doc/documentation.rb +0 -59
  214. data/tasks/doc/link/file_url.rb +0 -30
  215. data/tasks/doc/link/relative_path.rb +0 -61
  216. data/tasks/doc/link/web_url.rb +0 -55
  217. data/tasks/doc/link.rb +0 -52
  218. data/tasks/doc/link_audit.rb +0 -116
  219. data/tasks/doc/problem.rb +0 -40
  220. data/tasks/doc/source_file.rb +0 -93
  221. data/tasks/doc.rake +0 -905
  222. data/tasks/example_viewer.html.erb +0 -172
  223. data/tasks/extension.rake +0 -14
  224. data/tasks/license/headers_md.rb +0 -223
  225. data/tasks/license/headers_rb.rb +0 -210
  226. data/tasks/license/license_utils.rb +0 -130
  227. data/tasks/license/snippets_md.rb +0 -315
  228. data/tasks/license/snippets_rdoc.rb +0 -150
  229. data/tasks/license.rake +0 -91
  230. data/tasks/lint.rake +0 -170
  231. data/tasks/rbs_predicates/predicate_catalog.rb +0 -52
  232. data/tasks/rbs_predicates/predicate_tests.rb +0 -124
  233. data/tasks/rbs_predicates/rbs_signature.rb +0 -63
  234. data/tasks/rbs_predicates.rake +0 -31
  235. data/tasks/rdoc_config.rb +0 -29
  236. data/tasks/resources/build.yml.erb +0 -60
  237. data/tasks/resources/index.html.erb +0 -141
  238. data/tasks/resources/rubies.yml +0 -7
  239. data/tasks/sourcehut.rake +0 -110
  240. data/tasks/steep.rake +0 -11
  241. data/tasks/terminal_preview/app_screenshot.rb +0 -45
  242. data/tasks/terminal_preview/crash_report.rb +0 -54
  243. data/tasks/terminal_preview/example_app.rb +0 -27
  244. data/tasks/terminal_preview/launcher_script.rb +0 -48
  245. data/tasks/terminal_preview/preview_collection.rb +0 -60
  246. data/tasks/terminal_preview/preview_timing.rb +0 -24
  247. data/tasks/terminal_preview/safety_confirmation.rb +0 -58
  248. data/tasks/terminal_preview/saved_screenshot.rb +0 -56
  249. data/tasks/terminal_preview/system_appearance.rb +0 -13
  250. data/tasks/terminal_preview/terminal_window.rb +0 -138
  251. data/tasks/terminal_preview/window_id.rb +0 -16
  252. data/tasks/terminal_preview.rake +0 -30
  253. data/tasks/test.rake +0 -36
  254. data/tasks/website/index_page.rb +0 -30
  255. data/tasks/website/version.rb +0 -122
  256. data/tasks/website/version_menu.rb +0 -68
  257. data/tasks/website/versioned_documentation.rb +0 -83
  258. data/tasks/website/website.rb +0 -53
  259. data/tasks/website.rake +0 -28
@@ -1,169 +0,0 @@
1
- <!--
2
- SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
- SPDX-License-Identifier: CC-BY-SA-4.0
4
- -->
5
-
6
- # Future Work
7
-
8
- Ideas for post-v1.0.0 development. These do not block the initial release.
9
-
10
- ---
11
-
12
- ## Port Upstream Ratatui Examples
13
-
14
- Ratatui ships [example applications](https://github.com/ratatui/ratatui/tree/main/examples) demonstrating real-world patterns. Porting these to RatatuiRuby would:
15
-
16
- 1. Validate API parity in realistic usage
17
- 2. Provide learning resources for Ruby developers
18
- 3. Surface gaps in the alignment audit
19
-
20
- **Candidates for porting:**
21
-
22
- - `demo2` — Kitchen sink showcasing all widgets
23
- - `async` — Background task handling
24
- - `user_input` — Text input patterns
25
- - `popup` — Modal dialogs
26
-
27
- ---
28
-
29
- ## Cross-Platform Distribution
30
-
31
- [CosmoRuby](https://github.com/igravious/cosmoruby) aims to build Ruby with [Cosmopolitan Libc](https://justine.lol/cosmopolitan/), producing single binaries that run on Linux, macOS, Windows, and BSD without recompilation.
32
-
33
- **Gap:** RatatuiRuby is a native extension. CosmoRuby does not yet support native extensions.
34
-
35
- **When this becomes viable:**
36
-
37
- - CosmoRuby adds native extension support
38
- - Demand emerges for single-binary TUI app distribution
39
-
40
- ---
41
-
42
- ## Terminal Graphics Protocols
43
-
44
- Terminal graphics protocols (Sixel, Kitty graphics, iTerm2 inline images) bypass the character cell model. Supporting them requires extension points that do not exist today.
45
-
46
- ### What Third Parties Need
47
-
48
- | Extension Point | Purpose | Status |
49
- |-----------------|---------|--------|
50
- | `Draw::RawCmd` | Write raw escape sequences, bypassing the cell buffer | Not available |
51
- | Terminal capability queries | Detect if terminal supports Sixel, Kitty, etc. | Not available |
52
- | Frame hooks | Run code before/after buffer flush | Not available |
53
-
54
- ### Why These Matter
55
-
56
- The `ratatui-image` crate provides graphics support for Rust Ratatui apps. A `ratatui_ruby-sixels` gem could wrap it, but only if RatatuiRuby exposes:
57
-
58
- 1. **Raw output access** — Sixel data writes directly to stdout as escape sequences
59
- 2. **Capability detection** — Apps need to query terminal support before sending graphics
60
- 3. **Render coordination** — Graphics must be positioned after the cell buffer renders
61
-
62
- ### Implementation Sketch
63
-
64
- <!-- SPDX-SnippetBegin -->
65
- <!--
66
- SPDX-FileCopyrightText: 2026 Kerrick Long
67
- SPDX-License-Identifier: MIT-0
68
- -->
69
- ```ruby
70
- # Proposed API (not implemented)
71
-
72
- # 1. Raw draw command
73
- class Draw
74
- RawCmd = Data.define(:bytes)
75
- def self.raw(bytes) = RawCmd.new(bytes:)
76
- end
77
-
78
- # 2. Terminal queries
79
- RatatuiRuby.terminal_supports?(:sixel) # => true/false
80
- RatatuiRuby.terminal_supports?(:kitty) # => true/false
81
- RatatuiRuby.terminal_size_pixels # => { width: 1920, height: 1080 }
82
-
83
- # 3. Custom widget using raw output
84
- class SixelImage
85
- def render(area)
86
- sixel_data = encode_image_as_sixel(@image, area)
87
- [RatatuiRuby::Draw.raw(sixel_data)]
88
- end
89
- end
90
- ```
91
- <!-- SPDX-SnippetEnd -->
92
-
93
- ---
94
-
95
- ## Custom Backends
96
-
97
- Currently hardcoded to Crossterm. A third party might want:
98
-
99
- - SSH backend (render to a remote terminal)
100
- - Web backend (render to browser canvas)
101
- - Recording backend (capture frames for replay)
102
-
103
- **Gap:** No backend plugin architecture.
104
-
105
- ---
106
-
107
- ## Custom Event Sources
108
-
109
- Currently hardcoded to Crossterm events. A third party might want:
110
-
111
- - Network events (WebSocket messages as TUI events)
112
- - File watcher events
113
- - IPC events
114
-
115
- **Gap:** No event source plugin architecture.
116
-
117
- ---
118
-
119
- ## Event Test Doubles
120
-
121
- The TestHelper module provides `MockFrame` and `StubRect` for testing render logic in isolation. However, testing `handle_event` currently requires `init_test_terminal` and `inject_test_event`.
122
-
123
- For pure unit tests of Kit components, stub event objects would be useful:
124
-
125
- <!-- SPDX-SnippetBegin -->
126
- <!--
127
- SPDX-FileCopyrightText: 2026 Kerrick Long
128
- SPDX-License-Identifier: MIT-0
129
- -->
130
- ```ruby
131
- # Proposed API (not implemented)
132
- StubKeyEvent = Data.define(:code, :modifiers) do
133
- def initialize(code:, modifiers: [])
134
- super
135
- end
136
-
137
- def key? = true
138
- def mouse? = false
139
- end
140
-
141
- StubMouseEvent = Data.define(:kind, :button, :x, :y, :modifiers) do
142
- def initialize(kind:, button: "left", x:, y:, modifiers: [])
143
- super
144
- end
145
-
146
- def key? = false
147
- def mouse? = true
148
- def down? = kind == "down"
149
- def up? = kind == "up"
150
- end
151
-
152
- # Usage
153
- event = StubKeyEvent.new(code: "a", modifiers: ["ctrl"])
154
- result = component.handle_event(event)
155
- assert_equal :consumed, result
156
- ```
157
- <!-- SPDX-SnippetEnd -->
158
-
159
- This would let developers test component event handling without terminal dependencies.
160
-
161
- ## Prioritization
162
-
163
- When prioritizing post-1.0 work, consider:
164
-
165
- 1. **Port upstream examples** — Validates parity, provides learning resources
166
- 2. **`Draw::RawCmd`** — Lowest effort, highest impact for graphics support
167
- 3. **Terminal capability queries** — Required for any graphics work
168
- 4. **Backend plugins** — Large undertaking, defer until clear demand
169
- 5. **CosmoRuby** — Blocked on upstream; monitor progress
@@ -1,259 +0,0 @@
1
- <!--
2
- SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
- SPDX-License-Identifier: CC-BY-SA-4.0
4
- -->
5
-
6
- # Feature Request: Expose Paragraph Span Rects
7
-
8
- ## Summary
9
-
10
- `Paragraph` computes the bounding rect for each span during word-wrapping and rendering, but does not expose this information. Interactive applications need these rects for:
11
-
12
- - Mouse click hit-testing on clickable text (links, buttons)
13
- - Accessibility tooling that needs semantic element positions
14
- - Tooltip positioning relative to specific text spans
15
-
16
- ## The Problem
17
-
18
- Building clickable text within paragraphs requires knowing where each span renders after word wrapping. When a user clicks within a paragraph, the application cannot determine which specific span was clicked without duplicating the internal word-wrapping algorithm.
19
-
20
- Currently, the only options are:
21
-
22
- 1. **Recompute the layout manually.** Duplicate the logic from `WordWrapper` and `LineTruncator`, accounting for word boundaries, trimming, and alignment. This is extremely fragile—the wrapping algorithm is complex and any upstream change breaks the user's code.
23
-
24
- 2. **Use character counting.** Calculate `chars_before / width` for y-offset and `chars_before % width` for x-offset. This breaks with:
25
- - Non-monospace Unicode (CJK, emoji)
26
- - Word-level wrapping (spans don't split at character boundaries)
27
- - Alignment (center/right shifts all positions)
28
- - Trimmed leading whitespace
29
-
30
- Neither approach is satisfactory.
31
-
32
- ## Use Case
33
-
34
- Consider a TUI welcome screen with a clickable link:
35
-
36
- ```
37
- ┌Hello, Rooibos!───────────────────────────────────────┐
38
- │ │
39
- │ Welcome to Rooibos! You will find the Ruby code for │
40
- │ this application in lib/saturday.rb. The tests that │
41
- │ verify it are at test/test_saturday.rb. You can run │
42
- │ the tests with bundle exec rake test. Visit │
43
- │ www.rooibos.run to learn about Rooibos and to find │
44
- │ other Rooibos developers. You can press Control + C │
45
- │ to exit at any time. │
46
- │ │
47
- └───────────────────────────────────────────────────────┘
48
- ```
49
-
50
- The link ` www.rooibos.run ` appears on row 5 after word-wrapping. The application wants to:
51
-
52
- 1. Detect clicks on the link span
53
- 2. Highlight the link on hover
54
- 3. Open the URL when clicked
55
-
56
- Without span rects, the application must manually compute where the link renders after wrapping:
57
-
58
- <!--
59
- SPDX-SnippetBegin
60
- SPDX-FileCopyrightText: 2026 Kerrick Long
61
- SPDX-License-Identifier: MIT-0
62
- -->
63
- ```rust
64
- // Manual calculation - fragile and duplicates internal logic
65
- let text_before_link = "Welcome to Rooibos! You will find the Ruby code for \
66
- this application in lib/saturday.rb. The tests that verify it are at \
67
- test/test_saturday.rb. You can run the tests with bundle exec rake test. Visit ";
68
- let chars_before = text_before_link.width(); // ~204 characters
69
- let inner_width = block.inner(area).width; // ~74 characters
70
-
71
- let y_offset = chars_before / inner_width; // Wrong: doesn't account for word wrapping
72
- let x_offset = chars_before % inner_width; // Wrong: words don't wrap mid-word
73
- ```
74
- <!--
75
- SPDX-SnippetEnd
76
- -->
77
-
78
- This fails because `WordWrapper`:
79
-
80
- - Wraps at word boundaries, not character positions
81
- - May trim leading whitespace on wrapped lines
82
- - Produces lines of varying length
83
-
84
- The computed position is always wrong by several cells.
85
-
86
- ## Current State (v0.30.0)
87
-
88
- `Paragraph::render_paragraph` uses `WordWrapper` or `LineTruncator` to compose lines:
89
-
90
- <!--
91
- SPDX-SnippetBegin
92
- SPDX-FileCopyrightText: 2016-2022 Florian Dehau
93
- SPDX-FileCopyrightText: 2023-2025 The Ratatui Developers
94
- SPDX-License-Identifier: MIT
95
- -->
96
- ```rust
97
- // From src/widgets/paragraph.rs - private rendering logic
98
- fn render_paragraph(&self, text_area: Rect, buf: &mut Buffer) {
99
- let styled = self.text.iter().map(|line| {
100
- let graphemes = line.styled_graphemes(self.text.style);
101
- let alignment = line.alignment.unwrap_or(self.alignment);
102
- (graphemes, alignment)
103
- });
104
-
105
- if let Some(Wrap { trim }) = self.wrap {
106
- let mut line_composer = WordWrapper::new(styled, text_area.width, trim);
107
- render_lines(line_composer, text_area, buf);
108
- } else {
109
- // ...LineTruncator path...
110
- }
111
- }
112
-
113
- fn render_line(wrapped: &WrappedLine<'_, '_>, area: Rect, buf: &mut Buffer, y: u16) {
114
- let mut x = get_line_offset(wrapped.width, area.width, wrapped.alignment);
115
- for StyledGrapheme { symbol, style } in wrapped.graphemes {
116
- // Position is computed here but not exposed
117
- let position = Position::new(area.left() + x, area.top() + y);
118
- buf[position].set_symbol(symbol).set_style(*style);
119
- x += u16::try_from(width).unwrap_or(u16::MAX);
120
- }
121
- }
122
- ```
123
- <!--
124
- SPDX-SnippetEnd
125
- -->
126
-
127
- The grapheme positions are computed during rendering but never associated back to the source spans.
128
-
129
- ## Proposed API
130
-
131
- Following the pattern established by `Block::inner(area)` and `Layout::split()`, add a pure computation method that takes an area and returns computed span rects without rendering:
132
-
133
- ### Option 1: Return all span rects
134
-
135
- <!--
136
- SPDX-SnippetBegin
137
- SPDX-FileCopyrightText: 2026 Kerrick Long
138
- SPDX-License-Identifier: MIT-0
139
- -->
140
- ```rust
141
- impl Paragraph {
142
- /// Returns the bounding rect for each span given an area.
143
- ///
144
- /// For wrapped paragraphs, a span that wraps across multiple lines
145
- /// returns a rect covering all lines it occupies.
146
- ///
147
- /// # Example
148
- ///
149
- /// ```rust
150
- /// let link_span = Span::styled(" www.rooibos.run ", Style::new().underlined());
151
- /// let text = Text::from(Line::from(vec![
152
- /// Span::raw("Visit "),
153
- /// link_span.clone(),
154
- /// Span::raw(" for more info."),
155
- /// ]));
156
- /// let paragraph = Paragraph::new(text).wrap(Wrap { trim: true });
157
- ///
158
- /// let span_rects = paragraph.span_rects(area);
159
- /// if span_rects[1].contains(mouse_position) {
160
- /// // User clicked the link!
161
- /// }
162
- /// ```
163
- pub fn span_rects(&self, area: Rect) -> Vec<Rect> { ... }
164
- }
165
- ```
166
- <!--
167
- SPDX-SnippetEnd
168
- -->
169
-
170
- ### Option 2: Lookup by span index
171
-
172
- <!--
173
- SPDX-SnippetBegin
174
- SPDX-FileCopyrightText: 2026 Kerrick Long
175
- SPDX-License-Identifier: MIT-0
176
- -->
177
- ```rust
178
- /// Returns the rect for the span at the given index.
179
- pub fn span_rect(&self, area: Rect, line_index: usize, span_index: usize) -> Option<Rect> { ... }
180
- ```
181
- <!--
182
- SPDX-SnippetEnd
183
- -->
184
-
185
- ### Option 3: Iterator-based (memory efficient)
186
-
187
- <!--
188
- SPDX-SnippetBegin
189
- SPDX-FileCopyrightText: 2026 Kerrick Long
190
- SPDX-License-Identifier: MIT-0
191
- -->
192
- ```rust
193
- /// Returns an iterator over (line_index, span_index, Rect) tuples.
194
- pub fn span_rects_iter(&self, area: Rect) -> impl Iterator<Item = (usize, usize, Rect)> { ... }
195
- ```
196
- <!--
197
- SPDX-SnippetEnd
198
- -->
199
-
200
- ## Implementation Notes
201
-
202
- The implementation would reuse `WordWrapper`/`LineTruncator` in a non-rendering mode:
203
-
204
- 1. Process the text through the line composer (same as `render_paragraph`)
205
- 2. Track grapheme positions as they're composed (same loop as `render_line`)
206
- 3. Group grapheme positions by source span
207
- 4. Return span bounding rects
208
-
209
- Key considerations:
210
-
211
- - **Wrapped spans**: A span that wraps to the next line should return a rect covering both lines (bounding box) or multiple rects (one per line fragment)
212
- - **Empty spans**: Zero-width spans should return the position where they would appear
213
- - **Scroll offset**: Rects should be adjusted by the paragraph's scroll offset
214
- - **Block**: The area should be the inner area after block borders/padding
215
-
216
- ## Workaround
217
-
218
- Without this API, users must reimplement word wrapping. This is impractical for production use—the workaround in RatatuiRuby uses simple character math that produces incorrect positions:
219
-
220
- <!--
221
- SPDX-SnippetBegin
222
- SPDX-FileCopyrightText: 2026 Kerrick Long
223
- SPDX-License-Identifier: MIT-0
224
- -->
225
- ```rust
226
- // This is WRONG but the only option without span_rects
227
- let chars_before = preceding_spans.iter().map(|s| s.width()).sum();
228
- let x = area.x + (chars_before % area.width);
229
- let y = area.y + (chars_before / area.width);
230
- ```
231
- <!--
232
- SPDX-SnippetEnd
233
- -->
234
-
235
- The correct implementation requires processing the entire text through `WordWrapper`, which is private.
236
-
237
- ## Impact
238
-
239
- This feature benefits any application with clickable or interactive text:
240
-
241
- - **Hyperlinks**: Click to open URLs in wrapped text
242
- - **Command help**: Click command names to execute them
243
- - **Error messages**: Click file paths to open editors
244
- - **Documentation viewers**: Interactive code examples
245
- - **Accessibility**: Screen readers need element positions
246
-
247
- Rich text interaction is a natural expectation for modern TUI applications. Word-wrapped paragraphs with clickable elements are common in web UIs—TUIs should offer the same capability.
248
-
249
- ## Related
250
-
251
- - `Block::inner(area)` - Same pattern: pure computation of content area
252
- - `Layout::split(area, constraints)` - Same pattern: pure computation of child areas
253
- - `Tabs::title_rects(area)` (proposed in separate issue) - Same pattern for tab hit-testing
254
-
255
- ---
256
-
257
- This issue includes creative contributions from Claude (Anthropic) via Antigravity (Google). [https://declare-ai.org/1.0.0/creative.html](https://declare-ai.org/1.0.0/creative.html)
258
-
259
- *Discovered while implementing link click handling for RatatuiRuby's Saturday demo app.*
@@ -1,173 +0,0 @@
1
- <!--
2
- SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
- SPDX-License-Identifier: CC-BY-SA-4.0
4
- -->
5
-
6
- # Feature Request: Expose Tabs Title Rects
7
-
8
- ## Summary
9
-
10
- `Tabs` computes the bounding rect for each tab title during rendering but does not expose this information. Interactive applications need these rects for mouse click hit-testing.
11
-
12
- ## The Problem
13
-
14
- Building clickable tab interfaces requires knowing where each tab renders. When a user clicks within the tabs area, the application cannot determine which specific tab was clicked without duplicating the internal layout algorithm.
15
-
16
- Currently, the only options are:
17
-
18
- 1. **Recompute the layout manually.** Duplicate the logic from `render_tabs`, accounting for padding, dividers, and title widths. This is fragile—any upstream change breaks the user's code.
19
- 2. **Use coarse hit-testing.** Check if a click is anywhere in the tabs area, then guess based on x-position. This breaks when titles have different widths or styled content.
20
-
21
- Neither approach is satisfactory.
22
-
23
- ## Use Case
24
-
25
- Consider a TUI with a tabbed interface:
26
-
27
- ```
28
- ┌Announce v0.7.3───────────────────────────────emate┐
29
- │ Preview Email ▸ Preview Commit ▸ Announce │
30
- │ │
31
- └───────────────────────────────────────────────────┘
32
- ```
33
-
34
- The application wants to detect clicks on individual tab titles (`"Preview Email"`, `"Preview Commit"`, `"Announce"`) and switch to that tab.
35
-
36
- Without title rects, the application must manually compute where each tab renders:
37
-
38
- ```rust
39
- // Manual calculation - fragile and duplicates internal logic
40
- let divider_width = 3; // " ▸ " is 3 characters
41
- let content_row = area.y + 1; // Skip top border
42
- let mut x = area.x + 2; // Skip border + padding
43
-
44
- let tab_rects: Vec<Rect> = titles.iter().map(|title| {
45
- let tab_width = title.len() as u16;
46
- let rect = Rect::new(x, content_row, tab_width, 1);
47
- x += tab_width + divider_width;
48
- rect
49
- }).collect();
50
- ```
51
-
52
- This duplicates private logic from `Tabs::render_tabs` and breaks when:
53
-
54
- - Padding is configured differently (`padding_left`, `padding_right`)
55
- - Divider width changes
56
- - Upstream layout logic changes
57
- - A block is present (affects inner area calculation)
58
-
59
- ## Current State (v0.30.0)
60
-
61
- `Tabs` has a private `render_tabs` method that computes title areas:
62
-
63
- ```rust
64
- // From src/widgets/tabs.rs - private rendering logic
65
- fn render_tabs(&self, tabs_area: Rect, buf: &mut Buffer) {
66
- let mut x = tabs_area.left();
67
- for (i, title) in self.titles.iter().enumerate() {
68
- // ...padding and title rendering...
69
-
70
- // Title rect is computed here but not exposed
71
- if Some(i) == self.selected {
72
- buf.set_style(
73
- Rect {
74
- x,
75
- y: tabs_area.top(),
76
- width: pos.0.saturating_sub(x),
77
- height: 1,
78
- },
79
- self.highlight_style,
80
- );
81
- }
82
- // ...
83
- }
84
- }
85
- ```
86
-
87
- The rect is computed for applying `highlight_style` but is not accessible to users.
88
-
89
- ## Proposed API
90
-
91
- Following the pattern established by `Block::inner(area)`, add a pure computation method that takes an area and returns computed sub-rects without rendering:
92
-
93
- ```rust
94
- impl Tabs {
95
- /// Returns the bounding rect for each tab title given an area.
96
- ///
97
- /// The rects are returned in the same order as titles were added.
98
- /// Useful for hit-testing mouse clicks against specific tabs.
99
- ///
100
- /// # Example
101
- ///
102
- /// ```rust
103
- /// let tabs = Tabs::new(["Tab 1", "Tab 2", "Tab 3"])
104
- /// .divider(" | ");
105
- ///
106
- /// let rects = tabs.title_rects(area);
107
- /// for (i, rect) in rects.iter().enumerate() {
108
- /// if rect.contains(mouse_position) {
109
- /// selected_tab = i;
110
- /// break;
111
- /// }
112
- /// }
113
- /// ```
114
- pub fn title_rects(&self, area: Rect) -> Vec<Rect> { ... }
115
- }
116
- ```
117
-
118
- Alternatively, a single-lookup method:
119
-
120
- ```rust
121
- /// Returns the rect for the tab at the given index.
122
- pub fn title_rect(&self, area: Rect, index: usize) -> Option<Rect> { ... }
123
- ```
124
-
125
- ## Workaround
126
-
127
- Without this API, users must replicate the tab layout algorithm. Here is the current approach used in RatatuiRuby:
128
-
129
- ```rust
130
- // Manually compute tab title positions
131
- let divider_width = 3; // " ▸ " is 3 characters
132
- let content_row = area.y + 1; // Skip top border
133
- let mut x = area.x + 2; // Skip left border + padding
134
-
135
- let tab_rects: Vec<Rect> = TABS.iter().map(|title| {
136
- let tab_width = title.len() as u16;
137
- let rect = Rect::new(x, content_row, tab_width, 1);
138
- x += tab_width + divider_width;
139
- rect
140
- }).collect();
141
-
142
- // Hit testing
143
- for (i, rect) in tab_rects.iter().enumerate() {
144
- if rect.contains(click_position) {
145
- current_tab = i;
146
- break;
147
- }
148
- }
149
- ```
150
-
151
- This works for simple cases but breaks when:
152
-
153
- - `padding_left` or `padding_right` are non-default
154
- - The divider is styled (Span width differs from string length)
155
- - A block wraps the tabs (inner area differs)
156
- - Title text is styled (Line width differs from string length)
157
-
158
- ## Impact
159
-
160
- This feature benefits any application with clickable tabs:
161
-
162
- - Tab-based navigation interfaces
163
- - Multi-panel applications with panel selectors
164
- - Mode switchers (edit/view/preview)
165
- - Category selectors
166
-
167
- The `Tabs` widget is commonly used for navigation. Mouse interaction is a natural expectation for TUI applications running in modern terminals with mouse support.
168
-
169
- ---
170
-
171
- This issue includes creative contributions from Claude (Anthropic) via Antigravity (Google). [https://declare-ai.org/1.0.0/creative.html](https://declare-ai.org/1.0.0/creative.html)
172
-
173
- *Discovered while implementing click handling for tab navigation in RatatuiRuby.*