rubytui 1.2.3 → 1.2.5
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/README.md +23 -17
- data/lib/rubytui/app.rb +8 -6
- data/lib/rubytui/backends/ansi_backend.rb +83 -50
- data/lib/rubytui/buffer.rb +11 -8
- data/lib/rubytui/errors.rb +2 -0
- data/lib/rubytui/event.rb +14 -4
- data/lib/rubytui/gif.rb +290 -0
- data/lib/rubytui/input/parser.rb +224 -14
- data/lib/rubytui/input/reader.rb +156 -65
- data/lib/rubytui/layout/flex.rb +1 -1
- data/lib/rubytui/layout/layout.rb +25 -10
- data/lib/rubytui/text/line.rb +8 -4
- data/lib/rubytui/text/span.rb +3 -2
- data/lib/rubytui/text/text.rb +10 -5
- data/lib/rubytui/unicode.rb +1 -1
- data/lib/rubytui/version.rb +1 -1
- data/lib/rubytui/widgets/async_image.rb +42 -30
- data/lib/rubytui/widgets/block.rb +5 -5
- data/lib/rubytui/widgets/canvas.rb +26 -14
- data/lib/rubytui/widgets/chart.rb +48 -35
- data/lib/rubytui/widgets/gauge.rb +2 -2
- data/lib/rubytui/widgets/image.rb +47 -20
- data/lib/rubytui/widgets/input_field.rb +59 -17
- data/lib/rubytui/widgets/list.rb +28 -19
- data/lib/rubytui/widgets/paragraph.rb +53 -24
- data/lib/rubytui/widgets/scrollbar.rb +8 -5
- data/lib/rubytui/widgets/table.rb +26 -17
- data/lib/rubytui/widgets/tabs.rb +7 -5
- data/lib/rubytui.rb +15 -9
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3422d29230acde2c0e9da429cf3ddf83a355706183039778574e3e3d9b5875b9
|
|
4
|
+
data.tar.gz: '059ab8b520b2d61ca15a0ba129b211ea4c44a52cb4dd826e5401172bb8ed9c1c'
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 388a89d892b0f28d9ebbd9e0dd57f9dea31a65bca3d1d9da2f7af2c446fd9ddaddf05cc1d6cc5e3364860dd80225137573272230bf9dbed727c1edb075f750ae
|
|
7
|
+
data.tar.gz: 3c74612dc4bd6540af8b37cfdbec084ce5b6c292c9e2a64f9256b41ced9bca5855685930840adf199525a45de2b753a8b9bd099cf6ba0b26528db239de3c59e9
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# RubyTUI
|
|
2
2
|
|
|
3
|
-
RubyTUI is a terminal user interface library
|
|
3
|
+
RubyTUI is a terminal user interface library for 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
4
|
|
|
5
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
6
|
|
|
@@ -82,6 +82,8 @@ line = RubyTUI::Line.new([
|
|
|
82
82
|
paragraph = RubyTUI::Widgets::Paragraph.new(text: line)
|
|
83
83
|
```
|
|
84
84
|
|
|
85
|
+
Paragraph layers styles from the outside in: the paragraph's `style:`, then the `Text`'s style, then each `Line`'s, then each `Span`'s, with later ones winning.
|
|
86
|
+
|
|
85
87
|
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
88
|
|
|
87
89
|
```ruby
|
|
@@ -131,14 +133,14 @@ All built-in widgets live in `RubyTUI::Widgets`. Most accept a `block:` argument
|
|
|
131
133
|
|--------|------|-------------|
|
|
132
134
|
| `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
135
|
| `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
|
+
| `List` + `ListState` | stateful | Scrollable, selectable list with a highlight symbol and style; scrolls to keep the selection visible. `ListState.new(selected: nil)` shows no selection. |
|
|
137
|
+
| `Table` + `TableState` | stateful | Rows with an optional header and separator, row selection, and fixed or equally divided column widths. `TableState.new(selected: nil)` shows no selection. |
|
|
136
138
|
| `Gauge` | stateless | Horizontal progress bar with eighth-of-a-cell precision and a centered label (the percentage by default). |
|
|
137
139
|
| `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
|
+
| `Scrollbar` | stateless | Vertical or horizontal scroll indicator with a proportional thumb and optional arrows. `style:` is the base style; `track_style:` and `thumb_style:` are applied on top. |
|
|
141
|
+
| `Tabs` | stateless | Horizontal tab bar with active and inactive styles, an optional `highlight_style:` applied on top of the active tab, and a configurable divider. |
|
|
140
142
|
| `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. |
|
|
143
|
+
| `Chart` + `Dataset` | stateless | Line chart of one or more datasets with optional labeled axes and fixed or data-derived bounds; `style:` sets the base style (e.g. background) of the whole chart. Dataset names are not displayed. |
|
|
142
144
|
| `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
145
|
| `InputField` + `InputState` | stateful | Single-line text input with a cursor, horizontal scrolling and a placeholder. |
|
|
144
146
|
| `Image` | stateless | PNG or raw RGBA image drawn with the Kitty graphics protocol. See [Images](#images). |
|
|
@@ -192,7 +194,7 @@ end
|
|
|
192
194
|
|
|
193
195
|
## Input
|
|
194
196
|
|
|
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.
|
|
197
|
+
`reader.poll(timeout_ms: 100)` waits up to the given time and returns a `RubyTUI::KeyEvent`, a `RubyTUI::MouseEvent`, a `RubyTUI::ResizeEvent` (the terminal size changed since the previous call; it has `width` and `height`), or `nil` when no recognized input arrived. Replies that the terminal writes to the input, such as image acknowledgements, are ignored. `reader.read` blocks until an event arrives and raises `EOFError` once the input is closed; `reader.eof?` reports whether that has happened. At end of input, `poll` waits out its timeout and returns `nil`.
|
|
196
198
|
|
|
197
199
|
### Keyboard
|
|
198
200
|
|
|
@@ -265,8 +267,8 @@ end
|
|
|
265
267
|
|
|
266
268
|
`RubyTUI.run(mouse: true)` enables reporting of button presses, releases, drags and the scroll wheel. A `MouseEvent` has:
|
|
267
269
|
|
|
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
|
+
- `button`: `:left`, `:middle`, `:right`, `:scroll_up`, `:scroll_down`, `:scroll_left`, `:scroll_right` or `:none` (constants `RubyTUI::MouseEvent::LEFT` and so on)
|
|
271
|
+
- `action`: `:press`, `:release` or `:drag` (scroll-wheel events are reported as `:press`). `:move`, pointer motion with no button held, is only reported if the terminal is switched to any-motion mode (1003), which `RubyTUI.run(mouse: true)` does not do.
|
|
270
272
|
- `x`, `y`: zero-based cell coordinates
|
|
271
273
|
- `ctrl?`, `alt?`, `shift?`
|
|
272
274
|
|
|
@@ -296,12 +298,12 @@ For structured applications, include `RubyTUI::App` and implement the hooks belo
|
|
|
296
298
|
| Hook | Purpose |
|
|
297
299
|
|------|---------|
|
|
298
300
|
| `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. |
|
|
301
|
+
| `handle_event(event)` | Converts an input event (`KeyEvent`, `MouseEvent` or `ResizeEvent`) into a message, or returns `nil` to ignore it. |
|
|
300
302
|
| `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
303
|
| `update(model, msg)` | Returns the next model. Call `throw(:quit)` or `throw(:quit, value)` to leave the loop. |
|
|
302
304
|
| `view(model, frame)` | Draws the model into the frame. |
|
|
303
305
|
| `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)
|
|
306
|
+
| `on_stop(value)` | Called once after the loop ends and before the terminal is restored, with the value given to `throw(:quit, value)`, or the last model after a bare `throw(:quit)`. |
|
|
305
307
|
|
|
306
308
|
```ruby
|
|
307
309
|
require "rubytui"
|
|
@@ -384,7 +386,7 @@ RubyTUI.print_gauge(ratio: 0.75, title: " Progress ", width: 40)
|
|
|
384
386
|
```
|
|
385
387
|
|
|
386
388
|
- `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
|
|
389
|
+
- `print_box(title:, content:, border:, width:, ...)` takes a String (split on newlines), an Array of lines, or a `Text`, `Line` or `Span`; the box is as tall as the content after wrapping to the box width. It also accepts `alignment:`, `text_style:`, `style:` and `border_style:`.
|
|
388
390
|
- `print_gauge(ratio:, label:, title:, width:, ...)` also accepts `gauge_style:`, `border:` and `border_style:`. The border is drawn only when `title:` is given.
|
|
389
391
|
|
|
390
392
|
`border:` is one of `:rounded` (the default), `:light`, `:double`, `:thick` or `:plain`, or a `RubyTUI::Symbols` border set.
|
|
@@ -409,13 +411,18 @@ puts buf.to_ansi
|
|
|
409
411
|
|
|
410
412
|
- PNG is sent to the terminal as is; its dimensions are read from the file header.
|
|
411
413
|
- Raw 8-bit RGBA pixel data requires `format: :rgba` together with `pixel_width:` and `pixel_height:`.
|
|
412
|
-
-
|
|
414
|
+
- GIF is decoded by RubyTUI (no external tools). An animated GIF plays in the terminal using the protocol's animation support, with its own frame timing and loop count; `animate: false` shows only the first frame. A terminal without animation support is expected to show the first frame. Decoding is CPU-bound (roughly 30 ms per 480×270 frame), so load large GIFs with `AsyncImage`.
|
|
415
|
+
- JPEG, WebP 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")`.
|
|
416
|
+
- Any other `format:` value raises `ArgumentError`.
|
|
413
417
|
|
|
414
418
|
```ruby
|
|
415
419
|
# From a PNG file
|
|
416
420
|
image = RubyTUI::Widgets::Image.new(path: "photo.png")
|
|
417
421
|
frame.render_widget(image, area)
|
|
418
422
|
|
|
423
|
+
# An animated GIF (plays in the terminal; animate: false shows the first frame)
|
|
424
|
+
image = RubyTUI::Widgets::Image.new(path: "spinner.gif")
|
|
425
|
+
|
|
419
426
|
# With a border and title
|
|
420
427
|
image = RubyTUI::Widgets::Image.new(
|
|
421
428
|
path: "photo.png",
|
|
@@ -464,7 +471,7 @@ Images are tracked by position and content. When an image disappears, moves or c
|
|
|
464
471
|
|
|
465
472
|
### AsyncImage
|
|
466
473
|
|
|
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.
|
|
474
|
+
`RubyTUI::Widgets::AsyncImage` runs a loader on a background thread and renders the image once the loader returns PNG or GIF data. Create it once and keep it across frames.
|
|
468
475
|
|
|
469
476
|
```ruby
|
|
470
477
|
image = RubyTUI::Widgets::AsyncImage.new(
|
|
@@ -481,9 +488,8 @@ image.ready? # => true once the image can be drawn
|
|
|
481
488
|
image.error? # => true if the loader raised or returned unsupported data; see image.error
|
|
482
489
|
```
|
|
483
490
|
|
|
484
|
-
-
|
|
485
|
-
- `
|
|
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.
|
|
491
|
+
- Until the image is ready, `placeholder:` is drawn: a string is shown centered (`Loading...` if no placeholder is given), and a widget that includes `RubyTUI::Widget` (all built-in widgets do) is rendered in the area. After a failure, `error_placeholder:` (default `[Error]`) is drawn. With `auto_start: false`, call `start_loading` to begin; `cancel` stops the loader thread.
|
|
492
|
+
- `on_ready:` is called on the loader thread once the image is ready (not on a cache hit); an exception it raises does not change the widget's state. `on_error:` receives the exception when the loader raises or returns data that is not a supported image (including `nil`); such data is not cached.
|
|
487
493
|
- `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
494
|
- `block:`, `style:` and `cell_size:` work as for `Image`.
|
|
489
495
|
|
data/lib/rubytui/app.rb
CHANGED
|
@@ -109,9 +109,9 @@ module RubyTUI
|
|
|
109
109
|
#
|
|
110
110
|
# Called before the terminal is restored.
|
|
111
111
|
#
|
|
112
|
-
# @param model [Object
|
|
113
|
-
# the
|
|
114
|
-
# <tt>throw(:quit)</tt>
|
|
112
|
+
# @param model [Object] the value passed as <tt>throw(:quit, value)</tt>,
|
|
113
|
+
# or the last model returned by {#init} or {#update} for a bare
|
|
114
|
+
# <tt>throw(:quit)</tt> or <tt>throw(:quit, nil)</tt>
|
|
115
115
|
# @return [void]
|
|
116
116
|
def on_stop(model)
|
|
117
117
|
end
|
|
@@ -119,7 +119,9 @@ module RubyTUI
|
|
|
119
119
|
# Run the application.
|
|
120
120
|
#
|
|
121
121
|
# The main loop renders, polls for events, and dispatches messages.
|
|
122
|
-
# Call <tt>throw(:quit)</tt> from {#update} to exit the loop cleanly
|
|
122
|
+
# Call <tt>throw(:quit)</tt> from {#update} to exit the loop cleanly;
|
|
123
|
+
# {#on_stop} then receives the last model, or +value+ for
|
|
124
|
+
# <tt>throw(:quit, value)</tt>.
|
|
123
125
|
#
|
|
124
126
|
# Wraps {RubyTUI.run} (raw mode, hidden cursor, alternate screen), so
|
|
125
127
|
# the terminal is restored when the loop exits or raises. Each
|
|
@@ -138,7 +140,7 @@ module RubyTUI
|
|
|
138
140
|
model = init
|
|
139
141
|
on_start(terminal, reader)
|
|
140
142
|
|
|
141
|
-
|
|
143
|
+
quit_value = catch(:quit) do
|
|
142
144
|
loop do
|
|
143
145
|
terminal.draw do |frame|
|
|
144
146
|
view(model, frame)
|
|
@@ -156,7 +158,7 @@ module RubyTUI
|
|
|
156
158
|
end
|
|
157
159
|
end
|
|
158
160
|
|
|
159
|
-
on_stop(model)
|
|
161
|
+
on_stop(quit_value.nil? ? model : quit_value)
|
|
160
162
|
end
|
|
161
163
|
end
|
|
162
164
|
|
|
@@ -64,7 +64,7 @@ module RubyTUI
|
|
|
64
64
|
@buffer << cell.symbol
|
|
65
65
|
|
|
66
66
|
# Wide characters advance terminal cursor by 2
|
|
67
|
-
w = cell.symbol.length == 1 ?
|
|
67
|
+
w = cell.symbol.length == 1 ? Unicode.char_width(cell.symbol) : 1
|
|
68
68
|
last_x = x + w - 1
|
|
69
69
|
last_y = y
|
|
70
70
|
end
|
|
@@ -167,17 +167,18 @@ module RubyTUI
|
|
|
167
167
|
# Restore the input's terminal state saved by {#enable_raw_mode}.
|
|
168
168
|
#
|
|
169
169
|
# Runs +stty+ with the saved state on the input. Falls back to
|
|
170
|
-
# +cooked!+ (when the input supports it) if no state was saved or
|
|
171
|
-
# +stty+ raises.
|
|
170
|
+
# +cooked!+ (when the input supports it) if no state was saved, or
|
|
171
|
+
# +stty+ raises, exits non-zero or cannot be run.
|
|
172
172
|
#
|
|
173
173
|
# @return [void]
|
|
174
174
|
def disable_raw_mode
|
|
175
175
|
if @original_state
|
|
176
|
-
begin
|
|
176
|
+
restored = begin
|
|
177
177
|
system("stty", @original_state, in: @input, err: File::NULL)
|
|
178
178
|
rescue StandardError
|
|
179
|
-
|
|
179
|
+
false
|
|
180
180
|
end
|
|
181
|
+
@input.cooked! if !restored && @input.respond_to?(:cooked!)
|
|
181
182
|
@original_state = nil
|
|
182
183
|
elsif @input.respond_to?(:cooked!)
|
|
183
184
|
@input.cooked!
|
|
@@ -199,37 +200,41 @@ module RubyTUI
|
|
|
199
200
|
#
|
|
200
201
|
# Sends CSI 16 t and parses the response. Must be called before
|
|
201
202
|
# entering raw mode or while in raw mode with careful stdin handling.
|
|
202
|
-
# Falls back to [9, 18] when the terminal does not answer within 100 ms
|
|
203
|
-
#
|
|
204
|
-
#
|
|
203
|
+
# Falls back to [9, 18] when the terminal does not answer within 100 ms,
|
|
204
|
+
# the query raises +IOError+, +Errno::EIO+ or +Errno::ENOTTY+, or the
|
|
205
|
+
# input does not support raw mode (e.g. a StringIO; no query is sent);
|
|
206
|
+
# the result, including the fallback, is cached.
|
|
205
207
|
#
|
|
206
208
|
# @return [Array(Integer, Integer)] cell dimensions [width_px, height_px]
|
|
207
209
|
def query_cell_pixel_size
|
|
208
210
|
return @cell_pixel_size if @cell_pixel_size
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
@
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
212
|
+
# Inputs without raw mode (e.g. StringIO) cannot read the reply
|
|
213
|
+
if @input.respond_to?(:raw)
|
|
214
|
+
begin
|
|
215
|
+
@input.raw do
|
|
216
|
+
@output.write("\e[16t")
|
|
217
|
+
@output.flush
|
|
218
|
+
|
|
219
|
+
response = +""
|
|
220
|
+
deadline = Time.now + 0.1
|
|
221
|
+
|
|
222
|
+
while Time.now < deadline
|
|
223
|
+
if IO.select([@input], nil, nil, 0.01)
|
|
224
|
+
char = @input.read_nonblock(1, exception: false)
|
|
225
|
+
break if char == :wait_readable || char.nil?
|
|
226
|
+
response << char
|
|
227
|
+
break if char == "t"
|
|
228
|
+
end
|
|
224
229
|
end
|
|
225
|
-
end
|
|
226
230
|
|
|
227
|
-
|
|
228
|
-
|
|
231
|
+
if response =~ /\e\[6;(\d+);(\d+)t/
|
|
232
|
+
@cell_pixel_size = [$2.to_i, $1.to_i]
|
|
233
|
+
end
|
|
229
234
|
end
|
|
235
|
+
rescue IOError, Errno::EIO, Errno::ENOTTY
|
|
236
|
+
# Terminal doesn't support query
|
|
230
237
|
end
|
|
231
|
-
rescue IOError, Errno::EIO, Errno::ENOTTY
|
|
232
|
-
# Terminal doesn't support query
|
|
233
238
|
end
|
|
234
239
|
|
|
235
240
|
@cell_pixel_size ||= [9, 18]
|
|
@@ -244,7 +249,16 @@ module RubyTUI
|
|
|
244
249
|
# Transmits the image data as chunked base64 in APC escape sequences.
|
|
245
250
|
# The terminal decodes and renders the image at the cursor position.
|
|
246
251
|
# Each image gets a unique ID for precise deletion.
|
|
247
|
-
#
|
|
252
|
+
#
|
|
253
|
+
# An animated placement (with an +:animation+ entry, see {Buffer#set_image})
|
|
254
|
+
# is sent as the root frame followed by one <tt>a=f</tt> transmission per extra
|
|
255
|
+
# frame, the root frame's gap (<tt>a=a,r=1,z=</tt>), and a play command: with
|
|
256
|
+
# no loop count it runs once and stops on the last frame (<tt>s=2</tt>); a loop
|
|
257
|
+
# count of 0 loops forever (<tt>s=3,v=1</tt>); a loop count of +n+ repeats it
|
|
258
|
+
# +n+ more times (<tt>s=3,v=n+1</tt>). With +:compressed+, the pixel data
|
|
259
|
+
# (and every frame) is already zlib-deflated and is sent with <tt>o=z</tt>.
|
|
260
|
+
# Terminals without animation support show the root frame. Appends to the
|
|
261
|
+
# output buffer; does not flush.
|
|
248
262
|
#
|
|
249
263
|
# @param placement [Hash{Symbol => Object}] image placement from {Buffer#image_placements}
|
|
250
264
|
# @return [void]
|
|
@@ -256,33 +270,39 @@ module RubyTUI
|
|
|
256
270
|
# Position cursor at image origin
|
|
257
271
|
@buffer << "#{CSI}#{placement[:y] + 1};#{placement[:x] + 1}H"
|
|
258
272
|
|
|
259
|
-
encoded = [placement[:data]].pack("m0") # strict base64, no newlines
|
|
260
273
|
format_code = placement[:format] == :png ? 100 : 32
|
|
274
|
+
compressed = placement[:compressed]
|
|
275
|
+
|
|
276
|
+
# C=1: do not move the cursor after displaying — keeps the
|
|
277
|
+
# terminal's cursor position in sync with the backend's model
|
|
278
|
+
params = +"a=T,f=#{format_code},i=#{image_id},C=1"
|
|
279
|
+
params << ",s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}" if format_code == 32
|
|
280
|
+
params << ",o=z" if compressed
|
|
281
|
+
|
|
282
|
+
# Source rectangle for cropping/scrolling (pixel coordinates within image)
|
|
283
|
+
if placement[:src_x] && placement[:src_y]
|
|
284
|
+
params << ",x=#{placement[:src_x]},y=#{placement[:src_y]}"
|
|
285
|
+
params << ",w=#{placement[:src_w]},h=#{placement[:src_h]}" if placement[:src_w] && placement[:src_h]
|
|
286
|
+
end
|
|
261
287
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
chunks.each_with_index do |chunk, i|
|
|
266
|
-
more = i < chunks.length - 1 ? 1 : 0
|
|
267
|
-
|
|
268
|
-
if i == 0
|
|
269
|
-
# C=1: do not move the cursor after displaying — keeps the
|
|
270
|
-
# terminal's cursor position in sync with the backend's model
|
|
271
|
-
params = +"a=T,f=#{format_code},i=#{image_id},C=1"
|
|
272
|
-
params << ",s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}" if format_code == 32
|
|
288
|
+
params << ",c=#{placement[:cols]},r=#{placement[:rows]}"
|
|
289
|
+
send_chunked(params, "", placement[:data])
|
|
273
290
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
params << ",x=#{placement[:src_x]},y=#{placement[:src_y]}"
|
|
277
|
-
params << ",w=#{placement[:src_w]},h=#{placement[:src_h]}" if placement[:src_w] && placement[:src_h]
|
|
278
|
-
end
|
|
291
|
+
anim = placement[:animation]
|
|
292
|
+
return unless anim
|
|
279
293
|
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
end
|
|
294
|
+
size = +"f=32,s=#{placement[:pixel_width]},v=#{placement[:pixel_height]}"
|
|
295
|
+
size << ",o=z" if compressed
|
|
296
|
+
anim[:frames].each_with_index do |frame, i|
|
|
297
|
+
send_chunked("a=f,i=#{image_id},#{size},z=#{anim[:delays][i + 1]}", "a=f,", frame)
|
|
285
298
|
end
|
|
299
|
+
@buffer << "\e_Ga=a,i=#{image_id},r=1,z=#{anim[:delays][0]}\e\\"
|
|
300
|
+
play = case anim[:loops]
|
|
301
|
+
when nil then "s=2"
|
|
302
|
+
when 0 then "s=3,v=1"
|
|
303
|
+
else "s=3,v=#{anim[:loops] + 1}"
|
|
304
|
+
end
|
|
305
|
+
@buffer << "\e_Ga=a,i=#{image_id},#{play}\e\\"
|
|
286
306
|
end
|
|
287
307
|
|
|
288
308
|
# Delete a Kitty graphics protocol image by ID.
|
|
@@ -334,6 +354,19 @@ module RubyTUI
|
|
|
334
354
|
|
|
335
355
|
private
|
|
336
356
|
|
|
357
|
+
# Base64-encode +payload+ and emit it as Kitty graphics APC sequences in
|
|
358
|
+
# 4096-byte chunks (safe for all Kitty-compatible terminals). The first
|
|
359
|
+
# chunk carries +first+; later chunks carry +cont+ (empty, or <tt>a=f,</tt>
|
|
360
|
+
# for animation frames, which the protocol requires on every chunk).
|
|
361
|
+
def send_chunked(first, cont, payload)
|
|
362
|
+
chunks = [payload].pack("m0").scan(/.{1,4096}/m) # strict base64, no newlines
|
|
363
|
+
chunks.each_with_index do |chunk, i|
|
|
364
|
+
more = i < chunks.length - 1 ? 1 : 0
|
|
365
|
+
params = i.zero? ? "#{first},m=#{more}" : "#{cont}m=#{more}"
|
|
366
|
+
@buffer << "\e_G#{params};#{chunk}\e\\"
|
|
367
|
+
end
|
|
368
|
+
end
|
|
369
|
+
|
|
337
370
|
def apply_style(style)
|
|
338
371
|
return if style == @prev_style
|
|
339
372
|
|
data/lib/rubytui/buffer.rb
CHANGED
|
@@ -203,9 +203,15 @@ module RubyTUI
|
|
|
203
203
|
# @param src_y [Integer, nil] source rectangle Y offset in pixels
|
|
204
204
|
# @param src_w [Integer, nil] source rectangle width in pixels
|
|
205
205
|
# @param src_h [Integer, nil] source rectangle height in pixels
|
|
206
|
+
# @param animation [Hash, nil] extra frames for an animated image:
|
|
207
|
+
# <tt>{frames: [rgba, ...], delays: [ms, ...], loops: Integer or nil}</tt>,
|
|
208
|
+
# where +data+ is the first frame and +delays+ covers every frame
|
|
209
|
+
# @param compressed [Boolean] +data+ and the animation frames are already
|
|
210
|
+
# zlib-deflated and are sent with the protocol's <tt>o=z</tt> (default: false)
|
|
206
211
|
# @return [void]
|
|
207
212
|
def set_image(x:, y:, cols:, rows:, data:, pixel_width:, pixel_height:, format:,
|
|
208
|
-
content_hash: nil, src_x: nil, src_y: nil, src_w: nil, src_h: nil
|
|
213
|
+
content_hash: nil, src_x: nil, src_y: nil, src_w: nil, src_h: nil,
|
|
214
|
+
animation: nil, compressed: false)
|
|
209
215
|
# Key by position + content hash to detect when image changes at same position
|
|
210
216
|
key = content_hash ? [x, y, content_hash] : [x, y]
|
|
211
217
|
|
|
@@ -223,6 +229,9 @@ module RubyTUI
|
|
|
223
229
|
placement[:src_h] = src_h
|
|
224
230
|
end
|
|
225
231
|
|
|
232
|
+
placement[:animation] = animation if animation
|
|
233
|
+
placement[:compressed] = true if compressed
|
|
234
|
+
|
|
226
235
|
@image_placements[key] = placement
|
|
227
236
|
end
|
|
228
237
|
|
|
@@ -273,9 +282,8 @@ module RubyTUI
|
|
|
273
282
|
prev_style = nil
|
|
274
283
|
|
|
275
284
|
@area.height.times do |row|
|
|
276
|
-
# Build the row content
|
|
285
|
+
# Build the row content
|
|
277
286
|
row_buf = String.new(capacity: @area.width * 2)
|
|
278
|
-
last_styled_col = -1
|
|
279
287
|
col = 0
|
|
280
288
|
display_col = 0 # track actual terminal column for cursor correction
|
|
281
289
|
|
|
@@ -298,11 +306,6 @@ module RubyTUI
|
|
|
298
306
|
|
|
299
307
|
row_buf << cell.symbol
|
|
300
308
|
|
|
301
|
-
# Track last non-default cell for trimming
|
|
302
|
-
unless cell.empty?
|
|
303
|
-
last_styled_col = col
|
|
304
|
-
end
|
|
305
|
-
|
|
306
309
|
w = Unicode.string_width(cell.symbol)
|
|
307
310
|
display_col += w
|
|
308
311
|
col += [w, 1].max
|
data/lib/rubytui/errors.rb
CHANGED
|
@@ -4,8 +4,10 @@ module RubyTUI
|
|
|
4
4
|
# Base class for all RubyTUI-specific errors.
|
|
5
5
|
class Error < StandardError; end
|
|
6
6
|
# Error category for terminal-related failures.
|
|
7
|
+
# @deprecated Never raised by RubyTUI; will be removed in 2.0.
|
|
7
8
|
class TerminalError < Error; end
|
|
8
9
|
# Error category for layout-related failures.
|
|
10
|
+
# @deprecated Never raised by RubyTUI; will be removed in 2.0.
|
|
9
11
|
class LayoutError < Error; end
|
|
10
12
|
# Raised by {Widgets::Image} when given image data in a format the Kitty
|
|
11
13
|
# graphics protocol path does not accept (JPEG, WebP, GIF or BMP).
|
data/lib/rubytui/event.rb
CHANGED
|
@@ -17,7 +17,8 @@ module RubyTUI
|
|
|
17
17
|
|
|
18
18
|
# @param code [Symbol] key code, one of the {Input::Key} key-code constants
|
|
19
19
|
# (e.g. {Input::Key::ENTER}, {Input::Key::CHAR})
|
|
20
|
-
# @param char [String, nil] the character, for {Input::Key::CHAR} events
|
|
20
|
+
# @param char [String, nil] the character, for {Input::Key::CHAR} events; a
|
|
21
|
+
# UTF-8 String when produced by {Input::Parser} (default: nil)
|
|
21
22
|
# @param modifiers [Integer] bitmask of {Input::Key::SHIFT}, {Input::Key::ALT} and
|
|
22
23
|
# {Input::Key::CTRL} (default: {Input::Key::NONE})
|
|
23
24
|
def initialize(code:, char: nil, modifiers: Input::Key::NONE)
|
|
@@ -75,6 +76,10 @@ module RubyTUI
|
|
|
75
76
|
SCROLL_UP = :scroll_up
|
|
76
77
|
# Scroll wheel down (reported with action {PRESS}).
|
|
77
78
|
SCROLL_DOWN = :scroll_down
|
|
79
|
+
# Horizontal scroll left, wheel button 6 (reported with action {PRESS}).
|
|
80
|
+
SCROLL_LEFT = :scroll_left
|
|
81
|
+
# Horizontal scroll right, wheel button 7 (reported with action {PRESS}).
|
|
82
|
+
SCROLL_RIGHT = :scroll_right
|
|
78
83
|
# No specific button (legacy-protocol release, or motion with no button held).
|
|
79
84
|
NONE = :none
|
|
80
85
|
|
|
@@ -86,9 +91,11 @@ module RubyTUI
|
|
|
86
91
|
PRESS = :press
|
|
87
92
|
# Button released.
|
|
88
93
|
RELEASE = :release
|
|
89
|
-
# Pointer motion
|
|
94
|
+
# Pointer motion with a button held.
|
|
90
95
|
DRAG = :drag
|
|
91
|
-
# Pointer motion with no button held
|
|
96
|
+
# Pointer motion with no button held (button {NONE}). Terminals report it
|
|
97
|
+
# only in any-event tracking (mode 1003), which
|
|
98
|
+
# {AnsiBackend#enable_mouse} does not turn on.
|
|
92
99
|
MOVE = :move
|
|
93
100
|
|
|
94
101
|
# @!endgroup
|
|
@@ -96,7 +103,7 @@ module RubyTUI
|
|
|
96
103
|
attr_reader :button, :action, :x, :y, :modifiers
|
|
97
104
|
|
|
98
105
|
# @param button [Symbol] button constant ({LEFT}, {MIDDLE}, {RIGHT},
|
|
99
|
-
# {SCROLL_UP}, {SCROLL_DOWN} or {NONE})
|
|
106
|
+
# {SCROLL_UP}, {SCROLL_DOWN}, {SCROLL_LEFT}, {SCROLL_RIGHT} or {NONE})
|
|
100
107
|
# @param action [Symbol] action constant ({PRESS}, {RELEASE}, {DRAG} or {MOVE})
|
|
101
108
|
# @param x [Integer] column (0-based cell coordinate)
|
|
102
109
|
# @param y [Integer] row (0-based cell coordinate)
|
|
@@ -144,6 +151,9 @@ module RubyTUI
|
|
|
144
151
|
end
|
|
145
152
|
|
|
146
153
|
# A terminal resize event carrying the new size in cells.
|
|
154
|
+
#
|
|
155
|
+
# {Input::Reader#poll} and {Input::Reader#read} return one when the size of
|
|
156
|
+
# the input terminal has changed since the previous call.
|
|
147
157
|
class ResizeEvent < Event
|
|
148
158
|
attr_reader :width, :height
|
|
149
159
|
|