ratatui_ruby 1.0.0 → 1.1.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 (88) 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 +3 -2
  7. data/CHANGELOG.md +33 -7
  8. data/Steepfile +1 -0
  9. data/doc/concepts/application_testing.md +5 -5
  10. data/doc/concepts/event_handling.md +1 -1
  11. data/doc/contributors/design/ruby_frontend.md +40 -12
  12. data/doc/contributors/design/rust_backend.md +13 -1
  13. data/doc/contributors/releasing.md +215 -0
  14. data/doc/contributors/todo/align/api_completeness_audit-finished.md +6 -0
  15. data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +1 -7
  16. data/doc/contributors/todo/align/term.md +351 -0
  17. data/doc/contributors/upstream_requests/paragraph_span_rects.md +259 -0
  18. data/doc/getting_started/quickstart.md +1 -1
  19. data/doc/getting_started/why.md +3 -3
  20. data/doc/images/app_external_editor.gif +0 -0
  21. data/doc/index.md +1 -6
  22. data/examples/app_external_editor/README.md +62 -0
  23. data/examples/app_external_editor/app.rb +344 -0
  24. data/examples/widget_list/app.rb +2 -4
  25. data/examples/widget_table/app.rb +8 -2
  26. data/ext/ratatui_ruby/Cargo.lock +1 -1
  27. data/ext/ratatui_ruby/Cargo.toml +1 -1
  28. data/ext/ratatui_ruby/src/events.rs +171 -203
  29. data/ext/ratatui_ruby/src/lib.rs +36 -0
  30. data/ext/ratatui_ruby/src/lib_header.rs +11 -0
  31. data/ext/ratatui_ruby/src/terminal/capabilities.rs +46 -0
  32. data/ext/ratatui_ruby/src/terminal/init.rs +92 -0
  33. data/ext/ratatui_ruby/src/terminal/mod.rs +12 -3
  34. data/ext/ratatui_ruby/src/terminal/queries.rs +15 -0
  35. data/ext/ratatui_ruby/src/terminal/query.rs +64 -2
  36. data/lib/ratatui_ruby/backend/window_size.rb +50 -0
  37. data/lib/ratatui_ruby/backend.rb +59 -0
  38. data/lib/ratatui_ruby/event/key/navigation.rb +10 -1
  39. data/lib/ratatui_ruby/event/key.rb +84 -0
  40. data/lib/ratatui_ruby/event/mouse.rb +95 -3
  41. data/lib/ratatui_ruby/event/resize.rb +45 -3
  42. data/lib/ratatui_ruby/layout/alignment.rb +91 -0
  43. data/lib/ratatui_ruby/layout/layout.rb +1 -2
  44. data/lib/ratatui_ruby/layout/size.rb +10 -3
  45. data/lib/ratatui_ruby/layout.rb +4 -0
  46. data/lib/ratatui_ruby/terminal/capabilities.rb +316 -0
  47. data/lib/ratatui_ruby/terminal/viewport.rb +1 -1
  48. data/lib/ratatui_ruby/terminal.rb +66 -0
  49. data/lib/ratatui_ruby/test_helper/global_state.rb +111 -0
  50. data/lib/ratatui_ruby/test_helper.rb +3 -0
  51. data/lib/ratatui_ruby/version.rb +1 -1
  52. data/lib/ratatui_ruby/widgets/table.rb +2 -2
  53. data/lib/ratatui_ruby.rb +25 -4
  54. data/sig/examples/app_external_editor/app.rbs +12 -0
  55. data/sig/generated/event_key_predicates.rbs +1348 -0
  56. data/sig/ratatui_ruby/backend/window_size.rbs +17 -0
  57. data/sig/ratatui_ruby/backend.rbs +12 -0
  58. data/sig/ratatui_ruby/event.rbs +7 -0
  59. data/sig/ratatui_ruby/layout/alignment.rbs +26 -0
  60. data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -0
  61. data/sig/ratatui_ruby/terminal/capabilities.rbs +38 -0
  62. data/sig/ratatui_ruby/terminal/viewport.rbs +15 -1
  63. data/tasks/bump/bump_workflow.rb +49 -0
  64. data/tasks/bump/changelog.rb +57 -0
  65. data/tasks/bump/patch_release.rb +19 -0
  66. data/tasks/bump/release_branch.rb +17 -0
  67. data/tasks/bump/release_from_trunk.rb +49 -0
  68. data/tasks/bump/repository.rb +54 -0
  69. data/tasks/bump/ruby_gem.rb +6 -26
  70. data/tasks/bump/sem_ver.rb +4 -0
  71. data/tasks/bump/unreleased_section.rb +17 -0
  72. data/tasks/bump.rake +21 -11
  73. data/tasks/doc/documentation.rb +59 -0
  74. data/tasks/doc/link/file_url.rb +30 -0
  75. data/tasks/doc/link/relative_path.rb +61 -0
  76. data/tasks/doc/link/web_url.rb +55 -0
  77. data/tasks/doc/link.rb +52 -0
  78. data/tasks/doc/link_audit.rb +116 -0
  79. data/tasks/doc/problem.rb +40 -0
  80. data/tasks/doc/source_file.rb +93 -0
  81. data/tasks/doc.rake +18 -0
  82. data/tasks/rbs_predicates/predicate_catalog.rb +52 -0
  83. data/tasks/rbs_predicates/predicate_tests.rb +124 -0
  84. data/tasks/rbs_predicates/rbs_signature.rb +63 -0
  85. data/tasks/rbs_predicates.rake +31 -0
  86. data/tasks/test.rake +3 -0
  87. data/tasks/website/version.rb +23 -28
  88. metadata +38 -1
