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.
- checksums.yaml +4 -4
- data/ext/ratatui_ruby/Cargo.lock +1 -1
- data/ext/ratatui_ruby/Cargo.toml +1 -1
- data/lib/ratatui_ruby/version.rb +1 -1
- metadata +1 -255
- data/.builds/ruby-3.2.yml +0 -54
- data/.builds/ruby-3.3.yml +0 -54
- data/.builds/ruby-3.4.yml +0 -54
- data/.builds/ruby-4.0.0.yml +0 -54
- data/.pre-commit-config.yaml +0 -16
- data/.rubocop.yml +0 -10
- data/AGENTS.md +0 -147
- data/CHANGELOG.md +0 -736
- data/README.md +0 -187
- data/README.rdoc +0 -302
- data/Rakefile +0 -11
- data/Steepfile +0 -50
- data/doc/concepts/application_architecture.md +0 -321
- data/doc/concepts/application_testing.md +0 -193
- data/doc/concepts/async.md +0 -190
- data/doc/concepts/custom_widgets.md +0 -247
- data/doc/concepts/debugging.md +0 -401
- data/doc/concepts/event_handling.md +0 -162
- data/doc/concepts/interactive_design.md +0 -146
- data/doc/contributors/auditing/parity.md +0 -239
- data/doc/contributors/design/ruby_frontend.md +0 -448
- data/doc/contributors/design/rust_backend.md +0 -434
- data/doc/contributors/design.md +0 -11
- data/doc/contributors/developing_examples.md +0 -400
- data/doc/contributors/documentation_style.md +0 -121
- data/doc/contributors/index.md +0 -21
- data/doc/contributors/releasing.md +0 -215
- data/doc/contributors/todo/align/api_completeness_audit-finished.md +0 -381
- data/doc/contributors/todo/align/api_completeness_audit-unfinished.md +0 -200
- data/doc/contributors/todo/align/term.md +0 -351
- data/doc/contributors/todo/align/terminal.md +0 -647
- data/doc/contributors/todo/future_work.md +0 -169
- data/doc/contributors/upstream_requests/paragraph_span_rects.md +0 -259
- data/doc/contributors/upstream_requests/tab_rects.md +0 -173
- data/doc/contributors/upstream_requests/title_rects.md +0 -132
- data/doc/custom.css +0 -22
- data/doc/getting_started/quickstart.md +0 -291
- data/doc/getting_started/why.md +0 -93
- data/doc/images/app_all_events.png +0 -0
- data/doc/images/app_cli_rich_moments.gif +0 -0
- data/doc/images/app_color_picker.png +0 -0
- data/doc/images/app_debugging_showcase.gif +0 -0
- data/doc/images/app_debugging_showcase.png +0 -0
- data/doc/images/app_external_editor.gif +0 -0
- data/doc/images/app_login_form.png +0 -0
- data/doc/images/app_stateful_interaction.png +0 -0
- data/doc/images/verify_quickstart_dsl.png +0 -0
- data/doc/images/verify_quickstart_layout.png +0 -0
- data/doc/images/verify_quickstart_lifecycle.png +0 -0
- data/doc/images/verify_readme_usage.png +0 -0
- data/doc/images/widget_barchart.png +0 -0
- data/doc/images/widget_block.png +0 -0
- data/doc/images/widget_box.png +0 -0
- data/doc/images/widget_calendar.png +0 -0
- data/doc/images/widget_canvas.png +0 -0
- data/doc/images/widget_cell.png +0 -0
- data/doc/images/widget_center.png +0 -0
- data/doc/images/widget_chart.png +0 -0
- data/doc/images/widget_gauge.png +0 -0
- data/doc/images/widget_layout_split.png +0 -0
- data/doc/images/widget_line_gauge.png +0 -0
- data/doc/images/widget_list.png +0 -0
- data/doc/images/widget_map.png +0 -0
- data/doc/images/widget_overlay.png +0 -0
- data/doc/images/widget_popup.png +0 -0
- data/doc/images/widget_ratatui_logo.png +0 -0
- data/doc/images/widget_ratatui_mascot.png +0 -0
- data/doc/images/widget_rect.png +0 -0
- data/doc/images/widget_render.png +0 -0
- data/doc/images/widget_rich_text.png +0 -0
- data/doc/images/widget_scroll_text.png +0 -0
- data/doc/images/widget_scrollbar.png +0 -0
- data/doc/images/widget_sparkline.png +0 -0
- data/doc/images/widget_style_colors.png +0 -0
- data/doc/images/widget_table.png +0 -0
- data/doc/images/widget_tabs.png +0 -0
- data/doc/images/widget_text_width.png +0 -0
- data/doc/index.md +0 -34
- data/doc/troubleshooting/async.md +0 -4
- data/doc/troubleshooting/terminal_limitations.md +0 -131
- data/doc/troubleshooting/tui_output.md +0 -197
- data/examples/app_all_events/README.md +0 -114
- data/examples/app_all_events/app.rb +0 -98
- data/examples/app_all_events/model/app_model.rb +0 -159
- data/examples/app_all_events/model/event_color_cycle.rb +0 -43
- data/examples/app_all_events/model/event_entry.rb +0 -94
- data/examples/app_all_events/model/msg.rb +0 -39
- data/examples/app_all_events/model/timestamp.rb +0 -56
- data/examples/app_all_events/update.rb +0 -75
- data/examples/app_all_events/view/app_view.rb +0 -80
- data/examples/app_all_events/view/controls_view.rb +0 -54
- data/examples/app_all_events/view/counts_view.rb +0 -61
- data/examples/app_all_events/view/live_view.rb +0 -72
- data/examples/app_all_events/view/log_view.rb +0 -57
- data/examples/app_all_events/view.rb +0 -9
- data/examples/app_cli_rich_moments/README.md +0 -81
- data/examples/app_cli_rich_moments/app.rb +0 -189
- data/examples/app_color_picker/README.md +0 -156
- data/examples/app_color_picker/app.rb +0 -76
- data/examples/app_color_picker/clipboard.rb +0 -86
- data/examples/app_color_picker/color.rb +0 -193
- data/examples/app_color_picker/controls.rb +0 -92
- data/examples/app_color_picker/copy_dialog.rb +0 -168
- data/examples/app_color_picker/export_pane.rb +0 -128
- data/examples/app_color_picker/harmony.rb +0 -58
- data/examples/app_color_picker/input.rb +0 -176
- data/examples/app_color_picker/main_container.rb +0 -180
- data/examples/app_color_picker/palette.rb +0 -111
- data/examples/app_debugging_showcase/README.md +0 -119
- data/examples/app_debugging_showcase/app.rb +0 -318
- data/examples/app_external_editor/README.md +0 -62
- data/examples/app_external_editor/app.rb +0 -344
- data/examples/app_login_form/README.md +0 -58
- data/examples/app_login_form/app.rb +0 -109
- data/examples/app_stateful_interaction/README.md +0 -35
- data/examples/app_stateful_interaction/app.rb +0 -328
- data/examples/timeout_demo.rb +0 -45
- data/examples/verify_quickstart_dsl/README.md +0 -55
- data/examples/verify_quickstart_dsl/app.rb +0 -49
- data/examples/verify_quickstart_layout/README.md +0 -77
- data/examples/verify_quickstart_layout/app.rb +0 -73
- data/examples/verify_quickstart_lifecycle/README.md +0 -68
- data/examples/verify_quickstart_lifecycle/app.rb +0 -62
- data/examples/verify_readme_usage/README.md +0 -49
- data/examples/verify_readme_usage/app.rb +0 -42
- data/examples/verify_website_managed/README.md +0 -48
- data/examples/verify_website_managed/app.rb +0 -36
- data/examples/verify_website_menu/README.md +0 -60
- data/examples/verify_website_menu/app.rb +0 -84
- data/examples/verify_website_spinner/README.md +0 -44
- data/examples/verify_website_spinner/app.rb +0 -34
- data/examples/widget_barchart/README.md +0 -58
- data/examples/widget_barchart/app.rb +0 -240
- data/examples/widget_block/README.md +0 -44
- data/examples/widget_block/app.rb +0 -258
- data/examples/widget_box/README.md +0 -54
- data/examples/widget_box/app.rb +0 -255
- data/examples/widget_calendar/README.md +0 -48
- data/examples/widget_calendar/app.rb +0 -115
- data/examples/widget_canvas/README.md +0 -31
- data/examples/widget_canvas/app.rb +0 -130
- data/examples/widget_cell/README.md +0 -45
- data/examples/widget_cell/app.rb +0 -112
- data/examples/widget_center/README.md +0 -33
- data/examples/widget_center/app.rb +0 -118
- data/examples/widget_chart/README.md +0 -50
- data/examples/widget_chart/app.rb +0 -220
- data/examples/widget_gauge/README.md +0 -50
- data/examples/widget_gauge/app.rb +0 -229
- data/examples/widget_layout_split/README.md +0 -53
- data/examples/widget_layout_split/app.rb +0 -260
- data/examples/widget_line_gauge/README.md +0 -50
- data/examples/widget_line_gauge/app.rb +0 -219
- data/examples/widget_list/README.md +0 -58
- data/examples/widget_list/app.rb +0 -382
- data/examples/widget_map/README.md +0 -48
- data/examples/widget_map/app.rb +0 -95
- data/examples/widget_overlay/README.md +0 -45
- data/examples/widget_overlay/app.rb +0 -250
- data/examples/widget_popup/README.md +0 -45
- data/examples/widget_popup/app.rb +0 -106
- data/examples/widget_ratatui_logo/README.md +0 -43
- data/examples/widget_ratatui_logo/app.rb +0 -104
- data/examples/widget_ratatui_mascot/README.md +0 -43
- data/examples/widget_ratatui_mascot/app.rb +0 -95
- data/examples/widget_rect/README.md +0 -53
- data/examples/widget_rect/app.rb +0 -222
- data/examples/widget_render/README.md +0 -46
- data/examples/widget_render/app.rb +0 -186
- data/examples/widget_render/app.rbs +0 -41
- data/examples/widget_rich_text/README.md +0 -44
- data/examples/widget_rich_text/app.rb +0 -193
- data/examples/widget_scroll_text/README.md +0 -46
- data/examples/widget_scroll_text/app.rb +0 -109
- data/examples/widget_scrollbar/README.md +0 -46
- data/examples/widget_scrollbar/app.rb +0 -155
- data/examples/widget_sparkline/README.md +0 -51
- data/examples/widget_sparkline/app.rb +0 -277
- data/examples/widget_style_colors/README.md +0 -43
- data/examples/widget_style_colors/app.rb +0 -83
- data/examples/widget_table/README.md +0 -57
- data/examples/widget_table/app.rb +0 -285
- data/examples/widget_tabs/README.md +0 -50
- data/examples/widget_tabs/app.rb +0 -183
- data/examples/widget_text_width/README.md +0 -44
- data/examples/widget_text_width/app.rb +0 -117
- data/migrate_to_buffer.rb +0 -145
- data/mise.toml +0 -8
- data/tasks/autodoc/examples.rb +0 -87
- data/tasks/autodoc/member.rb +0 -58
- data/tasks/autodoc/name.rb +0 -21
- data/tasks/autodoc.rake +0 -21
- data/tasks/bump/bump_workflow.rb +0 -49
- data/tasks/bump/cargo_lockfile.rb +0 -21
- data/tasks/bump/changelog.rb +0 -104
- data/tasks/bump/header.rb +0 -32
- data/tasks/bump/history.rb +0 -32
- data/tasks/bump/links.rb +0 -69
- data/tasks/bump/manifest.rb +0 -33
- data/tasks/bump/patch_release.rb +0 -19
- data/tasks/bump/release_branch.rb +0 -17
- data/tasks/bump/release_from_trunk.rb +0 -49
- data/tasks/bump/repository.rb +0 -54
- data/tasks/bump/ruby_gem.rb +0 -29
- data/tasks/bump/sem_ver.rb +0 -44
- data/tasks/bump/unreleased_section.rb +0 -73
- data/tasks/bump.rake +0 -61
- data/tasks/doc/documentation.rb +0 -59
- data/tasks/doc/link/file_url.rb +0 -30
- data/tasks/doc/link/relative_path.rb +0 -61
- data/tasks/doc/link/web_url.rb +0 -55
- data/tasks/doc/link.rb +0 -52
- data/tasks/doc/link_audit.rb +0 -116
- data/tasks/doc/problem.rb +0 -40
- data/tasks/doc/source_file.rb +0 -93
- data/tasks/doc.rake +0 -905
- data/tasks/example_viewer.html.erb +0 -172
- data/tasks/extension.rake +0 -14
- data/tasks/license/headers_md.rb +0 -223
- data/tasks/license/headers_rb.rb +0 -210
- data/tasks/license/license_utils.rb +0 -130
- data/tasks/license/snippets_md.rb +0 -315
- data/tasks/license/snippets_rdoc.rb +0 -150
- data/tasks/license.rake +0 -91
- data/tasks/lint.rake +0 -170
- data/tasks/rbs_predicates/predicate_catalog.rb +0 -52
- data/tasks/rbs_predicates/predicate_tests.rb +0 -124
- data/tasks/rbs_predicates/rbs_signature.rb +0 -63
- data/tasks/rbs_predicates.rake +0 -31
- data/tasks/rdoc_config.rb +0 -29
- data/tasks/resources/build.yml.erb +0 -60
- data/tasks/resources/index.html.erb +0 -141
- data/tasks/resources/rubies.yml +0 -7
- data/tasks/sourcehut.rake +0 -110
- data/tasks/steep.rake +0 -11
- data/tasks/terminal_preview/app_screenshot.rb +0 -45
- data/tasks/terminal_preview/crash_report.rb +0 -54
- data/tasks/terminal_preview/example_app.rb +0 -27
- data/tasks/terminal_preview/launcher_script.rb +0 -48
- data/tasks/terminal_preview/preview_collection.rb +0 -60
- data/tasks/terminal_preview/preview_timing.rb +0 -24
- data/tasks/terminal_preview/safety_confirmation.rb +0 -58
- data/tasks/terminal_preview/saved_screenshot.rb +0 -56
- data/tasks/terminal_preview/system_appearance.rb +0 -13
- data/tasks/terminal_preview/terminal_window.rb +0 -138
- data/tasks/terminal_preview/window_id.rb +0 -16
- data/tasks/terminal_preview.rake +0 -30
- data/tasks/test.rake +0 -36
- data/tasks/website/index_page.rb +0 -30
- data/tasks/website/version.rb +0 -122
- data/tasks/website/version_menu.rb +0 -68
- data/tasks/website/versioned_documentation.rb +0 -83
- data/tasks/website/website.rb +0 -53
- 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.*
|