tuile 0.2.0 → 0.4.0

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.
@@ -0,0 +1,456 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A read-only viewer for prose: chunks of formatted text that scroll
6
+ # vertically. Shape-wise a hybrid between {Label} (string-shaped content
7
+ # via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
8
+ #
9
+ # Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
10
+ # lines wider than the viewport are word-wrapped via {StyledString#wrap}
11
+ # (style spans are preserved across wrap boundaries — unlike the older
12
+ # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
13
+ # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
14
+ # so embedded ANSI is honored) or a {StyledString} directly; {#text}
15
+ # always returns the {StyledString}.
16
+ #
17
+ # For incremental updates pick the right primitive: {#append} (aliased
18
+ # as `<<`) is verbatim and stream-friendly — chunks are concatenated
19
+ # straight onto the buffer, with embedded `\n` becoming hard breaks.
20
+ # {#add_line} is the "log entry" convenience — it starts the content on
21
+ # a fresh line by inserting a leading `\n` when the buffer is non-empty.
22
+ # {#remove_last_n_lines} pops hard lines back off the tail — the
23
+ # inverse of building up a region with {#append} / {#add_line}, so a
24
+ # caller streaming reformattable content (e.g. partially-rendered
25
+ # Markdown that may need to retract its last paragraph) can replace
26
+ # the tail without rewriting the whole text. Turn on {#auto_scroll}
27
+ # to keep the latest content in view.
28
+ #
29
+ # TextView is meant to be the content of a {Window} — focus indication and
30
+ # keyboard-hint surfacing rely on the surrounding window chrome.
31
+ class TextView < Component
32
+ def initialize
33
+ super
34
+ # `@hard_lines` is the logical model — one entry per `\n`-delimited
35
+ # line of the original text, width-independent. `@physical_lines` is
36
+ # the rendered view — each hard line word-wrapped to `wrap_width`
37
+ # and padded with trailing blanks, so painting a row is a lookup.
38
+ # Resizing rebuilds `@physical_lines` from `@hard_lines`; `#append`
39
+ # extends both.
40
+ @hard_lines = []
41
+ @physical_lines = []
42
+ @text = StyledString::EMPTY
43
+ @content_size = Size::ZERO
44
+ @blank_line = StyledString::EMPTY
45
+ @top_line = 0
46
+ @auto_scroll = false
47
+ @scrollbar_visibility = :gone
48
+ end
49
+
50
+ # @return [StyledString] the current text. Defaults to an empty
51
+ # {StyledString}. Internally the text is stored as an array of hard
52
+ # lines so {#append} can stay O(appended) instead of re-scanning the
53
+ # whole buffer; the joined {StyledString} returned here is
54
+ # reconstructed on first read after a mutation and cached, so
55
+ # repeated reads are O(1) but the first read after {#append} pays
56
+ # O(total spans).
57
+ def text
58
+ @text ||= build_text
59
+ end
60
+
61
+ # @return [Integer] index of the first visible physical line.
62
+ attr_reader :top_line
63
+
64
+ # @return [Symbol] `:gone` or `:visible`.
65
+ attr_reader :scrollbar_visibility
66
+
67
+ # @return [Boolean] if true, mutating the text scrolls the viewport so
68
+ # the last line stays in view. Default `false`.
69
+ attr_reader :auto_scroll
70
+
71
+ # Replaces the text. Embedded `\n` characters become hard line breaks.
72
+ # A `String` is parsed via {StyledString.parse} (so embedded ANSI is
73
+ # honored); a `StyledString` is used as-is; `nil` is coerced to an
74
+ # empty {StyledString}.
75
+ # @param value [String, StyledString, nil]
76
+ # @return [void]
77
+ def text=(value)
78
+ new_text = StyledString.parse(value)
79
+ return if text == new_text
80
+
81
+ @text = new_text
82
+ @hard_lines = new_text.empty? ? [] : new_text.lines
83
+ @content_size = compute_content_size
84
+ rewrap
85
+ update_top_line_if_auto_scroll
86
+ invalidate
87
+ end
88
+
89
+ # @return [Boolean] true iff {#text} is empty (no hard lines).
90
+ def empty? = @hard_lines.empty?
91
+
92
+ # Appends `str` verbatim. Embedded `\n` characters become hard line
93
+ # breaks; otherwise the text is concatenated onto the current last
94
+ # hard line. Designed for streaming use (e.g. an LLM chat window
95
+ # receiving partial messages — feed each chunk straight in). Accepts
96
+ # the same input forms as {#text=}; empty/`nil` input is a no-op.
97
+ #
98
+ # For the "add an entry on a new line" pattern use {#add_line}.
99
+ #
100
+ # Cost is O(appended + width-of-current-last-hard-line) — the
101
+ # previously last hard line is re-wrapped (because the extension may
102
+ # cause it to wrap differently), any additional hard lines created by
103
+ # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
104
+ # and rebuilt on demand.
105
+ # @param str [String, StyledString, nil]
106
+ # @return [void]
107
+ def append(str)
108
+ screen.check_locked
109
+ appended = StyledString.parse(str)
110
+ return if appended.empty?
111
+
112
+ new_segments = appended.lines
113
+ width = wrap_width
114
+
115
+ if empty?
116
+ new_segments.each do |hl|
117
+ @hard_lines << hl
118
+ append_physical_lines(hl, width)
119
+ end
120
+ else
121
+ extension = new_segments.first
122
+ unless extension.empty?
123
+ old_last = @hard_lines.pop
124
+ drop_physical_rows_for(old_last, width)
125
+ extended = old_last + extension
126
+ @hard_lines << extended
127
+ append_physical_lines(extended, width)
128
+ end
129
+ new_segments[1..].each do |hl|
130
+ @hard_lines << hl
131
+ append_physical_lines(hl, width)
132
+ end
133
+ end
134
+
135
+ @text = nil
136
+ @content_size = compute_content_size
137
+ update_top_line_if_auto_scroll
138
+ invalidate
139
+ end
140
+
141
+ # Verbatim append, returning `self` for chainability (`view << a << b`).
142
+ # @param str [String, StyledString, nil]
143
+ # @return [self]
144
+ def <<(str)
145
+ append(str)
146
+ self
147
+ end
148
+
149
+ # Appends `str` as a new entry: starts a fresh hard line first (when
150
+ # the buffer is non-empty) and then appends `str`. Equivalent to
151
+ # `append("\n" + str)` on a non-empty buffer, or `append(str)` on an
152
+ # empty one. `nil` and `""` produce a blank entry on a non-empty
153
+ # buffer and a no-op on an empty buffer (matches the old `append`
154
+ # semantics for "log line" callers).
155
+ # @param str [String, StyledString, nil]
156
+ # @return [void]
157
+ def add_line(str)
158
+ parsed = StyledString.parse(str)
159
+ if empty?
160
+ append(parsed)
161
+ else
162
+ append(StyledString.plain("\n") + parsed)
163
+ end
164
+ end
165
+
166
+ # Drops the last `n` hard lines from the buffer. The inverse of
167
+ # building up a tail region with {#append} / {#add_line}: a caller
168
+ # streaming partially-rendered content whose tail must occasionally
169
+ # be retracted (e.g. Markdown-to-ANSI where a new token reformats
170
+ # the table being built) can call `remove_last_n_lines(k)` followed
171
+ # by `append(new_tail)` to replace the damaged region in place.
172
+ #
173
+ # `n == 0` and the empty-buffer case are no-ops (no invalidation).
174
+ # `n >= hard-line count` empties the buffer.
175
+ #
176
+ # Operates on **hard lines** (the `\n`-delimited entries the
177
+ # buffer stores), not on wrapped physical rows — same granularity
178
+ # as {#add_line}. Cost is O(rendered-rows of the popped lines).
179
+ # @param n [Integer] number of hard lines to drop; must be >= 0.
180
+ # @raise [TypeError] if `n` isn't an `Integer`.
181
+ # @raise [ArgumentError] if `n` is negative.
182
+ # @return [void]
183
+ def remove_last_n_lines(n)
184
+ raise TypeError, "expected Integer, got #{n.inspect}" unless n.is_a?(Integer)
185
+ raise ArgumentError, "n must not be negative, got #{n}" if n.negative?
186
+
187
+ screen.check_locked
188
+ return if n.zero? || empty?
189
+
190
+ width = wrap_width
191
+ to_drop = [n, @hard_lines.size].min
192
+ to_drop.times do
193
+ popped = @hard_lines.pop
194
+ drop_physical_rows_for(popped, width)
195
+ end
196
+
197
+ @text = nil
198
+ @content_size = compute_content_size
199
+ @top_line = top_line_max if @top_line > top_line_max
200
+ update_top_line_if_auto_scroll
201
+ invalidate
202
+ end
203
+
204
+ # Clears the text. Equivalent to `text = ""`.
205
+ # @return [void]
206
+ def clear
207
+ self.text = StyledString::EMPTY
208
+ end
209
+
210
+ # @param new_top_line [Integer] 0 or greater. Not clamped against the
211
+ # number of lines (matches {List#top_line=}).
212
+ # @return [void]
213
+ def top_line=(new_top_line)
214
+ raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
215
+ raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
216
+ return if @top_line == new_top_line
217
+
218
+ @top_line = new_top_line
219
+ invalidate
220
+ end
221
+
222
+ # @param value [Symbol] `:gone` or `:visible`.
223
+ # @return [void]
224
+ def scrollbar_visibility=(value)
225
+ raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
226
+ return if @scrollbar_visibility == value
227
+
228
+ @scrollbar_visibility = value
229
+ rewrap
230
+ invalidate
231
+ end
232
+
233
+ # Sets `auto_scroll`. If true, immediately scrolls to the bottom.
234
+ # @param value [Boolean]
235
+ # @return [void]
236
+ def auto_scroll=(value)
237
+ @auto_scroll = value ? true : false
238
+ update_top_line_if_auto_scroll
239
+ end
240
+
241
+ def focusable? = true
242
+
243
+ def tab_stop? = true
244
+
245
+ # @return [Size] longest hard-line's display width × number of hard
246
+ # lines. Reported on the *unwrapped* text — wrap-aware sizing would
247
+ # be circular (width depends on width). Empty text returns
248
+ # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
249
+ # {#append}, so reads are O(1).
250
+ attr_reader :content_size
251
+
252
+ # @param key [String]
253
+ # @return [Boolean]
254
+ def handle_key(key)
255
+ return false unless active?
256
+ return true if super
257
+
258
+ case key
259
+ when *Keys::DOWN_ARROWS then move_top_line_by(1)
260
+ when *Keys::UP_ARROWS then move_top_line_by(-1)
261
+ when Keys::PAGE_DOWN then move_top_line_by(viewport_lines)
262
+ when Keys::PAGE_UP then move_top_line_by(-viewport_lines)
263
+ when Keys::CTRL_D then move_top_line_by(viewport_lines / 2)
264
+ when Keys::CTRL_U then move_top_line_by(-viewport_lines / 2)
265
+ when *Keys::HOMES, "g" then move_top_line_to(0)
266
+ when *Keys::ENDS_, "G" then move_top_line_to(top_line_max)
267
+ else return false
268
+ end
269
+ true
270
+ end
271
+
272
+ # @param event [MouseEvent]
273
+ # @return [void]
274
+ def handle_mouse(event)
275
+ super
276
+ case event.button
277
+ when :scroll_down then move_top_line_by(4)
278
+ when :scroll_up then move_top_line_by(-4)
279
+ end
280
+ end
281
+
282
+ # Paints the text into {#rect}.
283
+ #
284
+ # Skips the {Component#repaint} default's auto-clear: every row is
285
+ # painted explicitly (with padded blanks past the last line), so the
286
+ # "fully draw over your rect" contract is met without an upfront wipe.
287
+ # @return [void]
288
+ def repaint
289
+ return if rect.empty?
290
+
291
+ scrollbar = if scrollbar_visible?
292
+ VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
293
+ end
294
+ (0...rect.height).each do |row|
295
+ line = paintable_line(row + @top_line, row, scrollbar)
296
+ screen.print TTY::Cursor.move_to(rect.left, rect.top + row), line
297
+ end
298
+ end
299
+
300
+ protected
301
+
302
+ # Rewraps the text on width changes. Wrap width depends on
303
+ # {#rect}`.width` and the scrollbar gutter, both of which trigger
304
+ # this hook.
305
+ # @return [void]
306
+ def on_width_changed
307
+ super
308
+ rewrap
309
+ end
310
+
311
+ private
312
+
313
+ # @return [Integer] number of visible lines.
314
+ def viewport_lines = rect.height
315
+
316
+ # @return [Integer] the max value of {#top_line} for scroll-key clamping.
317
+ def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
318
+
319
+ # Recomputes {@physical_lines} for the current text and wrap width,
320
+ # pre-padding every line to `wrap_width` so {#paintable_line} is just
321
+ # a lookup + optional scrollbar-char append at paint time (and the
322
+ # rendered ANSI is cached on each line via {StyledString#to_ansi}'s
323
+ # memoization, so re-painting on scroll is near-free). Clamps
324
+ # {@top_line} if the new line count puts it out of range.
325
+ # @return [void]
326
+ def rewrap
327
+ width = wrap_width
328
+ @blank_line = pad_to(StyledString::EMPTY, width)
329
+ @physical_lines = []
330
+ @hard_lines.each { |hl| append_physical_lines(hl, width) }
331
+ @top_line = top_line_max if @top_line > top_line_max
332
+ end
333
+
334
+ # Wraps `hard_line` at `width` and appends the padded physical lines
335
+ # to {@physical_lines}. Empty hard lines (e.g. from a `"\n\n"` run)
336
+ # and degenerate `width <= 0` both emit a single {@blank_line} row,
337
+ # matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
338
+ # would have produced for those cases.
339
+ # @param hard_line [StyledString] one hard-broken line (no embedded `"\n"`).
340
+ # @param width [Integer]
341
+ # @return [void]
342
+ def append_physical_lines(hard_line, width)
343
+ if hard_line.empty? || width <= 0
344
+ @physical_lines << @blank_line
345
+ else
346
+ hard_line.wrap(width).each { |line| @physical_lines << pad_to(line, width) }
347
+ end
348
+ end
349
+
350
+ # Pops from {@physical_lines} the rows that `hard_line` previously
351
+ # contributed (the inverse of {#append_physical_lines} for the same
352
+ # input). Used by {#append} when extending the last hard line: its
353
+ # old wrapped rows are dropped, then the extended hard line is
354
+ # re-wrapped and appended.
355
+ # @param hard_line [StyledString]
356
+ # @param width [Integer]
357
+ # @return [void]
358
+ def drop_physical_rows_for(hard_line, width)
359
+ count = hard_line.empty? || width <= 0 ? 1 : hard_line.wrap(width).size
360
+ count.times { @physical_lines.pop }
361
+ end
362
+
363
+ # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
364
+ # default-styled `"\n"` between hard lines. Called from the {#text}
365
+ # reader when the cache is cold. Cost is O(total spans).
366
+ # @return [StyledString]
367
+ def build_text
368
+ return StyledString::EMPTY if @hard_lines.empty?
369
+ return @hard_lines.first if @hard_lines.size == 1
370
+
371
+ newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
372
+ spans = []
373
+ @hard_lines.each_with_index do |hl, i|
374
+ spans << newline if i.positive?
375
+ spans.concat(hl.spans)
376
+ end
377
+ StyledString.new(spans)
378
+ end
379
+
380
+ # @return [Size] {#content_size} computed from {@hard_lines}.
381
+ def compute_content_size
382
+ return Size::ZERO if @hard_lines.empty?
383
+
384
+ Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
385
+ end
386
+
387
+ # @return [Integer] column width available for wrapped text — viewport
388
+ # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
389
+ # width is non-positive, which yields a degenerate "no wrap" result.
390
+ def wrap_width
391
+ return 0 if rect.width <= 0
392
+
393
+ rect.width - (scrollbar_visible? ? 1 : 0)
394
+ end
395
+
396
+ # @param delta [Integer] negative scrolls up, positive scrolls down.
397
+ # @return [void]
398
+ def move_top_line_by(delta)
399
+ move_top_line_to(@top_line + delta)
400
+ end
401
+
402
+ # @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
403
+ # @return [void]
404
+ def move_top_line_to(target)
405
+ clamped = target.clamp(0, top_line_max)
406
+ self.top_line = clamped unless @top_line == clamped
407
+ end
408
+
409
+ # @return [void]
410
+ def update_top_line_if_auto_scroll
411
+ return unless @auto_scroll
412
+
413
+ target = (@physical_lines.size - viewport_lines).clamp(0, nil)
414
+ self.top_line = target if @top_line != target
415
+ end
416
+
417
+ # @return [Boolean]
418
+ def scrollbar_visible?
419
+ return false if rect.empty?
420
+
421
+ @scrollbar_visibility == :visible
422
+ end
423
+
424
+ # Pads `line` with trailing default-styled spaces out to `width` display
425
+ # columns. Callers rely on {StyledString#wrap} having already
426
+ # constrained the line to `<= width`, so no truncation is performed.
427
+ # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
428
+ # `wrap_width == 0` case (rect.width == 1 with scrollbar).
429
+ # @param line [StyledString]
430
+ # @param width [Integer]
431
+ # @return [StyledString]
432
+ def pad_to(line, width)
433
+ return StyledString::EMPTY if width <= 0
434
+
435
+ diff = width - line.display_width
436
+ return line if diff <= 0
437
+
438
+ line + StyledString.plain(" " * diff)
439
+ end
440
+
441
+ # @param index [Integer] 0-based index into `@physical_lines`.
442
+ # @param row_in_viewport [Integer] 0-based row within the viewport.
443
+ # @param scrollbar [VerticalScrollBar, nil]
444
+ # @return [String] paintable ANSI-encoded line exactly `rect.width`
445
+ # columns wide. Body lines come pre-padded from {#rewrap}, so this
446
+ # reduces to a memoized {StyledString#to_ansi} read plus an
447
+ # ASCII-string concat of the scrollbar glyph when one is present.
448
+ def paintable_line(index, row_in_viewport, scrollbar)
449
+ line = @physical_lines[index] || @blank_line
450
+ return line.to_ansi unless scrollbar
451
+
452
+ line.to_ansi + scrollbar.scrollbar_char(row_in_viewport)
453
+ end
454
+ end
455
+ end
456
+ end
@@ -8,8 +8,9 @@ module Tuile
8
8
  #
