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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/tuile/color.rb +127 -0
- data/lib/tuile/component/label.rb +33 -3
- data/lib/tuile/component/text_view.rb +693 -54
- data/lib/tuile/event_queue.rb +104 -9
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/screen.rb +2 -0
- data/lib/tuile/styled_string.rb +28 -61
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +533 -52
- metadata +2 -1
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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},
|
|
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
data/lib/tuile/styled_string.rb
CHANGED
|
@@ -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
|
-
#
|
|
59
|
-
#
|
|
60
|
-
#
|
|
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 [
|
|
63
|
+
# @return [Color, nil]
|
|
66
64
|
# @!attribute [r] bg
|
|
67
|
-
# @return [
|
|
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
|
-
#
|
|
76
|
-
#
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
|
491
|
-
#
|
|
492
|
-
#
|
|
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
|
|
504
|
-
#
|
|
505
|
-
#
|
|
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,
|
|
560
|
-
codes.concat(color_codes(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 [
|
|
567
|
-
# @param
|
|
568
|
-
# @
|
|
569
|
-
#
|
|
570
|
-
def color_codes(color,
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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