tuile 0.3.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.
@@ -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
@@ -5,7 +5,7 @@ module Tuile
5
5
  #
6
6
  # @!attribute [r] button
7
7
  # @return [Symbol, nil] one of `:left`, `:middle`, `:right`, `:scroll_up`,
8
- # `:scroll_down`; `nil` if not known.
8
+ # `:scroll_down`, `:scroll_left`, `:scroll_right`; `nil` if not known.
9
9
  # @!attribute [r] x
10
10
  # @return [Integer] x coordinate, 0-based.
11
11
  # @!attribute [r] y
@@ -14,17 +14,34 @@ module Tuile
14
14
  # @return [Point] the event's position.
15
15
  def point = Point.new(x, y)
16
16
 
17
- # Checks whether given key is a mouse event key
17
+ # Checks whether given key is a mouse event key. Returns true on the X10
18
+ # `\e[M` prefix regardless of length — {.parse} is the place that
19
+ # validates the full 6-byte shape and raises on malformed input.
18
20
  # @param key [String] key read via {Keys.getkey}
19
21
  # @return [Boolean] true if it is a mouse event
20
22
  def self.mouse_event?(key)
21
- key.start_with?("\e[M") && key.size >= 6
23
+ key.start_with?("\e[M")
22
24
  end
23
25
 
26
+ # Parses an X10 mouse report (`\e[M` + 3 bytes: button, x, y).
27
+ #
28
+ # Raises {Tuile::Error} when `key` starts with the mouse prefix but is
29
+ # not exactly 6 bytes long. Both shorter and longer inputs are bugs in
30
+ # the upstream key-reader: a shorter prefix means the tail was lost on
31
+ # the way in, and a longer one means we over-consumed into the next
32
+ # escape sequence. We refuse to silently truncate either case because
33
+ # the trailing `\e` of an over-read corrupts the *next* getkey, and the
34
+ # corruption then surfaces as garbled keystrokes in focused inputs
35
+ # rather than as a parser failure pointing at the actual cause.
24
36
  # @param key [String] key read via {Keys.getkey}
25
- # @return [MouseEvent, nil]
37
+ # @return [MouseEvent, nil] `nil` if `key` is not a mouse event
38
+ # @raise [Tuile::Error] if `key` is a malformed mouse event
26
39
  def self.parse(key)
27
40
  return nil unless mouse_event?(key)
41
+ unless key.bytesize == 6
42
+ raise Tuile::Error,
43
+ "malformed mouse event: expected 6 bytes after \\e[M prefix, got #{key.bytesize}: #{key.inspect}"
44
+ end
28
45
 
29
46
  button = key[3].ord - 32
30
47
  # XTerm reports coordinates 1-based (column N is encoded as N + 32);
@@ -37,6 +54,8 @@ module Tuile
37
54
  when 1 then :middle
38
55
  when 64 then :scroll_up
39
56
  when 65 then :scroll_down
57
+ when 66 then :scroll_left
58
+ when 67 then :scroll_right
40
59
  end
41
60
  MouseEvent.new(button, x, y)
42
61
  end
data/lib/tuile/screen.rb CHANGED
@@ -39,8 +39,17 @@ module Tuile
39
39
  # stack and status bar.
40
40
  @pane = ScreenPane.new
41
41
  @on_error = ->(e) { raise e }
42
+ # App-level keyboard shortcuts dispatched by {#handle_key} before keys
43
+ # reach the pane. See {#register_global_shortcut}.
44
+ @global_shortcuts = {}
42
45
  end
43
46
 
47
+ # Entry in the global shortcut registry: the block to run, whether it
48
+ # pre-empts open popups, and an optional preformatted status-bar hint.
49
+ # @api private
50
+ Shortcut = Data.define(:block, :over_popups, :hint)
51
+ private_constant :Shortcut
52
+
44
53
  # @return [ScreenPane] the structural root of the component tree.