9
9
  # The window's `content` is unset by default; assign one via {#content=}.
10
10
  #
11
- # Window is considered invisible if {#rect} is empty or one of left/top is
12
- # negative. The window won't draw when invisible.
11
+ # Window is considered invisible if {#rect} is empty. The window won't
12
+ # draw when invisible. (Repaint of detached windows is short-circuited
13
+ # by {Component#invalidate}; subclasses don't need to re-check.)
13
14
  class Window < Component
14
15
  include Component::HasContent
15
16
 
@@ -90,9 +91,9 @@ module Tuile
90
91
  # @param value [Boolean]
91
92
  # @return [void]
92
93
  def scrollbar=(value)
93
- unless content.is_a?(Component::List)
94
+ unless content.respond_to?(:scrollbar_visibility=)
94
95
  raise Tuile::Error,
95
- "scrollbar= requires a Component::List as content, got #{content.inspect}"
96
+ "scrollbar= requires a content component that supports scrollbar_visibility=, got #{content.inspect}"
96
97
  end
97
98
 
98
99
  content.scrollbar_visibility = value ? :visible : :gone
@@ -125,12 +126,6 @@ module Tuile
125
126
  Size.new(inner_w + 2, inner_h + 2)
126
127
  end
127
128
 
128
- # @return [Boolean] true if {#rect} is off screen and the window won't
129
- # paint.
130
- def visible?
131
- !@rect.empty? && !@rect.top.negative? && !@rect.left.negative?
132
- end
133
-
134
129
  # Fully repaints the window: both frame and contents.
