rubytui 1.2.3 → 1.2.4
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 +17 -15
- data/lib/rubytui/app.rb +8 -6
- data/lib/rubytui/backends/ansi_backend.rb +32 -27
- data/lib/rubytui/buffer.rb +1 -7
- data/lib/rubytui/errors.rb +2 -0
- data/lib/rubytui/event.rb +14 -4
- 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 +8 -4
- 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 +14 -9
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: ac917512f05261231e65601213ab71f6205cd68818e3da3adb1f867c66759719
|
|
4
|
+
data.tar.gz: bbad14e5928bde95d26c6cfcf2143f68f228f51559519b68f1c83eae5dec1ced
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 11827373b3850a674cf837353c4a60c8927abe4da423e0dfff1c2b3ee39b1c733f211f209151446140b3a257a5033d9bfe50e69b9daa8eeb211db34a4d6496e1
|
|
7
|
+
data.tar.gz: a887bd14dbb30d02e9fd0801b2acbe56acb543dcf25e216a4cb7f23e580ae69dabc6d5d15e420b708c107d7b28ec00683a6efeeaef9a266cd57f005f8a317f24
|
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.
|
|
@@ -410,6 +412,7 @@ puts buf.to_ansi
|
|
|
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
|
- 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")`.
|
|
415
|
+
- Any other `format:` value raises `ArgumentError`.
|
|
413
416
|
|
|
414
417
|
```ruby
|
|
415
418
|
# From a PNG file
|
|
@@ -481,9 +484,8 @@ image.ready? # => true once the image can be drawn
|
|
|
481
484
|
image.error? # => true if the loader raised or returned unsupported data; see image.error
|
|
482
485
|
```
|
|
483
486
|
|
|
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.
|
|
487
|
+
- 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.
|
|
488
|
+
- `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
489
|
- `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
490
|
- `block:`, `style:` and `cell_size:` work as for `Image`.
|
|
489
491
|
|
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]
|
data/lib/rubytui/buffer.rb
CHANGED
|
@@ -273,9 +273,8 @@ module RubyTUI
|
|
|
273
273
|
prev_style = nil
|
|
274
274
|
|
|
275
275
|
@area.height.times do |row|
|
|
276
|
-
# Build the row content
|
|
276
|
+
# Build the row content
|
|
277
277
|
row_buf = String.new(capacity: @area.width * 2)
|
|
278
|
-
last_styled_col = -1
|
|
279
278
|
col = 0
|
|
280
279
|
display_col = 0 # track actual terminal column for cursor correction
|
|
281
280
|
|
|
@@ -298,11 +297,6 @@ module RubyTUI
|
|
|
298
297
|
|
|
299
298
|
row_buf << cell.symbol
|
|
300
299
|
|
|
301
|
-
# Track last non-default cell for trimming
|
|
302
|
-
unless cell.empty?
|
|
303
|
-
last_styled_col = col
|
|
304
|
-
end
|
|
305
|
-
|
|
306
300
|
w = Unicode.string_width(cell.symbol)
|
|
307
301
|
display_col += w
|
|
308
302
|
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
|
|
data/lib/rubytui/input/parser.rb
CHANGED
|
@@ -5,36 +5,224 @@ module RubyTUI
|
|
|
5
5
|
# Decodes raw terminal input into {KeyEvent} and {MouseEvent} objects.
|
|
6
6
|
#
|
|
7
7
|
# Recognised input: printable ASCII, Enter, Tab, Backspace, Ctrl+letter,
|
|
8
|
-
#
|
|
9
|
-
# Alt+character (ESC followed by one character),
|
|
8
|
+
# single UTF-8 characters (as raw bytes, as read by {Reader}, or as a UTF-8
|
|
9
|
+
# String), ESC, Alt+character (ESC followed by one character),
|
|
10
10
|
# CSI and SS3 key sequences (arrows, Home/End, Insert/Delete, Page Up/Down,
|
|
11
11
|
# Back-tab, F1-F12, xterm modified arrows such as <tt>ESC [ 1 ; 5 A</tt>),
|
|
12
|
+
# the same key sequences preceded by ESC (Alt+key as sent by rxvt-style
|
|
13
|
+
# terminals, e.g. <tt>ESC ESC [ A</tt> is Alt+Up),
|
|
12
14
|
# and SGR and legacy mouse reports.
|
|
13
15
|
#
|
|
16
|
+
# String sequences that terminals send as replies are recognised and
|
|
17
|
+
# produce no event: APC (<tt>ESC _</tt>, e.g. Kitty graphics responses),
|
|
18
|
+
# OSC (<tt>ESC ]</tt>), DCS (<tt>ESC P</tt>), SOS (<tt>ESC X</tt>) and PM
|
|
19
|
+
# (<tt>ESC ^</tt>), each ended by ST (ESC and a backslash) or BEL. Because these
|
|
20
|
+
# introducers start a string, Alt+P, Alt+X, Alt+], Alt+^ and Alt+_ are
|
|
21
|
+
# reported only when the input ends with the string they start still
|
|
22
|
+
# unfinished (see {#parse_prefix}).
|
|
23
|
+
#
|
|
24
|
+
# The +char+ of every {KeyEvent} it returns is a UTF-8 String.
|
|
25
|
+
#
|
|
14
26
|
# @example
|
|
15
27
|
# Input::Parser.new.parse("\e[A") # => KeyEvent(up)
|
|
16
28
|
class Parser
|
|
29
|
+
# Longest CSI sequence, in bytes from ESC to the final byte. Recognised
|
|
30
|
+
# keys and SGR mouse reports are far shorter; the limit only bounds the
|
|
31
|
+
# bytes scanned for a final byte.
|
|
32
|
+
MAX_CSI_BYTES = 256
|
|
33
|
+
private_constant :MAX_CSI_BYTES
|
|
34
|
+
|
|
35
|
+
# Second byte of ESC P (DCS), ESC X (SOS), ESC ] (OSC), ESC ^ (PM) and
|
|
36
|
+
# ESC _ (APC), which start string sequences
|
|
37
|
+
STRING_INTRODUCERS = [0x50, 0x58, 0x5D, 0x5E, 0x5F].freeze
|
|
38
|
+
private_constant :STRING_INTRODUCERS
|
|
39
|
+
|
|
17
40
|
# Parse raw bytes into an Event.
|
|
18
41
|
#
|
|
42
|
+
# Input in any encoding is decoded byte-wise; a character is returned as
|
|
43
|
+
# UTF-8. Bytes that are not valid UTF-8 yield nil instead of raising.
|
|
44
|
+
#
|
|
19
45
|
# @param bytes [String, nil] raw bytes of one key press or mouse report
|
|
20
46
|
# @return [KeyEvent, MouseEvent, nil] the decoded event, or nil if +bytes+
|
|
21
|
-
# is nil, empty or not a recognised sequence
|
|
47
|
+
# is nil, empty, invalid UTF-8 or not a recognised sequence
|
|
48
|
+
# @see #parse_prefix
|
|
22
49
|
def parse(bytes)
|
|
23
50
|
return nil if bytes.nil? || bytes.empty?
|
|
24
51
|
|
|
52
|
+
bytes = bytes.b
|
|
25
53
|
# ESC sequence
|
|
26
54
|
if bytes.start_with?("\e")
|
|
27
55
|
parse_escape(bytes)
|
|
28
|
-
elsif bytes.
|
|
56
|
+
elsif bytes.bytesize == 1
|
|
29
57
|
parse_single(bytes)
|
|
30
58
|
else
|
|
31
59
|
# Multi-byte UTF-8 character
|
|
32
|
-
|
|
60
|
+
char = utf8_char(bytes)
|
|
61
|
+
char && KeyEvent.new(code: Key::CHAR, char: char)
|
|
33
62
|
end
|
|
34
63
|
end
|
|
35
64
|
|
|
65
|
+
# Parse the first event at the front of a buffer that may hold several.
|
|
66
|
+
#
|
|
67
|
+
# Finds where the first key press, escape sequence or mouse report in
|
|
68
|
+
# +bytes+ ends and decodes it with {#parse}. Unrecognised sequences are
|
|
69
|
+
# still measured, so the caller can drop them and carry on.
|
|
70
|
+
#
|
|
71
|
+
# A CSI sequence (<tt>ESC [</tt>) is at most 256 bytes long. If no
|
|
72
|
+
# final byte comes within its first 256 bytes, only <tt>ESC [</tt> is
|
|
73
|
+
# taken, as Alt+[, and the bytes after it are left to be decoded as
|
|
74
|
+
# input of their own; this does not depend on +partial+.
|
|
75
|
+
#
|
|
76
|
+
# A terminal string sequence (APC, OSC, DCS, SOS, PM) runs up to and
|
|
77
|
+
# including its ST or BEL terminator and gives no event. An ESC inside
|
|
78
|
+
# it that does not start ST ends it just before that ESC, also with no
|
|
79
|
+
# event. There is no length limit ({Reader} applies one).
|
|
80
|
+
#
|
|
81
|
+
# @param bytes [String] buffered raw input
|
|
82
|
+
# @param partial [Boolean] true if more input may follow +bytes+: an
|
|
83
|
+
# incomplete sequence at the front (such as a lone ESC, an unfinished
|
|
84
|
+
# string sequence, or ESC ESC that may start Alt+key) is then left
|
|
85
|
+
# unconsumed. When false, it is decoded as it stands: ESC ESC is a
|
|
86
|
+
# plain ESC key; an unfinished string sequence (no terminator, and no
|
|
87
|
+
# ESC followed by another byte) is taken to be typed, so only its
|
|
88
|
+
# introducer is taken, as Alt+character (2 bytes, e.g. <tt>ESC P</tt>
|
|
89
|
+
# is Alt+P), and the bytes after it are left to be decoded as input
|
|
90
|
+
# of their own; any other unfinished sequence is taken whole (a lone
|
|
91
|
+
# ESC is the ESC key, <tt>ESC [</tt> and <tt>ESC O</tt> are Alt+[ and
|
|
92
|
+
# Alt+O, and a cut-off UTF-8 character, mouse report or longer CSI
|
|
93
|
+
# sequence gives no event) (default: false)
|
|
94
|
+
# @return [Array(Event, Integer)] a two-element array: the event (nil if
|
|
95
|
+
# the sequence was not recognised or is a string sequence) and the
|
|
96
|
+
# number of bytes it took up. The count is 0 only when +bytes+ is
|
|
97
|
+
# empty or, with +partial+, holds just the start of a sequence.
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
# Input::Parser.new.parse_prefix("\e[A\e[B") # => [KeyEvent(up), 3]
|
|
101
|
+
# Input::Parser.new.parse_prefix("\e_Gi=1;OK\e\\q") # => [nil, 11]
|
|
102
|
+
# Input::Parser.new.parse_prefix("\ePab") # => [KeyEvent(Alt+P), 2]
|
|
103
|
+
def parse_prefix(bytes, partial: false)
|
|
104
|
+
bytes = bytes.b unless bytes.encoding == Encoding::BINARY
|
|
105
|
+
length = sequence_length(bytes)
|
|
106
|
+
length ||= partial ? 0 : incomplete_length(bytes)
|
|
107
|
+
return [nil, 0] if length.zero?
|
|
108
|
+
|
|
109
|
+
[parse(bytes.byteslice(0, length)), length]
|
|
110
|
+
end
|
|
111
|
+
|
|
36
112
|
private
|
|
37
113
|
|
|
114
|
+
# Byte length of the first sequence in +bytes+ (binary), 0 if empty,
|
|
115
|
+
# or nil if +bytes+ ends before the sequence is complete.
|
|
116
|
+
def sequence_length(bytes)
|
|
117
|
+
return 0 if bytes.empty?
|
|
118
|
+
|
|
119
|
+
first = bytes.getbyte(0)
|
|
120
|
+
if first == 0x1B
|
|
121
|
+
escape_length(bytes)
|
|
122
|
+
elsif first < 0x80
|
|
123
|
+
1
|
|
124
|
+
else
|
|
125
|
+
utf8_length(bytes, 0)
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
# Length to take for a sequence cut short by the end of input: all of
|
|
130
|
+
# it, except that ESC ESC starts with a plain ESC key and an unfinished
|
|
131
|
+
# string sequence with its introducer (Alt+character).
|
|
132
|
+
def incomplete_length(bytes)
|
|
133
|
+
return 1 if bytes.start_with?("\e\e")
|
|
134
|
+
return 2 if bytes.getbyte(0) == 0x1B && STRING_INTRODUCERS.include?(bytes.getbyte(1))
|
|
135
|
+
|
|
136
|
+
bytes.bytesize
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def escape_length(bytes)
|
|
140
|
+
second = bytes.getbyte(1)
|
|
141
|
+
case second
|
|
142
|
+
when nil then nil # lone ESC so far
|
|
143
|
+
when 0x1B then alt_sequence_length(bytes)
|
|
144
|
+
when *STRING_INTRODUCERS
|
|
145
|
+
string_length(bytes)
|
|
146
|
+
when 0x5B # CSI: ESC [
|
|
147
|
+
third = bytes.getbyte(2)
|
|
148
|
+
if third.nil?
|
|
149
|
+
nil
|
|
150
|
+
elsif third == 0x4D # legacy mouse: ESC [ M Cb Cx Cy
|
|
151
|
+
bytes.bytesize >= 6 ? 6 : nil
|
|
152
|
+
else
|
|
153
|
+
csi_length(bytes)
|
|
154
|
+
end
|
|
155
|
+
when 0x4F # SS3: ESC O X
|
|
156
|
+
bytes.bytesize >= 3 ? 3 : nil
|
|
157
|
+
else # Alt+character
|
|
158
|
+
return 2 if second < 0x80
|
|
159
|
+
|
|
160
|
+
char_length = utf8_length(bytes, 1)
|
|
161
|
+
char_length && char_length + 1
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# CSI: parameter/intermediate bytes 0x20-0x3F, then a final byte 0x40-0x7E.
|
|
166
|
+
# Any other byte ends a malformed sequence just before it. With no
|
|
167
|
+
# final byte within MAX_CSI_BYTES, ESC [ alone (Alt+[).
|
|
168
|
+
def csi_length(bytes)
|
|
169
|
+
(2...[bytes.bytesize, MAX_CSI_BYTES].min).each do |i|
|
|
170
|
+
b = bytes.getbyte(i)
|
|
171
|
+
return i + 1 if b.between?(0x40, 0x7E)
|
|
172
|
+
return i unless b.between?(0x20, 0x3F)
|
|
173
|
+
end
|
|
174
|
+
bytes.bytesize >= MAX_CSI_BYTES ? 2 : nil
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ESC ESC: both ESCs and the CSI or SS3 sequence after the second, if
|
|
178
|
+
# together they are a key (Alt+key); otherwise 1, the first ESC as a
|
|
179
|
+
# plain key.
|
|
180
|
+
def alt_sequence_length(bytes)
|
|
181
|
+
rest = bytes.byteslice(1..)
|
|
182
|
+
return 1 unless [nil, 0x5B, 0x4F].include?(rest.getbyte(1))
|
|
183
|
+
|
|
184
|
+
length = escape_length(rest)
|
|
185
|
+
return nil if length.nil?
|
|
186
|
+
|
|
187
|
+
parse(bytes.byteslice(0, length + 1)).is_a?(KeyEvent) ? length + 1 : 1
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# String sequence: ends after BEL or ST (ESC \), or just before any other
|
|
191
|
+
# ESC; nil while no terminator or other ESC has come.
|
|
192
|
+
def string_length(bytes)
|
|
193
|
+
stop = bytes.index(/[\a\e]/, 2)
|
|
194
|
+
return nil if stop.nil?
|
|
195
|
+
return stop + 1 if bytes.getbyte(stop) == 0x07
|
|
196
|
+
|
|
197
|
+
following = bytes.getbyte(stop + 1)
|
|
198
|
+
return nil if following.nil? # ESC at the end: may be the start of ST
|
|
199
|
+
|
|
200
|
+
following == 0x5C ? stop + 2 : stop
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Length of the UTF-8 character starting at +start+; an invalid sequence
|
|
204
|
+
# ends just before the first byte that does not continue it.
|
|
205
|
+
def utf8_length(bytes, start)
|
|
206
|
+
need = case bytes.getbyte(start)
|
|
207
|
+
when 0xC2..0xDF then 2
|
|
208
|
+
when 0xE0..0xEF then 3
|
|
209
|
+
when 0xF0..0xF4 then 4
|
|
210
|
+
else 1
|
|
211
|
+
end
|
|
212
|
+
(1...need).each do |k|
|
|
213
|
+
b = bytes.getbyte(start + k)
|
|
214
|
+
return nil if b.nil?
|
|
215
|
+
return k unless b.between?(0x80, 0xBF)
|
|
216
|
+
end
|
|
217
|
+
need
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
# +raw+ as a UTF-8 String if it is exactly one valid character, else nil.
|
|
221
|
+
def utf8_char(raw)
|
|
222
|
+
char = String.new(raw, encoding: Encoding::UTF_8)
|
|
223
|
+
char if char.valid_encoding? && char.length == 1
|
|
224
|
+
end
|
|
225
|
+
|
|
38
226
|
def parse_single(byte)
|
|
39
227
|
ord = byte.ord
|
|
40
228
|
|
|
@@ -49,11 +237,11 @@ module RubyTUI
|
|
|
49
237
|
KeyEvent.new(code: Key::BACKSPACE)
|
|
50
238
|
when 1..26
|
|
51
239
|
# Ctrl+A through Ctrl+Z
|
|
52
|
-
char = (ord + 96).chr # Convert to lowercase letter
|
|
240
|
+
char = (ord + 96).chr(Encoding::UTF_8) # Convert to lowercase letter
|
|
53
241
|
KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::CTRL)
|
|
54
242
|
when 32..126
|
|
55
243
|
# Printable ASCII
|
|
56
|
-
KeyEvent.new(code: Key::CHAR, char: byte)
|
|
244
|
+
KeyEvent.new(code: Key::CHAR, char: utf8_char(byte))
|
|
57
245
|
else
|
|
58
246
|
nil
|
|
59
247
|
end
|
|
@@ -79,9 +267,16 @@ module RubyTUI
|
|
|
79
267
|
# SS3 sequences: ESC O ...
|
|
80
268
|
when /\A\eO([A-Za-z])\z/
|
|
81
269
|
parse_ss3($1)
|
|
82
|
-
# Alt+
|
|
83
|
-
when /\A\e(
|
|
84
|
-
|
|
270
|
+
# Alt+key (rxvt-style): ESC + CSI or SS3 key sequence
|
|
271
|
+
when /\A\e(\e[\[O].+)\z/m
|
|
272
|
+
key = parse_escape($1)
|
|
273
|
+
if key.is_a?(KeyEvent)
|
|
274
|
+
KeyEvent.new(code: key.code, char: key.char, modifiers: key.modifiers | Key::ALT)
|
|
275
|
+
end
|
|
276
|
+
# Alt+character: ESC + one (possibly multi-byte) character
|
|
277
|
+
when /\A\e(.+)\z/
|
|
278
|
+
char = utf8_char($1)
|
|
279
|
+
char && KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::ALT)
|
|
85
280
|
else
|
|
86
281
|
nil
|
|
87
282
|
end
|
|
@@ -154,7 +349,7 @@ module RubyTUI
|
|
|
154
349
|
end
|
|
155
350
|
|
|
156
351
|
# SGR extended mouse mode: ESC [ < Cb ; Cx ; Cy M (press) or m (release)
|
|
157
|
-
# Cb encodes button + modifiers +
|
|
352
|
+
# Cb encodes button + modifiers + motion flag
|
|
158
353
|
def parse_sgr_mouse(cb, cx, cy, terminator)
|
|
159
354
|
# Coordinates are 1-based in SGR mode
|
|
160
355
|
x = cx - 1
|
|
@@ -172,8 +367,12 @@ module RubyTUI
|
|
|
172
367
|
is_scroll = (cb & 64) != 0
|
|
173
368
|
|
|
174
369
|
if is_scroll
|
|
175
|
-
button = button_bits
|
|
370
|
+
button = scroll_button(button_bits)
|
|
176
371
|
action = MouseEvent::PRESS
|
|
372
|
+
elsif is_drag && button_bits == 3
|
|
373
|
+
# Motion with no button held
|
|
374
|
+
button = MouseEvent::NONE
|
|
375
|
+
action = MouseEvent::MOVE
|
|
177
376
|
else
|
|
178
377
|
button = case button_bits
|
|
179
378
|
when 0 then MouseEvent::LEFT
|
|
@@ -209,11 +408,12 @@ module RubyTUI
|
|
|
209
408
|
button_bits = cb & 3
|
|
210
409
|
|
|
211
410
|
if is_scroll
|
|
212
|
-
button = button_bits
|
|
411
|
+
button = scroll_button(button_bits)
|
|
213
412
|
action = MouseEvent::PRESS
|
|
214
413
|
elsif button_bits == 3
|
|
414
|
+
# Release, or motion with no button held
|
|
215
415
|
button = MouseEvent::NONE
|
|
216
|
-
action = MouseEvent::RELEASE
|
|
416
|
+
action = is_drag ? MouseEvent::MOVE : MouseEvent::RELEASE
|
|
217
417
|
else
|
|
218
418
|
button = case button_bits
|
|
219
419
|
when 0 then MouseEvent::LEFT
|
|
@@ -226,6 +426,16 @@ module RubyTUI
|
|
|
226
426
|
|
|
227
427
|
MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
|
|
228
428
|
end
|
|
429
|
+
|
|
430
|
+
# Wheel button for the low two bits of a wheel report (codes 64-67)
|
|
431
|
+
def scroll_button(button_bits)
|
|
432
|
+
case button_bits
|
|
433
|
+
when 0 then MouseEvent::SCROLL_UP
|
|
434
|
+
when 1 then MouseEvent::SCROLL_DOWN
|
|
435
|
+
when 2 then MouseEvent::SCROLL_LEFT
|
|
436
|
+
else MouseEvent::SCROLL_RIGHT
|
|
437
|
+
end
|
|
438
|
+
end
|
|
229
439
|
end
|
|
230
440
|
end
|
|
231
441
|
end
|