rubytui 1.2.6 → 1.2.7

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: 1c02474959ea48e1132fe6eb9d4928bfd903380165103601f7af0f4de781fcd2
4
- data.tar.gz: b341e4db0fe3e8804415b3436418b9289882bece33a49a6912499854e286c58d
3
+ metadata.gz: 79cdd36b58280934698cfd3e90ff14ba85665e495826dfbbc350efa038177f06
4
+ data.tar.gz: a4304020e612c2d330cfd9aae0284e3780c7bed6c6b054e913f1e4330a38aa97
5
5
  SHA512:
6
- metadata.gz: e7c0955376bb8cce509abcb7d17caf24d18b94c1e72931ace799d36307290a1432786cc4428a89163c6482b14cfdc3b7a07833ad9a5783fb33b16040c0af9cf7
7
- data.tar.gz: 2ceee4bcd1442108229131a7fbd5d5f36a8617185224e67bc4ac6393d59608003edbd873a6daaf904d4b1af3449fc46aab2d96cc19ba8981dc1e9f5c68eadb02
6
+ metadata.gz: f56ab22ff3594c7f6c634017342e24ddb16fcaa59d76c3e3dc28b4e6be5d47142009f24d8e96f7135e291e0acb0051a838de0b8e7a248e5a33f9751520d8a82f
7
+ data.tar.gz: b3c031a1efcc1bc437f787bf88ea388a9ba7c3717b5bc57f739af2f3c245405fac6cc0048937768dcd716e670cb61ea01176df07b5e760e09fff68d2dc69806a
data/README.md CHANGED
@@ -198,13 +198,25 @@ end
198
198
 
199
199
  ### Keyboard
200
200
 
201
- A `KeyEvent` has a `code`, a `char` and modifier predicates (`ctrl?`, `alt?`, `shift?`).
201
+ A `KeyEvent` has a `code`, a `char` and modifier predicates (`ctrl?`, `alt?`, `shift?`, `super?`).
202
202
 
203
203
  - `code` is one of the symbols in `RubyTUI::Input::Key`: `CHAR`, `ENTER`, `ESCAPE`, `BACKSPACE`, `DELETE`, `TAB`, `BACKTAB` (Shift+Tab), `UP`, `DOWN`, `LEFT`, `RIGHT`, `HOME`, `END_KEY`, `PAGE_UP`, `PAGE_DOWN`, `INSERT` and `F1` to `F12`.
204
- - For `CHAR` events, `char` holds the character. Ctrl+letter arrives as the lowercase letter with `ctrl?` true, except Ctrl+H, Ctrl+I and Ctrl+M, which the terminal sends as Backspace, Tab and Enter. Alt+key arrives with `alt?` true.
205
- - Arrow keys, Home and End also report Shift, Alt and Ctrl modifiers.
204
+ - For `CHAR` events, `char` holds the character. Ctrl+letter arrives as the lowercase letter with `ctrl?` true, except Ctrl+H, Ctrl+I and Ctrl+M, which the terminal sends as Backspace, Tab and Enter. Ctrl+Space arrives as `" "` with `ctrl?` true. Alt+key arrives with `alt?` true, including Alt+Backspace, Alt+Enter, Alt+Tab and Alt+Escape as those keys.
205
+ - Arrow keys, Home, End, Page Up, Page Down, Insert, Delete and the function keys also report Shift, Alt and Ctrl modifiers.
206
206
  - A lone Escape key press is reported after a 50 ms wait that separates it from escape sequences.
207
207
  - In raw mode, Ctrl+C does not send `SIGINT`; it arrives as a key event, so handle it if your application should quit on it.