135
130
  #
136
131
  # Window deliberately paints over its entire rect (border around the
@@ -143,7 +138,7 @@ module Tuile
143
138
  # cycle.
144
139
  # @return [void]
145
140
  def repaint
146
- return unless visible?
141
+ return if rect.empty?
147
142
 
148
143
  super
149
144
  repaint_border
@@ -168,7 +163,7 @@ module Tuile
168
163
  # Paints the window border.
169
164
  # @return [void]
170
165
  def repaint_border
171
- return unless visible?
166
+ return if rect.empty?
172
167
 
173
168
  frame = build_frame(frame_caption)
174
169
  frame = Rainbow(frame).green if active?
@@ -4,8 +4,10 @@ module Tuile
4
4
  # A UI component which is positioned on the screen and draws characters into
5
5
  # its bounding rectangle (in {#repaint}).
6
6
  #
7
- # Component is considered invisible if {#rect} is empty or one of left/top is
8
- # negative. The component won't draw when invisible.
7
+ # Painting is gated by attachment: a detached component (one whose {#root}
8
+ # isn't {Screen#pane}) is never enqueued for repaint via {#invalidate}, and
9
+ # any stale invalidation entries are filtered out at drain time. Subclasses
10
+ # can paint freely in {#repaint} without re-asserting attachment.
9
11
  class Component
