rubytui 1.2.3
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 +7 -0
- data/LICENSE +21 -0
- data/README.md +574 -0
- data/lib/rubytui/app.rb +174 -0
- data/lib/rubytui/backend.rb +158 -0
- data/lib/rubytui/backends/ansi_backend.rb +391 -0
- data/lib/rubytui/backends/test_backend.rb +223 -0
- data/lib/rubytui/buffer.rb +400 -0
- data/lib/rubytui/cell.rb +55 -0
- data/lib/rubytui/color.rb +153 -0
- data/lib/rubytui/color_mode.rb +203 -0
- data/lib/rubytui/errors.rb +13 -0
- data/lib/rubytui/event.rb +161 -0
- data/lib/rubytui/frame.rb +70 -0
- data/lib/rubytui/input/key.rb +93 -0
- data/lib/rubytui/input/parser.rb +231 -0
- data/lib/rubytui/input/reader.rb +119 -0
- data/lib/rubytui/layout/constraint.rb +83 -0
- data/lib/rubytui/layout/flex.rb +16 -0
- data/lib/rubytui/layout/layout.rb +205 -0
- data/lib/rubytui/modifier.rb +67 -0
- data/lib/rubytui/rect.rb +126 -0
- data/lib/rubytui/stateful_widget.rb +20 -0
- data/lib/rubytui/style.rb +143 -0
- data/lib/rubytui/symbols.rb +88 -0
- data/lib/rubytui/terminal.rb +218 -0
- data/lib/rubytui/text/line.rb +67 -0
- data/lib/rubytui/text/span.rb +34 -0
- data/lib/rubytui/text/text.rb +82 -0
- data/lib/rubytui/unicode.rb +162 -0
- data/lib/rubytui/version.rb +6 -0
- data/lib/rubytui/widget.rb +20 -0
- data/lib/rubytui/widgets/async_image.rb +248 -0
- data/lib/rubytui/widgets/block.rb +260 -0
- data/lib/rubytui/widgets/canvas.rb +248 -0
- data/lib/rubytui/widgets/chart.rb +224 -0
- data/lib/rubytui/widgets/gauge.rb +139 -0
- data/lib/rubytui/widgets/image.rb +330 -0
- data/lib/rubytui/widgets/input_field.rb +245 -0
- data/lib/rubytui/widgets/list.rb +186 -0
- data/lib/rubytui/widgets/paragraph.rb +181 -0
- data/lib/rubytui/widgets/popup.rb +140 -0
- data/lib/rubytui/widgets/scrollbar.rb +175 -0
- data/lib/rubytui/widgets/sparkline.rb +86 -0
- data/lib/rubytui/widgets/table.rb +231 -0
- data/lib/rubytui/widgets/tabs.rb +90 -0
- data/lib/rubytui.rb +389 -0
- metadata +89 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1ec32a08aa7695cdc9b1db38895f10df4e37ff057dec2799b924fc2a6d8df860
|
|
4
|
+
data.tar.gz: 5a8a38833a21651b6372a420aea4be5e17b6f214653818b198c81303c45bf29d
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: b0c26618a8ab168de7278fb24d0234b332983e0426ec945c2bb92ddc69da270788fd6d20ac578d792e9ac2d04398f374912ac118ebb316fdcb85aeb517197f97
|
|
7
|
+
data.tar.gz: 00a6150cc97eca7ca31eead19b04c1bbb0426a2c794dbf5208657199cc0e298a8d445c29aaf015519d41793d93890862cad04755b4bc182dfd98bdfa633c09dd
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 CoCL
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
# RubyTUI
|
|
2
|
+
|
|
3
|
+
RubyTUI is a terminal user interface library written in pure Ruby with no runtime dependencies. It provides immediate-mode rendering with double buffering, constraint-based layouts, built-in widgets, keyboard and mouse input, an optional Model-View-Update application framework, and inline images through the Kitty graphics protocol.
|
|
4
|
+
|
|
5
|
+
It is suited both to full-screen interactive applications and to command-line tools that only need styled, one-shot output such as tables, boxes and progress bars.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
gem install rubytui
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or add it to your Gemfile:
|
|
14
|
+
|
|
15
|
+
```ruby
|
|
16
|
+
gem "rubytui"
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Quick Start
|
|
20
|
+
|
|
21
|
+
```ruby
|
|
22
|
+
require "rubytui"
|
|
23
|
+
|
|
24
|
+
RubyTUI.run do |terminal, reader|
|
|
25
|
+
loop do
|
|
26
|
+
terminal.draw do |frame|
|
|
27
|
+
style = RubyTUI::Style.new.fg(RubyTUI::Color::GREEN).bold
|
|
28
|
+
frame.buffer.set_string(0, 0, "Hello, RubyTUI! Press q to quit.", style)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
event = reader.poll(timeout_ms: 50)
|
|
32
|
+
break if event.is_a?(RubyTUI::KeyEvent) && event.char == "q"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`RubyTUI.run` puts the terminal into raw mode, hides the cursor and switches to the alternate screen, then yields a `RubyTUI::Terminal` and a `RubyTUI::Input::Reader`. The terminal is restored when the block returns or raises.
|
|
38
|
+
|
|
39
|
+
## Core Concepts
|
|
40
|
+
|
|
41
|
+
### Rendering model
|
|
42
|
+
|
|
43
|
+
RubyTUI redraws the whole interface on every frame. `Terminal#draw` yields a `RubyTUI::Frame`; you render widgets into it, and when the block returns the terminal compares the new frame with the previous one and writes only the cells that changed.
|
|
44
|
+
|
|
45
|
+
- `frame.area` is a `RubyTUI::Rect` covering the whole screen.
|
|
46
|
+
- `frame.render_widget(widget, rect)` renders a stateless widget.
|
|
47
|
+
- `frame.render_stateful_widget(widget, rect, state)` renders a stateful widget.
|
|
48
|
+
- `frame.buffer` is the underlying `RubyTUI::Buffer`, a grid of cells (a character plus a style). Write to it directly with `set_string(x, y, text, style)` and `set_style(rect, style)`.
|
|
49
|
+
- `frame.set_cursor_position(x, y)` shows the terminal cursor at a position after the frame is drawn; otherwise the cursor stays hidden.
|
|
50
|
+
|
|
51
|
+
The terminal handles `SIGWINCH` (replacing any existing handler for it): after a resize, the next `draw` uses the new size and clears the screen. `terminal.force_redraw!` makes the next `draw` repaint everything.
|
|
52
|
+
|
|
53
|
+
`RubyTUI.run` accepts `mouse:` (default `false`), `alternate_screen:` (default `true`; pass `false` to keep the output in the scrollback after exit), `input:` and `output:` (default `$stdin` and `$stdout`; for example, pass `input: File.open("/dev/tty")` when standard input is redirected). For manual control use `RubyTUI.init`, which returns `[terminal, reader]`, and `RubyTUI.restore(terminal)`, passing the same `alternate_screen:` value to both.
|
|
54
|
+
|
|
55
|
+
### Widgets
|
|
56
|
+
|
|
57
|
+
A widget is any object that responds to `render(area, buf)`. A stateful widget responds to `render(area, buf, state)`, where the state object belongs to your application and persists across frames (for example the selected row of a list). Most widgets are cheap to construct and are typically built inside `draw` from your current data; `AsyncImage` is the exception and must be kept across frames.
|
|
58
|
+
|
|
59
|
+
### Styles and colors
|
|
60
|
+
|
|
61
|
+
`RubyTUI::Style` is immutable; each builder method returns a new style.
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
base = RubyTUI::Style.new.fg(RubyTUI::Color::WHITE).bg(RubyTUI::Color.indexed(236))
|
|
65
|
+
warning = base.patch(RubyTUI::Style.new.fg(RubyTUI::Color.rgb(255, 170, 0)).bold)
|
|
66
|
+
|
|
67
|
+
warning.bold? # => true
|
|
68
|
+
warning.italic? # => false
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
- Colors: the 16 named constants (`RubyTUI::Color::RED`, `RubyTUI::Color::BRIGHT_BLUE`, ...; `GRAY` and the `LIGHT_*` names are aliases for the bright variants), `RubyTUI::Color::RESET` for the terminal default, `RubyTUI::Color.indexed(0..255)` and `RubyTUI::Color.rgb(r, g, b)`.
|
|
72
|
+
- Modifiers: `bold`, `dim`, `italic`, `underlined`, `reversed`, `strikethrough`, each with a query method (`bold?`, ...). `add_modifier` and `remove_modifier` take `RubyTUI::Modifier` constants.
|
|
73
|
+
- `a.patch(b)` layers `b` over `a`: colors set in `b` win, and modifiers are combined.
|
|
74
|
+
|
|
75
|
+
Styled text is built from `RubyTUI::Span` (text plus style), `RubyTUI::Line` (a sequence of spans) and `RubyTUI::Text` (a sequence of lines; a string is split on newlines):
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
line = RubyTUI::Line.new([
|
|
79
|
+
RubyTUI::Span.new("Status: ", RubyTUI::Style.new.bold),
|
|
80
|
+
RubyTUI::Span.new("OK", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN))
|
|
81
|
+
])
|
|
82
|
+
paragraph = RubyTUI::Widgets::Paragraph.new(text: line)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Colors are sent to the terminal exactly as specified. To support terminals with fewer colors, use `RubyTUI::ColorMode` to detect the capability and convert colors yourself:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
mode = RubyTUI::ColorMode.detect # => :truecolor, :color256 or :basic16
|
|
89
|
+
accent = RubyTUI::ColorMode.downgrade(RubyTUI::Color.rgb(255, 128, 0), mode)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
`detect` inspects `COLORTERM`, `TERM` and `TERM_PROGRAM` and caches the result; `RubyTUI::ColorMode.force(mode)` overrides it. `downgrade(color, mode)` uses the detected mode when `mode` is omitted. For `:color256` it maps RGB colors to the nearest 256-color index; for `:basic16` it maps RGB and indexed colors to a named color; other colors are returned unchanged.
|
|
93
|
+
|
|
94
|
+
### Layout
|
|
95
|
+
|
|
96
|
+
`RubyTUI::Layout::Layout` splits a `Rect` into rows or columns according to a list of constraints and returns one `Rect` per constraint.
|
|
97
|
+
|
|
98
|
+
```ruby
|
|
99
|
+
area = RubyTUI::Rect.new(0, 0, 80, 24)
|
|
100
|
+
|
|
101
|
+
header, body, footer = RubyTUI::Layout::Layout.vertical([
|
|
102
|
+
RubyTUI::Constraint.length(3),
|
|
103
|
+
RubyTUI::Constraint.fill(1),
|
|
104
|
+
RubyTUI::Constraint.length(1)
|
|
105
|
+
]).split(area)
|
|
106
|
+
|
|
107
|
+
sidebar, main = RubyTUI::Layout::Layout.horizontal(
|
|
108
|
+
[RubyTUI::Constraint.percentage(25), RubyTUI::Constraint.fill(1)],
|
|
109
|
+
spacing: 1
|
|
110
|
+
).split(body)
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
| Constraint | Size |
|
|
114
|
+
|------------|------|
|
|
115
|
+
| `length(n)` | `n` cells |
|
|
116
|
+
| `percentage(n)` | `n` percent of the available space |
|
|
117
|
+
| `ratio(num, den)` | `num / den` of the available space |
|
|
118
|
+
| `fill(weight = 1)` | A share of the remaining space, proportional to `weight` |
|
|
119
|
+
| `min(n)` | At least `n` cells, plus a weight-1 share of the remaining space |
|
|
120
|
+
| `max(n)` | A weight-1 share of the remaining space, at most `n` cells |
|
|
121
|
+
|
|
122
|
+
`length`, `percentage` and `ratio` are allocated first, in order; the remaining space is then divided among `fill`, `min` and `max`. `spacing:` inserts a gap between segments. When the constraints leave space unused, `flex:` decides where it goes: `:start` and `:stretch` (the default) leave it at the end, and `:end`, `:center`, `:space_between` and `:space_around` position the segments accordingly. The same values are available as constants in `RubyTUI::Layout::Flex`.
|
|
123
|
+
|
|
124
|
+
`RubyTUI::Rect` accepts positional (`Rect.new(x, y, width, height)`, `Rect[x, y, width, height]`) or keyword (`Rect.new(x:, y:, width:, height:)`) arguments, and provides `right`, `bottom`, `empty?`, `inner(margin)` and `inner_rect(top:, right:, bottom:, left:)`. Layouts nest by splitting the rects returned by another layout.
|
|
125
|
+
|
|
126
|
+
## Widgets
|
|
127
|
+
|
|
128
|
+
All built-in widgets live in `RubyTUI::Widgets`. Most accept a `block:` argument (a `Block`) that draws a border and title around them.
|
|
129
|
+
|
|
130
|
+
| Widget | Kind | Description |
|
|
131
|
+
|--------|------|-------------|
|
|
132
|
+
| `Block` | stateless | Borders on all or selected sides, title aligned left, center or right, padding, background style. `block.inner(area)` returns the area inside the borders and padding. |
|
|
133
|
+
| `Paragraph` | stateless | Multi-line styled text with word, character or no wrapping, aligned left, center or right. |
|
|
134
|
+
| `List` + `ListState` | stateful | Scrollable, selectable list with a highlight symbol and style; scrolls to keep the selection visible. |
|
|
135
|
+
| `Table` + `TableState` | stateful | Rows with an optional header and separator, row selection, and fixed or equally divided column widths. |
|
|
136
|
+
| `Gauge` | stateless | Horizontal progress bar with eighth-of-a-cell precision and a centered label (the percentage by default). |
|
|
137
|
+
| `Sparkline` | stateless | Compact bar chart, one column per value, showing the most recent values that fit. |
|
|
138
|
+
| `Scrollbar` | stateless | Vertical or horizontal scroll indicator with a proportional thumb and optional arrows. |
|
|
139
|
+
| `Tabs` | stateless | Horizontal tab bar with active and inactive styles and a configurable divider. |
|
|
140
|
+
| `Canvas` | stateless | Points, lines and text labels in data coordinates, drawn with Braille characters (2x4 dots per cell). |
|
|
141
|
+
| `Chart` + `Dataset` | stateless | Line chart of one or more datasets with optional labeled axes and fixed or data-derived bounds. Dataset names are not displayed. |
|
|
142
|
+
| `Popup` | stateless | Overlay that clears its area and draws a block and an optional shadow; sized in cells or as a percentage of the parent, centered or placed at a fixed position. `popup.inner(area)` returns its content area. |
|
|
143
|
+
| `InputField` + `InputState` | stateful | Single-line text input with a cursor, horizontal scrolling and a placeholder. |
|
|
144
|
+
| `Image` | stateless | PNG or raw RGBA image drawn with the Kitty graphics protocol. See [Images](#images). |
|
|
145
|
+
| `AsyncImage` | stateless | Loads image data on a background thread and draws a placeholder until it is ready. |
|
|
146
|
+
|
|
147
|
+
`Block` borders are chosen with `borders:` (`:all`, the default, `:none`, a single side such as `:top`, or an array of sides) and drawn with `border_set:`: `RubyTUI::Symbols::ROUNDED` (the default), `LIGHT`, `DOUBLE`, `THICK` or `PLAIN` (ASCII `+`, `-`, `|`).
|
|
148
|
+
|
|
149
|
+
`Canvas` keeps every shape added to it, so create a new canvas for each frame and give it both `x_bounds:` and `y_bounds:`:
|
|
150
|
+
|
|
151
|
+
```ruby
|
|
152
|
+
canvas = RubyTUI::Widgets::Canvas.new(x_bounds: [0, 10], y_bounds: [0, 10])
|
|
153
|
+
canvas.line(0, 0, 10, 10).point(5, 2).label(1, 9, "peak")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
The following example renders several widgets once to standard output:
|
|
157
|
+
|
|
158
|
+
```ruby
|
|
159
|
+
require "rubytui"
|
|
160
|
+
|
|
161
|
+
RubyTUI.render_once(width: 60, height: 12) do |frame|
|
|
162
|
+
tabs_area, middle, bottom = RubyTUI::Layout::Layout.vertical([
|
|
163
|
+
RubyTUI::Constraint.length(1),
|
|
164
|
+
RubyTUI::Constraint.fill(1),
|
|
165
|
+
RubyTUI::Constraint.length(3)
|
|
166
|
+
]).split(frame.area)
|
|
167
|
+
left, right = RubyTUI::Layout::Layout.horizontal(
|
|
168
|
+
[RubyTUI::Constraint.percentage(50), RubyTUI::Constraint.fill(1)],
|
|
169
|
+
spacing: 1
|
|
170
|
+
).split(middle)
|
|
171
|
+
|
|
172
|
+
tabs = RubyTUI::Widgets::Tabs.new(titles: ["Overview", "Logs", "Help"], selected: 0)
|
|
173
|
+
frame.render_widget(tabs, tabs_area)
|
|
174
|
+
|
|
175
|
+
table = RubyTUI::Widgets::Table.new(
|
|
176
|
+
header: ["Host", "Status"],
|
|
177
|
+
rows: [["web-1", "up"], ["db-1", "down"]],
|
|
178
|
+
block: RubyTUI::Widgets::Block.new(title: " Hosts ")
|
|
179
|
+
)
|
|
180
|
+
frame.render_stateful_widget(table, left, RubyTUI::Widgets::TableState.new)
|
|
181
|
+
|
|
182
|
+
sparkline = RubyTUI::Widgets::Sparkline.new(
|
|
183
|
+
data: [3, 5, 2, 8, 6, 9, 4],
|
|
184
|
+
block: RubyTUI::Widgets::Block.new(title: " Load ")
|
|
185
|
+
)
|
|
186
|
+
frame.render_widget(sparkline, right)
|
|
187
|
+
|
|
188
|
+
gauge = RubyTUI::Widgets::Gauge.new(ratio: 0.62, block: RubyTUI::Widgets::Block.new(title: " Disk "))
|
|
189
|
+
frame.render_widget(gauge, bottom)
|
|
190
|
+
end
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Input
|
|
194
|
+
|
|
195
|
+
`reader.poll(timeout_ms: 100)` waits up to the given time and returns a `RubyTUI::KeyEvent`, a `RubyTUI::MouseEvent`, or `nil` when no recognized input arrived. `reader.read` blocks until an event arrives.
|
|
196
|
+
|
|
197
|
+
### Keyboard
|
|
198
|
+
|
|
199
|
+
A `KeyEvent` has a `code`, a `char` and modifier predicates (`ctrl?`, `alt?`, `shift?`).
|
|
200
|
+
|
|
201
|
+
- `code` is one of the symbols in `RubyTUI::Input::Key`: `CHAR`, `ENTER`, `ESCAPE`, `BACKSPACE`, `DELETE`, `TAB`, `BACKTAB` (Shift+Tab), `UP`, `DOWN`, `LEFT`, `RIGHT`, `HOME`, `END_KEY`, `PAGE_UP`, `PAGE_DOWN`, `INSERT` and `F1` to `F12`.
|
|
202
|
+
- For `CHAR` events, `char` holds the character. Ctrl+letter arrives as the lowercase letter with `ctrl?` true, except Ctrl+H, Ctrl+I and Ctrl+M, which the terminal sends as Backspace, Tab and Enter. Alt+key arrives with `alt?` true.
|
|
203
|
+
- Arrow keys, Home and End also report Shift, Alt and Ctrl modifiers.
|
|
204
|
+
- A lone Escape key press is reported after a 50 ms wait that separates it from escape sequences.
|
|
205
|
+
- In raw mode, Ctrl+C does not send `SIGINT`; it arrives as a key event, so handle it if your application should quit on it.
|
|
206
|
+
|
|
207
|
+
```ruby
|
|
208
|
+
require "rubytui"
|
|
209
|
+
|
|
210
|
+
items = ["Apples", "Bananas", "Cherries"]
|
|
211
|
+
state = RubyTUI::Widgets::ListState.new
|
|
212
|
+
|
|
213
|
+
RubyTUI.run do |terminal, reader|
|
|
214
|
+
loop do
|
|
215
|
+
terminal.draw do |frame|
|
|
216
|
+
body, footer = RubyTUI::Layout::Layout.vertical([
|
|
217
|
+
RubyTUI::Constraint.fill(1),
|
|
218
|
+
RubyTUI::Constraint.length(1)
|
|
219
|
+
]).split(frame.area)
|
|
220
|
+
|
|
221
|
+
list = RubyTUI::Widgets::List.new(
|
|
222
|
+
items: items,
|
|
223
|
+
block: RubyTUI::Widgets::Block.new(title: " Fruit ")
|
|
224
|
+
)
|
|
225
|
+
frame.render_stateful_widget(list, body, state)
|
|
226
|
+
frame.buffer.set_string(footer.x, footer.y, "Up/Down to move, q or Ctrl+C to quit")
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
event = reader.poll(timeout_ms: 50)
|
|
230
|
+
next unless event.is_a?(RubyTUI::KeyEvent)
|
|
231
|
+
|
|
232
|
+
case event.code
|
|
233
|
+
when RubyTUI::Input::Key::UP then state.select_previous(items.length)
|
|
234
|
+
when RubyTUI::Input::Key::DOWN then state.select_next(items.length)
|
|
235
|
+
when RubyTUI::Input::Key::CHAR
|
|
236
|
+
break if event.char == "q" || (event.ctrl? && event.char == "c")
|
|
237
|
+
end
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
### Text input
|
|
243
|
+
|
|
244
|
+
`InputState#handle_key(event)` applies a key event to the input and returns `true` if it consumed the event. Printable characters are inserted; Backspace, Delete, Left, Right, Home and End edit and move the cursor; Ctrl+A and Ctrl+E move to the start and end; Ctrl+K and Ctrl+U delete to the end and to the start; Ctrl+W deletes the previous word. Other keys, such as Enter, Tab and Escape, are left to your application.
|
|
245
|
+
|
|
246
|
+
```ruby
|
|
247
|
+
state = RubyTUI::Widgets::InputState.new
|
|
248
|
+
field = RubyTUI::Widgets::InputField.new(
|
|
249
|
+
placeholder: "Search...",
|
|
250
|
+
block: RubyTUI::Widgets::Block.new(title: " Query ")
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
# Inside terminal.draw:
|
|
254
|
+
frame.render_stateful_widget(field, area, state)
|
|
255
|
+
|
|
256
|
+
# In the event loop:
|
|
257
|
+
if event.is_a?(RubyTUI::KeyEvent) && event.code == RubyTUI::Input::Key::ENTER
|
|
258
|
+
search(state.text)
|
|
259
|
+
elsif event
|
|
260
|
+
state.handle_key(event)
|
|
261
|
+
end
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Mouse
|
|
265
|
+
|
|
266
|
+
`RubyTUI.run(mouse: true)` enables reporting of button presses, releases, drags and the scroll wheel. A `MouseEvent` has:
|
|
267
|
+
|
|
268
|
+
- `button`: `:left`, `:middle`, `:right`, `:scroll_up`, `:scroll_down` or `:none` (constants `RubyTUI::MouseEvent::LEFT` and so on)
|
|
269
|
+
- `action`: `:press`, `:release` or `:drag` (scroll-wheel events are reported as `:press`)
|
|
270
|
+
- `x`, `y`: zero-based cell coordinates
|
|
271
|
+
- `ctrl?`, `alt?`, `shift?`
|
|
272
|
+
|
|
273
|
+
```ruby
|
|
274
|
+
require "rubytui"
|
|
275
|
+
|
|
276
|
+
RubyTUI.run(mouse: true) do |terminal, reader|
|
|
277
|
+
message = "Click or scroll; press q to quit"
|
|
278
|
+
|
|
279
|
+
loop do
|
|
280
|
+
terminal.draw { |frame| frame.buffer.set_string(0, 0, message) }
|
|
281
|
+
|
|
282
|
+
case (event = reader.poll(timeout_ms: 50))
|
|
283
|
+
when RubyTUI::MouseEvent
|
|
284
|
+
message = "#{event.action} #{event.button} at #{event.x},#{event.y}"
|
|
285
|
+
when RubyTUI::KeyEvent
|
|
286
|
+
break if event.char == "q"
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## App Framework (Model-View-Update)
|
|
293
|
+
|
|
294
|
+
For structured applications, include `RubyTUI::App` and implement the hooks below. Every hook has a default, so implement only the ones you need.
|
|
295
|
+
|
|
296
|
+
| Hook | Purpose |
|
|
297
|
+
|------|---------|
|
|
298
|
+
| `init` | Returns the initial model (any object; a Hash by default). |
|
|
299
|
+
| `handle_event(event)` | Converts an input event into a message, or returns `nil` to ignore it. |
|
|
300
|
+
| `handle_tick(model)` | Called after every poll, whether or not an event arrived; returns a message or `nil`. Use it for animations and periodic work. |
|
|
301
|
+
| `update(model, msg)` | Returns the next model. Call `throw(:quit)` or `throw(:quit, value)` to leave the loop. |
|
|
302
|
+
| `view(model, frame)` | Draws the model into the frame. |
|
|
303
|
+
| `on_start(terminal, reader)` | Called once after `init`, before the loop starts. |
|
|
304
|
+
| `on_stop(value)` | Called once after the loop ends and before the terminal is restored, with the value given to `throw(:quit, value)` (or `nil`). |
|
|
305
|
+
|
|
306
|
+
```ruby
|
|
307
|
+
require "rubytui"
|
|
308
|
+
|
|
309
|
+
class Counter
|
|
310
|
+
include RubyTUI::App
|
|
311
|
+
|
|
312
|
+
def init
|
|
313
|
+
{ count: 0 }
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def handle_event(event)
|
|
317
|
+
return unless event.is_a?(RubyTUI::KeyEvent)
|
|
318
|
+
|
|
319
|
+
case event.char
|
|
320
|
+
when "+" then :increment
|
|
321
|
+
when "-" then :decrement
|
|
322
|
+
when "q" then :quit
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
|
|
326
|
+
def update(model, msg)
|
|
327
|
+
case msg
|
|
328
|
+
when :increment then model.merge(count: model[:count] + 1)
|
|
329
|
+
when :decrement then model.merge(count: model[:count] - 1)
|
|
330
|
+
when :quit then throw(:quit, model)
|
|
331
|
+
else model
|
|
332
|
+
end
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
def view(model, frame)
|
|
336
|
+
frame.buffer.set_string(0, 0, "Count: #{model[:count]} (+/- to change, q to quit)")
|
|
337
|
+
end
|
|
338
|
+
end
|
|
339
|
+
|
|
340
|
+
Counter.run!
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
`Counter.run!` is shorthand for `Counter.new.run!`. `run!` accepts `mouse:` (default `false`), `tick_ms:` (the poll timeout per loop iteration, default 50), `input:` and `output:`, and sets up and restores the terminal like `RubyTUI.run`. Each iteration draws `view`, polls for an event, passes it through `handle_event` and `update`, then calls `handle_tick`.
|
|
344
|
+
|
|
345
|
+
## One-Shot Output for CLI Tools
|
|
346
|
+
|
|
347
|
+
These functions render once and write ANSI-styled text to `$stdout` (or to `output:`), with no raw mode, alternate screen or event loop. The output stays in the terminal scrollback. When `width:` is omitted, the width of the output terminal is used (80 columns if it is not a terminal).
|
|
348
|
+
|
|
349
|
+
### render_once
|
|
350
|
+
|
|
351
|
+
`RubyTUI.render_once` yields a `Frame` for any widgets and layouts, prints the result followed by a newline, and returns the frame. Pass `height:` explicitly; it defaults to the terminal height.
|
|
352
|
+
|
|
353
|
+
```ruby
|
|
354
|
+
require "rubytui"
|
|
355
|
+
|
|
356
|
+
RubyTUI.render_once(width: 40, height: 3) do |frame|
|
|
357
|
+
block = RubyTUI::Widgets::Block.new(title: " Report ")
|
|
358
|
+
frame.render_widget(block, frame.area)
|
|
359
|
+
frame.buffer.set_string(2, 1, "All checks passed", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN))
|
|
360
|
+
end
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
### Print helpers
|
|
364
|
+
|
|
365
|
+
```ruby
|
|
366
|
+
require "rubytui"
|
|
367
|
+
|
|
368
|
+
RubyTUI.print_table(
|
|
369
|
+
header: ["Name", "Price", "Change"],
|
|
370
|
+
rows: [["AAPL", "150.00", "+2.3%"], ["GOOG", "2800", "-1.1%"]],
|
|
371
|
+
title: " Stocks ",
|
|
372
|
+
border: :rounded,
|
|
373
|
+
width: 40
|
|
374
|
+
)
|
|
375
|
+
|
|
376
|
+
RubyTUI.print_box(
|
|
377
|
+
title: " Summary ",
|
|
378
|
+
content: "Total: $1,234\nProfit: +5.2%",
|
|
379
|
+
border_style: RubyTUI::Style.new.fg(RubyTUI::Color::YELLOW),
|
|
380
|
+
width: 30
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
RubyTUI.print_gauge(ratio: 0.75, title: " Progress ", width: 40)
|
|
384
|
+
```
|
|
385
|
+
|
|
386
|
+
- `print_table(header:, rows:, title:, border:, width:, ...)` also accepts `widths:`, `column_spacing:`, `header_style:`, `style:` and `border_style:`. The border is drawn only when `title:` is given.
|
|
387
|
+
- `print_box(title:, content:, border:, width:, ...)` takes a string (split on newlines) or an array of lines, and also accepts `alignment:`, `text_style:`, `style:` and `border_style:`.
|
|
388
|
+
- `print_gauge(ratio:, label:, title:, width:, ...)` also accepts `gauge_style:`, `border:` and `border_style:`. The border is drawn only when `title:` is given.
|
|
389
|
+
|
|
390
|
+
`border:` is one of `:rounded` (the default), `:light`, `:double`, `:thick` or `:plain`, or a `RubyTUI::Symbols` border set.
|
|
391
|
+
|
|
392
|
+
### Printing a buffer
|
|
393
|
+
|
|
394
|
+
```ruby
|
|
395
|
+
require "rubytui"
|
|
396
|
+
|
|
397
|
+
buf = RubyTUI::Buffer.new(40, 1)
|
|
398
|
+
buf.set_string(0, 0, "Hello!", RubyTUI::Style.new.fg(RubyTUI::Color::GREEN).bold)
|
|
399
|
+
puts buf.to_ansi
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
`Buffer.new(width, height)` creates a buffer at the origin; `Buffer.new(rect)` uses an existing `Rect`. `buf.to_ansi` returns the content as a string with ANSI styles and one line per row; `buf.print(io = $stdout)` writes it.
|
|
403
|
+
|
|
404
|
+
## Images
|
|
405
|
+
|
|
406
|
+
`RubyTUI::Widgets::Image` displays images in terminals that implement the [Kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/), such as kitty, Ghostty and WezTerm. The specification lists other terminals that implement it.
|
|
407
|
+
|
|
408
|
+
### Formats
|
|
409
|
+
|
|
410
|
+
- PNG is sent to the terminal as is; its dimensions are read from the file header.
|
|
411
|
+
- Raw 8-bit RGBA pixel data requires `format: :rgba` together with `pixel_width:` and `pixel_height:`.
|
|
412
|
+
- JPEG, WebP, GIF and BMP data is detected and raises `RubyTUI::UnsupportedImageFormat`. Convert such images to PNG first, for example with ImageMagick: `system("magick", "input.jpg", "output.png")`.
|
|
413
|
+
|
|
414
|
+
```ruby
|
|
415
|
+
# From a PNG file
|
|
416
|
+
image = RubyTUI::Widgets::Image.new(path: "photo.png")
|
|
417
|
+
frame.render_widget(image, area)
|
|
418
|
+
|
|
419
|
+
# With a border and title
|
|
420
|
+
image = RubyTUI::Widgets::Image.new(
|
|
421
|
+
path: "photo.png",
|
|
422
|
+
block: RubyTUI::Widgets::Block.new(title: " Photo ")
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
# From raw RGBA pixels
|
|
426
|
+
image = RubyTUI::Widgets::Image.new(data: rgba_bytes, pixel_width: 800, pixel_height: 600, format: :rgba)
|
|
427
|
+
```
|
|
428
|
+
|
|
429
|
+
### Sizing
|
|
430
|
+
|
|
431
|
+
`fit:` controls how the image is placed in its area:
|
|
432
|
+
|
|
433
|
+
- `:stretch` (the default) scales the image to fill the area without preserving its aspect ratio.
|
|
434
|
+
- `:contain` uses the largest centered part of the area that preserves the aspect ratio.
|
|
435
|
+
- `:cover` computes a placement that would cover the area and limits it to the area. The image is not cropped, so the result is the same as `:stretch`; to show part of an image, use `src_rect:`.
|
|
436
|
+
|
|
437
|
+
Aspect-ratio calculations need the size of a terminal cell in pixels. Pass it as `cell_size: [width, height]`; the default is `[9, 18]`. `terminal.cell_pixel_size` asks the terminal for the real value, waits up to 100 ms for the reply, falls back to `[9, 18]`, and caches the result. Call it once, before your event loop starts reading input.
|
|
438
|
+
|
|
439
|
+
```ruby
|
|
440
|
+
cell_size = terminal.cell_pixel_size # e.g. [9, 18]
|
|
441
|
+
image = RubyTUI::Widgets::Image.new(path: "photo.png", fit: :contain, cell_size: cell_size)
|
|
442
|
+
|
|
443
|
+
# Or compute the placement yourself
|
|
444
|
+
dims = RubyTUI::Widgets::Image.png_dimensions(File.binread("photo.png")) # => [width, height] or nil
|
|
445
|
+
rect = RubyTUI::Widgets::Image.fit_area(area, dims[0], dims[1], cell_size: cell_size, mode: :contain)
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
### Cropping and scrolling
|
|
449
|
+
|
|
450
|
+
`src_rect:` selects a rectangle of the source image, in pixels, to display. Changing its offset between frames scrolls through a large image:
|
|
451
|
+
|
|
452
|
+
```ruby
|
|
453
|
+
image = RubyTUI::Widgets::Image.new(
|
|
454
|
+
path: "tall_image.png",
|
|
455
|
+
src_rect: RubyTUI::Rect.new(0, scroll_offset_px, image_width_px, visible_height_px)
|
|
456
|
+
)
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
### Lifecycle
|
|
460
|
+
|
|
461
|
+
Images are tracked by position and content. When an image disappears, moves or changes between frames, the old image is deleted from the terminal and the following frame is repainted in full. `terminal.clear_all_images!` removes every image immediately.
|
|
462
|
+
|
|
463
|
+
`RubyTUI::Widgets::Image.detect_format(data)` returns `:png`, `:jpeg`, `:webp`, `:gif` or `:bmp` from the file signature, or `:rgba` when the data is not recognized.
|
|
464
|
+
|
|
465
|
+
### AsyncImage
|
|
466
|
+
|
|
467
|
+
`RubyTUI::Widgets::AsyncImage` runs a loader on a background thread and renders the image once the loader returns PNG data. Create it once and keep it across frames.
|
|
468
|
+
|
|
469
|
+
```ruby
|
|
470
|
+
image = RubyTUI::Widgets::AsyncImage.new(
|
|
471
|
+
loader: -> { File.binread("large.png") },
|
|
472
|
+
fit: :contain,
|
|
473
|
+
on_error: ->(error) { warn error.message }
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
# Inside terminal.draw:
|
|
477
|
+
frame.render_widget(image, area)
|
|
478
|
+
|
|
479
|
+
image.loading? # => true while the loader runs
|
|
480
|
+
image.ready? # => true once the image can be drawn
|
|
481
|
+
image.error? # => true if the loader raised or returned unsupported data; see image.error
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
- While loading, `Loading...` is drawn centered in the area; after a failure, `error_placeholder:` (default `[Error]`) is drawn.
|
|
485
|
+
- `placeholder:` accepts a widget that includes `RubyTUI::Widget` (all built-in widgets do), which is drawn instead until the image is ready, or a string, which is shown before loading starts when `auto_start: false` is given. With `auto_start: false`, call `start_loading` to begin; `cancel` stops the loader thread.
|
|
486
|
+
- `on_ready:` is called on the loader thread when the loader has returned (not on a cache hit); `on_error:` receives the exception raised by the loader.
|
|
487
|
+
- `cache:` (a Hash shared between instances) and `key:` reuse loaded data: when the cache already holds the key, the widget is ready immediately and the loader is not called.
|
|
488
|
+
- `block:`, `style:` and `cell_size:` work as for `Image`.
|
|
489
|
+
|
|
490
|
+
## Custom Widgets
|
|
491
|
+
|
|
492
|
+
Any object with a `render(area, buf)` method can be rendered. Including `RubyTUI::Widget` is optional and documents the protocol.
|
|
493
|
+
|
|
494
|
+
```ruby
|
|
495
|
+
require "rubytui"
|
|
496
|
+
|
|
497
|
+
class Badge
|
|
498
|
+
include RubyTUI::Widget
|
|
499
|
+
|
|
500
|
+
def initialize(label)
|
|
501
|
+
@label = label
|
|
502
|
+
end
|
|
503
|
+
|
|
504
|
+
def render(area, buf)
|
|
505
|
+
text = RubyTUI::Unicode.truncate_to_width(" #{@label} ", area.width)
|
|
506
|
+
style = RubyTUI::Style.new.fg(RubyTUI::Color::BLACK).bg(RubyTUI::Color::CYAN)
|
|
507
|
+
buf.set_string(area.x, area.y, text, style)
|
|
508
|
+
end
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
RubyTUI.render_once(width: 20, height: 1) do |frame|
|
|
512
|
+
frame.render_widget(Badge.new("beta"), frame.area)
|
|
513
|
+
end
|
|
514
|
+
```
|
|
515
|
+
|
|
516
|
+
Stateful widgets implement `render(area, buf, state)`, may include `RubyTUI::StatefulWidget`, and are rendered with `frame.render_stateful_widget`.
|
|
517
|
+
|
|
518
|
+
## Unicode Width
|
|
519
|
+
|
|
520
|
+
Text is measured and stored by grapheme cluster, the unit a terminal displays as one character:
|
|
521
|
+
|
|
522
|
+
- CJK characters and emoji occupy two columns, including symbols with emoji presentation by default (such as β
) and text symbols followed by the emoji variation selector U+FE0F (such as β οΈ).
|
|
523
|
+
- Combining marks, such as Latin diacritics and Devanagari vowel signs, stay in the same cell as their base character.
|
|
524
|
+
- `Buffer#set_string` stores each cluster in one cell and reserves the following cell for two-column clusters.
|
|
525
|
+
|
|
526
|
+
`RubyTUI::Unicode` exposes the same calculations:
|
|
527
|
+
|
|
528
|
+
```ruby
|
|
529
|
+
RubyTUI::Unicode.string_width("Hiπ₯") # => 4
|
|
530
|
+
RubyTUI::Unicode.truncate_to_width("δΈζε", 5) # => "δΈζ"
|
|
531
|
+
RubyTUI::Unicode.cluster_width("β οΈ") # => 2
|
|
532
|
+
RubyTUI::Unicode.char_width("a") # => 1
|
|
533
|
+
```
|
|
534
|
+
|
|
535
|
+
## Testing
|
|
536
|
+
|
|
537
|
+
`RubyTUI::TestBackend` is an in-memory backend for testing rendering without a terminal.
|
|
538
|
+
|
|
539
|
+
```ruby
|
|
540
|
+
require "minitest/autorun"
|
|
541
|
+
require "rubytui"
|
|
542
|
+
|
|
543
|
+
class TitleTest < Minitest::Test
|
|
544
|
+
def test_block_title
|
|
545
|
+
backend = RubyTUI::TestBackend.new(width: 20, height: 3)
|
|
546
|
+
terminal = RubyTUI::Terminal.new(backend: backend)
|
|
547
|
+
|
|
548
|
+
terminal.draw do |frame|
|
|
549
|
+
frame.render_widget(RubyTUI::Widgets::Block.new(title: " Hi "), frame.area)
|
|
550
|
+
end
|
|
551
|
+
|
|
552
|
+
assert_equal "Hi", backend.text_at(3, 0, 2)
|
|
553
|
+
assert_equal "β°βββββββββββββββββββ―", backend.row_text(2)
|
|
554
|
+
assert_equal "β", backend.cell_at(0, 0).symbol
|
|
555
|
+
end
|
|
556
|
+
end
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
- `cell_at(x, y)` returns the `RubyTUI::Cell` (`symbol` and `style`) at a position.
|
|
560
|
+
- `text_at(x, y, length)` returns the characters of `length` cells starting at a position.
|
|
561
|
+
- `row_text(y)` returns a whole row without trailing whitespace.
|
|
562
|
+
- `draw_count`, `total_changes` and `draw_history` record what each `draw` sent; `cursor_visible`, `cursor_x` and `cursor_y` record the cursor; `image_history` records image placements; `reset!` clears the grid and history.
|
|
563
|
+
|
|
564
|
+
Widgets can also be tested without a terminal by rendering them into a `Buffer` and reading cells with `buf[x, y]`.
|
|
565
|
+
|
|
566
|
+
## Requirements
|
|
567
|
+
|
|
568
|
+
- Ruby 3.2 or later
|
|
569
|
+
- A terminal that supports ANSI escape sequences
|
|
570
|
+
- For images, a terminal that implements the Kitty graphics protocol
|
|
571
|
+
|
|
572
|
+
## License
|
|
573
|
+
|
|
574
|
+
Released under the MIT License. The full text is in the `LICENSE` file included with the gem.
|