tuile 0.9.0 → 0.10.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 +32 -0
- data/DECISIONS.md +1961 -0
- data/README.md +31 -24
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +82 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +363 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +19 -13
- data/examples/sampler.rb +435 -20
- data/ideas/new-components.md +109 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +133 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +281 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- data/lib/tuile/component/layout.rb +3 -17
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +106 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +112 -83
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +2043 -614
- metadata +18 -7
data/lib/tuile/component.rb
CHANGED
|
@@ -13,6 +13,8 @@ module Tuile
|
|
|
13
13
|
@rect = Rect.new(0, 0, 0, 0)
|
|
14
14
|
@active = false
|
|
15
15
|
@on_theme_changed = nil
|
|
16
|
+
@bg_color = nil
|
|
17
|
+
@children = []
|
|
16
18
|
end
|
|
17
19
|
|
|
18
20
|
# @return [Rect] the rectangle the component occupies on screen.
|
|
@@ -47,30 +49,59 @@ module Tuile
|
|
|
47
49
|
screen.focused = self
|
|
48
50
|
end
|
|
49
51
|
|
|
50
|
-
#
|
|
52
|
+
# @return [Color, Theme::Ref, nil] this component's own background — the
|
|
53
|
+
# value as set, so a {Theme::Ref} comes back unresolved; `nil` when unset,
|
|
54
|
+
# in which case it inherits from the parent (see {#effective_bg_color}),
|
|
55
|
+
# ultimately the terminal default. {#effective_bg_color} is the resolved
|
|
56
|
+
# {Color} to paint.
|
|
57
|
+
attr_reader :bg_color
|
|
58
|
+
|
|
59
|
+
# Tints this component and every descendant that doesn't set its own
|
|
60
|
+
# background (they re-resolve via {#effective_bg_color}) — set it once on a
|
|
61
|
+
# container / {Component::Popup} to tint a whole subtree. Invalidates the
|
|
62
|
+
# subtree so it repaints.
|
|
51
63
|
#
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
# any direct children whose rects leave gaps in {#rect}. Concretely:
|
|
64
|
+
# A {Theme::Ref} is re-resolved against the theme each paint, so it tracks
|
|
65
|
+
# light/dark flips with no {#on_theme_changed} hook; a {Color} is fixed:
|
|
55
66
|
#
|
|
56
|
-
#
|
|
57
|
-
#
|
|
58
|
-
# - Container with children that fully tile {#rect}: skipped — the
|
|
59
|
-
# children themselves will repaint and cover everything.
|
|
60
|
-
# - Container with gappy children (e.g. a form layout where widgets
|
|
61
|
-
# don't tile): clears, then invalidates the children so they re-paint
|
|
62
|
-
# on top of the cleared background. This is what makes mixed
|
|
63
|
-
# field/button forms safe without each container learning a custom
|
|
64
|
-
# damage-tracking pass.
|
|
67
|
+
# panel.bg_color = Theme.ref(:panel_bg) # theme-tracked
|
|
68
|
+
# panel.bg_color = Color::GREY27 # fixed
|
|
65
69
|
#
|
|
66
|
-
#
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
71
|
-
#
|
|
70
|
+
# @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil] a
|
|
71
|
+
# {Theme::Ref}, else a color coerced via {Color.coerce}; `nil` unsets
|
|
72
|
+
# (inherit from the parent).
|
|
73
|
+
# @raise [KeyError] when a {Theme::Ref} names an absent custom token —
|
|
74
|
+
# validated eagerly at assignment, not deferred to paint.
|
|
75
|
+
# @return [void]
|
|
76
|
+
def bg_color=(color)
|
|
77
|
+
color = Color.coerce(color) unless color.is_a?(Theme::Ref)
|
|
78
|
+
return if @bg_color == color
|
|
79
|
+
|
|
80
|
+
color.resolve(screen.theme) if color.is_a?(Theme::Ref) # fail fast on a bad token
|
|
81
|
+
@bg_color = color
|
|
82
|
+
on_tree { |c| screen.invalidate(c) } if attached?
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [Color, nil] the background actually painted: this component's own
|
|
86
|
+
# {#bg_color} if set (a {Theme::Ref} resolved against the current theme),
|
|
87
|
+
# else the nearest ancestor's, else `nil` (terminal default). Resolved at
|
|
88
|
+
# paint time — never cached, so the subtree tracks both an ancestor's
|
|
89
|
+
# {#bg_color=} and a {Screen#theme=} on its next repaint.
|
|
90
|
+
def effective_bg_color
|
|
91
|
+
own = @bg_color
|
|
92
|
+
own = own.resolve(screen.theme) if own.is_a?(Theme::Ref)
|
|
93
|
+
own || parent&.effective_bg_color
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Repaints the component. The default does the bookkeeping most components
|
|
97
|
+
# need: it clears the background, and for a container whose children leave
|
|
98
|
+
# gaps in {#rect} it re-invalidates those children so they repaint over the
|
|
99
|
+
# cleared area (what makes mixed-width form layouts safe). A container whose
|
|
100
|
+
# children fully tile {#rect} is left alone — the children cover everything.
|
|
72
101
|
#
|
|
73
|
-
#
|
|
102
|
+
# Call `super` from your own `repaint` to inherit this. Skip it only if you
|
|
103
|
+
# paint the whole {#rect} yourself ({Window}'s border, {Component::List}'s
|
|
104
|
+
# row-by-row paint). Never draw outside {#rect}. Only called when attached.
|
|
74
105
|
# @return [void]
|
|
75
106
|
def repaint
|
|
76
107
|
return if rect.empty?
|
|
@@ -80,41 +111,17 @@ module Tuile
|
|
|
80
111
|
children.each { |c| screen.invalidate(c) }
|
|
81
112
|
end
|
|
82
113
|
|
|
83
|
-
# Called when a
|
|
84
|
-
#
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
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.
|
|
114
|
+
# Called when a key is pressed; override to act on keys you care about (the
|
|
115
|
+
# default reports every key unhandled). A component only receives keys while
|
|
116
|
+
# it's on the focus chain — or when app code hands it one directly — so act
|
|
117
|
+
# on the key alone and never gate on your own {#active?} state. See book ch5
|
|
118
|
+
# for how a keystroke is routed to reach here.
|
|
95
119
|
# @param _key [String] a key.
|
|
96
120
|
# @return [Boolean] true if the key was handled, false if not.
|
|
97
121
|
def handle_key(_key)
|
|
98
122
|
false
|
|
99
123
|
end
|
|
100
124
|
|
|
101
|
-
# A global keyboard shortcut. When pressed, will focus this component.
|
|
102
|
-
# @return [String, nil] shortcut, `nil` by default.
|
|
103
|
-
attr_accessor :key_shortcut
|
|
104
|
-
|
|
105
|
-
# @param key [String] keyboard key to look up.
|
|
106
|
-
# @return [Component, nil] the component whose {#key_shortcut} matches `key`,
|
|
107
|
-
# or nil.
|
|
108
|
-
def find_shortcut_component(key)
|
|
109
|
-
return self if key_shortcut == key
|
|
110
|
-
|
|
111
|
-
children.each do |child|
|
|
112
|
-
sc = child.find_shortcut_component(key)
|
|
113
|
-
return sc unless sc.nil?
|
|
114
|
-
end
|
|
115
|
-
nil
|
|
116
|
-
end
|
|
117
|
-
|
|
118
125
|
# Handles mouse event. Default implementation focuses this component when
|
|
119
126
|
# clicked (if {#focusable?}).
|
|
120
127
|
# @param event [MouseEvent]
|
|
@@ -140,16 +147,11 @@ module Tuile
|
|
|
140
147
|
|
|
141
148
|
# Whether this component is a valid focus target. `false` by default —
|
|
142
149
|
# passive components like {Label} are decoration and don't accept focus.
|
|
143
|
-
# The flag gates click-to-focus
|
|
144
|
-
#
|
|
145
|
-
#
|
|
146
|
-
#
|
|
147
|
-
#
|
|
148
|
-
#
|
|
149
|
-
# See also {#tab_stop?}: focusable controls _can_ receive focus (via click
|
|
150
|
-
# or programmatic assignment), but only tab stops participate in Tab /
|
|
151
|
-
# Shift+Tab cycling. Containers like {Window} and {Popup} are focusable
|
|
152
|
-
# (so a click on chrome lands focus) but are not tab stops.
|
|
150
|
+
# The flag gates click-to-focus and the container focus-cascade. Independent
|
|
151
|
+
# from {#active?}: every component carries the active flag, but only
|
|
152
|
+
# focusable ones can become a focus target that puts themselves and their
|
|
153
|
+
# ancestors on the active chain. Focusable is broader than {#tab_stop?} —
|
|
154
|
+
# a {Window} is focusable (a click on chrome lands focus) but not a tab stop.
|
|
153
155
|
# @return [Boolean] true if this component can be focused.
|
|
154
156
|
def focusable? = false
|
|
155
157
|
|
|
@@ -172,10 +174,16 @@ module Tuile
|
|
|
172
174
|
# @return [Component] the root component of this component hierarchy.
|
|
173
175
|
def root = parent.nil? ? self : parent.root
|
|
174
176
|
|
|
175
|
-
#
|
|
176
|
-
#
|
|
177
|
-
#
|
|
178
|
-
|
|
177
|
+
# Child components in paint order (siblings left to right, earlier ones
|
|
178
|
+
# painted under later ones), maintained by {#add_child} / {#remove_child}.
|
|
179
|
+
#
|
|
180
|
+
# Not meant to be overridden: a container that computed this from its own
|
|
181
|
+
# slots could disagree with the parent pointers, and {#attached?} walks the
|
|
182
|
+
# chain while subtree walks use this list. Named slots are readers *over*
|
|
183
|
+
# the array (`Window#footer`), never a second copy of it.
|
|
184
|
+
# @return [Array<Component>] child components. Must not be mutated by
|
|
185
|
+
# callers! May be empty.
|
|
186
|
+
attr_reader :children
|
|
179
187
|
|
|
180
188
|
# Calls block for this component and for every descendant component.
|
|
181
189
|
# @yield [component]
|
|
@@ -201,31 +209,33 @@ module Tuile
|
|
|
201
209
|
attr_writer :on_theme_changed
|
|
202
210
|
|
|
203
211
|
# Called on every attached component (pre-order, popups included) when
|
|
204
|
-
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=}
|
|
205
|
-
#
|
|
212
|
+
# {Screen#theme} changes — at {Screen#theme=} / {Screen#theme_def=} and on
|
|
213
|
+
# OS appearance flips. The hook exists for app *content* whose colors were
|
|
214
|
+
# baked in from the old theme (a {Label#text} / {List#lines} {StyledString}
|
|
215
|
+
# styled with `theme[:accent]`); rebuild it here by re-running the code that
|
|
216
|
+
# rendered it. See book ch6 for why built-in accents need no such handling.
|
|
206
217
|
#
|
|
207
|
-
#
|
|
208
|
-
#
|
|
209
|
-
#
|
|
210
|
-
# {TextView#text} {StyledString} styled with `theme[:accent]` and the
|
|
211
|
-
# like. Only the app knows which of its colors were theme-derived (as
|
|
212
|
-
# opposed to inherent to the data, e.g. log-level colors), so it rebuilds
|
|
213
|
-
# them here, re-running the same code that rendered them initially.
|
|
214
|
-
#
|
|
215
|
-
# Runs on the UI thread; {Screen#theme} already returns the new theme.
|
|
216
|
-
# Mutating content (`text=`, `lines=`, …) is safe — repaint coalesces per
|
|
217
|
-
# event-loop tick. Do not assign {Screen#theme=} from inside the hook.
|
|
218
|
-
#
|
|
219
|
-
# Subclasses overriding this should call `super` so an assigned
|
|
218
|
+
# Runs on the UI thread with {Screen#theme} already updated, so mutating
|
|
219
|
+
# content (`text=`, `lines=`, …) is safe. Do not assign {Screen#theme=}
|
|
220
|
+
# here. Subclasses overriding this must call `super` so an assigned
|
|
220
221
|
# {#on_theme_changed=} listener keeps firing.
|
|
221
222
|
# @return [void]
|
|
222
223
|
def on_theme_changed
|
|
223
224
|
@on_theme_changed&.call
|
|
224
225
|
end
|
|
225
226
|
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
|
|
227
|
+
# Whether this component's tree is mounted on a UI, {ScreenPane} being the
|
|
228
|
+
# root of every displayed tree.
|
|
229
|
+
#
|
|
230
|
+
# A property of the parent chain alone — no {Screen} is consulted, so
|
|
231
|
+
# assembling a tree needs no screen in the process at all:
|
|
232
|
+
#
|
|
233
|
+
# layout = Component::Layout::Absolute.new
|
|
234
|
+
# layout.add(label) # legal with no Screen; neither is attached yet
|
|
235
|
+
# screen.content = layout # now both are
|
|
236
|
+
#
|
|
237
|
+
# @return [Boolean] true if {#root} is a {ScreenPane}.
|
|
238
|
+
def attached? = root.is_a?(ScreenPane)
|
|
229
239
|
|
|
230
240
|
# Called by container components after `child` has been detached from
|
|
231
241
|
# `self.children` (its `parent` is already nil and it is no longer in the
|
|
@@ -265,8 +275,128 @@ module Tuile
|
|
|
265
275
|
|
|
266
276
|
protected
|
|
267
277
|
|
|
268
|
-
#
|
|
269
|
-
|
|
278
|
+
# Adopts `child`: places it in {#children} and wires its parent pointer.
|
|
279
|
+
#
|
|
280
|
+
# add_child(@status_bar) # paints last
|
|
281
|
+
# add_child(popup, at: @children.index(@status_bar)) # …just before it
|
|
282
|
+
#
|
|
283
|
+
# @param child [Component] must not already have a parent.
|
|
284
|
+
# @param at [Integer, nil] index to insert at; appends when nil.
|
|
285
|
+
# @raise [TypeError] if `child` is not a {Component}.
|
|
286
|
+
# @raise [ArgumentError] if `child` already has a parent.
|
|
287
|
+
# @return [void]
|
|
288
|
+
def add_child(child, at: nil)
|
|
289
|
+
raise TypeError, "expected Component, got #{child.inspect}" unless child.is_a? Component
|
|
290
|
+
raise ArgumentError, "#{child} already has a parent #{child.parent}" unless child.parent.nil?
|
|
291
|
+
|
|
292
|
+
at.nil? ? @children.push(child) : @children.insert(at, child)
|
|
293
|
+
child.parent = self
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Drops `child` and notifies {#on_child_removed}.
|
|
297
|
+
# @param child [Component]
|
|
298
|
+
# @raise [ArgumentError] if `child` is not a child of this component.
|
|
299
|
+
# @return [void]
|
|
300
|
+
def remove_child(child)
|
|
301
|
+
detach_child(child)
|
|
302
|
+
on_child_removed(child)
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
# Drops `child` *without* notifying — for a container swapping a named slot,
|
|
306
|
+
# which owes the {#on_child_removed} call once the new occupant is wired:
|
|
307
|
+
#
|
|
308
|
+
# detach_child(old)
|
|
309
|
+
# @content = new
|
|
310
|
+
# add_child(new, at: 0)
|
|
311
|
+
# on_child_removed(old) # focus repair cascades into the *new* content
|
|
312
|
+
#
|
|
313
|
+
# The child leaves {#children} before its pointer is cleared, so nothing
|
|
314
|
+
# observes a child whose parent has disowned it while still listing it.
|
|
315
|
+
# @param child [Component]
|
|
316
|
+
# @raise [ArgumentError] if `child` is not a child of this component.
|
|
317
|
+
# @return [void]
|
|
318
|
+
def detach_child(child)
|
|
319
|
+
raise ArgumentError, "#{child} is not a child of #{self}" unless @children.include?(child)
|
|
320
|
+
|
|
321
|
+
@children.delete(child)
|
|
322
|
+
child.parent = nil
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
# Called once this component's tree has been mounted on a {ScreenPane},
|
|
326
|
+
# i.e. when {#attached?} flips to true — the place to acquire whatever is
|
|
327
|
+
# supposed to live for exactly as long as the component is on screen:
|
|
328
|
+
#
|
|
329
|
+
# def on_attached
|
|
330
|
+
# @ticker = screen.event_queue.tick_fps(10) { advance }
|
|
331
|
+
# end
|
|
332
|
+
#
|
|
333
|
+
# def on_detached
|
|
334
|
+
# @ticker&.cancel
|
|
335
|
+
# @ticker = nil
|
|
336
|
+
# end
|
|
337
|
+
#
|
|
338
|
+
# `on_attached` starts what `on_detached` stops; both must be cheap and
|
|
339
|
+
# idempotent, since a component moved between parents is genuinely detached
|
|
340
|
+
# in between and gets both, in that order. Whatever you acquire here you
|
|
341
|
+
# must release in {#on_detached} — nothing else will. Not a destructor:
|
|
342
|
+
# process teardown does *not* fire {#on_detached}.
|
|
343
|
+
#
|
|
344
|
+
# {#invalidate} needs no guard: {#attached?} is already true here (and
|
|
345
|
+
# already false in {#on_detached}, where it no-ops). Do not read {#rect} —
|
|
346
|
+
# a parent assigns it *after* wiring, so it is still stale. Runs on the
|
|
347
|
+
# thread that owns the UI.
|
|
348
|
+
# @return [void]
|
|
349
|
+
def on_attached; end
|
|
350
|
+
|
|
351
|
+
# Mirror of {#on_attached}, called once the tree has been unmounted — see
|
|
352
|
+
# there for the contract. Two things are still mid-flight when it runs, both
|
|
353
|
+
# deliberate: {Screen#focused} may still point into this subtree (repair
|
|
354
|
+
# happens after), and the ex-parent's own bookkeeping may not be finished.
|
|
355
|
+
# So release resources here and don't inspect the tree around you.
|
|
356
|
+
# @return [void]
|
|
357
|
+
def on_detached; end
|
|
358
|
+
|
|
359
|
+
# Rewires the parent pointer and, when that changes whether the component is
|
|
360
|
+
# {#attached?}, fires {#on_attached} / {#on_detached} across the whole
|
|
361
|
+
# subtree. The sole firing site: `add_child` / `detach_child` are the only
|
|
362
|
+
# callers, and they update {#children} *before* calling this, so a hook sees
|
|
363
|
+
# a tree whose list and pointers already agree.
|
|
364
|
+
#
|
|
365
|
+
# Reparenting inside an already-attached tree fires nothing (attachedness
|
|
366
|
+
# doesn't change), and neither does building a detached tree.
|
|
367
|
+
# @param new_parent [Component, nil]
|
|
368
|
+
# @return [void]
|
|
369
|
+
def parent=(new_parent)
|
|
370
|
+
was_attached = attached?
|
|
371
|
+
@parent = new_parent
|
|
372
|
+
return if was_attached == attached?
|
|
373
|
+
|
|
374
|
+
fire_lifecycle(attached?)
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Walks self-then-children calling one lifecycle hook, delivering at most one
|
|
378
|
+
# call per component per transition however the hooks mutate the tree. Two
|
|
379
|
+
# guards, because a hook runs *before* its own children are visited:
|
|
380
|
+
#
|
|
381
|
+
# - the **snapshot** covers a child a hook *adds* — it isn't in `kids`, and
|
|
382
|
+
# fires exactly once through its own `parent=`;
|
|
383
|
+
# - the **state re-check** covers a child a hook *removes*. Matching on
|
|
384
|
+
# current attachedness rather than on `parent.equal?(self)`: a child pulled
|
|
385
|
+
# out during a detach walk is *already* detached, so its own `parent=` saw
|
|
386
|
+
# no transition and stayed silent — a parentage check would skip it too and
|
|
387
|
+
# it would never hear `on_detached` at all. The reverse case (pulled out
|
|
388
|
+
# during an *attach* walk) gets `on_detached` from its own `parent=` and no
|
|
389
|
+
# `on_attached`, which is why the hooks are required to be idempotent: an
|
|
390
|
+
# unpaired detach releases nothing, whereas firing `on_attached` at a
|
|
391
|
+
# component that is no longer attached would start a ticker nothing stops.
|
|
392
|
+
#
|
|
393
|
+
# @param attached [Boolean] true to fire {#on_attached}, false for {#on_detached}.
|
|
394
|
+
# @return [void]
|
|
395
|
+
def fire_lifecycle(attached)
|
|
396
|
+
kids = children.dup
|
|
397
|
+
attached ? on_attached : on_detached
|
|
398
|
+
kids.each { _1.fire_lifecycle(attached) if _1.attached? == attached }
|
|
399
|
+
end
|
|
270
400
|
|
|
271
401
|
# Called whenever the component width changes. Does nothing by default.
|
|
272
402
|
# @return [void]
|
|
@@ -300,11 +430,44 @@ module Tuile
|
|
|
300
430
|
total >= rect.width * rect.height
|
|
301
431
|
end
|
|
302
432
|
|
|
303
|
-
# Clears the background:
|
|
304
|
-
#
|
|
433
|
+
# Clears the background: fills every cell with a blank in the
|
|
434
|
+
# {#effective_bg_color} (the terminal default when none is inherited).
|
|
435
|
+
#
|
|
436
|
+
# A component that paints part of its {#rect} itself passes just the part it
|
|
437
|
+
# *doesn't* — blanking a cell it is about to overwrite anyway makes that cell
|
|
438
|
+
# dirty, and {Buffer#flush} then re-emits it even though nothing visibly
|
|
439
|
+
# changed.
|
|
440
|
+
# @param area [Rect] the region to blank; defaults to the whole {#rect}.
|
|
441
|
+
# @return [void]
|
|
442
|
+
def clear_background(area = rect)
|
|
443
|
+
bg = effective_bg_color
|
|
444
|
+
screen.buffer.fill(area, bg ? StyledString::Style.new(bg:) : StyledString::Style::DEFAULT)
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
# {Buffer#set_line} wrapper that fills {#effective_bg_color} behind any span
|
|
448
|
+
# with no bg of its own (via {StyledString#under_bg}), so an inherited
|
|
449
|
+
# {#bg_color} shows through the content a component paints. A no-op layer
|
|
450
|
+
# when nothing is inherited. Self-painters (those skipping the {#repaint}
|
|
451
|
+
# auto-clear) paint through this instead of {Screen#buffer} directly.
|
|
452
|
+
# @param x [Integer] starting column.
|
|
453
|
+
# @param y [Integer] row.
|
|
454
|
+
# @param styled [StyledString]
|
|
455
|
+
# @return [void]
|
|
456
|
+
def draw_line(x, y, styled)
|
|
457
|
+
screen.buffer.set_line(x, y, styled.under_bg(effective_bg_color))
|
|
458
|
+
end
|
|
459
|
+
|
|
460
|
+
# {#draw_line}'s single-grapheme counterpart: writes `grapheme` at `(x, y)`,
|
|
461
|
+
# filling {#effective_bg_color} when `style` carries no bg of its own.
|
|
462
|
+
# @param x [Integer] column.
|
|
463
|
+
# @param y [Integer] row.
|
|
464
|
+
# @param grapheme [String] one grapheme cluster.
|
|
465
|
+
# @param style [StyledString::Style]
|
|
305
466
|
# @return [void]
|
|
306
|
-
def
|
|
307
|
-
|
|
467
|
+
def draw_char(x, y, grapheme, style = StyledString::Style::DEFAULT)
|
|
468
|
+
bg = effective_bg_color
|
|
469
|
+
style = style.merge(bg:) if bg && style.bg.nil?
|
|
470
|
+
screen.buffer.set_char(x, y, grapheme, style)
|
|
308
471
|
end
|
|
309
472
|
end
|
|
310
473
|
end
|
data/lib/tuile/event_queue.rb
CHANGED
|
@@ -48,24 +48,18 @@ module Tuile
|
|
|
48
48
|
end
|
|
49
49
|
|
|
50
50
|
# Schedules `block` to fire on the event-loop thread every `seconds`,
|
|
51
|
-
# passing a 0-based monotonically increasing tick counter
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
-
#
|
|
55
|
-
# (poll a status, redraw a clock). For animation, where frames-per-second
|
|
56
|
-
# is the natural unit, {#tick_fps} reads better.
|
|
57
|
-
#
|
|
58
|
-
# The returned {Ticker} controls the schedule — call {Ticker#cancel} to
|
|
59
|
-
# stop it.
|
|
51
|
+
# passing a 0-based monotonically increasing tick counter — `tick(0.2)`
|
|
52
|
+
# fires five times a second. Use it for periodic UI refresh (poll a status,
|
|
53
|
+
# redraw a clock); for animation, {#tick_fps} reads more naturally. The
|
|
54
|
+
# returned {Ticker} controls the schedule — {Ticker#cancel} stops it.
|
|
60
55
|
#
|
|
61
56
|
# **Errors:** if `block` raises, the {Ticker} cancels itself and the
|
|
62
|
-
# exception flows through the normal event-loop error path
|
|
63
|
-
# {Screen#on_error}
|
|
64
|
-
#
|
|
57
|
+
# exception flows through the normal event-loop error path
|
|
58
|
+
# ({Screen#on_error} by default) — auto-cancel keeps a broken block from
|
|
59
|
+
# spamming `on_error` at the tick rate.
|
|
65
60
|
#
|
|
66
|
-
# Tickers reuse `concurrent-ruby`'s shared timer thread
|
|
67
|
-
#
|
|
68
|
-
# threads, just more work on the shared scheduler.
|
|
61
|
+
# Tickers reuse `concurrent-ruby`'s shared timer thread, so adding more
|
|
62
|
+
# tickers doesn't add threads.
|
|
69
63
|
#
|
|
70
64
|
# @param seconds [Numeric] interval between firings, must be positive.
|
|
71
65
|
# Fractional values are fine (`tick(0.05)` ⇒ ~20 firings a second).
|
|
@@ -149,8 +143,11 @@ module Tuile
|
|
|
149
143
|
end
|
|
150
144
|
end
|
|
151
145
|
|
|
152
|
-
# @return [Boolean] true if
|
|
153
|
-
def
|
|
146
|
+
# @return [Boolean] true if a {#run_loop} is in progress on *any* thread.
|
|
147
|
+
def running? = @run_lock.locked?
|
|
148
|
+
|
|
149
|
+
# @return [Boolean] true if this thread is the one running {#run_loop}.
|
|
150
|
+
def on_loop_thread? = @run_lock.owned?
|
|
154
151
|
|
|
155
152
|
# Stops ongoing {#run_loop}. The stop may not be immediate: {#run_loop} may
|
|
156
153
|
# process a bunch of events before terminating.
|
|
@@ -8,8 +8,17 @@ module Tuile
|
|
|
8
8
|
@tickers = []
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
#
|
|
12
|
-
|
|
11
|
+
# Lets a spec assert that a component started a ticker, and — via
|
|
12
|
+
# {FakeTicker#cancelled?} — that it cancelled one rather than merely
|
|
13
|
+
# dropping it. Cancelled tickers stay here until the next {#tick_once}
|
|
14
|
+
# prunes them.
|
|
15
|
+
# @return [Array<FakeTicker>] the registered tickers, in creation order.
|
|
16
|
+
attr_reader :tickers
|
|
17
|
+
|
|
18
|
+
# @return [Boolean] always false — {#run_loop} raises, so no loop ever runs.
|
|
19
|
+
def running? = false
|
|
20
|
+
# @return [Boolean] always true.
|
|
21
|
+
def on_loop_thread? = true
|
|
13
22
|
# @return [void]
|
|
14
23
|
def stop; end
|
|
15
24
|
|
data/lib/tuile/fake_screen.rb
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
|
-
# Testing only — a screen which doesn't paint anything
|
|
5
|
-
#
|
|
4
|
+
# Testing only — a screen which doesn't paint anything, so the TTY running
|
|
5
|
+
# the tests is not painted over. It runs no event loop, so
|
|
6
|
+
# {Screen#check_locked} admits the thread that called {Screen.fake}: a spec
|
|
7
|
+
# mutating the UI from a *spawned* thread raises, exactly as an app would.
|
|
6
8
|
#
|
|
7
9
|
# Intended for unit-testing individual components: instantiate a component,
|
|
8
10
|
# mutate it, and assert against {#prints} or {#invalidated?}. It does not
|
|
@@ -35,9 +37,6 @@ module Tuile
|
|
|
35
37
|
# on `prints` for cursor and housekeeping escapes.
|
|
36
38
|
attr_reader :prints
|
|
37
39
|
|
|
38
|
-
# @return [void]
|
|
39
|
-
def check_locked; end
|
|
40
|
-
|
|
41
40
|
# @return [void]
|
|
42
41
|
def clear
|
|
43
42
|
@prints.clear
|
data/lib/tuile/fraction.rb
CHANGED
|
@@ -2,16 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
# A width/height ratio, each a float in `0.0..1.0` — the single relational
|
|
5
|
-
# sizing primitive in Tuile
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
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.
|
|
5
|
+
# sizing primitive in Tuile, scoped to one job: sizing a {Component::Popup}
|
|
6
|
+
# against the screen (a popup has no siblings competing for space, so "half
|
|
7
|
+
# the screen, centered" beats a hard-coded cell count that breaks on the next
|
|
8
|
+
# terminal size). It is deliberately *not* a general layout primitive — tiled
|
|
9
|
+
# components get explicit integer rects computed by their parent's `rect=`.
|
|
10
|
+
# See book ch3 for the layout model.
|
|
15
11
|
#
|
|
16
12
|
# Resolve it against a reference {Size} (the screen) to get concrete integer
|
|
17
13
|
# cells:
|