10
12
  def initialize
11
13
  @rect = Rect.new(0, 0, 0, 0)
@@ -66,9 +68,11 @@ module Tuile
66
68
  # responsibility for {#rect}. Everything else should call super.
67
69
  #
68
70
  # A component must not draw outside of {#rect}.
71
+ #
72
+ # Only called when the component is attached.
69
73
  # @return [void]
70
74
  def repaint
71
- return if rect.empty? || rect.left.negative? || rect.top.negative?
75
+ return if rect.empty?
72
76
  return if children.any? && children_tile_rect?
73
77
 
74
78
  clear_background
@@ -251,8 +255,16 @@ module Tuile
251
255
 
252
256
  # Invalidates the component: {Screen} records this component as
253
257
  # needs-repaint and once all events are processed, will call {#repaint}.
258
+ #
259
+ # No-op when the component is not {#attached?} — a detached component has
260
+ # no place on the screen to paint to, so {Screen} must never end up
261
+ # repainting it. Callers don't need to guard their own `invalidate` calls;
262
+ # mutating a detached component (e.g. setting `lines=` on a {List} sitting
263
+ # inside a closed {Component::Popup}) is silent.
254
264
  # @return [void]
255
265
  def invalidate
266
+ return unless attached?
267
+
256
268
  screen.invalidate(self)
