tuile 0.7.0 → 0.9.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 +26 -0
- data/README.md +51 -24
- data/book/01-first-app.md +186 -0
- data/book/02-repaint.md +177 -0
- data/book/03-layout.md +379 -0
- data/book/04-event-loop.md +209 -0
- data/book/05-focus.md +188 -0
- data/book/06-theming.md +250 -0
- data/book/07-components.md +231 -0
- data/book/08-testing.md +186 -0
- data/book/README.md +79 -0
- data/examples/hello_world.rb +1 -2
- data/examples/sampler.rb +109 -0
- data/lib/tuile/ansi.rb +16 -0
- data/lib/tuile/buffer.rb +481 -0
- data/lib/tuile/component/button.rb +3 -14
- data/lib/tuile/component/has_content.rb +0 -6
- data/lib/tuile/component/info_window.rb +4 -2
- data/lib/tuile/component/label.rb +15 -23
- data/lib/tuile/component/layout.rb +0 -21
- data/lib/tuile/component/list.rb +10 -37
- data/lib/tuile/component/log_window.rb +6 -5
- data/lib/tuile/component/picker_window.rb +4 -2
- data/lib/tuile/component/popup.rb +85 -55
- data/lib/tuile/component/text_area.rb +1 -1
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_input.rb +25 -9
- data/lib/tuile/component/text_view.rb +6 -30
- data/lib/tuile/component/window.rb +77 -112
- data/lib/tuile/component.rb +16 -71
- data/lib/tuile/event_queue.rb +31 -10
- data/lib/tuile/fake_event_queue.rb +21 -5
- data/lib/tuile/fake_screen.rb +14 -1
- data/lib/tuile/fraction.rb +46 -0
- data/lib/tuile/screen.rb +98 -105
- data/lib/tuile/screen_pane.rb +82 -19
- data/lib/tuile/styled_string.rb +40 -30
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +653 -282
- metadata +13 -3
- data/lib/tuile/sizing.rb +0 -59
|
@@ -20,38 +20,46 @@ module Tuile
|
|
|
20
20
|
@border_right = 1
|
|
21
21
|
@caption = caption
|
|
22
22
|
@content = nil
|
|
23
|
-
# Optional bottom-row
|
|
24
|
-
#
|
|
23
|
+
# Optional bottom-row widget slot (e.g. a search field), spanning the
|
|
24
|
+
# full inner width; and optional bottom-border chrome text embedded in
|
|
25
|
+
# the border line (mutually exclusive — the component, when present,
|
|
26
|
+
# occupies the row and hides the text).
|
|
25
27
|
@footer = nil
|
|
26
|
-
@
|
|
27
|
-
update_content_size
|
|
28
|
+
@footer_text = StyledString::EMPTY
|
|
28
29
|
end
|
|
29
30
|
|
|
30
31
|
def focusable? = true
|
|
31
32
|
|
|
32
|
-
# @return [Component, nil] optional component
|
|
33
|
-
# row.
|
|
33
|
+
# @return [Component, nil] optional focusable component occupying the
|
|
34
|
+
# bottom border row, always spanning the full inner width.
|
|
34
35
|
attr_reader :footer
|
|
35
36
|
|
|
36
|
-
# @return [
|
|
37
|
-
#
|
|
38
|
-
#
|
|
39
|
-
attr_reader :
|
|
40
|
-
|
|
41
|
-
# Sets the
|
|
42
|
-
#
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
37
|
+
# @return [StyledString] optional chrome embedded into the bottom border
|
|
38
|
+
# line, mirroring {#caption} on the top line. Empty by default; hidden
|
|
39
|
+
# whenever a {#footer} component is present.
|
|
40
|
+
attr_reader :footer_text
|
|
41
|
+
|
|
42
|
+
# Sets the bottom-border chrome. Accepts a `String` (parsed via
|
|
43
|
+
# {StyledString.parse}), a {StyledString}, or `nil` (clears it). The text
|
|
44
|
+
# embeds into the bottom border at its own width with the border's dashes
|
|
45
|
+
# filling the remainder, clipped to the inner width — border decoration,
|
|
46
|
+
# not a component (not focusable). Hidden whenever a {#footer} component
|
|
47
|
+
# occupies the bottom row (see the precedence note on {#footer=}).
|
|
48
|
+
# @param text [String, StyledString, nil]
|
|
49
|
+
def footer_text=(text)
|
|
50
|
+
new_text = StyledString.parse(text)
|
|
51
|
+
return if @footer_text == new_text
|
|
52
|
+
|
|
53
|
+
@footer_text = new_text
|
|
54
|
+
invalidate # repaint the bottom border line
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Sets the bottom-row widget slot. The footer occupies the bottom border
|
|
58
|
+
# row, spanning the full inner width, and is positioned automatically;
|
|
59
|
+
# pass `nil` to remove.
|
|
60
|
+
#
|
|
61
|
+
# Precedence: a footer component present hides {#footer_text}; absent, the
|
|
62
|
+
# text embeds into the bottom border. No window needs both at once.
|
|
55
63
|
#
|
|
56
64
|
# Symmetric to {#content=}: validates the new component, swaps parent
|
|
57
65
|
# pointers, invalidates the old/new components and the window border, and
|
|
@@ -83,14 +91,6 @@ module Tuile
|
|
|
83
91
|
@footer.nil? ? super : super + [@footer]
|
|
84
92
|
end
|
|
85
93
|
|
|
86
|
-
# @param key [String]
|
|
87
|
-
# @return [Boolean]
|
|
88
|
-
def handle_key(key)
|
|
89
|
-
return @footer.handle_key(key) if @footer&.active?
|
|
90
|
-
|
|
91
|
-
super
|
|
92
|
-
end
|
|
93
|
-
|
|
94
94
|
# @param event [MouseEvent]
|
|
95
95
|
# @return [void]
|
|
96
96
|
def handle_mouse(event)
|
|
@@ -130,36 +130,6 @@ module Tuile
|
|
|
130
130
|
def caption=(new_caption)
|
|
131
131
|
@caption = new_caption
|
|
132
132
|
invalidate
|
|
133
|
-
update_content_size
|
|
134
|
-
end
|
|
135
|
-
|
|
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
|
|
163
133
|
end
|
|
164
134
|
|
|
165
135
|
# Fully repaints the window: both frame and contents.
|
|
@@ -186,7 +156,6 @@ module Tuile
|
|
|
186
156
|
super
|
|
187
157
|
# The shortcut key is shown in the caption — repaint.
|
|
188
158
|
invalidate
|
|
189
|
-
update_content_size
|
|
190
159
|
end
|
|
191
160
|
|
|
192
161
|
protected
|
|
@@ -197,14 +166,50 @@ module Tuile
|
|
|
197
166
|
content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
|
|
198
167
|
end
|
|
199
168
|
|
|
200
|
-
# Paints the window border.
|
|
169
|
+
# Paints the window border into the {Screen#buffer}. Title is clipped to
|
|
170
|
+
# the inner width so the box never overflows {#rect}; when the window is
|
|
171
|
+
# active the whole border is drawn in {Theme#active_border_color}.
|
|
201
172
|
# @return [void]
|
|
202
173
|
def repaint_border
|
|
203
174
|
return if rect.empty?
|
|
204
175
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
176
|
+
w = rect.width
|
|
177
|
+
h = rect.height
|
|
178
|
+
top = rect.top
|
|
179
|
+
left = rect.left
|
|
180
|
+
inner_w = [w - 2, 0].max
|
|
181
|
+
title = frame_caption.to_s
|
|
182
|
+
title = title[0, inner_w] if title.length > inner_w
|
|
183
|
+
dashes = "─" * (inner_w - title.length)
|
|
184
|
+
|
|
185
|
+
fg = active? ? screen.theme.active_border_color : nil
|
|
186
|
+
bar = StyledString::Style.new(fg: fg)
|
|
187
|
+
buf = screen.buffer
|
|
188
|
+
buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
|
|
189
|
+
(1..(h - 2)).each do |dy|
|
|
190
|
+
buf.set_char(left, top + dy, "│", bar)
|
|
191
|
+
buf.set_char(left + w - 1, top + dy, "│", bar)
|
|
192
|
+
end
|
|
193
|
+
buf.set_line(left, top + h - 1, bottom_border(inner_w, fg)) if h >= 2
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Builds the bottom border line. The corners take the border color; the
|
|
197
|
+
# interior is plain dashes when a {#footer} component occupies the row
|
|
198
|
+
# (it overpaints them) or when there's no chrome, otherwise it carries
|
|
199
|
+
# {#footer_text} embedded at its own width — keeping the text's own
|
|
200
|
+
# styling — with dashes filling the remainder up to the inner width.
|
|
201
|
+
# @param inner_w [Integer] the border's interior width.
|
|
202
|
+
# @param fg [Color, nil] the active-border color, or nil when inactive.
|
|
203
|
+
# @return [StyledString]
|
|
204
|
+
def bottom_border(inner_w, fg)
|
|
205
|
+
interior =
|
|
206
|
+
if @footer || @footer_text.empty?
|
|
207
|
+
StyledString.styled("─" * inner_w, fg: fg)
|
|
208
|
+
else
|
|
209
|
+
embedded = @footer_text.slice(0, inner_w)
|
|
210
|
+
embedded + StyledString.styled("─" * (inner_w - embedded.display_width), fg: fg)
|
|
211
|
+
end
|
|
212
|
+
StyledString.styled("└", fg: fg) + interior + StyledString.styled("┘", fg: fg)
|
|
208
213
|
end
|
|
209
214
|
|
|
210
215
|
# The caption text as it appears in the rendered border, including the
|
|
@@ -215,56 +220,16 @@ module Tuile
|
|
|
215
220
|
key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
|
|
216
221
|
end
|
|
217
222
|
|
|
218
|
-
# Builds the border as a single string with embedded cursor-positioning
|
|
219
|
-
# escapes, mirroring the layout {TTY::Box.frame} used to produce. Title
|
|
220
|
-
# is clipped to fit the inner width so the box never overflows {#rect}.
|
|
221
|
-
# @param caption [String]
|
|
222
|
-
# @return [String]
|
|
223
|
-
def build_frame(caption)
|
|
224
|
-
w = @rect.width
|
|
225
|
-
h = @rect.height
|
|
226
|
-
top = @rect.top
|
|
227
|
-
left = @rect.left
|
|
228
|
-
inner_w = [w - 2, 0].max
|
|
229
|
-
|
|
230
|
-
title = caption.to_s
|
|
231
|
-
title = title[0, inner_w] if title.length > inner_w
|
|
232
|
-
dashes = "─" * (inner_w - title.length)
|
|
233
|
-
|
|
234
|
-
out = +""
|
|
235
|
-
out << TTY::Cursor.move_to(left, top) << "┌#{title}#{dashes}┐"
|
|
236
|
-
(1..(h - 2)).each do |dy|
|
|
237
|
-
out << TTY::Cursor.move_to(left, top + dy) << "│"
|
|
238
|
-
out << TTY::Cursor.move_to(left + w - 1, top + dy) << "│"
|
|
239
|
-
end
|
|
240
|
-
out << TTY::Cursor.move_to(left, top + h - 1) << "└#{"─" * inner_w}┘" if h >= 2
|
|
241
|
-
out
|
|
242
|
-
end
|
|
243
|
-
|
|
244
223
|
private
|
|
245
224
|
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
#
|
|
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.
|
|
225
|
+
# Positions the footer over the bottom border row, spanning the full
|
|
226
|
+
# inner width (the only dimension a bottom-row widget needs — the window
|
|
227
|
+
# already knows it).
|
|
262
228
|
# @return [void]
|
|
263
229
|
def layout_footer
|
|
264
230
|
return if @footer.nil? || rect.empty?
|
|
265
231
|
|
|
266
|
-
|
|
267
|
-
width = @footer_sizing.resolve(available, @footer.content_size.width)
|
|
232
|
+
width = [rect.width - 2, 0].max
|
|
268
233
|
@footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
|
|
269
234
|
end
|
|
270
235
|
end
|
data/lib/tuile/component.rb
CHANGED
|
@@ -12,7 +12,6 @@ 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
15
|
@on_theme_changed = nil
|
|
17
16
|
end
|
|
18
17
|
|
|
@@ -81,27 +80,22 @@ module Tuile
|
|
|
81
80
|
children.each { |c| screen.invalidate(c) }
|
|
82
81
|
end
|
|
83
82
|
|
|
84
|
-
# Called when a character is pressed on the keyboard.
|
|
83
|
+
# Called when a character is pressed on the keyboard. The default does
|
|
84
|
+
# nothing and reports the key as unhandled; input components
|
|
85
|
+
# ({Component::TextField}, {Component::List}, {Component::Button}, …)
|
|
86
|
+
# override it to act on keys they care about.
|
|
85
87
|
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
# @param
|
|
88
|
+
# Dispatch is owned by {ScreenPane#handle_key}: a {#key_shortcut} match
|
|
89
|
+
# anywhere in the active scope is captured first (suppressed while a
|
|
90
|
+
# cursor-owner is mid-edit), then the key is delivered to {Screen#focused}
|
|
91
|
+
# and bubbles up its ancestor chain until some component handles it. A
|
|
92
|
+
# component therefore only ever receives keys when it is on the focus chain
|
|
93
|
+
# — or when app code hands it a key directly — so it acts on the key alone
|
|
94
|
+
# and must never gate on its own {#active?} state.
|
|
95
|
+
# @param _key [String] a key.
|
|
94
96
|
# @return [Boolean] true if the key was handled, false if not.
|
|
95
|
-
def handle_key(
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
c = find_shortcut_component(key)
|
|
99
|
-
if !c.nil?
|
|
100
|
-
screen.focused = c
|
|
101
|
-
true
|
|
102
|
-
else
|
|
103
|
-
false
|
|
104
|
-
end
|
|
97
|
+
def handle_key(_key)
|
|
98
|
+
false
|
|
105
99
|
end
|
|
106
100
|
|
|
107
101
|
# A global keyboard shortcut. When pressed, will focus this component.
|
|
@@ -257,30 +251,6 @@ module Tuile
|
|
|
257
251
|
end
|
|
258
252
|
end
|
|
259
253
|
|
|
260
|
-
# The {Size} big enough to show the entire component contents without
|
|
261
|
-
# scrolling. Plain components have no intrinsic content and report
|
|
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.
|
|
267
|
-
# @return [Size]
|
|
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
|
|
283
|
-
|
|
284
254
|
# Where the hardware terminal cursor should sit when this component is the
|
|
285
255
|
# cursor owner. Returns `nil` to indicate the cursor should be hidden. The
|
|
286
256
|
# {Screen} positions the hardware cursor after each repaint cycle by
|
|
@@ -295,33 +265,13 @@ module Tuile
|
|
|
295
265
|
|
|
296
266
|
protected
|
|
297
267
|
|
|
298
|
-
# @
|
|
268
|
+
# @return [Component, nil]
|
|
299
269
|
attr_writer :parent
|
|
300
270
|
|
|
301
271
|
# Called whenever the component width changes. Does nothing by default.
|
|
302
272
|
# @return [void]
|
|
303
273
|
def on_width_changed; end
|
|
304
274
|
|
|
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
|
-
|
|
325
275
|
# Invalidates the component: {Screen} records this component as
|
|
326
276
|
# needs-repaint and once all events are processed, will call {#repaint}.
|
|
327
277
|
#
|
|
@@ -354,12 +304,7 @@ module Tuile
|
|
|
354
304
|
# component's rect.
|
|
355
305
|
# @return [void]
|
|
356
306
|
def clear_background
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
spaces = " " * rect.width
|
|
360
|
-
(rect.top..(rect.top + rect.height - 1)).each do |row|
|
|
361
|
-
screen.print TTY::Cursor.move_to(rect.left, row), spaces
|
|
362
|
-
end
|
|
307
|
+
screen.buffer.fill(rect)
|
|
363
308
|
end
|
|
364
309
|
end
|
|
365
310
|
end
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -47,10 +47,13 @@ module Tuile
|
|
|
47
47
|
latch.wait
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
-
# Schedules `block` to fire on the event-loop thread
|
|
51
|
-
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
50
|
+
# Schedules `block` to fire on the event-loop thread every `seconds`,
|
|
51
|
+
# passing a 0-based monotonically increasing tick counter. The interval is
|
|
52
|
+
# in **seconds** — the conventional scheduling unit (`sleep`,
|
|
53
|
+
# `Concurrent::TimerTask#execution_interval`, …) — so `tick(0.2)` fires five
|
|
54
|
+
# times a second. Use it for periodic UI refresh from a background task
|
|
55
|
+
# (poll a status, redraw a clock). For animation, where frames-per-second
|
|
56
|
+
# is the natural unit, {#tick_fps} reads better.
|
|
54
57
|
#
|
|
55
58
|
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
56
59
|
# stop it.
|
|
@@ -64,19 +67,37 @@ module Tuile
|
|
|
64
67
|
# ({Concurrent}.global_timer_set) — adding more tickers does not add more
|
|
65
68
|
# threads, just more work on the shared scheduler.
|
|
66
69
|
#
|
|
70
|
+
# @param seconds [Numeric] interval between firings, must be positive.
|
|
71
|
+
# Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
|
|
72
|
+
# @yield [tick] called on the event-loop thread each firing.
|
|
73
|
+
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
74
|
+
# @yieldreturn [void]
|
|
75
|
+
# @return [Ticker]
|
|
76
|
+
def tick(seconds, &block)
|
|
77
|
+
raise ArgumentError, "block required" unless block
|
|
78
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
79
|
+
raise ArgumentError, "seconds must be a positive Numeric, got #{seconds.inspect}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
Ticker.new(self, seconds, block)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Frames-per-second convenience over {#tick}: `tick_fps(15)` is exactly
|
|
86
|
+
# `tick(1.0 / 15)`. Reads naturally for animation (a `/-\|` spinner, a
|
|
87
|
+
# progress pulse) where you think in frames, not intervals.
|
|
67
88
|
# @param fps [Numeric] firings per second, must be positive. Fractional
|
|
68
|
-
# values are fine (`
|
|
89
|
+
# values are fine (`tick_fps(0.5)` ⇒ one firing every two seconds).
|
|
69
90
|
# @yield [tick] called on the event-loop thread each firing.
|
|
70
91
|
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
71
92
|
# @yieldreturn [void]
|
|
72
93
|
# @return [Ticker]
|
|
73
|
-
def
|
|
94
|
+
def tick_fps(fps, &block)
|
|
74
95
|
raise ArgumentError, "block required" unless block
|
|
75
96
|
unless fps.is_a?(Numeric) && fps.positive?
|
|
76
97
|
raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
|
|
77
98
|
end
|
|
78
99
|
|
|
79
|
-
|
|
100
|
+
tick(1.0 / fps, &block)
|
|
80
101
|
end
|
|
81
102
|
|
|
82
103
|
# Runs the event loop and blocks. Must be run from at most one thread at the
|
|
@@ -227,9 +248,9 @@ module Tuile
|
|
|
227
248
|
# ({Screen#on_error} for the default Tuile setup).
|
|
228
249
|
class Ticker
|
|
229
250
|
# @param event_queue [EventQueue] queue to dispatch tick calls onto.
|
|
230
|
-
# @param
|
|
251
|
+
# @param interval [Numeric] seconds between firings (positive).
|
|
231
252
|
# @param block [Proc] called as `block.call(tick_count)` on each fire.
|
|
232
|
-
def initialize(event_queue,
|
|
253
|
+
def initialize(event_queue, interval, block)
|
|
233
254
|
@event_queue = event_queue
|
|
234
255
|
@block = block
|
|
235
256
|
@tick = 0
|
|
@@ -239,7 +260,7 @@ module Tuile
|
|
|
239
260
|
# one-shot guard against double-shutdown and well-defined visibility
|
|
240
261
|
# on non-MRI Rubies.
|
|
241
262
|
@cancelled = Concurrent::AtomicBoolean.new(false)
|
|
242
|
-
@timer = Concurrent::TimerTask.new(execution_interval:
|
|
263
|
+
@timer = Concurrent::TimerTask.new(execution_interval: interval) do
|
|
243
264
|
@event_queue.submit { fire }
|
|
244
265
|
end
|
|
245
266
|
@timer.execute
|
|
@@ -33,23 +33,39 @@ module Tuile
|
|
|
33
33
|
def post(event); end
|
|
34
34
|
|
|
35
35
|
# Mirrors {EventQueue#tick} but timeless: returns a {FakeTicker} that
|
|
36
|
-
# only fires when a test calls {#tick_once}. The `
|
|
36
|
+
# only fires when a test calls {#tick_once}. The `seconds` argument is
|
|
37
37
|
# validated the same way the real queue validates it, then discarded —
|
|
38
38
|
# the fake has no clock, so frame cadence is up to the test.
|
|
39
39
|
#
|
|
40
|
-
# @param
|
|
41
|
-
# for parity with {EventQueue#tick}; otherwise unused.
|
|
40
|
+
# @param seconds [Numeric] interval between firings, must be positive.
|
|
41
|
+
# Validated for parity with {EventQueue#tick}; otherwise unused.
|
|
42
42
|
# @yield [tick] called on each {#tick_once}.
|
|
43
43
|
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
44
44
|
# @yieldreturn [void]
|
|
45
45
|
# @return [FakeTicker]
|
|
46
|
-
def tick(
|
|
46
|
+
def tick(seconds, &block)
|
|
47
|
+
raise ArgumentError, "block required" unless block
|
|
48
|
+
unless seconds.is_a?(Numeric) && seconds.positive?
|
|
49
|
+
raise ArgumentError, "seconds must be a positive Numeric, got #{seconds.inspect}"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
FakeTicker.new(block).tap { |t| @tickers << t }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Mirrors {EventQueue#tick_fps}: validates `fps` for parity, then delegates
|
|
56
|
+
# to {#tick} (the fake discards the interval regardless).
|
|
57
|
+
# @param fps [Numeric] firings per second, must be positive.
|
|
58
|
+
# @yield [tick] called on each {#tick_once}.
|
|
59
|
+
# @yieldparam tick [Integer] 0-based monotonically increasing counter.
|
|
60
|
+
# @yieldreturn [void]
|
|
61
|
+
# @return [FakeTicker]
|
|
62
|
+
def tick_fps(fps, &block)
|
|
47
63
|
raise ArgumentError, "block required" unless block
|
|
48
64
|
unless fps.is_a?(Numeric) && fps.positive?
|
|
49
65
|
raise ArgumentError, "fps must be a positive Numeric, got #{fps.inspect}"
|
|
50
66
|
end
|
|
51
67
|
|
|
52
|
-
|
|
68
|
+
tick(1.0 / fps, &block)
|
|
53
69
|
end
|
|
54
70
|
|
|
55
71
|
# Test helper: fires every live ticker's user block once and prunes
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -25,10 +25,14 @@ module Tuile
|
|
|
25
25
|
super
|
|
26
26
|
@event_queue = FakeEventQueue.new
|
|
27
27
|
@size = Size.new(160, 50)
|
|
28
|
+
@buffer.resize(@size) # super sized it to the test runner's TTY
|
|
28
29
|
@prints = []
|
|
29
30
|
end
|
|
30
31
|
|
|
31
|
-
# @return [Array<String>] whatever {#print}
|
|
32
|
+
# @return [Array<String>] whatever {#print} / {#emit} produced so far.
|
|
33
|
+
# Component painting lands in {#buffer}, not here — assert on
|
|
34
|
+
# {Buffer#row_text} / {Buffer#row_ansi} / {Buffer#cell} for content, and
|
|
35
|
+
# on `prints` for cursor and housekeeping escapes.
|
|
32
36
|
attr_reader :prints
|
|
33
37
|
|
|
34
38
|
# @return [void]
|
|
@@ -46,6 +50,15 @@ module Tuile
|
|
|
46
50
|
@prints += args
|
|
47
51
|
end
|
|
48
52
|
|
|
53
|
+
# Captures the assembled repaint frame instead of writing to the test
|
|
54
|
+
# runner's TTY. Lands in {#prints} so cursor/sync escapes can be asserted;
|
|
55
|
+
# painted content is read from {#buffer}.
|
|
56
|
+
# @param str [String]
|
|
57
|
+
# @return [void]
|
|
58
|
+
def emit(str)
|
|
59
|
+
@prints << str
|
|
60
|
+
end
|
|
61
|
+
|
|
49
62
|
# @param component [Component] the component to check.
|
|
50
63
|
# @return [Boolean]
|
|
51
64
|
def invalidated?(component) = @invalidated.include?(component)
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
# A width/height ratio, each a float in `0.0..1.0` — the single relational
|
|
5
|
+
# sizing primitive in Tuile. It exists for exactly one job: sizing a
|
|
6
|
+
# {Component::Popup} against the screen. A popup has no siblings competing for
|
|
7
|
+
# space and no rectangle in a tiled layout, so "half the screen, centered" is
|
|
8
|
+
# the sensible default, and that wants a ratio rather than a hard-coded cell
|
|
9
|
+
# count that would be wrong on the next terminal size.
|
|
10
|
+
#
|
|
11
|
+
# Tiled components are *not* sized this way: their parent computes explicit
|
|
12
|
+
# integer rects in its own `rect=` and hands them down. `Fraction` is
|
|
13
|
+
# deliberately scoped to {Component::Popup#size=} and is not a general layout
|
|
14
|
+
# primitive.
|
|
15
|
+
#
|
|
16
|
+
# Resolve it against a reference {Size} (the screen) to get concrete integer
|
|
17
|
+
# cells:
|
|
18
|
+
#
|
|
19
|
+
# Fraction::HALF.resolve(Size.new(80, 24)) # => 40x12
|
|
20
|
+
#
|
|
21
|
+
# Integer arguments are coerced to float, so `Fraction.new(1, 1) == FULL`.
|
|
22
|
+
class Fraction < Data.define(:width, :height)
|
|
23
|
+
# @param width [Numeric] fraction of the reference width, `0.0..1.0`.
|
|
24
|
+
# @param height [Numeric] fraction of the reference height, `0.0..1.0`.
|
|
25
|
+
def initialize(width:, height:)
|
|
26
|
+
super(width: width.to_f, height: height.to_f)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Resolves this fraction against a reference size, rounding each axis to the
|
|
30
|
+
# nearest cell and flooring at 1 — so a fraction never yields a zero-size
|
|
31
|
+
# result on a tiny terminal.
|
|
32
|
+
# @param reference [Size] the size to take a fraction of (usually the screen).
|
|
33
|
+
# @return [Size]
|
|
34
|
+
def resolve(reference)
|
|
35
|
+
Size.new([(reference.width * width).round, 1].max,
|
|
36
|
+
[(reference.height * height).round, 1].max)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Half the reference size on each axis — the default {Component::Popup} size.
|
|
40
|
+
# @return [Fraction]
|
|
41
|
+
HALF = new(0.5, 0.5)
|
|
42
|
+
# The full reference size on each axis — fullscreen for a {Component::Popup}.
|
|
43
|
+
# @return [Fraction]
|
|
44
|
+
FULL = new(1.0, 1.0)
|
|
45
|
+
end
|
|
46
|
+
end
|