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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ec32a08aa7695cdc9b1db38895f10df4e37ff057dec2799b924fc2a6d8df860
4
- data.tar.gz: 5a8a38833a21651b6372a420aea4be5e17b6f214653818b198c81303c45bf29d
3
+ metadata.gz: 3422d29230acde2c0e9da429cf3ddf83a355706183039778574e3e3d9b5875b9
4
+ data.tar.gz: '059ab8b520b2d61ca15a0ba129b211ea4c44a52cb4dd826e5401172bb8ed9c1c'
5
5
  SHA512:
6
- metadata.gz: b0c26618a8ab168de7278fb24d0234b332983e0426ec945c2bb92ddc69da270788fd6d20ac578d792e9ac2d04398f374912ac118ebb316fdcb85aeb517197f97
7
- data.tar.gz: 00a6150cc97eca7ca31eead19b04c1bbb0426a2c794dbf5208657199cc0e298a8d445c29aaf015519d41793d93890862cad04755b4bc182dfd98bdfa633c09dd
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 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.
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)` (or `nil`). |
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 string (split on newlines) or an array of lines, and also accepts `alignment:`, `text_style:`, `style:` and `border_style:`.
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
- - 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")`.
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
- - 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.
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, nil] the result of the <tt>catch(:quit)</tt> block:
113
- # the value passed as <tt>throw(:quit, value)</tt>, or nil for a bare
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
- model = catch(:quit) do
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 ? Buffer.char_width(cell.symbol) : 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
- @input.cooked! if @input.respond_to?(:cooked!)
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
- # or the query raises +IOError+, +Errno::EIO+ or +Errno::ENOTTY+; the
204
- # result, including the fallback, is cached.
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
- begin
211
- @input.raw do
212
- @output.write("\e[16t")
213
- @output.flush
214
-
215
- response = +""
216
- deadline = Time.now + 0.1
217
-
218
- while Time.now < deadline
219
- if IO.select([@input], nil, nil, 0.01)
220
- char = @input.read_nonblock(1, exception: false)
221
- break if char == :wait_readable || char.nil?
222
- response << char
223
- break if char == "t"
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
- if response =~ /\e\[6;(\d+);(\d+)t/
228
- @cell_pixel_size = [$2.to_i, $1.to_i]
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
- # Appends to the output buffer; does not flush.
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
- # Chunk into 4096-byte pieces (safe for all Kitty-compatible terminals)
263
- chunks = encoded.scan(/.{1,4096}/m)
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
- # Source rectangle for cropping/scrolling (pixel coordinates within image)
275
- if placement[:src_x] && placement[:src_y]
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
- params << ",c=#{placement[:cols]},r=#{placement[:rows]},m=#{more}"
281
- @buffer << "\e_G#{params};#{chunk}\e\\"
282
- else
283
- @buffer << "\e_Gm=#{more};#{chunk}\e\\"
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
 
@@ -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 and track the last non-empty column
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
@@ -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 (default: nil)
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 reported by the terminal (motion flag set).
94
+ # Pointer motion with a button held.
90
95
  DRAG = :drag
91
- # Pointer motion with no button held. Currently never emitted by {Input::Parser}.
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