45
54
  attr_reader :pane
46
55
 
@@ -148,15 +157,45 @@ module Tuile
148
157
  @pane.on_tree { it.active = active.include?(it) }
149
158
  @focused.on_focus
150
159
  end
151
- # Popups own their own "q Close" prefix in #keyboard_hint; for the tiled
152
- # case Screen tacks on the global "q quit" instead.
160
+ refresh_status_bar
161
+ end
162
+
163
+ # Rebuild the status-bar text from the current focus and global-shortcut
164
+ # registry. Called from {#focused=} and whenever the global registry
165
+ # changes. Popups own their own "q Close" prefix in `#keyboard_hint`;
166
+ # for the tiled case Screen tacks on the global "q quit" instead.
167
+ # Global-shortcut hints get spliced in too — see {#global_shortcut_hints}
168
+ # for the over_popups filter rule.
169
+ # @api private
170
+ # @return [void]
171
+ def refresh_status_bar
153
172
  top_popup = @pane.popups.last
173
+ globals = global_shortcut_hints(popup_open: !top_popup.nil?)
154
174
  @pane.status_bar.text = if top_popup.nil?
155
- "q #{Rainbow("quit").cadetblue} #{active_window&.keyboard_hint}".strip
175
+ ["q #{Rainbow("quit").cadetblue}", *globals,
176
+ active_window&.keyboard_hint].compact.reject(&:empty?).join(" ")
156
177
  else
157
- top_popup.keyboard_hint
178
+ [*globals, top_popup.keyboard_hint].reject(&:empty?).join(" ")
158
179
  end
159
180
  end
181
+ private :refresh_status_bar
182
+
183
+ # Status-bar hints from currently-registered global shortcuts.
184
+ # When a popup is open, only `over_popups: true` shortcuts contribute —
185
+ # the rest don't fire in that context, so showing them would be a lie.
186
+ # Insertion order is preserved (Hash iteration order).
187
+ # @api private
188
+ # @param popup_open [Boolean]
189
+ # @return [Array<String>]
190
+ def global_shortcut_hints(popup_open:)
191
+ @global_shortcuts.each_value.filter_map do |s|
192
+ next if s.hint.nil? || s.hint.empty?
193
+ next if popup_open && !s.over_popups
194
+
195
+ s.hint
196
+ end
197
+ end
198
+ private :global_shortcut_hints
160
199
 
161
200
  # Internal — use {Component::Popup#open} instead. Adds the popup to
162
201
  # {#pane}, centers and focuses it.
@@ -172,16 +211,24 @@ module Tuile
172
211
 
173
212
  # Runs event loop – waits for keys and sends them to active window. The
174
213
  # function exits when the 'ESC' or 'q' key is pressed.
214
+ #
215
+ # @param capture_mouse [Boolean] when true (default), enables xterm mouse
216
+ # tracking so clicks and scroll wheel arrive as {MouseEvent}s and feed
217
+ # {Component#handle_mouse}. When false, no tracking escape sequence is
218
+ # written: the terminal keeps its native click handling, which is what
219
+ # you want if the app benefits more from select-to-copy than from
220
+ # click-to-focus. Components' `handle_mouse` is simply never invoked
221
+ # from the loop in that mode (the terminal stops sending the bytes).
175
222
  # @return [void]
176
- def run_event_loop
223
+ def run_event_loop(capture_mouse: true)
177
224
  @pretend_ui_lock = false
178
225
  $stdin.echo = false
179
- print MouseEvent.start_tracking
226
+ print MouseEvent.start_tracking if capture_mouse
180
227
  $stdin.raw do
181
228
  event_loop
182
229
  end
183
230
  ensure
184
- print MouseEvent.stop_tracking
231
+ print MouseEvent.stop_tracking if capture_mouse
185
232
  print TTY::Cursor.show
186
233
  $stdin.echo = true
