tuile 0.4.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.
@@ -47,17 +47,56 @@ module Tuile
47
47
  latch.wait
48
48
  end
49
49
 
50
+ # Schedules `block` to fire on the event-loop thread roughly `fps` times
51
+ # per second, passing a 0-based monotonically increasing tick counter. Use
52
+ # it for animations (e.g. a `/-\|` spinner in a {Component::Label}) or
53
+ # periodic UI refresh from a background task.
54
+ #
55
+ # The returned {Ticker} controls the schedule — call {Ticker#cancel} to
56
+ # stop it.
57
+ #
58
+ # **Errors:** if `block` raises, the {Ticker} cancels itself and the
59
+ # exception flows through the normal event-loop error path — i.e.
60
+ # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
61
+ # broken block from spamming `on_error` at the tick rate.
62
+ #
63
+ # Tickers reuse `concurrent-ruby`'s shared timer thread
64
+ # ({Concurrent}.global_timer_set) — adding more tickers does not add more
65
+ # threads, just more work on the shared scheduler.
66
+ #
67
+ # @param fps [Numeric] firings per second, must be positive. Fractional
68
+ # values are fine (`fps: 0.5` ⇒ one tick every two seconds).
69
+ # @yield [tick] called on the event-loop thread each firing.
70
+ # @yieldparam tick [Integer] 0-based monotonically increasing counter.
71
+ # @yieldreturn [void]
72
+ # @return [Ticker]
73
+ def tick(fps, &block)
74
+ raise ArgumentError, "block required" unless block
75
+ unless fps.is_a?(Numeric) && fps.positive?
76
+ raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
77
+ end
78
+
79
+ Ticker.new(self, fps, block)
80
+ end
81
+
50
82
  # Runs the event loop and blocks. Must be run from at most one thread at the
51
83
  # same time. Blocks until some thread calls {#stop}. Calls block for all
52
- # events submitted via {#post}; the block is always called from the thread
53
- # running this function.
84
+ # events; the block is always called from the thread running this function.
54
85
  #
55
- # Any exception raised by block is re-thrown, causing this function to
56
- # terminate.
57
- # @yield [event] called for each non-internal event.
86
+ # Any exception raised by the block is re-thrown, causing this function to
87
+ # terminate. Wrap the block body in `rescue` if you want to handle errors
88
+ # without tearing down the loop — see {Screen#event_loop} for an example.
89
+ #
90
+ # **Procs are yielded too.** A {#submit}ed block arrives as a `Proc` event;
91
+ # the consumer is responsible for invoking it (typically `event.call`).
92
+ # Yielding rather than dispatching inline means a raise inside the
93
+ # submitted block flows through the consumer's `rescue` like any other
94
+ # event-handler error, instead of bypassing it.
95
+ # @yield [event] called for each posted event.
58
96
  # @yieldparam event [Object] a posted event — typically a {KeyEvent},
59
- # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, or any object pushed
60
- # via {#post}.
97
+ # {MouseEvent}, {TTYSizeEvent}, {EmptyQueueEvent}, a `Proc` from {#submit},
98
+ # or any object pushed via {#post}. {ErrorEvent}s are not yielded — they
99
+ # terminate the loop directly.
61
100
  # @yieldreturn [void]
62
101
  # @return [void]
63
102
  def run_loop(&)
@@ -152,6 +191,64 @@ module Tuile
152
191
  include Singleton
153
192
  end
154
193
 