257
269
  end
258
270
 
data/lib/tuile/keys.rb CHANGED
@@ -42,19 +42,71 @@ module Tuile
42
42
  # @return [String]
43
43
  PAGE_DOWN = "\e[6~"
44
44
  # @return [String]
45
- BACKSPACE = ""
45
+ BACKSPACE = "\x7f"
46
46
  # @return [String]
47
47
  DELETE = "\e[3~"
48
+
49
+ # Ctrl+letter sends bytes 0x01..0x1a. Note that {CTRL_H} == `"\b"`,
50
+ # {CTRL_I} == {TAB}, {CTRL_J} == `"\n"`, and {CTRL_M} == {ENTER} —
51
+ # terminals deliver these key combinations indistinguishably from the
52
+ # corresponding named keys.
53
+ # @return [String]
54
+ CTRL_A = "\x01"
55
+ # @return [String]
56
+ CTRL_B = "\x02"
57
+ # @return [String]
58
+ CTRL_C = "\x03"
59
+ # @return [String]
60
+ CTRL_D = "\x04"
61
+ # @return [String]
62
+ CTRL_E = "\x05"
63
+ # @return [String]
64
+ CTRL_F = "\x06"
65
+ # @return [String]
66
+ CTRL_G = "\x07"
48
67
  # @return [String]