187
234
  end
@@ -197,6 +244,80 @@ module Tuile
197
244
  # @return [Boolean] true if focus moved.
198
245
  def focus_previous = cycle_focus(forward: false)
199
246
 
247
+ # Registers an app-level keyboard shortcut. When `key` arrives, the block
248
+ # is invoked on the event-loop thread (so it may freely mutate UI) before
249
+ # the key reaches any component. Re-registering the same key replaces the
250
+ # previous binding; use {#unregister_global_shortcut} to remove one.
251
+ #
252
+ # Only unprintable keys are accepted — control characters (Ctrl+letter,
253
+ # ESC, BACKSPACE, ENTER, …) and multi-character escape sequences (arrows,
254
+ # F-keys, …). Printable keys raise {ArgumentError}: they'd hijack typing
255
+ # into a {Component::TextField} and should be expressed as
256
+ # {Component#key_shortcut} instead, which the dispatcher suppresses while
257
+ # a text widget owns the hardware cursor. TAB and SHIFT_TAB are also
258
+ # rejected because {#handle_key} intercepts them for focus navigation
259
+ # before the global registry is consulted, so a binding on them would
260
+ # silently never fire.
261
+ #
262
+ # Pass `hint:` to surface the shortcut in the status bar. It's a
263
+ # preformatted string the caller fully owns (so colors and the key label
264
+ # style stay consistent with whatever the host app uses elsewhere). The
265
+ # framework splices it in like any other status hint: in the tiled case,
266
+ # right after `q quit` and before the active window's own hint; while a
267
+ # popup is open, only hints from `over_popups: true` shortcuts are
268
+ # shown, and they're prepended before the popup's `q Close`.
269
+ #
270
+ # Example — open a log popup with Ctrl+L from anywhere, even while a
271
+ # popup is already on screen:
272
+ #
273
+ # screen.register_global_shortcut(Keys::CTRL_L,
274
+ # over_popups: true,
275
+ # hint: "^L #{Rainbow("log").cadetblue}") do
276
+ # log_popup.open
277
+ # end
278
+ #
279
+ # @param key [String] unprintable key (e.g. {Keys::CTRL_L}, {Keys::ESC},
280
+ # {Keys::PAGE_UP}).
281
+ # @param over_popups [Boolean] when true, fires even while a modal popup
282
+ # is open (pre-empting the popup's own key handling). When false
283
+ # (default), the shortcut is suppressed while any popup is open and
284
+ # the popup gets the key instead.
285
+ # @param hint [String, nil] preformatted status-bar hint (e.g.
286
+ # `"^L #{Rainbow("log").cadetblue}"`). When nil (default) the shortcut
287
+ # is silent in the status bar.
288
+ # @yield invoked with no arguments when `key` is pressed.
289
+ # @return [void]
290
+ def register_global_shortcut(key, over_popups: false, hint: nil, &block)
291
+ raise ArgumentError, "block required" if block.nil?
292
+ raise ArgumentError, "key must be a String, got #{key.inspect}" unless key.is_a?(String)
293
+ raise ArgumentError, "key cannot be empty" if key.empty?
294
+ if Keys.printable?(key)
295
+ raise ArgumentError,
296
+ "global shortcut key must be unprintable; got #{key.inspect}. " \
297
+ "Use Component#key_shortcut for printable keys (it's suppressed " \
298
+ "while a text widget owns the cursor, so it won't hijack typing)."
299
+ end
300
+ if [Keys::TAB, Keys::SHIFT_TAB].include?(key)
301
+ raise ArgumentError,
302
+ "#{key == Keys::TAB ? "TAB" : "SHIFT_TAB"} is reserved for focus navigation"
303
+ end
304
+ unless hint.nil? || hint.is_a?(String)
305
+ raise ArgumentError, "hint must be a String or nil, got #{hint.inspect}"
306
+ end
307
+
308
+ @global_shortcuts[key] = Shortcut.new(block: block, over_popups: over_popups, hint: hint)
309
+ refresh_status_bar
310
+ end
311
+
312
+ # Removes a shortcut previously installed by {#register_global_shortcut}.
313
+ # No-op if `key` was not registered.
314
+ # @param key [String]
315
+ # @return [void]
316
+ def unregister_global_shortcut(key)
317
+ @global_shortcuts.delete(key)
318
+ refresh_status_bar
319
+ end
320
+
200
321
  # @return [Component, nil] current active tiled component.