194
+ # Handle returned by {EventQueue#tick}. Cancel a running ticker via
195
+ # {#cancel}.
196
+ #
197
+ # Internally wraps a `Concurrent::TimerTask` whose firing posts a single
198
+ # submit-block to the owning {EventQueue}; the user's block therefore
199
+ # always runs on the event-loop thread and may freely mutate UI. If the
200
+ # user block raises, the Ticker auto-cancels and the exception is
201
+ # re-raised so it flows through the loop's normal error handling
202
+ # ({Screen#on_error} for the default Tuile setup).
203
+ class Ticker
204
+ # @param event_queue [EventQueue] queue to dispatch tick calls onto.
205
+ # @param fps [Numeric] firings per second (positive).
206
+ # @param block [Proc] called as `block.call(tick_count)` on each fire.
207
+ def initialize(event_queue, fps, block)
208
+ @event_queue = event_queue
209
+ @block = block
210
+ @tick = 0
211
+ # AtomicBoolean rather than a plain ivar: cancel may run on any
212
+ # thread (caller code, the event-loop thread from inside the block,
213
+ # or the IO executor on an error path), and we want both a CAS-style
214
+ # one-shot guard against double-shutdown and well-defined visibility
215
+ # on non-MRI Rubies.
216
+ @cancelled = Concurrent::AtomicBoolean.new(false)
217
+ @timer = Concurrent::TimerTask.new(execution_interval: 1.0 / fps) do
218
+ @event_queue.submit { fire }
219
+ end
220
+ @timer.execute
221
+ end
222
+
223
+ # @return [Boolean] true once {#cancel} has been called.
224
+ def cancelled? = @cancelled.true?
225
+
226
+ # Stops the ticker. Idempotent and safe to call from any thread,
227
+ # including from inside the tick block. Any tick already queued on the
228
+ # event loop at the moment of cancellation is dropped before the user
229
+ # block runs.
230
+ # @return [void]
231
+ def cancel
232
+ return unless @cancelled.make_true # CAS: only the winner shuts down
233
+
234
+ @timer.shutdown
235
+ end
236
+
237
+ private
238
+
239
+ # Runs on the event-loop thread.
240
+ # @return [void]
241
+ def fire
242
+ return if @cancelled.true?
243
+
244
+ @block.call(@tick)
245
+ @tick += 1
246
+ rescue StandardError
247
+ cancel
248
+ raise
249
+ end
250
+ end
251
+
155
252
  private
156
253
 
157
254
  # @return [void]
@@ -169,8 +266,6 @@ module Tuile
169
266
  # while the loop's own backtrace shows up in the wrapper.
170
267
  raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
171
268
  end
172
- elsif event.is_a? Proc
173
- event.call
174
269
  else
175
270
  yield event
176
271
  end
@@ -4,6 +4,10 @@ module Tuile
4
4
  # A "synchronous" event queue – no loop is run, submitted blocks are run right
5
5
  # away and submitted events are thrown away. Intended for testing only.
6
6
  class FakeEventQueue
7
+ def initialize
8
+ @tickers = []
9
+ end
10
+
7
11
  # @return [Boolean]
8
12
  def locked? = true
9
13
  # @return [void]
@@ -27,5 +31,70 @@ module Tuile
27
31
  # @param event [Object]
28
32
  # @return [void]
29
33
  def post(event); end