49
68
  CTRL_H = "\b"
50
- # @return [Array<String>]
51
- BACKSPACES = [BACKSPACE, CTRL_H].freeze
52
69
  # @return [String]
53
- CTRL_U = ""
70
+ CTRL_I = "\t"
71
+ # @return [String]
72
+ CTRL_J = "\n"
73
+ # @return [String]
74
+ CTRL_K = "\x0b"
75
+ # @return [String]
76
+ CTRL_L = "\x0c"
77
+ # @return [String]
78
+ CTRL_M = "\r"
79
+ # @return [String]
80
+ CTRL_N = "\x0e"
81
+ # @return [String]
82
+ CTRL_O = "\x0f"
83
+ # @return [String]
84
+ CTRL_P = "\x10"
54
85
  # @return [String]
55
- CTRL_D = ""
86
+ CTRL_Q = "\x11"
56
87
  # @return [String]
57
- ENTER = "
88
+ CTRL_R = "\x12"
89
+ # @return [String]
90
+ CTRL_S = "\x13"
91
+ # @return [String]
92
+ CTRL_T = "\x14"
93
+ # @return [String]
94
+ CTRL_U = "\x15"
95
+ # @return [String]
96
+ CTRL_V = "\x16"
97
+ # @return [String]
98
+ CTRL_W = "\x17"
99
+ # @return [String]
100
+ CTRL_X = "\x18"
101
+ # @return [String]
102
+ CTRL_Y = "\x19"
103
+ # @return [String]
104
+ CTRL_Z = "\x1a"
105
+
106
+ # @return [Array<String>]
107
+ BACKSPACES = [BACKSPACE, CTRL_H].freeze
108
+ # @return [String]
109
+ ENTER = "\r"
58
110
  # @return [String]
