tuile 0.3.0 → 0.5.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.
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
 
@@ -456,6 +598,8 @@ module Tuile
456
598
  layout
457
599
  when EventQueue::EmptyQueueEvent
458
600
  repaint
601
+ when Proc
602
+ event.call
459
603
  end
460
604
  rescue StandardError => e
461
605
  @on_error.call(e)
@@ -53,18 +53,16 @@ module Tuile
53
53
  # Raised by {.parse} on malformed or unsupported escape sequences.
54
54
  class ParseError < Error; end
55
55
 
56
- # A frozen value type describing the visual style of a {Span}.
57
- #
58
- # `fg` and `bg` accept:
59
- # - `nil` the terminal default (SGR 39 / 49)
60
- # - a symbol from {COLOR_SYMBOLS} — 8 standard + 8 bright ANSI colors
61
- # - an Integer 0..255 — 256-color palette index (SGR 38;5;N / 48;5;N)
62
- # - an `[r, g, b]` Array of three 0..255 Integers — 24-bit RGB
56
+ # A frozen value type describing the visual style of a {Span}. Colors are
57
+ # stored as {Color} instances (or `nil` for the terminal default); inputs
58
+ # to {.new} and {#merge} are coerced via {Color.coerce}, so the four
59
+ # accepted color forms — `nil`, Symbol, Integer 0..255, RGB Array work
60
+ # transparently.
63
61
  #
64
62
  # @!attribute [r] fg
65
- # @return [Symbol, Integer, Array<Integer>, nil]
63
+ # @return [Color, nil]
66
64
  # @!attribute [r] bg
67
- # @return [Symbol, Integer, Array<Integer>, nil]
65
+ # @return [Color, nil]
68
66
  # @!attribute [r] bold
69
67
  # @return [Boolean]
70
68
  # @!attribute [r] italic
@@ -72,42 +70,16 @@ module Tuile
72
70
  # @!attribute [r] underline
73
71
  # @return [Boolean]
74
72
  class Style < Data.define(:fg, :bg, :bold, :italic, :underline)
75
- # Symbolic color names recognized by {#fg} and {#bg}. Order is
76
- # significant: indices 0..7 map to standard ANSI colors (SGR 30..37 fg
77
- # / 40..47 bg); indices 8..15 map to bright variants (SGR 90..97 /
78
- # 100..107).
79
- # @return [Array<Symbol>]
80
- COLOR_SYMBOLS = %i[
81
- black red green yellow blue magenta cyan white
82
- bright_black bright_red bright_green bright_yellow
83
- bright_blue bright_magenta bright_cyan bright_white
84
- ].freeze
85
-
86
- # @param fg [Symbol, Integer, Array<Integer>, nil]
87
- # @param bg [Symbol, Integer, Array<Integer>, nil]
73
+ # @param fg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
74
+ # @param bg [Color, Symbol, Integer, Array<Integer>, nil] coerced via {Color.coerce}.
88
75
  # @param bold [Boolean]
89
76
  # @param italic [Boolean]
90
77
  # @param underline [Boolean]
91
78
  # @return [Style]
92
79
  # @raise [ArgumentError] when a color is not one of the accepted forms.
93
80
  def self.new(fg: nil, bg: nil, bold: false, italic: false, underline: false)
94
- validate_color!(fg, :fg)
95
- validate_color!(bg, :bg)
96
- super(fg:, bg:, bold:, italic:, underline:)
97
- end
98
-
99
- # @param color [Object]
100
- # @param which [Symbol]
101
- # @return [void]
102
- def self.validate_color!(color, which)
103
- return if color.nil? || COLOR_SYMBOLS.include?(color)
104
- return if color.is_a?(Integer) && color.between?(0, 255)
105
- return if color.is_a?(Array) && color.length == 3 &&
106
- color.all? { |v| v.is_a?(Integer) && v.between?(0, 255) }
107
-
108
- raise ArgumentError, "invalid #{which} color: #{color.inspect}"
81
+ super(fg: Color.coerce(fg), bg: Color.coerce(bg), bold:, italic:, underline:)
109
82
  end
110
- private_class_method :validate_color!
111
83
 
112
84
  # The style with no color and no attributes — what the terminal shows
113
85
  # without any SGR applied.