208
+ - On macOS, Option types characters (Option+A gives `å`) unless the terminal is set to send it as Alt: `macos-option-as-alt` in Ghostty, Option key "Esc+" in iTerm2's profile, "Use Option as Meta key" in Terminal.
209
+
210
+ #### Kitty keyboard protocol
211
+
212
+ `RubyTUI.run(kitty_keyboard: true)` (also `RubyTUI.init` and `App#run!`) asks the terminal to report keys with the [kitty keyboard protocol](https://sw.kovidgoyal.net/kitty/keyboard-protocol/), and switches it back off when the block exits or raises. On terminals that support it, such as Ghostty, iTerm2, kitty, Alacritty and foot:
213
+
214
+ - Ctrl+Shift+letter arrives with `ctrl?` and `shift?` true, and `char` is the shifted character (`"A"`) when the terminal reports it, otherwise the lowercase letter.
215
+ - Ctrl+I, Ctrl+M, Ctrl+H and Ctrl+[ arrive as Ctrl+letter, not as Tab, Enter, Backspace and Escape.
216
+ - Ctrl and Alt combine with digits and punctuation, and Cmd combinations that the terminal does not use itself arrive with `super?` true.
217
+ - Escape is reported without the 50 ms wait.
218
+
219
+ Plain and Shift-ed text, Enter, Tab and Backspace arrive as before, as do all keys on terminals without the protocol, such as macOS Terminal. Shift+Space is not reported separately.
208
220
 
209
221
  ```ruby
210
222
  require "rubytui"
@@ -243,7 +255,7 @@ end
243
255
 
244
256
  ### Text input
245
257
 
246
- `InputState#handle_key(event)` applies a key event to the input and returns `true` if it consumed the event. Printable characters are inserted; Backspace, Delete, Left, Right, Home and End edit and move the cursor; Ctrl+A and Ctrl+E move to the start and end; Ctrl+K and Ctrl+U delete to the end and to the start; Ctrl+W deletes the previous word. Other keys, such as Enter, Tab and Escape, are left to your application.
258
+ `InputState#handle_key(event)` applies a key event to the input and returns `true` if it consumed the event. Printable characters are inserted; Backspace, Delete, Left, Right, Home and End edit and move the cursor, treating a letter with its combining marks or an emoji sequence as one character; Ctrl+A and Ctrl+E move to the start and end; Ctrl+K and Ctrl+U delete to the end and to the start; Ctrl+W deletes the previous word. Other keys, such as Enter, Tab and Escape, are left to your application.
247
259
 
248
260
  ```ruby
249
261
  state = RubyTUI::Widgets::InputState.new
data/lib/rubytui/app.rb CHANGED
@@ -134,9 +134,12 @@ module RubyTUI
134
134
  # milliseconds (default: 50)
135
135
  # @param output [IO] output stream (default: $stdout)
136
136
  # @param input [IO] input stream (default: $stdin)
137
+ # @param kitty_keyboard [Boolean] report keys with the kitty keyboard
138
+ # protocol where the terminal supports it (see {RubyTUI.run}) (default: false)
137
139
  # @return [void]
138
- def run!(mouse: false, tick_ms: 50, output: $stdout, input: $stdin)
139
- RubyTUI.run(output: output, input: input, mouse: mouse) do |terminal, reader|
140
+ def run!(mouse: false, tick_ms: 50, output: $stdout, input: $stdin, kitty_keyboard: false)
141
+ RubyTUI.run(output: output, input: input, mouse: mouse,
142
+ kitty_keyboard: kitty_keyboard) do |terminal, reader|
140
143
  model = init
141
144
  on_start(terminal, reader)
142
145
 
@@ -116,6 +116,19 @@ module RubyTUI
116
116
  raise NotImplementedError
117
117
  end
118
118
 
119
+ # Turn on the kitty keyboard protocol (see {AnsiBackend#enable_kitty_keyboard}).
120
+ # No-op by default.
121
+ # @return [void]
122
+ def enable_kitty_keyboard
123
+ # no-op by default
124
+ end
125
+
126
+ # Turn off what {#enable_kitty_keyboard} turned on. No-op by default.
127
+ # @return [void]
128
+ def disable_kitty_keyboard
129
+ # no-op by default
130
+ end
131
+
119
132
  # Draw an image using the Kitty graphics protocol.
120
133
  # No-op by default; backends that support images override it.
121
134
  # @param placement [Hash{Symbol => Object}] image placement data with keys
@@ -7,7 +7,8 @@ module RubyTUI
7
7
  #
8
8
  # Output accumulates in an internal string buffer and is written to the
9
9
  # output IO only by {#flush}. {#enter_alternate_screen},
10
- # {#leave_alternate_screen}, {#enable_mouse}, {#disable_mouse} and
10
+ # {#leave_alternate_screen}, {#enable_mouse}, {#disable_mouse},
11
+ # {#enable_kitty_keyboard}, {#disable_kitty_keyboard} and
11
12
  # {#clear_all_images!} flush immediately.
12
13
  #
13
14
  # @see RubyTUI.init
@@ -17,6 +18,10 @@ module RubyTUI
17
18
  # Control Sequence Introducer (<tt>ESC [</tt>) that prefixes escape sequences.
18
19
  CSI = "\e["
19
20
 
21
+ # Kitty keyboard protocol flags pushed by {#enable_kitty_keyboard}:
22
+ # disambiguate escape codes (1) and report alternate keys (4).
23
+ KITTY_KEYBOARD_FLAGS = 0b101
24
+
20
25
  # @param output [IO] stream escape sequences are written to (default: $stdout)
21
26
  # @param input [IO] terminal input used for raw mode and cell-size queries
22
27
  # (default: $stdin)
@@ -144,6 +149,40 @@ module RubyTUI
144
149
  flush
145
150
  end
146
151
 
152
+ # Push {KITTY_KEYBOARD_FLAGS} onto the terminal's kitty keyboard mode
153
+ # stack (<tt>CSI > 5 u</tt>) and flush. Keys the legacy encoding cannot
154
+ # tell apart then arrive as <tt>CSI u</tt> sequences, which {Input::Parser}
155
+ # decodes: Esc, and Ctrl, Alt or Super (Cmd) with a key. Plain and
156
+ # Shift-ed text, Enter, Tab and Backspace are sent as before. A terminal
157
+ # without the protocol keeps sending keys as before. Does nothing if
158
+ # already on.
159
+ #
160
+ # Call it after {#enter_alternate_screen}, as each screen has its own stack.
161
+ #
162
+ # @return [void]
163
+ def enable_kitty_keyboard
164
+ return if @kitty_keyboard
165
+
166
+ @buffer << "#{CSI}>#{KITTY_KEYBOARD_FLAGS}u"
167
+ @kitty_keyboard = true
168
+ flush
169
+ end
170
+
171
+ # Pop the entry pushed by {#enable_kitty_keyboard} (<tt>CSI < u</tt>),
172
+ # restoring the terminal's previous keyboard mode, and flush. Does
173
+ # nothing if it is not on.
174
+ #
175
+ # Call it before {#leave_alternate_screen}.
176
+ #
177
+ # @return [void]
178
+ def disable_kitty_keyboard
179
+ return unless @kitty_keyboard
180
+
181
+ @buffer << "#{CSI}<u"
182
+ @kitty_keyboard = false
183
+ flush
184
+ end
185
+
147
186
  # Put the input into raw mode, saving its terminal state for {#disable_raw_mode}.
148
187
  #
149
188
  # Save the termios state of the SAME fd we are about to raw! — a backtick
@@ -97,6 +97,7 @@ module RubyTUI
97
97
  # Iterates by grapheme cluster so that combining sequences (Devanagari
98
98
  # matras/virama, emoji ZWJ, Latin diacritics) are stored as a single
99
99
  # cell symbol. Wide clusters (2-column) mark the next cell as a spacer.
100
+ # Control characters (zero width, e.g. a tab or newline) are skipped.
100
101
  # Clips at buffer boundaries.
101
102
  #
102
103
  # @param x [Integer] starting column
@@ -114,6 +115,7 @@ module RubyTUI
114
115
  break if col >= @area.right
115
116
 
116
117
  w = Unicode.cluster_width(gc)
118
+ next if w.zero?
117
119
 
118
120
  if col >= @area.x
119
121
  idx = index(col, y)
@@ -278,12 +280,12 @@ module RubyTUI
278
280
  # puts buf.to_ansi
279
281
  def to_ansi
280
282
  csi = "\e["
281
- result = String.new(capacity: @area.area * 2)
283
+ result = String.new(capacity: @area.area * 2, encoding: Encoding::UTF_8)
282
284
  prev_style = nil
283
285
 
284
286
  @area.height.times do |row|
285
287
  # Build the row content
286
- row_buf = String.new(capacity: @area.width * 2)
288
+ row_buf = String.new(capacity: @area.width * 2, encoding: Encoding::UTF_8)
287
289
  col = 0
288
290
  display_col = 0 # track actual terminal column for cursor correction
289
291
 
data/lib/rubytui/event.rb CHANGED
@@ -19,8 +19,8 @@ module RubyTUI
19
19
  # (e.g. {Input::Key::ENTER}, {Input::Key::CHAR})
20
20
  # @param char [String, nil] the character, for {Input::Key::CHAR} events; a
21
21
  # UTF-8 String when produced by {Input::Parser} (default: nil)
22
- # @param modifiers [Integer] bitmask of {Input::Key::SHIFT}, {Input::Key::ALT} and
23
- # {Input::Key::CTRL} (default: {Input::Key::NONE})
22
+ # @param modifiers [Integer] bitmask of {Input::Key::SHIFT}, {Input::Key::ALT},
23
+ # {Input::Key::CTRL} and {Input::Key::SUPER} (default: {Input::Key::NONE})
24
24
  def initialize(code:, char: nil, modifiers: Input::Key::NONE)
25
25
  @code = code
26
26
  @char = char
@@ -33,6 +33,8 @@ module RubyTUI
33
33
  def alt? = (@modifiers & Input::Key::ALT) != 0
34
34
  # @return [Boolean] true if the {Input::Key::SHIFT} flag is set
35
35
  def shift? = (@modifiers & Input::Key::SHIFT) != 0
36
+ # @return [Boolean] true if the {Input::Key::SUPER} flag (Cmd on macOS) is set
37
+ def super? = (@modifiers & Input::Key::SUPER) != 0
36
38
 
37
39
  # Value equality: same code, character and modifiers.
38
40
  #
@@ -53,6 +55,7 @@ module RubyTUI
53
55
  parts << "Ctrl" if ctrl?
54
56
  parts << "Alt" if alt?
55
57
  parts << "Shift" if shift?
58
+ parts << "Super" if super?
56
59
  parts << (@char || @code.to_s)
57
60
  "KeyEvent(#{parts.join("+")})"
58
61
  end
@@ -86,6 +86,11 @@ module RubyTUI
86
86
  ALT = 0b010
87
87
  # Ctrl modifier bit.
88
88
  CTRL = 0b100
89
+ # Super modifier bit: Cmd on macOS, the Windows key elsewhere. Reported
90
+ # only by terminals that send it in a modifier parameter, such as with
91
+ # the kitty keyboard protocol (see {RubyTUI.run}); xterm sends Meta in
92
+ # this bit.
93
+ SUPER = 0b1000
89
94
 
90
95
  # @!endgroup
91
96
  end
@@ -5,13 +5,32 @@ 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
+ # Ctrl+Space and Ctrl+\ ] ^ _ (bytes 0 and 28-31),
8
9
  # 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
+ # String), ESC, Alt+key (ESC followed by one character or control byte,
11
+ # e.g. <tt>ESC DEL</tt> is Alt+Backspace and <tt>ESC ESC</tt> Alt+Escape),
10
12
  # CSI and SS3 key sequences (arrows, Home/End, Insert/Delete, Page Up/Down,
11
- # Back-tab, F1-F12, xterm modified arrows such as <tt>ESC [ 1 ; 5 A</tt>),
13
+ # Back-tab, F1-F12, each with an optional xterm modifier parameter such as
14
+ # <tt>ESC [ 1 ; 5 A</tt> or <tt>ESC [ 5 ; 5 ~</tt>),
12
15
  # the same key sequences preceded by ESC (Alt+key as sent by rxvt-style
13
16
  # terminals, e.g. <tt>ESC ESC [ A</tt> is Alt+Up),
14
- # and SGR and legacy mouse reports.
17
+ # and SGR and legacy mouse reports (buttons 8-11 give no event).
18
+ #
19
+ # Keys sent as <tt>CSI code ; modifiers u</tt> (the kitty keyboard
20
+ # protocol, see {RubyTUI.run}) or <tt>CSI 27 ; modifiers ; code ~</tt>
21
+ # (xterm modifyOtherKeys) are recognised whether or not rubytui asked
22
+ # for them. The key code is a Unicode code point, so the {KeyEvent} is a
23
+ # character key, or Enter, Tab, Escape or Backspace for codes 13, 9, 27
24
+ # and 127, or a keypad key reported as the key it stands for (keypad
25
+ # Enter as Enter, keypad 1 as "1"). The character is the shifted key or
26
+ # the key's text when the sequence carries one, otherwise the key code
27
+ # itself, so Ctrl+Shift+A is <tt>char "a"</tt> with Ctrl and Shift
28
+ # unless the terminal also sends the shifted key "A". Shift+Tab is
29
+ # {Input::Key::BACKTAB} without Shift. Key releases, modifier and lock
30
+ # keys pressed on their own, F13 and above, and media keys give no event.
31
+ #
32
+ # In every modifier parameter the Shift, Alt, Ctrl and Super (Cmd) bits
33
+ # are reported; Hyper, Meta, Caps Lock and Num Lock are ignored.
15
34
  #
16
35
  # String sequences that terminals send as replies are recognised and
17
36
  # produce no event: APC (<tt>ESC _</tt>, e.g. Kitty graphics responses),
@@ -19,7 +38,8 @@ module RubyTUI
19
38
  # (<tt>ESC ^</tt>), each ended by ST (ESC and a backslash) or BEL. Because these
20
39
  # introducers start a string, Alt+P, Alt+X, Alt+], Alt+^ and Alt+_ are
21
40
  # reported only when the input ends with the string they start still
22
- # unfinished (see {#parse_prefix}).
41
+ # unfinished, or an ESC that does not start ST follows them (see
42
+ # {#parse_prefix}).
23
43
  #
24
44
  # The +char+ of every {KeyEvent} it returns is a UTF-8 String.
25
45
  #
@@ -37,6 +57,24 @@ module RubyTUI
37
57
  STRING_INTRODUCERS = [0x50, 0x58, 0x5D, 0x5E, 0x5F].freeze
38
58
  private_constant :STRING_INTRODUCERS
39
59
 
60
+ # Modifier bits reported in a KeyEvent; they match the protocol bits
61
+ MODIFIER_MASK = Key::SHIFT | Key::ALT | Key::CTRL | Key::SUPER
62
+ private_constant :MODIFIER_MASK
63
+
64
+ # Key codes in CSI u and modifyOtherKeys sequences that are not
65
+ # characters: C0 keys and the kitty keypad keys (57399-57426), as a key
66
+ # code Symbol or the character the key types
67
+ CODE_KEYS = {
68
+ 9 => Key::TAB, 13 => Key::ENTER, 27 => Key::ESCAPE, 127 => Key::BACKSPACE,
69
+ **(0..9).to_h { |digit| [57399 + digit, digit.to_s] },
70
+ 57409 => ".", 57410 => "/", 57411 => "*", 57412 => "-", 57413 => "+",
71
+ 57414 => Key::ENTER, 57415 => "=", 57416 => ",",
72
+ 57417 => Key::LEFT, 57418 => Key::RIGHT, 57419 => Key::UP, 57420 => Key::DOWN,
73
+ 57421 => Key::PAGE_UP, 57422 => Key::PAGE_DOWN, 57423 => Key::HOME,
74
+ 57424 => Key::END_KEY, 57425 => Key::INSERT, 57426 => Key::DELETE
75
+ }.freeze
76
+ private_constant :CODE_KEYS
77
+
40
78
  # Parse raw bytes into an Event.
41
79
  #
42
80
  # Input in any encoding is decoded byte-wise; a character is returned as
@@ -69,28 +107,30 @@ module RubyTUI
69
107
  # still measured, so the caller can drop them and carry on.
70
108
  #
71
109
  # 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+.
110
+ # final byte comes within its first 256 bytes, or a byte that cannot
111
+ # be part of a CSI sequence comes first, it was typed: only
112
+ # <tt>ESC [</tt> is taken, as Alt+[, and the bytes after it are left to
113
+ # be decoded as input of their own; this does not depend on +partial+.
75
114
  #
76
115
  # 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).
116
+ # including its ST or BEL terminator and gives no event. Replies hold
117
+ # no other ESC, so an ESC inside it that does not start ST means it was
118
+ # typed: only its introducer is taken, as Alt+character (2 bytes, e.g.
119
+ # <tt>ESC P</tt> is Alt+P), and the bytes after it are left to be
120
+ # decoded as input of their own. There is no length limit ({Reader}
121
+ # applies one).
80
122
  #
81
123
  # @param bytes [String] buffered raw input
82
124
  # @param partial [Boolean] true if more input may follow +bytes+: an
83
125
  # 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)
126
+ # CSI or string sequence, or ESC ESC that may start Alt+key) is then
127
+ # left unconsumed. When false, it is decoded as it stands: ESC ESC is
128
+ # a plain ESC key; an unfinished CSI or string sequence is taken to be
129
+ # typed, so only its introducer is taken, as Alt+[ or Alt+character
130
+ # (2 bytes), and the bytes after it are left to be decoded as input of
131
+ # their own; any other unfinished sequence is taken whole (a lone ESC
132
+ # is the ESC key, <tt>ESC O</tt> is Alt+O, and a cut-off UTF-8
133
+ # character gives no event) (default: false)
94
134
  # @return [Array(Event, Integer)] a two-element array: the event (nil if
95
135
  # the sequence was not recognised or is a string sequence) and the
96
136
  # number of bytes it took up. The count is 0 only when +bytes+ is
@@ -128,10 +168,11 @@ module RubyTUI
128
168
 
129
169
  # Length to take for a sequence cut short by the end of input: all of
130
170
  # it, except that ESC ESC starts with a plain ESC key and an unfinished
131
- # string sequence with its introducer (Alt+character).
171
+ # CSI or string sequence with its introducer (Alt+[ or Alt+character).
132
172
  def incomplete_length(bytes)
133
173
  return 1 if bytes.start_with?("\e\e")
134
- return 2 if bytes.getbyte(0) == 0x1B && STRING_INTRODUCERS.include?(bytes.getbyte(1))
174
+ return 2 if bytes.getbyte(0) == 0x1B &&
175
+ (bytes.getbyte(1) == 0x5B || STRING_INTRODUCERS.include?(bytes.getbyte(1)))
135
176
 
136
177
  bytes.bytesize
137
178
  end
@@ -163,13 +204,13 @@ module RubyTUI
163
204
  end
164
205
 
165
206
  # 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+[).
207
+ # Any other byte, or no final byte within MAX_CSI_BYTES, means it was
208
+ # typed: ESC [ alone (Alt+[).
168
209
  def csi_length(bytes)
169
210
  (2...[bytes.bytesize, MAX_CSI_BYTES].min).each do |i|
170
211
  b = bytes.getbyte(i)
171
212
  return i + 1 if b.between?(0x40, 0x7E)
172
- return i unless b.between?(0x20, 0x3F)
213
+ return 2 unless b.between?(0x20, 0x3F)
173
214
  end
174
215
  bytes.bytesize >= MAX_CSI_BYTES ? 2 : nil
175
216
  end
@@ -187,8 +228,9 @@ module RubyTUI
187
228
  parse(bytes.byteslice(0, length + 1)).is_a?(KeyEvent) ? length + 1 : 1
188
229
  end
189
230
 
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.
231
+ # String sequence: ends after BEL or ST (ESC \); nil while neither has
232
+ # come. Terminal replies hold no other ESC, so an ESC followed by any
233
+ # other byte means the introducer was typed: 2 (Alt+character).
192
234
  def string_length(bytes)
193
235
  stop = bytes.index(/[\a\e]/, 2)
194
236
  return nil if stop.nil?
@@ -197,7 +239,7 @@ module RubyTUI
197
239
  following = bytes.getbyte(stop + 1)
198
240
  return nil if following.nil? # ESC at the end: may be the start of ST
199
241
 
200
- following == 0x5C ? stop + 2 : stop
242
+ following == 0x5C ? stop + 2 : 2
201
243
  end
202
244
 
203
245
  # Length of the UTF-8 character starting at +start+; an invalid sequence
@@ -235,10 +277,15 @@ module RubyTUI
235
277
  KeyEvent.new(code: Key::BACKSPACE)
236
278
  when 8 # BS (backspace on some terminals)
237
279
  KeyEvent.new(code: Key::BACKSPACE)
280
+ when 0 # Ctrl+Space
281
+ KeyEvent.new(code: Key::CHAR, char: " ", modifiers: Key::CTRL)
238
282
  when 1..26
239
283
  # Ctrl+A through Ctrl+Z
240
284
  char = (ord + 96).chr(Encoding::UTF_8) # Convert to lowercase letter
241
285
  KeyEvent.new(code: Key::CHAR, char: char, modifiers: Key::CTRL)
286
+ when 28..31
287
+ # Ctrl+\ ] ^ _
288
+ KeyEvent.new(code: Key::CHAR, char: (ord + 64).chr(Encoding::UTF_8), modifiers: Key::CTRL)
242
289
  when 32..126
243
290
  # Printable ASCII
244
291
  KeyEvent.new(code: Key::CHAR, char: utf8_char(byte))
@@ -254,13 +301,10 @@ module RubyTUI
254
301
  # SGR mouse: ESC [ < Cb ; Cx ; Cy M/m
255
302
  when /\A\e\[<(\d+);(\d+);(\d+)([Mm])\z/
256
303
  parse_sgr_mouse($1.to_i, $2.to_i, $3.to_i, $4)
257
- # CSI sequences: ESC [ ...
258
- when /\A\e\[([A-Za-z])\z/
259
- parse_csi_letter($1)
260
- when /\A\e\[(\d+)~\z/
261
- parse_csi_tilde($1.to_i)
262
- when /\A\e\[1;(\d+)([A-Za-z])\z/
263
- parse_csi_modified($2, $1.to_i)
304
+ # CSI key sequences: ESC [ params final, params being ;-separated
305
+ # fields of :-separated numbers
306
+ when /\A\e\[([\d;:]*)([A-Za-z~])\z/
307
+ parse_csi_key($1, $2)
264
308
  # Normal mouse: ESC [ M Cb Cx Cy (legacy, 3 raw bytes after M)
265
309
  when /\A\e\[M(.{3})\z/m
266
310
  parse_normal_mouse($1)
@@ -273,16 +317,93 @@ module RubyTUI
273
317
  if key.is_a?(KeyEvent)
274
318
  KeyEvent.new(code: key.code, char: key.char, modifiers: key.modifiers | Key::ALT)
275
319
  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)
320
+ # Alt+key: ESC + one (possibly multi-byte) character, control byte
321
+ # (ESC DEL is Alt+Backspace) or ESC (Alt+Escape)
322
+ when /\A\e(.+)\z/m
323
+ key = if $1 == "\e"
324
+ KeyEvent.new(code: Key::ESCAPE)
325
+ elsif !$1.start_with?("\e")
326
+ parse($1)
327
+ end
328
+ with_modifiers(key, Key::ALT)
280
329
  else
281
330
  nil
282
331
  end
283
332
  end
284
333
 
285
- # CSI single letter: ESC [ X
334
+ # CSI key sequence. +params+ is the parameter string, +final+ the final
335
+ # byte. The second field is the modifier parameter, with an optional
336
+ # event type (1 press, 2 repeat, 3 release) after a colon.
337
+ def parse_csi_key(params, final)
338
+ fields = params.split(";", -1).map { |field| field.split(":", -1) }
339
+ number = fields.dig(0, 0)
340
+ mod_code, event_type = fields[1]
341
+ return nil if event_type == "3" # key release
342
+
343
+ modifiers = decode_modifiers(mod_code.to_s.empty? ? 1 : mod_code.to_i)
344
+ case final
345
+ when "u"
346
+ parse_csi_u(fields, modifiers)
347
+ when "~"
348
+ if number == "27" && fields.size == 3 # xterm modifyOtherKeys
349
+ parse_key_code(fields[2][0].to_i, modifiers)
350
+ else
351
+ with_modifiers(parse_csi_tilde(number.to_i), modifiers)
352
+ end
353
+ else
354
+ # The number is 1, or omitted when there are no modifiers
355
+ return nil unless [nil, "", "1"].include?(number)
356
+
357
+ with_modifiers(parse_csi_letter(final), modifiers)
358
+ end
359
+ end
360
+
361
+ # CSI u: ESC [ code[:shifted[:base]] ; modifiers ; text u, where text is
362
+ # :-separated code points. The base layout key is not used.
363
+ def parse_csi_u(fields, modifiers)
364
+ code, shifted = fields[0]
365
+ text = fields[2]&.map { |cp| codepoint_char(cp.to_i) }
366
+ if text&.all?
367
+ KeyEvent.new(code: Key::CHAR, char: text.join, modifiers: modifiers)
368
+ else
369
+ parse_key_code(code.to_i, modifiers, shifted.to_s.empty? ? nil : shifted.to_i)
370
+ end
371
+ end
372
+
373
+ # KeyEvent for a key code (and the shifted key, if sent) from a CSI u or
374
+ # modifyOtherKeys sequence, or nil if it has no key.
375
+ def parse_key_code(code, modifiers, shifted = nil)
376
+ if code == 9 && (modifiers & Key::SHIFT) != 0
377
+ return KeyEvent.new(code: Key::BACKTAB, modifiers: modifiers & ~Key::SHIFT)
378
+ end
379
+
380
+ case (key = CODE_KEYS[code])
381
+ when Symbol then KeyEvent.new(code: key, modifiers: modifiers)
382
+ when String then KeyEvent.new(code: Key::CHAR, char: key, modifiers: modifiers)
383
+ else
384
+ char = codepoint_char(shifted || code)
385
+ char && KeyEvent.new(code: Key::CHAR, char: char, modifiers: modifiers)
386
+ end
387
+ end
388
+
389
+ # +cp+ as a UTF-8 String, or nil if it is not a printable character:
390
+ # a C0 or C1 control code, DEL, a surrogate, past U+10FFFF, or in the
391
+ # private use area U+E000-U+F8FF, where the kitty keyboard protocol puts
392
+ # functional keys.
393
+ def codepoint_char(cp)
394
+ return nil if cp < 0x20 || cp.between?(0x7F, 0x9F) || cp.between?(0xD800, 0xDFFF) ||
395
+ cp.between?(0xE000, 0xF8FF) || cp > 0x10FFFF
396
+
397
+ cp.chr(Encoding::UTF_8)
398
+ end
399
+
400
+ # +event+ with +modifiers+ added, or nil if +event+ is nil
401
+ def with_modifiers(event, modifiers)
402
+ event && KeyEvent.new(code: event.code, char: event.char, modifiers: event.modifiers | modifiers)
403
+ end
404
+
405
+ # CSI letter: ESC [ X, or ESC [ 1 ; MOD X. CSI R is a cursor position
406
+ # report, not F3.
286
407
  def parse_csi_letter(letter)
287
408
  case letter
288
409
  when "A" then KeyEvent.new(code: Key::UP)
@@ -292,17 +413,26 @@ module RubyTUI
292
413
  when "H" then KeyEvent.new(code: Key::HOME)
293
414
  when "F" then KeyEvent.new(code: Key::END_KEY)
294
415
  when "Z" then KeyEvent.new(code: Key::BACKTAB)
416
+ when "P" then KeyEvent.new(code: Key::F1)
417
+ when "Q" then KeyEvent.new(code: Key::F2)
418
+ when "S" then KeyEvent.new(code: Key::F4)
295
419
  else nil
296
420
  end
297
421
  end
298
422
 
299
- # CSI tilde sequences: ESC [ N ~
423
+ # CSI tilde sequences: ESC [ N ~ or ESC [ N ; MOD ~
300
424
  def parse_csi_tilde(n)
301
425
  case n
426
+ when 1, 7 then KeyEvent.new(code: Key::HOME)
302
427
  when 2 then KeyEvent.new(code: Key::INSERT)
303
428
  when 3 then KeyEvent.new(code: Key::DELETE)
429
+ when 4, 8 then KeyEvent.new(code: Key::END_KEY)
304
430
  when 5 then KeyEvent.new(code: Key::PAGE_UP)
305
431
  when 6 then KeyEvent.new(code: Key::PAGE_DOWN)
432
+ when 11 then KeyEvent.new(code: Key::F1)
433
+ when 12 then KeyEvent.new(code: Key::F2)
434
+ when 13 then KeyEvent.new(code: Key::F3)
435
+ when 14 then KeyEvent.new(code: Key::F4)
306
436
  when 15 then KeyEvent.new(code: Key::F5)
307
437
  when 17 then KeyEvent.new(code: Key::F6)
308
438
  when 18 then KeyEvent.new(code: Key::F7)
@@ -315,15 +445,6 @@ module RubyTUI
315
445
  end
316
446
  end
317
447
 
318
- # CSI modified: ESC [ 1 ; MOD X (e.g., Shift+Up = ESC[1;2A)
319
- def parse_csi_modified(letter, mod_code)
320
- modifiers = decode_modifiers(mod_code)
321
- base = parse_csi_letter(letter)
322
- return nil unless base
323
-
324
- KeyEvent.new(code: base.code, char: base.char, modifiers: modifiers)
325
- end
326
-
327
448
  # SS3 sequences: ESC O X (function keys on some terminals)
328
449
  def parse_ss3(letter)
329
450
  case letter
@@ -337,20 +458,19 @@ module RubyTUI
337
458
  end
338
459
  end
339
460
 
340
- # Decode xterm modifier parameter:
341
- # 2=Shift, 3=Alt, 4=Shift+Alt, 5=Ctrl, 6=Shift+Ctrl, 7=Alt+Ctrl, 8=Shift+Alt+Ctrl
461
+ # Decode a modifier parameter: 1 plus a bit field of Shift (1), Alt (2),
462
+ # Ctrl (4) and Super (8), e.g. 2=Shift, 5=Ctrl, 6=Shift+Ctrl, 9=Super.
463
+ # Higher bits are dropped; 0 counts as 1 (no modifiers).
342
464
  def decode_modifiers(code)
343
- val = code - 1
344
- mods = Key::NONE
345
- mods |= Key::SHIFT if (val & 0b001) != 0
346
- mods |= Key::ALT if (val & 0b010) != 0
347
- mods |= Key::CTRL if (val & 0b100) != 0
348
- mods
465
+ [code - 1, 0].max & MODIFIER_MASK
349
466
  end
350
467
 
351
468
  # SGR extended mouse mode: ESC [ < Cb ; Cx ; Cy M (press) or m (release)
352
- # Cb encodes button + modifiers + motion flag
469
+ # Cb encodes button + modifiers + motion flag. Buttons 8-11 (Cb 128
470
+ # and up) give no event.
353
471
  def parse_sgr_mouse(cb, cx, cy, terminator)
472
+ return nil if (cb & 128) != 0
473
+
354
474
  # Coordinates are 1-based in SGR mode
355
475
  x = cx - 1
356
476
  y = cy - 1
@@ -392,9 +512,12 @@ module RubyTUI
392
512
  MouseEvent.new(button: button, action: action, x: x, y: y, modifiers: mods)
393
513
  end
394
514
 
395
- # Legacy normal mouse: ESC [ M Cb Cx Cy (raw bytes, 32-based encoding)
515
+ # Legacy normal mouse: ESC [ M Cb Cx Cy (raw bytes, 32-based encoding).
516
+ # Buttons 8-11 (Cb 128 and up) give no event.
396
517
  def parse_normal_mouse(raw)
397
518
  cb = raw.getbyte(0) - 32
519
+ return nil if cb.negative? || (cb & 128) != 0
520
+
398
521
  x = raw.getbyte(1) - 33
399
522
  y = raw.getbyte(2) - 33
400
523
 
@@ -21,14 +21,12 @@ module RubyTUI
21
21
  # String sequences that terminals write as replies (APC such as Kitty
22
22
  # graphics responses, OSC, DCS, SOS, PM; see {Parser}) are read and
23
23
  # discarded without an event when their terminator (ST or BEL) arrives.
24
- # An ESC inside one that does not start ST ends it just before that ESC,
25
- # also without an event.
26
24
  #
27
- # If no byte arrives for {ESC_TIMEOUT_MS}, or the input ends, before
28
- # either happens, the sequence was typed, not sent: its introducer is
29
- # returned as an Alt key (<tt>ESC P</tt> as Alt+P, likewise Alt+X,
30
- # Alt+], Alt+^ and Alt+_) and every byte after it, a final lone ESC
31
- # included, is decoded as normal input.
25
+ # If an ESC that does not start ST comes first, or no byte arrives for
26
+ # {ESC_TIMEOUT_MS}, or the input ends, the sequence was typed, not sent:
27
+ # its introducer is returned as an Alt key (<tt>ESC P</tt> as Alt+P,
28
+ # likewise Alt+X, Alt+], Alt+^ and Alt+_) and every byte after it, a
29
+ # final lone ESC included, is decoded as normal input.
32
30
  #
33
31
  # If one reaches 4096 bytes with no terminator while bytes keep
34
32
  # arriving, it is taken to be a reply: those bytes are discarded, and so
@@ -38,9 +36,10 @@ module RubyTUI
38
36
  # ends; an ESC not yet discarded at that point is decoded as normal
39
37
  # input.
40
38
  #
41
- # A CSI sequence with no final byte in its first 256 bytes is returned as
42
- # Alt+[, and the bytes after <tt>ESC [</tt> are decoded as normal input
43
- # (see {Parser#parse_prefix}).
39
+ # A CSI sequence with no final byte in its first 256 bytes, or cut off
40
+ # by a pause of {ESC_TIMEOUT_MS}, the end of input or a byte that cannot
41
+ # continue it, was typed: it is returned as Alt+[, and the bytes after
42
+ # <tt>ESC [</tt> are decoded as normal input (see {Parser#parse_prefix}).
44
43
  #
45
44
  # When the input is a TTY that reports its size (+winsize+), {#poll}
46
45
  # also returns a {ResizeEvent} after the terminal size changes.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module RubyTUI
4
4
  # Gem version string.
5
- VERSION = "1.2.6"
5
+ VERSION = "1.2.7"
6
6
  end
@@ -208,9 +208,11 @@ module RubyTUI
208
208
 
209
209
  return if render_area.empty?
210
210
 
211
- case @state
211
+ # Read the state and image together, as the loading thread sets both
212
+ state, image_widget = @mutex.synchronize { [@state, @image_widget] }
213
+ case state
212
214
  when STATE_READY
213
- @image_widget&.render(render_area, buf)
215
+ image_widget&.render(render_area, buf)
214
216
  when STATE_LOADING
215
217
  render_placeholder(render_area, buf, @placeholder || "Loading...")
216
218
  when STATE_ERROR
@@ -238,6 +238,7 @@ module RubyTUI
238
238
  end
239
239
  end
240
240
 
241
+ # A new frozen [top, right, bottom, left] Array, never the caller's
241
242
  def normalize_padding(padding)
242
243
  case padding
243
244
  when nil
@@ -248,12 +249,12 @@ module RubyTUI
248
249
  case padding.length
249
250
  when 1 then [padding[0]] * 4
250
251
  when 2 then [padding[0], padding[1], padding[0], padding[1]]
251
- when 4 then padding
252
+ when 4 then padding.dup
252
253
  else [0, 0, 0, 0]
253
254
  end
254
255
  else
255
256
  [0, 0, 0, 0]
256
- end
257
+ end.freeze
257
258
  end
258
259
  end
259
260
  end
@@ -40,10 +40,12 @@ module RubyTUI
40
40
 
41
41
  # @param x_bounds [Array(Numeric, Numeric), nil] <tt>[min, max]</tt> data range
42
42
  # mapped to the canvas width (default: nil, computed from the x coordinates
43
- # of points and lines)
43
+ # of points and lines). For given or computed bounds, an equal min and max
44
+ # are widened to <tt>max = min + 1</tt>
44
45
  # @param y_bounds [Array(Numeric, Numeric), nil] <tt>[min, max]</tt> data range
45
46
  # mapped to the canvas height, max at the top (default: nil, computed from
46
- # the y coordinates of points and lines)
47
+ # the y coordinates of points and lines; equal bounds are widened as for
48
+ # +x_bounds+)
47
49
  # @param style [Style] style for labels added without a style (default: {Style::DEFAULT})
48
50
  # @param marker_style [Style, nil] style for dots drawn without a style
49
51
  # (default: white foreground)
@@ -186,7 +188,10 @@ module RubyTUI
186
188
  # axis: 0 for x, 1 for y (index into points and into each line's endpoints)
187
189
  def compute_bounds(bounds, canvas_size, axis)
188
190
  if bounds
189
- [bounds[0].to_f, bounds[1].to_f]
191
+ mn = bounds[0].to_f
192
+ mx = bounds[1].to_f
193
+ mx = mn + 1 if mx == mn
194
+ [mn, mx]
190
195
  else
191
196
  coords = @points.map { |p| p[axis] } + @lines.flat_map { |l| [l[axis], l[axis + 2]] }
192
197
  return [0.0, canvas_size.to_f] if coords.empty?
@@ -29,19 +29,27 @@ module RubyTUI
29
29
  # @param datasets [Array<Dataset>] series to plot; datasets with fewer than
30
30
  # 2 points are skipped (default: [])
31
31
  # @param x_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
32
- # +min+/+max+ may be nil for bounds computed from the data. The bottom row of
32
+ # +min+ and +max+ may each be nil for a bound computed from the data (see
33
+ # below). The bottom row of
33
34
  # the area is always reserved for the x axis line and its min/max values;
34
35
  # when set, the label is also drawn centered on it, over the values. The
35
36
  # label and min value are truncated to the plot width (whole grapheme
36
- # clusters), and the max value is omitted when it would overlap the min
37
- # value (default: nil, bounds computed from the data and no label)
37
+ # clusters), and the max value is omitted unless at least one column
38
+ # separates it from the min value (default: nil, bounds computed from
39
+ # the data and no label)
38
40
  # @param y_axis [Array(String, Numeric, Numeric), nil] <tt>[label, min, max]</tt>;
39
- # +min+/+max+ may be nil for bounds computed from the data. When set, the
40
- # min/max values are drawn left of a vertical axis line and the label's first
41
- # character at mid-height. The label column is as wide as the wider of the
42
- # min/max values drawn, i.e. of the final bounds (computed bounds with equal
43
- # min and max are widened to <tt>max = min + 1</tt>) (default: nil, no y
44
- # axis; bounds computed from the data)
41
+ # +min+ and +max+ may each be nil for a bound computed from the data. When
42
+ # set, the min/max values are drawn left of a vertical axis line and the
43
+ # label's first grapheme cluster at mid-height, if it fits the label
44
+ # column. A plot (the area minus the x axis row) 1 row tall shows only
45
+ # the max value, and the label is drawn only when the plot is at least
46
+ # 3 rows tall. The label column is as wide as the wider of the min/max values
47
+ # drawn, i.e. of the final bounds (default: nil, no y axis; bounds
48
+ # computed from the data).
49
+ #
50
+ # For both axes, bounds with equal min and max are widened to
51
+ # <tt>max = min + 1</tt>, and a bound computed from the data that would
52
+ # reach or pass the explicit other bound is set 1 beyond it.
45
53
  # @param style [Style, nil] base style of the area inside +block+: when it is
46
54
  # not {Style::DEFAULT} it is patched onto every cell, and the axis lines,
47
55
  # labels and plotted dots are drawn with this style patched ({Style#patch})
@@ -143,32 +151,29 @@ module RubyTUI
143
151
  [format_num(y_min).length, format_num(y_max).length].max
144
152
  end
145
153
 
146
- def compute_x_bounds
147
- if @x_axis
148
- _, mn, mx = @x_axis
149
- return [mn, mx] if mn && mx
150
- end
151
-
152
- all_x = @datasets.flat_map { |ds| ds.data.map { |x, _| x } }
153
- return [0.0, 1.0] if all_x.empty?
154
-
155
- mn = all_x.min.to_f
156
- mx = all_x.max.to_f
157
- mx = mn + 1 if mx == mn
158
- [mn, mx]
159
- end
160
-
161
- def compute_y_bounds
162
- if @y_axis
163
- _, mn, mx = @y_axis
164
- return [mn, mx] if mn && mx
154
+ def compute_x_bounds = compute_bounds(@x_axis, 0)
155
+ def compute_y_bounds = compute_bounds(@y_axis, 1)
156
+
157
+ # [min, max] for an axis (<tt>[label, min, max]</tt> or nil): each
158
+ # explicit bound where set, otherwise the data's along coordinate
159
+ # +index+ (0 x, 1 y). Equal bounds are widened to max = min + 1; a
160
+ # computed bound that reaches or passes the explicit one is set 1
161
+ # beyond it.
162
+ def compute_bounds(axis, index)
163
+ _, mn, mx = axis
164
+ values = @datasets.flat_map { |ds| ds.data.map { |point| point[index] } }
165
+ if mn.nil? && mx.nil?
166
+ return [0.0, 1.0] if values.empty?
167
+
168
+ mn = values.min.to_f
169
+ mx = values.max.to_f
170
+ elsif mn.nil?
171
+ mn = values.min&.to_f || mx - 1
172
+ mn = mx - 1 if mn >= mx
173
+ elsif mx.nil?
174
+ mx = values.max&.to_f || mn + 1
175
+ mx = mn + 1 if mx <= mn
165
176
  end
166
-
167
- all_y = @datasets.flat_map { |ds| ds.data.map { |_, y| y } }
168
- return [0.0, 1.0] if all_y.empty?
169
-
170
- mn = all_y.min.to_f
171
- mx = all_y.max.to_f
172
177
  mx = mn + 1 if mx == mn
173
178
  [mn, mx]
174
179
  end
@@ -183,15 +188,19 @@ module RubyTUI
183
188
  buf[chart_area.x - 1, y]&.set("\u2502", @axis_style) if chart_area.x > render_area.x
184
189
  end
185
190
 
186
- # Y labels at top and bottom
191
+ # Y labels at top and bottom; a 1-row plot has room for the max only
187
192
  y_label_x = render_area.x
188
193
  buf.set_string(y_label_x, chart_area.y, format_num(y_max), @label_style)
189
- buf.set_string(y_label_x, chart_area.bottom - 1, format_num(y_min), @label_style)
194
+ if chart_area.height > 1
195
+ buf.set_string(y_label_x, chart_area.bottom - 1, format_num(y_min), @label_style)
196
+ end
190
197
 
191
- # Y axis label (vertically centered, first char)
192
- if label && !label.empty?
198
+ # Y axis label (vertically centered): its first grapheme cluster, if it
199
+ # fits the label column and the plot has a row between the values
200
+ if label && !label.empty? && chart_area.height > 2
193
201
  mid_y = chart_area.y + chart_area.height / 2
194
- buf.set_string(render_area.x, mid_y, label[0], @label_style)
202
+ first = Unicode.truncate_to_width(label.grapheme_clusters.first, chart_area.x - 1 - render_area.x)
203
+ buf.set_string(render_area.x, mid_y, first, @label_style)
195
204
  end
196
205
  end
197
206
 
@@ -209,12 +218,12 @@ module RubyTUI
209
218
  end
210
219
 
211
220
  # X labels at left and right, truncated to the plot width; the max label
212
- # is omitted when it would overlap the min label
221
+ # is omitted unless at least one column separates it from the min label
213
222
  min_str = Unicode.truncate_to_width(format_num(x_min), chart_area.width)
214
223
  max_str = format_num(x_max)
215
224
  buf.set_string(chart_area.x, x_axis_y, min_str, @label_style)
216
225
  max_x = chart_area.right - Unicode.string_width(max_str)
217
- if max_x >= chart_area.x + Unicode.string_width(min_str)
226
+ if max_x > chart_area.x + Unicode.string_width(min_str)
218
227
  buf.set_string(max_x, x_axis_y, max_str, @label_style)
219
228
  end
220
229
 
@@ -120,17 +120,20 @@ module RubyTUI
120
120
  # On empty portion: gauge color as fg, default bg
121
121
  empty_label_style = @label_style || Style.new.fg(gauge_fg).bold
122
122
 
123
+ # One cell per grapheme cluster; control characters are skipped
123
124
  cx = label_x
124
- label_text.each_char do |ch|
125
- ch_w = Unicode.char_width(ch)
125
+ label_text.grapheme_clusters.each do |gc|
126
+ gc_w = Unicode.cluster_width(gc)
127
+ next if gc_w.zero?
128
+
126
129
  col_offset = cx - render_area.x
127
130
  char_style = col_offset < boundary ? filled_label_style : empty_label_style
128
- buf[cx, label_y]&.set(ch, char_style)
129
- # Mark spacer for wide chars
130
- if ch_w == 2 && cx + 1 < render_area.right
131
+ buf[cx, label_y]&.set(gc, char_style)
132
+ # Mark spacer for wide clusters
133
+ if gc_w == 2 && cx + 1 < render_area.right
131
134
  buf[cx + 1, label_y]&.set("", char_style)
132
135
  end
133
- cx += ch_w
136
+ cx += gc_w
134
137
  end
135
138
  end
136
139
  end
@@ -26,48 +26,66 @@ module RubyTUI
26
26
 
27
27
  # Insert a character at the cursor position
28
28
  #
29
+ # A cursor past the end (or before the start) of the text is first moved
30
+ # to the end (or start).
31
+ #
29
32
  # @param char [String] character (or string) to insert; the cursor advances by its length
30
33
  # @return [void]
31
34
  def insert(char)
35
+ @cursor = @cursor.clamp(0, @text.length)
32
36
  @text.insert(@cursor, char)
33
37
  @cursor += char.length
34
38
  end
35
39
 
36
- # Delete character before cursor (backspace)
40
+ # Delete the grapheme cluster before the cursor (backspace)
37
41
  #
38
- # Does nothing when the cursor is at the start.
42
+ # A letter with its combining marks, or an emoji sequence, is deleted as
43
+ # a whole, as is a cluster the cursor is inside; the cursor moves to the
44
+ # cluster's start. Does nothing when the cursor is at the start. A cursor
45
+ # past the end of the text moves back one position.
39
46
  #
40
47
  # @return [void]
41
48
  def backspace
42
- return if @cursor == 0
49
+ return if @cursor <= 0
50
+ return @cursor -= 1 if @cursor > @text.length
43
51
 
44
- @text.slice!(@cursor - 1)
45
- @cursor -= 1
52
+ range = cluster_at(@cursor - 1)
53
+ @text.slice!(range)
54
+ @cursor = range.begin
46
55
  end
47
56
 
48
- # Delete character at cursor (delete key)
57
+ # Delete the grapheme cluster at the cursor (delete key)
49
58
  #
59
+ # The whole cluster is deleted; a cursor inside it moves to its start.
50
60
  # Does nothing when the cursor is at the end.
51
61
  #
52
62
  # @return [void]
53
63
  def delete
54
- return if @cursor >= @text.length
64
+ return if @cursor >= @text.length || @cursor.negative?
55
65
 
56
- @text.slice!(@cursor)
66
+ range = cluster_at(@cursor)
67
+ @text.slice!(range)
68
+ @cursor = range.begin
57
69
  end
58
70
 
59
- # Move cursor left
71
+ # Move cursor left, to the start of the previous grapheme cluster
60
72
  #
61
73
  # @return [void]
62
74
  def move_left
63
- @cursor = [@cursor - 1, 0].max
75
+ @cursor = if @cursor <= 0 then 0
76
+ elsif @cursor > @text.length then @cursor - 1
77
+ else cluster_at(@cursor - 1).begin
78
+ end
64
79
  end
65
80
 
66
- # Move cursor right
81
+ # Move cursor right, past the grapheme cluster at the cursor
67
82
  #
68
83
  # @return [void]
69
84
  def move_right
70
- @cursor = [@cursor + 1, @text.length].min
85
+ @cursor = if @cursor >= @text.length then @text.length
86
+ elsif @cursor.negative? then 0
87
+ else cluster_at(@cursor).end
88
+ end
71
89
  end
72
90
 
73
91
  # Move cursor to start
@@ -131,10 +149,11 @@ module RubyTUI
131
149
 
132
150
  # Handle a key event, returns true if the event was consumed
133
151
  #
134
- # Character keys without Ctrl insert <tt>event.char</tt>. Backspace, Delete,
152
+ # Character keys without Ctrl or Super insert <tt>event.char</tt>. Backspace, Delete,
135
153
  # Left, Right, Home and End edit or move. Ctrl-A / Ctrl-E move to start / end,
136
154
  # Ctrl-K / Ctrl-U delete to end / start, Ctrl-W deletes the previous word.
137
- # Other Ctrl combinations, other keys and non-{KeyEvent} events are not consumed.
155
+ # Other Ctrl combinations, Super (Cmd) combinations, other keys and
156
+ # non-{KeyEvent} events are not consumed.
138
157
  #
139
158
  # @param event [Event] the input event
140
159
  # @return [Boolean] true if the event was consumed
@@ -152,6 +171,8 @@ module RubyTUI
152
171
  when "w" then delete_word_back; true
153
172
  else false
154
173
  end
174
+ elsif event.super?
175
+ false
155
176
  else
156
177
  insert(event.char) if event.char
157
178
  true
@@ -166,6 +187,20 @@ module RubyTUI
166
187
  false
167
188
  end
168
189
  end
190
+
191
+ private
192
+
193
+ # Character range of the grapheme cluster that contains character
194
+ # +index+ (0 <= index < text length)
195
+ def cluster_at(index)
196
+ start = 0
197
+ @text.grapheme_clusters.each do |gc|
198
+ stop = start + gc.length
199
+ return start...stop if index < stop
200
+
201
+ start = stop
202
+ end
203
+ end
169
204
  end
170
205
 
171
206
  # InputField renders a single-line text input with cursor.
@@ -26,10 +26,11 @@ module RubyTUI
26
26
  # into lines on newlines, a Span is split the same way into one-span Lines
27
27
  # that keep its {Span#style}, +nil+ means no text, other objects are
28
28
  # converted with +to_s+ (default: nil)
29
- # @param style [Style] base style of the text. Each span is drawn with this
29
+ # @param style [Style, nil] base style of the text. Each span is drawn with this
30
30
  # style patched ({Style#patch}) with {Text#style}, then {Line#style}, then
31
31
  # {Span#style}, so later layers win. When it is not {Style::DEFAULT} it is
32
- # also patched onto every cell of the area (default: {Style::DEFAULT})
32
+ # also patched onto every cell of the area. +nil+ is treated as
33
+ # {Style::DEFAULT} (default: {Style::DEFAULT})
33
34
  # @param alignment [Symbol] +:left+ (default), +:center+ or +:right+
34
35
  # @param wrap [Symbol] {WRAP_WORD} (default), {WRAP_CHAR} or {WRAP_NONE}. Wrapped
35
36
  # lines take the style of the original line's first span and keep the
@@ -43,7 +44,7 @@ module RubyTUI
43
44
  block: nil # optional Block to render inside
44
45
  )
45
46
  @text = normalize_text(text)
46
- @style = style
47
+ @style = style || Style::DEFAULT
47
48
  @alignment = alignment
48
49
  @wrap = wrap
49
50
  @block = block
@@ -137,7 +138,7 @@ module RubyTUI
137
138
 
138
139
  words = plain.split(/(\s+)/)
139
140
  wrapped = []
140
- current = String.new
141
+ current = String.new(encoding: plain.encoding)
141
142
  current_width = 0
142
143
 
143
144
  words.each do |word|
@@ -165,7 +166,7 @@ module RubyTUI
165
166
  line_style = line.spans.first&.style || Style::DEFAULT
166
167
 
167
168
  wrapped = []
168
- current = String.new
169
+ current = String.new(encoding: plain.encoding)
169
170
  current_width = 0
170
171
 
171
172
  # Break only between grapheme clusters, so conjuncts and ZWJ sequences stay whole
@@ -173,7 +174,7 @@ module RubyTUI
173
174
  gc_width = Unicode.cluster_width(gc)
174
175
  if current_width + gc_width > max_width
175
176
  wrapped << Line.new([Span.new(current, line_style)], line.style) unless current.empty?
176
- current = String.new
177
+ current = String.new(encoding: plain.encoding)
177
178
  current_width = 0
178
179
  end
179
180
  current << gc
data/lib/rubytui.rb CHANGED
@@ -104,10 +104,13 @@ module RubyTUI
104
104
  # @param input [IO] input stream (default: $stdin)
105
105
  # @param mouse [Boolean] enable mouse event reporting (default: false)
106
106
  # @param alternate_screen [Boolean] enter alternate screen buffer (default: true)
107
+ # @param kitty_keyboard [Boolean] turn on the kitty keyboard protocol
108
+ # (see {.run}) (default: false)
107
109
  # @return [Array(Terminal, Input::Reader)] terminal and reader pair
108
110
  # @see .run
109
111
  # @see .restore
110
- def init(output: $stdout, input: $stdin, mouse: false, alternate_screen: true)
112
+ def init(output: $stdout, input: $stdin, mouse: false, alternate_screen: true,
113
+ kitty_keyboard: false)
111
114
  backend = AnsiBackend.new(output: output, input: input)
112
115
  terminal = Terminal.new(backend: backend)
113
116
  reader = Input::Reader.new(input: input)
@@ -115,6 +118,7 @@ module RubyTUI
115
118
  backend.enable_raw_mode
116
119
  backend.hide_cursor
117
120
  terminal.enter_alternate_screen if alternate_screen
121
+ backend.enable_kitty_keyboard if kitty_keyboard
118
122
  backend.enable_mouse if mouse
119
123
  backend.flush
120
124
 
@@ -123,7 +127,8 @@ module RubyTUI
123
127
 
124
128
  # Restore terminal to normal state.
125
129
  #
126
- # Disables mouse, shows cursor, leaves alternate screen, and disables raw mode.
130
+ # Disables mouse, shows cursor, turns off the kitty keyboard protocol if
131
+ # {.init} turned it on, leaves alternate screen, and disables raw mode.
127
132
  #
128
133
  # @param terminal [Terminal] the terminal to restore
129
134
  # @param alternate_screen [Boolean] whether alternate screen was used (must match {.init})
@@ -132,6 +137,7 @@ module RubyTUI
132
137
  def restore(terminal, alternate_screen: true)
133
138
  terminal.backend.disable_mouse
134
139
  terminal.backend.show_cursor
140
+ terminal.backend.disable_kitty_keyboard
135
141
  terminal.leave_alternate_screen if alternate_screen
136
142
  terminal.backend.disable_raw_mode
137
143
  terminal.backend.flush
@@ -147,6 +153,19 @@ module RubyTUI
147
153
  # @param mouse [Boolean] enable mouse event reporting (default: false)
148
154
  # @param alternate_screen [Boolean] use alternate screen buffer (default: true).
149
155
  # Set to +false+ to keep output in terminal scrollback after exit.
156
+ # @param kitty_keyboard [Boolean] ask the terminal to report keys with the
157
+ # kitty keyboard protocol (default: false). On terminals that support it
158
+ # (kitty, Ghostty, iTerm2, Alacritty, foot; WezTerm with its
159
+ # +enable_kitty_keyboard+ setting), Ctrl+Shift+letter,
160
+ # Ctrl or Alt with digits and punctuation, and Super (Cmd) combinations
161
+ # that the terminal passes on are reported, and Ctrl+I, Ctrl+M, Ctrl+H
162
+ # and Ctrl+[ arrive as Ctrl+letter instead of Tab, Enter, Backspace and
163
+ # Escape. The Escape key arrives as a sequence of its own, without the
164
+ # wait that separates a lone ESC byte from an escape sequence. Plain and
165
+ # Shift-ed text, Enter, Tab and Backspace are unchanged, as are all keys
166
+ # on terminals without the protocol (such as macOS Terminal). The mode
167
+ # is turned off again when the block exits or raises.
168
+ # See {AnsiBackend#enable_kitty_keyboard}.
150
169
  # @yield [terminal, reader] the initialized terminal and input reader
151
170
  # @yieldparam terminal [Terminal]
152
171
  # @yieldparam reader [Input::Reader]
@@ -165,9 +184,11 @@ module RubyTUI
165
184
  # RubyTUI.run(alternate_screen: false) do |terminal, reader|
166
185
  # terminal.draw { |frame| frame.buffer.set_string(0, 0, "Stays visible") }
167
186
  # end
168
- def run(output: $stdout, input: $stdin, mouse: false, alternate_screen: true)
187
+ def run(output: $stdout, input: $stdin, mouse: false, alternate_screen: true,
188
+ kitty_keyboard: false)
169
189
  terminal, reader = init(output: output, input: input, mouse: mouse,
170
- alternate_screen: alternate_screen)
190
+ alternate_screen: alternate_screen,
191
+ kitty_keyboard: kitty_keyboard)
171
192
  yield terminal, reader
172
193
  ensure
173
194
  restore(terminal, alternate_screen: alternate_screen) if terminal
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rubytui
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.6
4
+ version: 1.2.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - CoCL
@@ -84,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
84
84
  - !ruby/object:Gem::Version
85
85
  version: '0'
86
86
  requirements: []
87
- rubygems_version: 4.0.19
87
+ rubygems_version: 4.0.23
88
88
  specification_version: 4
89
89
  summary: Zero-dependency terminal user interface library
90
90
  test_files: []