tuile 0.4.0 → 0.6.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 +28 -0
- data/README.md +150 -4
- data/examples/file_commander.rb +4 -3
- data/examples/sampler.rb +1 -0
- data/lib/tuile/ansi.rb +4 -3
- data/lib/tuile/color.rb +249 -0
- data/lib/tuile/component/button.rb +9 -5
- data/lib/tuile/component/label.rb +44 -16
- data/lib/tuile/component/list.rb +29 -19
- data/lib/tuile/component/picker_window.rb +2 -2
- data/lib/tuile/component/popup.rb +11 -1
- data/lib/tuile/component/text_area.rb +1 -2
- data/lib/tuile/component/text_field.rb +1 -2
- data/lib/tuile/component/text_input.rb +10 -15
- data/lib/tuile/component/text_view.rb +696 -58
- data/lib/tuile/component/window.rb +70 -16
- data/lib/tuile/component.rb +74 -5
- data/lib/tuile/event_queue.rb +130 -11
- data/lib/tuile/fake_event_queue.rb +69 -0
- data/lib/tuile/fake_screen.rb +8 -0
- data/lib/tuile/keys.rb +10 -0
- data/lib/tuile/screen.rb +98 -4
- data/lib/tuile/sizing.rb +59 -0
- data/lib/tuile/styled_string.rb +28 -61
- data/lib/tuile/terminal_background.rb +137 -0
- data/lib/tuile/theme.rb +202 -0
- data/lib/tuile/theme_def.rb +85 -0
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +0 -1
- data/sig/tuile.rbs +1160 -93
- metadata +6 -15
|
@@ -19,9 +19,12 @@ module Tuile
|
|
|
19
19
|
super()
|
|
20
20
|
@border_right = 1
|
|
21
21
|
@caption = caption
|
|
22
|
+
@content = nil
|
|
22
23
|
# Optional bottom-row chrome that overlays the bottom border (e.g. a
|
|
23
24
|
# search field).
|
|
24
25
|
@footer = nil
|
|
26
|
+
@footer_sizing = Sizing::FILL
|
|
27
|
+
update_content_size
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
def focusable? = true
|
|
@@ -30,8 +33,25 @@ module Tuile
|
|
|
30
33
|
# row.
|
|
31
34
|
attr_reader :footer
|
|
32
35
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
36
|
+
# @return [Sizing] how the footer's width is computed from the window's
|
|
37
|
+
# inner width; defaults to {Sizing::FILL} (the footer spans the full
|
|
38
|
+
# inner width). The footer's height is always 1 (the border row).
|
|
39
|
+
attr_reader :footer_sizing
|
|
40
|
+
|
|
41
|
+
# Sets the footer width policy and re-lays-out the footer.
|
|
42
|
+
# @param sizing [Sizing]
|
|
43
|
+
def footer_sizing=(sizing)
|
|
44
|
+
raise TypeError, "expected Sizing, got #{sizing.inspect}" unless sizing.is_a?(Sizing)
|
|
45
|
+
return if @footer_sizing == sizing
|
|
46
|
+
|
|
47
|
+
@footer_sizing = sizing
|
|
48
|
+
layout_footer
|
|
49
|
+
invalidate # repaint border cells the footer may have just vacated
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Sets the bottom-row chrome slot. The footer overlays the bottom border
|
|
53
|
+
# row and is positioned automatically — its width is governed by
|
|
54
|
+
# {#footer_sizing}; pass `nil` to remove.
|
|
35
55
|
#
|
|
36
56
|
# Symmetric to {#content=}: validates the new component, swaps parent
|
|
37
57
|
# pointers, invalidates the old/new components and the window border, and
|
|
@@ -110,20 +130,36 @@ module Tuile
|
|
|
110
130
|
def caption=(new_caption)
|
|
111
131
|
@caption = new_caption
|
|
112
132
|
invalidate
|
|
133
|
+
update_content_size
|
|
113
134
|
end
|
|
114
135
|
|
|
115
|
-
#
|
|
116
|
-
#
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
136
|
+
# Sets the new content. Also recomputes the window's natural size.
|
|
137
|
+
# @param new_content [Component, nil]
|
|
138
|
+
def content=(new_content)
|
|
139
|
+
super
|
|
140
|
+
update_content_size
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
|
|
144
|
+
# size changes, and folds a content resize into the window's own
|
|
145
|
+
# natural size (whose change then bubbles to the window's parent — e.g.
|
|
146
|
+
# a {Popup} re-self-sizes). The footer deliberately does *not*
|
|
147
|
+
# participate in the window's {#content_size}: it is decoration
|
|
148
|
+
# overlaying the border, and must not drive the window's size — if it
|
|
149
|
+
# doesn't fit, it is clipped to the inner width.
|
|
150
|
+
# @param child [Component]
|
|
151
|
+
# @return [void]
|
|
152
|
+
def on_child_content_size_changed(child)
|
|
153
|
+
if child.equal?(@footer)
|
|
154
|
+
old_rect = @footer.rect
|
|
155
|
+
layout_footer
|
|
156
|
+
# Repaint on any footer geometry change: a shrinking footer vacates
|
|
157
|
+
# border cells that must be re-dashed (a growing one merely
|
|
158
|
+
# overdraws, but distinguishing isn't worth the code).
|
|
159
|
+
invalidate if @footer.rect != old_rect
|
|
160
|
+
else
|
|
161
|
+
update_content_size
|
|
162
|
+
end
|
|
127
163
|
end
|
|
128
164
|
|
|
129
165
|
# Fully repaints the window: both frame and contents.
|
|
@@ -150,6 +186,7 @@ module Tuile
|
|
|
150
186
|
super
|
|
151
187
|
# The shortcut key is shown in the caption — repaint.
|
|
152
188
|
invalidate
|
|
189
|
+
update_content_size
|
|
153
190
|
end
|
|
154
191
|
|
|
155
192
|
protected
|
|
@@ -166,7 +203,7 @@ module Tuile
|
|
|
166
203
|
return if rect.empty?
|
|
167
204
|
|
|
168
205
|
frame = build_frame(frame_caption)
|
|
169
|
-
frame =
|
|
206
|
+
frame = screen.theme.active_border(frame) if active?
|
|
170
207
|
screen.print frame
|
|
171
208
|
end
|
|
172
209
|
|
|
@@ -206,11 +243,28 @@ module Tuile
|
|
|
206
243
|
|
|
207
244
|
private
|
|
208
245
|
|
|
246
|
+
# Recomputes the window's natural size: content's natural size (or the
|
|
247
|
+
# caption, whichever is wider) plus the 2-character border. The footer
|
|
248
|
+
# is deliberately excluded — see {#on_child_content_size_changed}. A
|
|
249
|
+
# window with no content or caption sizes to `Size.new(2, 2)` (bare
|
|
250
|
+
# border).
|
|
251
|
+
# @return [void]
|
|
252
|
+
def update_content_size
|
|
253
|
+
inner_w = [content&.content_size&.width || 0, frame_caption.length].max
|
|
254
|
+
inner_h = content&.content_size&.height || 0
|
|
255
|
+
self.content_size = Size.new(inner_w + 2, inner_h + 2)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Positions the footer over the bottom border row, with its width
|
|
259
|
+
# resolved by {#footer_sizing} against the inner width. A
|
|
260
|
+
# {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
|
|
261
|
+
# rect — i.e. it is invisible, as if never assigned.
|
|
209
262
|
# @return [void]
|
|
210
263
|
def layout_footer
|
|
211
264
|
return if @footer.nil? || rect.empty?
|
|
212
265
|
|
|
213
|
-
|
|
266
|
+
available = [rect.width - 2, 0].max
|
|
267
|
+
width = @footer_sizing.resolve(available, @footer.content_size.width)
|
|
214
268
|
@footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
|
|
215
269
|
end
|
|
216
270
|
end
|
data/lib/tuile/component.rb
CHANGED
|
@@ -12,6 +12,8 @@ module Tuile
|
|
|
12
12
|
def initialize
|
|
13
13
|
@rect = Rect.new(0, 0, 0, 0)
|
|
14
14
|
@active = false
|
|
15
|
+
@content_size = Size::ZERO
|
|
16
|
+
@on_theme_changed = nil
|
|
15
17
|
end
|
|
16
18
|
|
|
17
19
|
# @return [Rect] the rectangle the component occupies on screen.
|
|
@@ -195,6 +197,38 @@ module Tuile
|
|
|
195
197
|
# @return [void]
|
|
196
198
|
def on_focus; end
|
|
197
199
|
|
|
200
|
+
# Optional zero-arg listener fired by the base {#on_theme_changed} — the
|
|
201
|
+
# composition-style alternative to overriding the method, for apps that
|
|
202
|
+
# assemble stock components rather than subclass:
|
|
203
|
+
#
|
|
204
|
+
# label.on_theme_changed = -> { label.text = render_status_line }
|
|
205
|
+
#
|
|
206
|
+
# @return [Proc, nil]
|
|
207
|
+
attr_writer :on_theme_changed
|
|
208
|
+
|
|
209
|
+
# Called on every attached component (pre-order, popups included) when
|
|
210
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
211
|
+
# assignment and on OS appearance flips.
|
|
212
|
+
#
|
|
213
|
+
# Built-in components read {Screen#theme} at paint time, so their accents
|
|
214
|
+
# restyle automatically; this hook exists for *content* whose colors the
|
|
215
|
+
# app baked in from the old theme — a {Label#text} / {List#lines} /
|
|
216
|
+
# {TextView#text} {StyledString} styled with `theme[:accent]` and the
|
|
217
|
+
# like. Only the app knows which of its colors were theme-derived (as
|
|
218
|
+
# opposed to inherent to the data, e.g. log-level colors), so it rebuilds
|
|
219
|
+
# them here, re-running the same code that rendered them initially.
|
|
220
|
+
#
|
|
221
|
+
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
222
|
+
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
223
|
+
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
224
|
+
#
|
|
225
|
+
# Subclasses overriding this should call `super` so an assigned
|
|
226
|
+
# {#on_theme_changed=} listener keeps firing.
|
|
227
|
+
# @return [void]
|
|
228
|
+
def on_theme_changed
|
|
229
|
+
@on_theme_changed&.call
|
|
230
|
+
end
|
|
231
|
+
|
|
198
232
|
# @return [Boolean] true if this component's tree is currently mounted on
|
|
199
233
|
# the {Screen}, i.e. its root is the {ScreenPane}.
|
|
200
234
|
def attached? = root == screen.pane
|
|
@@ -225,12 +259,27 @@ module Tuile
|
|
|
225
259
|
|
|
226
260
|
# The {Size} big enough to show the entire component contents without
|
|
227
261
|
# scrolling. Plain components have no intrinsic content and report
|
|
228
|
-
# {Size::ZERO};
|
|
229
|
-
# {
|
|
230
|
-
#
|
|
231
|
-
# whatever content was assigned,
|
|
262
|
+
# {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
|
|
263
|
+
# {TextView}, {Window}) maintain it eagerly via {#content_size=} from
|
|
264
|
+
# their mutators, so reads are O(1). Used by callers like
|
|
265
|
+
# {Component::Popup} to auto-size to whatever content was assigned,
|
|
266
|
+
# regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
|
|
232
267
|
# @return [Size]
|
|
233
|
-
|
|
268
|
+
attr_reader :content_size
|
|
269
|
+
|
|
270
|
+
# Called by a child component whose {#content_size} just changed (fired
|
|
271
|
+
# from the child's {#content_size=}). Does nothing by default — a plain
|
|
272
|
+
# container is not size-coupled to its children. Containers that derive
|
|
273
|
+
# their own natural size or child layout from a child's natural size
|
|
274
|
+
# override this (e.g. {Component::Window} re-lays-out a
|
|
275
|
+
# {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
|
|
276
|
+
# {Component::Popup} re-self-sizes). If the receiver's own
|
|
277
|
+
# {#content_size} changes as a consequence, its {#content_size=} notifies
|
|
278
|
+
# *its* parent in turn — so the event bubbles exactly as far as geometry
|
|
279
|
+
# keeps changing, and stops where it doesn't.
|
|
280
|
+
# @param child [Component] the resized direct child.
|
|
281
|
+
# @return [void]
|
|
282
|
+
def on_child_content_size_changed(child); end
|
|
234
283
|
|
|
235
284
|
# Where the hardware terminal cursor should sit when this component is the
|
|
236
285
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
@@ -253,6 +302,26 @@ module Tuile
|
|
|
253
302
|
# @return [void]
|
|
254
303
|
def on_width_changed; end
|
|
255
304
|
|
|
305
|
+
# Memoizes the component's natural size and notifies {#parent} via
|
|
306
|
+
# {#on_child_content_size_changed} when the value actually changed.
|
|
307
|
+
# Subclasses call this from their content mutators (`text=`, `add_lines`,
|
|
308
|
+
# `caption=`, …) instead of caching ad-hoc.
|
|
309
|
+
#
|
|
310
|
+
# Call this as the *last* step of a mutator: the parent hook may
|
|
311
|
+
# reentrantly reposition this component (assign {#rect} — e.g. {Window}
|
|
312
|
+
# re-laying-out a wrap-content footer, or {Popup} re-self-sizing), which
|
|
313
|
+
# triggers {#on_width_changed} and {#repaint}-related recomputation, so
|
|
314
|
+
# all internal state must already be consistent.
|
|
315
|
+
# @param new_size [Size]
|
|
316
|
+
# @return [void]
|
|
317
|
+
def content_size=(new_size)
|
|
318
|
+
raise TypeError, "expected Size, got #{new_size.inspect}" unless new_size.is_a?(Size)
|
|
319
|
+
return if @content_size == new_size
|
|
320
|
+
|
|
321
|
+
@content_size = new_size
|
|
322
|
+
parent&.on_child_content_size_changed(self)
|
|
323
|
+
end
|
|
324
|
+
|
|
256
325
|
# Invalidates the component: {Screen} records this component as
|
|
257
326
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
258
327
|
#
|
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(&)
|
|
@@ -145,6 +184,31 @@ module Tuile
|
|
|
145
184
|
def size = Size.new(width, height)
|
|
146
185
|
end
|
|
147
186
|
|
|
187
|
+
# The terminal's color scheme changed — the user flipped the OS between
|
|
188
|
+
# light and dark appearance. Terminals supporting mode 2031 (kitty,
|
|
189
|
+
# foot, contour, ghostty, …) push the DSR-style report `\e[?997;1n`
|
|
190
|
+
# (dark) / `\e[?997;2n` (light) once {Screen#run_event_loop} enables
|
|
191
|
+
# the mode via {TerminalBackground::NOTIFY_ON}; the key thread parses
|
|
192
|
+
# it into this event and {Screen#event_loop} follows by assigning the
|
|
193
|
+
# matching {Theme}.
|
|
194
|
+
#
|
|
195
|
+
# @!attribute [r] scheme
|
|
196
|
+
# @return [Symbol] `:light` or `:dark`.
|
|
197
|
+
class ColorSchemeEvent < Data.define(:scheme)
|
|
198
|
+
# The DSR-style color-scheme report: `\e[?997;1n` dark, `\e[?997;2n`
|
|
199
|
+
# light.
|
|
200
|
+
# @return [Regexp]
|
|
201
|
+
REPORT = /\A\e\[\?997;([12])n\z/
|
|
202
|
+
|
|
203
|
+
# @param key [String] key read via {Keys.getkey}.
|
|
204
|
+
# @return [ColorSchemeEvent, nil] nil when `key` is not a
|
|
205
|
+
# color-scheme report.
|
|
206
|
+
def self.parse(key)
|
|
207
|
+
match = REPORT.match(key)
|
|
208
|
+
match && new(match[1] == "2" ? :light : :dark)
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
148
212
|
# Emitted once when the queue is cleared, all messages are processed and the
|
|
149
213
|
# event loop will block waiting for more messages. Perfect time for
|
|
150
214
|
# repainting windows.
|
|
@@ -152,6 +216,64 @@ module Tuile
|
|
|
152
216
|
include Singleton
|
|
153
217
|
end
|
|
154
218
|
|
|
219
|
+
# Handle returned by {EventQueue#tick}. Cancel a running ticker via
|
|
220
|
+
# {#cancel}.
|
|
221
|
+
#
|
|
222
|
+
# Internally wraps a `Concurrent::TimerTask` whose firing posts a single
|
|
223
|
+
# submit-block to the owning {EventQueue}; the user's block therefore
|
|
224
|
+
# always runs on the event-loop thread and may freely mutate UI. If the
|
|
225
|
+
# user block raises, the Ticker auto-cancels and the exception is
|
|
226
|
+
# re-raised so it flows through the loop's normal error handling
|
|
227
|
+
# ({Screen#on_error} for the default Tuile setup).
|
|
228
|
+
class Ticker
|
|
229
|
+
# @param event_queue [EventQueue] queue to dispatch tick calls onto.
|
|
230
|
+
# @param fps [Numeric] firings per second (positive).
|
|
231
|
+
# @param block [Proc] called as `block.call(tick_count)` on each fire.
|
|
232
|
+
def initialize(event_queue, fps, block)
|
|
233
|
+
@event_queue = event_queue
|
|
234
|
+
@block = block
|
|
235
|
+
@tick = 0
|
|
236
|
+
# AtomicBoolean rather than a plain ivar: cancel may run on any
|
|
237
|
+
# thread (caller code, the event-loop thread from inside the block,
|
|
238
|
+
# or the IO executor on an error path), and we want both a CAS-style
|
|
239
|
+
# one-shot guard against double-shutdown and well-defined visibility
|
|
240
|
+
# on non-MRI Rubies.
|
|
241
|
+
@cancelled = Concurrent::AtomicBoolean.new(false)
|
|
242
|
+
@timer = Concurrent::TimerTask.new(execution_interval: 1.0 / fps) do
|
|
243
|
+
@event_queue.submit { fire }
|
|
244
|
+
end
|
|
245
|
+
@timer.execute
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# @return [Boolean] true once {#cancel} has been called.
|
|
249
|
+
def cancelled? = @cancelled.true?
|
|
250
|
+
|
|
251
|
+
# Stops the ticker. Idempotent and safe to call from any thread,
|
|
252
|
+
# including from inside the tick block. Any tick already queued on the
|
|
253
|
+
# event loop at the moment of cancellation is dropped before the user
|
|
254
|
+
# block runs.
|
|
255
|
+
# @return [void]
|
|
256
|
+
def cancel
|
|
257
|
+
return unless @cancelled.make_true # CAS: only the winner shuts down
|
|
258
|
+
|
|
259
|
+
@timer.shutdown
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
private
|
|
263
|
+
|
|
264
|
+
# Runs on the event-loop thread.
|
|
265
|
+
# @return [void]
|
|
266
|
+
def fire
|
|
267
|
+
return if @cancelled.true?
|
|
268
|
+
|
|
269
|
+
@block.call(@tick)
|
|
270
|
+
@tick += 1
|
|
271
|
+
rescue StandardError
|
|
272
|
+
cancel
|
|
273
|
+
raise
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
155
277
|
private
|
|
156
278
|
|
|
157
279
|
# @return [void]
|
|
@@ -169,8 +291,6 @@ module Tuile
|
|
|
169
291
|
# while the loop's own backtrace shows up in the wrapper.
|
|
170
292
|
raise Tuile::Error, "background event raised: #{event.error.class}: #{event.error.message}"
|
|
171
293
|
end
|
|
172
|
-
elsif event.is_a? Proc
|
|
173
|
-
event.call
|
|
174
294
|
else
|
|
175
295
|
yield event
|
|
176
296
|
end
|
|
@@ -183,8 +303,7 @@ module Tuile
|
|
|
183
303
|
@key_thread = Thread.new do
|
|
184
304
|
loop do
|
|
185
305
|
key = Keys.getkey
|
|
186
|
-
event = MouseEvent.parse(key)
|
|
187
|
-
event = KeyEvent.new(key) if event.nil?
|
|
306
|
+
event = MouseEvent.parse(key) || ColorSchemeEvent.parse(key) || KeyEvent.new(key)
|
|
188
307
|
post event
|
|
189
308
|
end
|
|
190
309
|
rescue StandardError => e
|
|
@@ -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/fake_screen.rb
CHANGED
|
@@ -54,5 +54,13 @@ module Tuile
|
|
|
54
54
|
def invalidated_clear
|
|
55
55
|
@invalidated.clear
|
|
56
56
|
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# No terminal probing in tests: skip {TerminalBackground.detect}
|
|
61
|
+
# (which would write an OSC 11 query to the test runner's TTY and
|
|
62
|
+
# steal its input) and pin the deterministic default.
|
|
63
|
+
# @return [Symbol]
|
|
64
|
+
def detect_scheme = :dark
|
|
57
65
|
end
|
|
58
66
|
end
|
data/lib/tuile/keys.rb
CHANGED
|
@@ -160,6 +160,16 @@ module Tuile
|
|
|
160
160
|
char += $stdin.read(6 - char.bytesize)
|
|
161
161
|
end
|
|
162
162
|
|
|
163
|
+
# Private-mode CSI reports (`\e[?` params… final byte in 0x40..0x7E)
|
|
164
|
+
# can outgrow the 5-byte gulp above — the mode-2031 color-scheme
|
|
165
|
+
# notification `\e[?997;1n` (see {EventQueue::ColorSchemeEvent}) is 8
|
|
166
|
+
# bytes after the `\e`. Drain to the final byte with blocking 1-byte
|
|
167
|
+
# reads so the tail doesn't surface as phantom keypresses. Keyboard
|
|
168
|
+
# sequences never start with `\e[?`, so this can't eat a regular key.
|
|
169
|
+
if char.start_with?("\e[?")
|
|
170
|
+
char += $stdin.read(1) until char.match?(/[\x40-\x7e]\z/)
|
|
171
|
+
end
|
|
172
|
+
|
|
163
173
|
char
|
|
164
174
|
end
|
|
165
175
|
end
|