@@ -149,11 +121,11 @@ module Tuile
149
121
  # supported SGR alphabet raises {ParseError}.
150
122
  class Parser
151
123
  # @return [Array<Symbol>]
152
- STANDARD_COLORS = Style::COLOR_SYMBOLS[0, 8].freeze
124
+ STANDARD_COLORS = Color::COLOR_SYMBOLS[0, 8].freeze
153
125
  private_constant :STANDARD_COLORS
154
126
 
155
127
  # @return [Array<Symbol>]
156
- BRIGHT_COLORS = Style::COLOR_SYMBOLS[8, 8].freeze
128
+ BRIGHT_COLORS = Color::COLOR_SYMBOLS[8, 8].freeze
157
129
  private_constant :BRIGHT_COLORS
158
130
 
159
131
  # @param input [String]
@@ -487,14 +459,27 @@ module Tuile
487
459
  # `underline`). Useful for row-level highlights — the new bg overlays
488
460
  # without dropping foreground colors the original styling carried.
489
461
  #
490
- # @param bg [Symbol, Integer, Array<Integer>, nil] background color, in
491
- # any of the forms accepted by {Style.new}. `nil` clears bg back to
492
- # the terminal default.
462
+ # @param bg [Color, Symbol, Integer, Array<Integer>, nil] background
463
+ # color, coerced via {Color.coerce}. `nil` clears bg back to the
464
+ # terminal default.
493
465
  # @return [StyledString]
494
466
  def with_bg(bg)
495
467
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(bg: bg)) })
496
468
  end
497
469
 
470
+ # Returns a new {StyledString} with `fg` applied to every span, preserving
471
+ # each span's text and other style attributes (`bg`, `bold`, `italic`,
472
+ # `underline`). The new fg overlays without dropping background colors or
473
+ # text attributes the original styling carried.
474
+ #
475
+ # @param fg [Color, Symbol, Integer, Array<Integer>, nil] foreground
476
+ # color, coerced via {Color.coerce}. `nil` clears fg back to the
477
+ # terminal default.
478
+ # @return [StyledString]
479
+ def with_fg(fg)
480
+ self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
481
+ end
482
+
498
483
  # @return [String]
499
484
  def inspect
500
485
  "#<#{self.class.name} #{to_s.inspect}>"
@@ -543,26 +528,21 @@ module Tuile
543
528
  codes << (to.bold ? 1 : 22) if from.bold != to.bold
544
529
  codes << (to.italic ? 3 : 23) if from.italic != to.italic
545
530
  codes << (to.underline ? 4 : 24) if from.underline != to.underline
546
- codes.concat(color_codes(to.fg, base: 30, ext: 38)) if from.fg != to.fg
547
- codes.concat(color_codes(to.bg, base: 40, ext: 48)) if from.bg != to.bg
531
+ codes.concat(color_codes(to.fg, target: :fg)) if from.fg != to.fg
532
+ codes.concat(color_codes(to.bg, target: :bg)) if from.bg != to.bg
548
533
  return "" if codes.empty?
549
534
 
550
535
  "\e[#{codes.join(";")}m"
551
536
  end
552
537
 
553
- # @param color [Symbol, Integer, Array<Integer>, nil]
554
- # @param base [Integer] base SGR code — 30 for fg, 40 for bg.
555
- # @param ext [Integer] extended-color SGR code 38 for fg, 48 for bg.
556
- # @return [Array<Integer>]
557
- def color_codes(color, base:, ext:)
558
- case color
559
- when nil then [base + 9]
560
- when Symbol
561
- idx = Style::COLOR_SYMBOLS.index(color)
562
- idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
563
- when Integer then [ext, 5, color]
564
- when Array then [ext, 2, *color]
565
- end
538
+ # @param color [Color, nil]
539
+ # @param target [Symbol] `:fg` or `:bg`.
540
+ # @return [Array<Integer>] SGR codes; `[39]` / `[49]` for the "default" reset
541
+ # when `color` is `nil`, otherwise delegated to {Color#sgr_codes}.
542
+ def color_codes(color, target:)
543
+ return [target == :fg ? 39 : 49] if color.nil?
544
+
545
+ color.sgr_codes(target)
566
546
  end
567
547
 
568
548
  # @param start_or_range [Integer, Range]
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.5.0"
6
6
  end