201
322
  def active_window
202
323
  check_locked
@@ -289,6 +410,14 @@ module Tuile
289
410
  @frame_buffer = +""
290
411
  begin
291
412
  until @invalidated.empty?
413
+ # Defensive filter: a component can become detached between enqueue
414
+ # and drain (popup close, sibling removed mid-event-handling, focus
415
+ # repair). Detached components have no place on the screen and must
416
+ # never paint, even though Component#invalidate already gates them
417
+ # out — this catches the case where attachment changed since.
418
+ @invalidated.delete_if { |c| !c.attached? }
419
+ break if @invalidated.empty?
420
+
292
421
  did_paint = true
293
422
  popups = @pane.popups
294
423
 
@@ -417,10 +546,17 @@ module Tuile
417
546
  # A key has been pressed on the keyboard. Handle it, or forward to active
418
547
  # window.
419
548
  #
420
- # Tab / Shift+Tab are reserved navigation keys: intercepted here before
421
- # the pane sees them, so a focused {Component::TextField} (which would
422
- # otherwise swallow printable keys via the standard cursor-owner
423
- # suppression) doesn't trap them.
549
+ # Dispatch order:
550
+ # 1. Tab / Shift+Tab reserved focus navigation, intercepted before
551
+ # anything else so a focused {Component::TextField} (which would
552
+ # otherwise swallow printable keys via cursor-owner suppression)
553
+ # doesn't trap them.
554
+ # 2. App-level shortcuts from {#register_global_shortcut}. An entry
555
+ # registered with `over_popups: true` always fires; one with the
556
+ # default `over_popups: false` fires only when no popup is open
557
+ # (otherwise the popup receives the key normally).
558
+ # 3. {ScreenPane#handle_key}, which routes to the topmost popup or
559
+ # tiled content.
424
560
  # @param key [String]
425
561
  # @return [Boolean] true if the key was handled by some window.
426
562
  def handle_key(key)
@@ -432,7 +568,13 @@ module Tuile
432
568
  focus_previous
433
569
  true
434
570
  else
435
- @pane.handle_key(key)
571
+ shortcut = @global_shortcuts[key]
572
+ if !shortcut.nil? && (shortcut.over_popups || @pane.popups.empty?)
573
+ shortcut.block.call
574
+ true
575
+ else
576
+ @pane.handle_key(key)
577
+ end
436
578
  end
437
579
  end
438
580
 
@@ -495,6 +495,19 @@ module Tuile
495
495
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
496
496
  end
497
497
 
498
+ # Returns a new {StyledString} with `fg` applied to every span, preserving
499
+ # each span's text and other style attributes (`bg`, `bold`, `italic`,
500
+ # `underline`). The new fg overlays without dropping background colors or
501
+ # text attributes the original styling carried.
502
+ #
503
+ # @param fg [Symbol, Integer, Array<Integer>, nil] foreground color, in
504
+ # any of the forms accepted by {Style.new}. `nil` clears fg back to
505
+ # the terminal default.
506
+ # @return [StyledString]
507
+ def with_fg(fg)
508
+ self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
509
+ end
510
+
498
511
  # @return [String]
499
512
  def inspect
500
513
  "#<#{self.class.name} #{to_s.inspect}>"
data/lib/tuile/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Tuile
4
4
  # @return [String]
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.0"
6
6
  end