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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +574 -0
  4. data/lib/rubytui/app.rb +174 -0
  5. data/lib/rubytui/backend.rb +158 -0
  6. data/lib/rubytui/backends/ansi_backend.rb +391 -0
  7. data/lib/rubytui/backends/test_backend.rb +223 -0
  8. data/lib/rubytui/buffer.rb +400 -0
  9. data/lib/rubytui/cell.rb +55 -0
  10. data/lib/rubytui/color.rb +153 -0
  11. data/lib/rubytui/color_mode.rb +203 -0
  12. data/lib/rubytui/errors.rb +13 -0
  13. data/lib/rubytui/event.rb +161 -0
  14. data/lib/rubytui/frame.rb +70 -0
  15. data/lib/rubytui/input/key.rb +93 -0
  16. data/lib/rubytui/input/parser.rb +231 -0
  17. data/lib/rubytui/input/reader.rb +119 -0
  18. data/lib/rubytui/layout/constraint.rb +83 -0
  19. data/lib/rubytui/layout/flex.rb +16 -0
  20. data/lib/rubytui/layout/layout.rb +205 -0
  21. data/lib/rubytui/modifier.rb +67 -0
  22. data/lib/rubytui/rect.rb +126 -0
  23. data/lib/rubytui/stateful_widget.rb +20 -0
  24. data/lib/rubytui/style.rb +143 -0
  25. data/lib/rubytui/symbols.rb +88 -0
  26. data/lib/rubytui/terminal.rb +218 -0
  27. data/lib/rubytui/text/line.rb +67 -0
  28. data/lib/rubytui/text/span.rb +34 -0
  29. data/lib/rubytui/text/text.rb +82 -0
  30. data/lib/rubytui/unicode.rb +162 -0
  31. data/lib/rubytui/version.rb +6 -0
  32. data/lib/rubytui/widget.rb +20 -0
  33. data/lib/rubytui/widgets/async_image.rb +248 -0
  34. data/lib/rubytui/widgets/block.rb +260 -0
  35. data/lib/rubytui/widgets/canvas.rb +248 -0
  36. data/lib/rubytui/widgets/chart.rb +224 -0
  37. data/lib/rubytui/widgets/gauge.rb +139 -0
  38. data/lib/rubytui/widgets/image.rb +330 -0
  39. data/lib/rubytui/widgets/input_field.rb +245 -0
  40. data/lib/rubytui/widgets/list.rb +186 -0
  41. data/lib/rubytui/widgets/paragraph.rb +181 -0
  42. data/lib/rubytui/widgets/popup.rb +140 -0
  43. data/lib/rubytui/widgets/scrollbar.rb +175 -0
  44. data/lib/rubytui/widgets/sparkline.rb +86 -0
  45. data/lib/rubytui/widgets/table.rb +231 -0
  46. data/lib/rubytui/widgets/tabs.rb +90 -0
  47. data/lib/rubytui.rb +389 -0
  48. 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.