34
+
35
+ # Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
36
+ # only fires when a test calls {#tick_once}. The `fps` argument is
37
+ # validated the same way the real queue validates it, then discarded —
38
+ # the fake has no clock, so frame cadence is up to the test.
39
+ #
40
+ # @param fps [Numeric] firings per second, must be positive. Validated
41
+ # for parity with {EventQueue#tick}; otherwise unused.
42
+ # @yield [tick] called on each {#tick_once}.
43
+ # @yieldparam tick [Integer] 0-based monotonically increasing counter.
44
+ # @yieldreturn [void]
45
+ # @return [FakeTicker]
46
+ def tick(fps, &block)
47
+ raise ArgumentError, "block required" unless block
48
+ unless fps.is_a?(Numeric) && fps.positive?
49
+ raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
50
+ end
51
+
52
+ FakeTicker.new(block).tap { |t| @tickers << t }
53
+ end
54
+
55
+ # Test helper: fires every live ticker's user block once and prunes
56
+ # cancelled tickers. No-op when no tickers are registered. Pumps once
57
+ # per call regardless of any ticker's fps — the fake has no clock, so
58
+ # tests pump N frames by calling this N times.
59
+ # @return [void]
60
+ def tick_once
61
+ @tickers.reject!(&:cancelled?)
62
+ @tickers.each(&:fire)
63
+ end
64
+
65
+ # Handle returned by {FakeEventQueue#tick}. Mirrors the public surface of
66
+ # {EventQueue::Ticker} (`cancel`, `cancelled?`) but does not auto-fire —
67
+ # the host {FakeEventQueue} drives firing via {FakeEventQueue#tick_once}.
68
+ class FakeTicker
69
+ # @param block [Proc] called as `block.call(tick_count)` on each {#fire}.
70
+ def initialize(block)
71
+ @block = block
72
+ @tick = 0
73
+ @cancelled = false
74
+ end
75
+
76
+ # @return [Boolean] true once {#cancel} has been called.
77
+ def cancelled? = @cancelled
78
+
79
+ # Marks the ticker cancelled. Idempotent. Subsequent {#fire} calls are
80
+ # no-ops; {FakeEventQueue#tick_once} also prunes the ticker on its next
81
+ # pass.
82
+ # @return [void]
83
+ def cancel
84
+ @cancelled = true
85
+ end
86
+
87
+ # Invokes the user block with the current tick counter, then advances.
88
+ # No-op when {#cancelled?}. Typically driven by
89
+ # {FakeEventQueue#tick_once}; safe to call directly from a test that
90
+ # wants to drive a single ticker.
91
+ # @return [void]
92
+ def fire
93
+ return if @cancelled
94
+
95
+ @block.call(@tick)
96
+ @tick += 1
97
+ end
98
+ end
30
99
  end
31
100
  end
data/lib/tuile/screen.rb CHANGED
@@ -598,6 +598,8 @@ module Tuile
598
598
  layout
599
599
  when EventQueue::EmptyQueueEvent
600
600
  repaint
601
+ when Proc
602
+ event.call
601
603
  end
602
604
  rescue StandardError => e
603
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,9 +459,9 @@ 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)) })
@@ -500,9 +472,9 @@ module Tuile
500
472
  # `underline`). The new fg overlays without dropping background colors or
501
473
  # text attributes the original styling carried.
502
474
  #
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.
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.
506
478
  # @return [StyledString]
507
479
  def with_fg(fg)
508
480
  self.class.new(@spans.map { |span| Span.new(text: span.text, style: span.style.merge(fg: fg)) })
@@ -556,26 +528,21 @@ module Tuile
556
528
  codes << (to.bold ? 1 : 22) if from.bold != to.bold
557
529
  codes << (to.italic ? 3 : 23) if from.italic != to.italic
558
530
  codes << (to.underline ? 4 : 24) if from.underline != to.underline
559
- codes.concat(color_codes(to.fg, base: 30, ext: 38)) if from.fg != to.fg
560
- 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
561
533
  return "" if codes.empty?
562
534
 
563
535
  "\e[#{codes.join(";")}m"
564
536
  end
565
537
 
566
- # @param color [Symbol, Integer, Array<Integer>, nil]
567
- # @param base [Integer] base SGR code — 30 for fg, 40 for bg.
568
- # @param ext [Integer] extended-color SGR code 38 for fg, 48 for bg.
569
- # @return [Array<Integer>]
570
- def color_codes(color, base:, ext:)
571
- case color
572
- when nil then [base + 9]
573
- when Symbol
574
- idx = Style::COLOR_SYMBOLS.index(color)
575
- idx < 8 ? [base + idx] : [base + 60 + (idx - 8)]
576
- when Integer then [ext, 5, color]
577
- when Array then [ext, 2, *color]
578
- 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)
579
546
  end
580
547
 
581
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.4.0"
5
+ VERSION = "0.5.0"
6
6
  end