@@ -0,0 +1,351 @@
1
+ <!--
2
+ SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
3
+ SPDX-License-Identifier: CC-BY-SA-4.0
4
+ -->
5
+
6
+ # TERM Environment Variable Respect
7
+
8
+ Expose terminal capability detection so applications can degrade gracefully.
9
+
10
+ ---
11
+
12
+ ## Crossterm Audit (2026-01-17)
13
+
14
+ What crossterm actually exposes that we can wrap:
15
+
16
+ ### Available Public APIs
17
+
18
+ | Function | Location | Notes |
19
+ |----------|----------|-------|
20
+ | `available_color_count()` | `src/style.rs:163` | Returns `u16`: 8, 256, or `u16::MAX` (truecolor). Checks `COLORTERM`, `TERM` env vars. |
21
+ | `force_color_output(bool)` | `src/style.rs:191` | Override `NO_COLOR` check globally. |
22
+ | `supports_keyboard_enhancement()` | `src/terminal/sys/unix.rs:188` | Requires `events` feature. Queries terminal via escape sequence. |
23
+ | `window_size()` | `src/terminal.rs:148` | Returns `WindowSize { columns, rows, width, height }` including **pixel dimensions**. |
24
+ | `supports_ansi()` | `src/ansi_support.rs:33` | **Windows only**. Checks VT processing + `TERM != "dumb"`. |
25
+
26
+ ### Internal Logic (Not Exposed, But Informs Design)
27
+
28
+ From `src/style.rs:174-180`:
29
+ <!-- SPDX-SnippetBegin -->
30
+ <!--
31
+ SPDX-FileCopyrightText: 2026 Kerrick Long
32
+ SPDX-License-Identifier: MIT-0
33
+ -->
34
+ ```rust
35
+ env::var("COLORTERM")
36
+ .or_else(|_| env::var("TERM"))
37
+ .map_or(DEFAULT, |x| match x {
38
+ _ if x.contains("24bit") || x.contains("truecolor") => u16::MAX,
39
+ _ if x.contains("256") => 256,
40
+ _ => DEFAULT, // 8 colors
41
+ })
42
+ ```
43
+ <!-- SPDX-SnippetEnd -->
44
+
45
+ From `src/ansi_support.rs:39-40` (Windows):
46
+ <!-- SPDX-SnippetBegin -->
47
+ <!--
48
+ SPDX-FileCopyrightText: 2026 Kerrick Long
49
+ SPDX-License-Identifier: MIT-0
50
+ -->
51
+ ```rust
52
+ let supported = enable_vt_processing().is_ok()
53
+ || std::env::var("TERM").map_or(false, |term| term != "dumb");
54
+ ```
55
+ <!-- SPDX-SnippetEnd -->
56
+
57
+ ### What Ratatui Exposes
58
+
59
+ The `Backend` trait in `ratatui-core/src/backend.rs` exposes:
60
+ - `size()` → `Result<Size>` (columns/rows)
61
+ - `window_size()` → `Result<WindowSize>` (columns/rows + pixels)
62
+
63
+ **No capability detection is exposed through ratatui itself.** We must call crossterm directly.
64
+
65
+ ### Audit Summary
66
+
67
+ | Proposed API | Implementation | Source |
68
+ |--------------|----------------|--------|
69
+ | `tty?` | Pure Ruby | `$stdout.tty?` |
70
+ | `dumb?` | Pure Ruby | `ENV["TERM"]` |
71
+ | `no_color?` | Pure Ruby | `ENV.key?("NO_COLOR")` |
72
+ | `force_color?` | Pure Ruby | `ENV.key?("FORCE_COLOR")` |
73
+ | `color_support` | **Wrap crossterm** | `crossterm::style::available_color_count()` |
74
+ | `supports_keyboard_enhancement?` | **Wrap crossterm** | `crossterm::terminal::supports_keyboard_enhancement()` |
75
+ | `size_pixels` | **Wrap crossterm** | `crossterm::terminal::window_size().{width,height}` |
76
+ | `interactive?` | Pure Ruby | Combines `tty?` + `dumb?` |
77
+ | `suggested_style` | Pure Ruby | Derives from `color_support` |
78
+
79
+ ---
80
+
81
+ ## Problem Statement
82
+
83
+ Rust-based CLI libraries often ignore the `TERM` environment variable and assume modern terminal features. This causes issues in:
84
+
85
+ - **CI environments** — `TERM=dumb` or missing entirely
86
+ - **Piped output** — stdout isn't a TTY
87
+ - **Legacy terminals** — SSH to old systems, embedded devices
88
+ - **Accessibility** — Screen readers need simpler output
89
+ - **`NO_COLOR` movement** — Users explicitly disable color
90
+
91
+ RatatuiRuby currently provides no capability detection APIs. Applications cannot query terminal support before rendering.
92
+
93
+ ---
94
+
95
+ ## Proposed API
96
+
97
+ > [!NOTE]
98
+ > These capability detection methods integrate with the `Terminal` class design from [terminal.md](terminal.md).
99
+ > They are **class methods** on `RatatuiRuby::Terminal`, not a separate module.
100
+
101
+ ### Phase 1: Environment Detection (Ruby-side)
102
+
103
+ Pure Ruby class methods using environment variables and standard library.
104
+
105
+ <!-- SPDX-SnippetBegin -->
106
+ <!--
107
+ SPDX-FileCopyrightText: 2026 Kerrick Long
108
+ SPDX-License-Identifier: MIT-0
109
+ -->
110
+ ```ruby
111
+ # lib/ratatui_ruby/terminal.rb (additions to existing Terminal class)
112
+ module RatatuiRuby
113
+ class Terminal
114
+ class << self
115
+ # Is stdout connected to a terminal?
116
+ def tty? = $stdout.tty?
117
+
118
+ # Is this a dumb terminal with no capabilities?
119
+ def dumb? = ENV["TERM"] == "dumb" || ENV["TERM"].to_s.empty?
120
+
121
+ # Should color be disabled? (NO_COLOR standard)
122
+ def no_color? = ENV.key?("NO_COLOR")
123
+
124
+ # Should color be forced? (overrides tty? check)
125
+ def force_color? = ENV.key?("FORCE_COLOR")
126
+
127
+ # Detected color support level
128
+ # :none - No color (dumb terminal, NO_COLOR set, not a tty)
129
+ # :basic - 16 colors (standard ANSI)
130
+ # :ansi256 - 256 colors
131
+ # :truecolor - 24-bit RGB
132
+ def color_support
133
+ return :none if no_color? || dumb?
134
+ return detect_color_level if tty? || force_color?
135
+ :none
136
+ end
137
+
138
+ private
139
+
140
+ def detect_color_level
141
+ colorterm = ENV["COLORTERM"]
142
+ return :truecolor if colorterm == "truecolor" || colorterm == "24bit"
143
+
144
+ term = ENV["TERM"].to_s
145
+ return :truecolor if term.include?("truecolor") || term.include?("24bit")
146
+ return :ansi256 if term.include?("256color")
147
+ return :basic if term.start_with?("xterm", "screen", "vt100", "linux")
148
+
149
+ :basic # Conservative default for unknown terminals
150
+ end
151
+ end
152
+ end
153
+ end
154
+ ```
155
+ <!-- SPDX-SnippetEnd -->
156
+
157
+ ### Phase 2: Crossterm Capability Queries (Rust-side)
158
+
159
+ Expose crossterm's runtime detection through Magnus bindings.
160
+
161
+ <!-- SPDX-SnippetBegin -->
162
+ <!--
163
+ SPDX-FileCopyrightText: 2026 Kerrick Long
164
+ SPDX-License-Identifier: MIT-0
165
+ -->
166
+ ```rust
167
+ // ext/ratatui_ruby/src/terminal_capabilities.rs
168
+
169
+ use crossterm::terminal;
170
+ use magnus::{function, Error, Module, Object};
171
+
172
+ /// Query if the terminal supports the Kitty keyboard protocol
173
+ pub fn supports_keyboard_enhancement() -> Result<bool, Error> {
174
+ Ok(terminal::supports_keyboard_enhancement()
175
+ .map_err(|e| Error::new(magnus::exception::runtime_error(), e.to_string()))?)
176
+ }
177
+
178
+ /// Query terminal size in pixels (if supported)
179
+ pub fn terminal_size_pixels() -> Result<Option<(u16, u16)>, Error> {
180
+ match crossterm::terminal::window_size() {
181
+ Ok(size) => Ok(Some((size.width, size.height))),
182
+ Err(_) => Ok(None),
183
+ }
184
+ }
185
+
186
+ pub fn register(ruby: &magnus::Ruby, module: &magnus::RModule) -> Result<(), Error> {
187
+ module.define_module_function("_supports_keyboard_enhancement",
188
+ function!(supports_keyboard_enhancement, 0))?;
189
+ module.define_module_function("_terminal_size_pixels",
190
+ function!(terminal_size_pixels, 0))?;
191
+ Ok(())
192
+ }
193
+ ```
194
+ <!-- SPDX-SnippetEnd -->
195
+
196
+ Ruby wrapper (class methods on Terminal):
197
+
198
+ <!-- SPDX-SnippetBegin -->
199
+ <!--
200
+ SPDX-FileCopyrightText: 2026 Kerrick Long
201
+ SPDX-License-Identifier: MIT-0
202
+ -->
203
+ ```ruby
204
+ # lib/ratatui_ruby/terminal.rb (continued)
205
+ module RatatuiRuby
206
+ class Terminal
207
+ class << self
208
+ # Does the terminal support the Kitty keyboard protocol?
209
+ def supports_keyboard_enhancement?
210
+ RatatuiRuby._supports_keyboard_enhancement
211
+ rescue
212
+ false
213
+ end
214
+
215
+ # Terminal size in pixels (for graphics protocols)
216
+ # Returns { width:, height: } or nil if unsupported
217
+ def size_pixels
218
+ result = RatatuiRuby._terminal_size_pixels
219
+ result ? { width: result[0], height: result[1] } : nil
220
+ rescue
221
+ nil
222
+ end
223
+ end
224
+ end
225
+ end
226
+ ```
227
+ <!-- SPDX-SnippetEnd -->
228
+
229
+ ### Phase 3: Graceful Degradation Helpers
230
+
231
+ Convenience class methods for common patterns.
232
+
233
+ <!-- SPDX-SnippetBegin -->
234
+ <!--
235
+ SPDX-FileCopyrightText: 2026 Kerrick Long
236
+ SPDX-License-Identifier: MIT-0
237
+ -->
238
+ ```ruby
239
+ # lib/ratatui_ruby/terminal.rb (continued)
240
+ module RatatuiRuby
241
+ class Terminal
242
+ class << self
243
+ # Should the application use TUI mode?
244
+ # Returns false for dumb terminals, piped output, etc.
245
+ def interactive?
246
+ tty? && !dumb?
247
+ end
248
+
249
+ # Suggested style based on terminal capabilities
250
+ def suggested_style
251
+ case color_support
252
+ when :none then :plain
253
+ when :basic then :ansi16
254
+ when :ansi256 then :ansi256
255
+ when :truecolor then :truecolor
256
+ end
257
+ end
258
+ end
259
+ end
260
+ end
261
+ ```
262
+ <!-- SPDX-SnippetEnd -->
263
+
264
+ ---
265
+
266
+ ## Implementation Plan
267
+
268
+ ### Phase 1: Environment Detection
269
+
270
+ | File | Change |
271
+ |------|--------|
272
+ | [NEW] `lib/ratatui_ruby/terminal.rb` | `Terminal` module with `tty?`, `dumb?`, `no_color?`, `force_color?`, `color_support` |
273
+ | [NEW] `sig/ratatui_ruby/terminal.rbs` | RBS type declarations |
274
+ | [NEW] `test/test_terminal_capabilities.rb` | Unit tests with mocked ENV |
275
+ | [MODIFY] `lib/ratatui_ruby.rb` | Require the new module |
276
+
277
+ ### Phase 2: Crossterm Queries
278
+
279
+ | File | Change |
280
+ |------|--------|
281
+ | [NEW] `ext/ratatui_ruby/src/terminal_capabilities.rs` | `supports_keyboard_enhancement`, `terminal_size_pixels` |
282
+ | [MODIFY] `ext/ratatui_ruby/src/lib.rs` | Register new module |
283
+ | [MODIFY] `lib/ratatui_ruby/terminal.rb` | Add `supports_keyboard_enhancement?`, `size_pixels` |
284
+ | [MODIFY] `sig/ratatui_ruby/terminal.rbs` | Add new method types |
285
+ | [MODIFY] `test/test_terminal_capabilities.rb` | Add integration tests |
286
+
287
+ ### Phase 3: Convenience Methods
288
+
289
+ | File | Change |
290
+ |------|--------|
291
+ | [MODIFY] `lib/ratatui_ruby/terminal.rb` | Add `interactive?`, `suggested_style` |
292
+ | [MODIFY] `sig/ratatui_ruby/terminal.rbs` | Add new method types |
293
+ | [MODIFY] `test/test_terminal_capabilities.rb` | Add convenience method tests |
294
+
295
+ ---
296
+
297
+ ## Documentation Updates
298
+
299
+ | File | Change |
300
+ |------|--------|
301
+ | [NEW] `doc/concepts/terminal_capabilities.md` | Guide for capability detection |
302
+ | [MODIFY] `doc/troubleshooting/terminal_limitations.md` | Reference new detection APIs |
303
+ | [MODIFY] `CHANGELOG.md` | Document new feature |
304
+
305
+ ---
306
+
307
+ ## Verification Plan
308
+
309
+ ### Automated Tests
310
+
311
+ <!-- SPDX-SnippetBegin -->
312
+ <!--
313
+ SPDX-FileCopyrightText: 2026 Kerrick Long
314
+ SPDX-License-Identifier: MIT-0
315
+ -->
316
+ ```bash
317
+ bundle exec rake test TEST=test/test_terminal_capabilities.rb
318
+ bundle exec steep check
319
+ ```
320
+ <!-- SPDX-SnippetEnd -->
321
+
322
+ ### Manual Verification
323
+
324
+ <!-- SPDX-SnippetBegin -->
325
+ <!--
326
+ SPDX-FileCopyrightText: 2026 Kerrick Long
327
+ SPDX-License-Identifier: MIT-0
328
+ -->
329
+ ```bash
330
+ # Test NO_COLOR respect
331
+ NO_COLOR=1 bundle exec ruby -e "require 'ratatui_ruby'; p RatatuiRuby::Terminal.color_support"
332
+ # Expected: :none
333
+
334
+ # Test dumb terminal
335
+ TERM=dumb bundle exec ruby -e "require 'ratatui_ruby'; p RatatuiRuby::Terminal.dumb?"
336
+ # Expected: true
337
+
338
+ # Test truecolor detection
339
+ COLORTERM=truecolor bundle exec ruby -e "require 'ratatui_ruby'; p RatatuiRuby::Terminal.color_support"
340
+ # Expected: :truecolor
341
+ ```
342
+ <!-- SPDX-SnippetEnd -->
343
+
344
+ ---
345
+
346
+ ## References
347
+
348
+ - [NO_COLOR Standard](https://no-color.org/)
349
+ - [Crossterm Terminal Queries](https://docs.rs/crossterm/latest/crossterm/terminal/index.html)
350
+ - [Kitty Keyboard Protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/)
351
+ - [terminfo(5)](https://man7.org/linux/man-pages/man5/terminfo.5.html)
@@ -0,0 +1,259 @@
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.*
@@ -8,7 +8,7 @@ Welcome to **ratatui_ruby**! This guide will help you get up and running with yo
8
8
 
9
9
  ## Installation
10
10
 
11
- See [Installation in the README](../README.md#installation) for setup instructions.
11
+ See [Installation in the README](../../README.md#installation) for setup instructions.
12
12
 
13
13
 
14
14
  ## Tutorials
@@ -24,7 +24,7 @@ RatatuiRuby gives you Rust's layout engine, rendering speed, and battle-tested w
24
24
 
25
25
  ## RatatuiRuby vs. CharmRuby
26
26
 
27
- [CharmRuby](https://github.com/marcoroth/charm_ruby) is an excellent project by Marco Roth. It provides Ruby bindings to Charm's Go libraries (Bubble Tea, Lipgloss). The Ruby ecosystem is better because both projects exist.
27
+ [CharmRuby](https://charm-ruby.dev) is an excellent project by Marco Roth. It provides Ruby bindings to Charm's Go libraries (Bubble Tea, Lipgloss). The Ruby ecosystem is better because both projects exist.
28
28
 
29
29
  So which one should you choose?
30
30
 
@@ -65,8 +65,8 @@ With RatatuiRuby, there's only Ruby. Rust compiles to plain machine code with no
65
65
 
66
66
  - **[ActiveRecord](https://guides.rubyonrails.org/active_record_basics.html)** — Query your database with elegant, chainable methods
67
67
  - **[RSpec](https://rspec.info/)** — Write expressive, readable tests with `describe`, `it`, and `expect`
68
- - **[Blocks](https://ruby-doc.org/docs/ruby-doc-bundle/UsersGuide/rg/blocks.html)** — Pass behavior to methods with `do...end`, the heart of Ruby's expressiveness
69
- - **[Metaprogramming](https://ruby-doc.org/docs/ruby-doc-bundle/UsersGuide/rg/objinitialization.html)** — Define methods dynamically, build DSLs, and write code that writes code
68
+ - **[Blocks](https://docs.ruby-lang.org/en/4.0/syntax/calling_methods_rdoc.html#label-Block+Argument)** — Pass behavior to methods with `do...end`, the heart of Ruby's expressiveness
69
+ - **[Metaprogramming](https://docs.ruby-lang.org/en/4.0/Module.html#method-i-class_eval)** — Define methods dynamically, build DSLs, and write code that writes code
70
70
  - **[Bundler](https://bundler.io/)** — Access 180,000+ gems with a single `bundle add`
71
71
 
72
72
  Build a dashboard for your Rails app. Monitor your Sidekiq jobs. Create developer tools in the same language as the code they inspect.
Binary file
data/doc/index.md CHANGED
@@ -25,14 +25,9 @@
25
25
 
26
26
  ### Troubleshooting
27
27
 
28
- - [Debugging](./troubleshooting/debugging.md): Debugging techniques and tools
28
+ - [Debugging](./concepts/debugging.md): Debugging techniques and tools
29
29
  - [Terminal Limitations](./troubleshooting/terminal_limitations.md): Platform quirks and workarounds
30
30
 
31
- ### Migration
32
-
33
- - [Migrating to v0.7.0](./migration/v0_7_0.md): Namespace changes and upgrade guide
34
-
35
-
36
31
  ## Documentation for Contributors
37
32
 
38
33
  - [Contributing Guidelines](https://man.sr.ht/~kerrick/ratatui_ruby/contributing.md): How to contribute patches and features