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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 1ec32a08aa7695cdc9b1db38895f10df4e37ff057dec2799b924fc2a6d8df860
4
- data.tar.gz: 5a8a38833a21651b6372a420aea4be5e17b6f214653818b198c81303c45bf29d
3
+ metadata.gz: ac917512f05261231e65601213ab71f6205cd68818e3da3adb1f867c66759719
4
+ data.tar.gz: bbad14e5928bde95d26c6cfcf2143f68f228f51559519b68f1c83eae5dec1ced
5
5
  SHA512:
6
- metadata.gz: b0c26618a8ab168de7278fb24d0234b332983e0426ec945c2bb92ddc69da270788fd6d20ac578d792e9ac2d04398f374912ac118ebb316fdcb85aeb517197f97
7
- data.tar.gz: 00a6150cc97eca7ca31eead19b04c1bbb0426a2c794dbf5208657199cc0e298a8d445c29aaf015519d41793d93890862cad04755b4bc182dfd98bdfa633c09dd
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 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.
@@ -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
- - While loading, `Loading...` is drawn centered in the area; after a failure, `error_placeholder:` (default `[Error]`) is drawn.
485
- - `placeholder:` accepts a widget that includes `RubyTUI::Widget` (all built-in widgets do), which is drawn instead until the image is ready, or a string, which is shown before loading starts when `auto_start: false` is given. With `auto_start: false`, call `start_loading` to begin; `cancel` stops the loader thread.
486
- - `on_ready:` is called on the loader thread when the loader has returned (not on a cache hit); `on_error:` receives the exception raised by the loader.
487
+ - 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, 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]
@@ -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 and track the last non-empty column
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
@@ -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
 
@@ -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
- # multi-byte UTF-8 characters given as raw bytes (as read by {Reader}), ESC,
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.length == 1
56
+ elsif bytes.bytesize == 1
29
57
  parse_single(bytes)
30
58
  else
31
59
  # Multi-byte UTF-8 character
32
- KeyEvent.new(code: Key::CHAR, char: bytes)
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+letter: ESC + letter
83
- when /\A\e(.)\z/
84
- KeyEvent.new(code: Key::CHAR, char: $1, modifiers: Key::ALT)
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 + drag flag
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 == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
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 == 0 ? MouseEvent::SCROLL_UP : MouseEvent::SCROLL_DOWN
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