59
111
  TAB = "\t"
60
112
  # The terminal sequence emitted by Shift+Tab in xterm-style terminals
@@ -62,6 +114,22 @@ module Tuile
62
114
  # @return [String]
63
115
  SHIFT_TAB = "\e[Z"
64
116
 
117
+ # True iff `key` is a single printable character — a one-character string
118
+ # whose codepoint is not in Unicode's C (Other) category. Rejects multi-
119
+ # character escape sequences ({UP_ARROW}, mouse events, …), control bytes
120
+ # ({TAB}, {ENTER}, {ESC}, {CTRL_A}..{CTRL_Z}, {BACKSPACE}), and the empty
121
+ # string; accepts ASCII letters/digits/punctuation/space *and* non-ASCII
122
+ # printables like "é".
123
+ #
124
+ # Used by {Screen#register_global_shortcut} to reject keys that would
125
+ # collide with typing, and by {Tuile::Component::TextField} to decide
126
+ # whether to insert a key at the caret.
127
+ # @param key [String]
128
+ # @return [Boolean]
129
+ def self.printable?(key)
130
+ key.length == 1 && !key.match?(/\p{C}/)
131
+ end
132
+
65
133
  # Grabs a key from stdin and returns it. Blocks until the key is obtained.
66
134
  # Reads a full ESC key sequence; see constants above for some values returned
67
135
  # by this function.
@@ -72,11 +140,26 @@ module Tuile
72
140
 
73
141
  # Escape sequence. Try to read more data.
74
142
  begin
75
- # Read 6 chars: mouse events are e.g. `\e[Mxyz`
76
- char += $stdin.read_nonblock(6)
143
+ # Read up to 5 bytes: that's the maximum tail length of any escape
144
+ # sequence Tuile recognizes after the initial \e (X10 mouse `[Mbxy`,
145
+ # CTRL+arrow `[1;5D`, etc.). Reading 6 here would over-read into the
146
+ # next sequence on tight mouse-event bursts — we'd silently steal
147
+ # the next event's leading \e and the rest of it would surface as
148
+ # individual printable keypresses in focused inputs.
149
+ char += $stdin.read_nonblock(5)
77
150
  rescue IO::EAGAINWaitReadable
78
151
  # The "ESC" key pressed => only the \e char is emitted.
152
+ return char
153
+ end
154
+
155
+ # If `read_nonblock` returned a partial X10 mouse-report prefix (the
156
+ # sequence is fixed-length: 3 bytes after `\e[M`), drain the remainder
157
+ # with a blocking read so the parser downstream sees a complete event
158
+ # instead of leaking tail bytes as keypresses.
159
+ if char.start_with?("\e[M") && char.bytesize < 6
160
+ char += $stdin.read(6 - char.bytesize)
79
161
  end
162
+
80
163
  char
81
164
  end
82
165
  end