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.
- checksums.yaml +4 -4
- data/.builds/ruby-3.2.yml +1 -1
- data/.builds/ruby-3.3.yml +1 -1
- data/.builds/ruby-3.4.yml +1 -1
- data/.builds/ruby-4.0.0.yml +1 -1
- data/AGENTS.md +3 -2
- data/CHANGELOG.md +33 -7
- data/Steepfile +1 -0
- data/doc/concepts/application_testing.md +5 -5
- data/doc/concepts/event_handling.md +1 -1
- data/doc/contributors/design/ruby_frontend.md +40 -12
- data/doc/contributors/design/rust_backend.md +13 -1
- data/doc/contributors/releasing.md +215 -0
- data/doc/contributors/todo/align/api_completeness_audit-finished.md +6 -0
- data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +1 -7
- data/doc/contributors/todo/align/term.md +351 -0
- data/doc/contributors/upstream_requests/paragraph_span_rects.md +259 -0
- data/doc/getting_started/quickstart.md +1 -1
- data/doc/getting_started/why.md +3 -3
- data/doc/images/app_external_editor.gif +0 -0
- data/doc/index.md +1 -6
- data/examples/app_external_editor/README.md +62 -0
- data/examples/app_external_editor/app.rb +344 -0
- data/examples/widget_list/app.rb +2 -4
- data/examples/widget_table/app.rb +8 -2
- data/ext/ratatui_ruby/Cargo.lock +1 -1
- data/ext/ratatui_ruby/Cargo.toml +1 -1
- data/ext/ratatui_ruby/src/events.rs +171 -203
- data/ext/ratatui_ruby/src/lib.rs +36 -0
- data/ext/ratatui_ruby/src/lib_header.rs +11 -0
- data/ext/ratatui_ruby/src/terminal/capabilities.rs +46 -0
- data/ext/ratatui_ruby/src/terminal/init.rs +92 -0
- data/ext/ratatui_ruby/src/terminal/mod.rs +12 -3
- data/ext/ratatui_ruby/src/terminal/queries.rs +15 -0
- data/ext/ratatui_ruby/src/terminal/query.rs +64 -2
- data/lib/ratatui_ruby/backend/window_size.rb +50 -0
- data/lib/ratatui_ruby/backend.rb +59 -0
- data/lib/ratatui_ruby/event/key/navigation.rb +10 -1
- data/lib/ratatui_ruby/event/key.rb +84 -0
- data/lib/ratatui_ruby/event/mouse.rb +95 -3
- data/lib/ratatui_ruby/event/resize.rb +45 -3
- data/lib/ratatui_ruby/layout/alignment.rb +91 -0
- data/lib/ratatui_ruby/layout/layout.rb +1 -2
- data/lib/ratatui_ruby/layout/size.rb +10 -3
- data/lib/ratatui_ruby/layout.rb +4 -0
- data/lib/ratatui_ruby/terminal/capabilities.rb +316 -0
- data/lib/ratatui_ruby/terminal/viewport.rb +1 -1
- data/lib/ratatui_ruby/terminal.rb +66 -0
- data/lib/ratatui_ruby/test_helper/global_state.rb +111 -0
- data/lib/ratatui_ruby/test_helper.rb +3 -0
- data/lib/ratatui_ruby/version.rb +1 -1
- data/lib/ratatui_ruby/widgets/table.rb +2 -2
- data/lib/ratatui_ruby.rb +25 -4
- data/sig/examples/app_external_editor/app.rbs +12 -0
- data/sig/generated/event_key_predicates.rbs +1348 -0
- data/sig/ratatui_ruby/backend/window_size.rbs +17 -0
- data/sig/ratatui_ruby/backend.rbs +12 -0
- data/sig/ratatui_ruby/event.rbs +7 -0
- data/sig/ratatui_ruby/layout/alignment.rbs +26 -0
- data/sig/ratatui_ruby/ratatui_ruby.rbs +2 -0
- data/sig/ratatui_ruby/terminal/capabilities.rbs +38 -0
- data/sig/ratatui_ruby/terminal/viewport.rbs +15 -1
- data/tasks/bump/bump_workflow.rb +49 -0
- data/tasks/bump/changelog.rb +57 -0
- data/tasks/bump/patch_release.rb +19 -0
- data/tasks/bump/release_branch.rb +17 -0
- data/tasks/bump/release_from_trunk.rb +49 -0
- data/tasks/bump/repository.rb +54 -0
- data/tasks/bump/ruby_gem.rb +6 -26
- data/tasks/bump/sem_ver.rb +4 -0
- data/tasks/bump/unreleased_section.rb +17 -0
- data/tasks/bump.rake +21 -11
- data/tasks/doc/documentation.rb +59 -0
- data/tasks/doc/link/file_url.rb +30 -0
- data/tasks/doc/link/relative_path.rb +61 -0
- data/tasks/doc/link/web_url.rb +55 -0
- data/tasks/doc/link.rb +52 -0
- data/tasks/doc/link_audit.rb +116 -0
- data/tasks/doc/problem.rb +40 -0
- data/tasks/doc/source_file.rb +93 -0
- data/tasks/doc.rake +18 -0
- data/tasks/rbs_predicates/predicate_catalog.rb +52 -0
- data/tasks/rbs_predicates/predicate_tests.rb +124 -0
- data/tasks/rbs_predicates/rbs_signature.rb +63 -0
- data/tasks/rbs_predicates.rake +31 -0
- data/tasks/test.rake +3 -0
- data/tasks/website/version.rb +23 -28
- 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](
|
|
11
|
+
See [Installation in the README](../../README.md#installation) for setup instructions.
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
## Tutorials
|
data/doc/getting_started/why.md
CHANGED
|
@@ -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://
|
|
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-
|
|
69
|
-
- **[Metaprogramming](https://ruby-
|
|
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](./
|
|
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
|