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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
@@ -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
- # Repaints the component.
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
- # The default does the bookkeeping that almost every component would
53
- # otherwise have to remember: it clears the background and re-invalidates
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
- # - Leaf (no children): always clears, so subclasses can paint their
57
- # content directly without an explicit `clear_background` call.
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
- # Subclasses that paint their entire rect themselves (e.g. {Window}'s
67
- # border draws over the area the default would clear; {Component::List}
68
- # explicitly paints every row) may skip super and take full
69
- # responsibility for {#rect}. Everything else should call super.
70
- #
71
- # A component must not draw outside of {#rect}.
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
- # Only called when the component is attached.
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 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.
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 ({#handle_mouse}) and the focus-cascade
144
- # in container components ({HasContent#on_focus}, {Layout#on_focus}).
145
- # Independent from {#active?}: every component carries the active flag, but
146
- # only focusable ones can become a focus target that puts themselves and
147
- # their ancestors on the active chain.
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
- # List of child components, defaults to an empty array.
176
- # @return [Array<Component>] child components. Must not be mutated! May be
177
- # empty.
178
- def children = []
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
- # assignment and on OS appearance flips.
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
- # Built-in components read {Screen#theme} at paint time, so their accents
208
- # restyle automatically; this hook exists for *content* whose colors the
209
- # app baked in from the old theme — a {Label#text} / {List#lines} /
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
- # @return [Boolean] true if this component's tree is currently mounted on
227
- # the {Screen}, i.e. its root is the {ScreenPane}.
228
- def attached? = root == screen.pane
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
- # @return [Component, nil]
269
- attr_writer :parent
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: prints spaces into all characters occupied by the
304
- # component's rect.
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 clear_background
307
- screen.buffer.fill(rect)
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
@@ -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. 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.
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 — i.e.
63
- # {Screen#on_error} for the default Tuile setup. Auto-cancel prevents a
64
- # broken block from spamming `on_error` at the tick rate.
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
- # ({Concurrent}.global_timer_set) — adding more tickers does not add more
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 this thread is running inside an event queue.
153
- def locked? = @run_lock.owned?
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
- # @return [Boolean]
12
- def locked? = true
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
 
@@ -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 and pretends that the
5
- # lock is held. This way, the TTY running the tests is not painted over.
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
@@ -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. 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.
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: