tuile 0.8.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.
@@ -4,18 +4,30 @@ module Tuile
4
4
  class Component
5
5
  # An overlay that wraps any {Component} as its content. Popup itself
6
6
  # paints nothing — it's a transparent host that handles its lifecycle
7
- # ({#open} / {#close} / {#open?}, ESC/q to close) and auto-sizes to the
8
- # wrapped content.
7
+ # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
8
+ # {#size} the {Screen} applies.
9
+ #
10
+ # The popup does *not* size itself to its content. Its box is declared by
11
+ # {#size} — a {Fraction} (resolved against the screen every layout pass, so
12
+ # it tracks resize) or an absolute {Size} (clamped to the screen). The
13
+ # default is {Fraction::HALF}: half the screen, centered. The wrapped
14
+ # content then fills that box and handles its own overflow by wrapping and
15
+ # scrolling, so use content that can — a {Component::TextView} or
16
+ # {Component::TextArea} — for anything longer than fits. A
17
+ # {Component::Label} only truncates.
9
18
  #
10
19
  # Modal by default: it centers on the screen, grabs focus, eats keys, and
11
20
  # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
12
- # that floats above the content (still painted on top, still auto-sized)
13
- # without taking focus or capturing input — the caller positions it (via
14
- # {#rect=}) and drives it from app code. That is the building block for an
15
- # autocomplete/slash-command list anchored to a {Component::TextField} or
16
- # {Component::TextArea} caret: typing keeps focus (and the cursor) in the
17
- # input, an {Component::TextInput#on_change} listener refills the list, and
18
- # an {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
21
+ # that floats above the content (still painted on top) without taking focus
22
+ # or capturing input — the caller positions it (via {#rect=}) and drives it
23
+ # from app code. That is the building block for an autocomplete/slash-command
24
+ # list anchored to a {Component::TextField} or {Component::TextArea} caret:
25
+ # typing keeps focus (and the cursor) in the input, an
26
+ # {Component::TextInput#on_change} listener refills the list, and an
27
+ # {Component::TextInput#on_key} interceptor forwards Up/Down/Enter to it.
28
+ # Such a caller owns the list data, so it sizes the overlay itself
29
+ # (`overlay.size = Size.new(longest, [items.size, 8].min)`) — still
30
+ # caller-decides, top-down.
19
31
  #
20
32
  # The wrapped content fills the popup's full {#rect}; if you want a frame
21
33
  # and caption, wrap a {Component::Window} (or any subclass — including
@@ -36,22 +48,43 @@ module Tuile
36
48
  include Component::HasContent
37
49
 
38
50
  # @param content [Component, nil] initial content; can be set later via
39
- # {#content=}. When provided here, the popup auto-sizes to fit.
51
+ # {#content=}. The content fills the popup's {#rect}; it does not
52
+ # determine the popup's size.
40
53
  # @param modal [Boolean] true (default) for a centered, focus-grabbing,
41
54
  # input-capturing modal; false for a non-modal overlay the caller
42
55
  # positions and drives (see the class docs).
43
- def initialize(content: nil, modal: true)
56
+ # @param size [Size, Fraction] the popup's size, applied top-down. A
57
+ # {Fraction} is resolved against the screen each layout pass; a {Size}
58
+ # is clamped to the screen. Defaults to {Fraction::HALF}.
59
+ def initialize(content: nil, modal: true, size: Fraction::HALF)
44
60
  super()
45
61
  @modal = modal
62
+ @size = size
46
63
  @content = nil
47
64
  self.content = content unless content.nil?
65
+ reposition
48
66
  end
49
67
 
68
+ # @return [Size, Fraction] the popup's declared size. See {#size=}.
69
+ attr_reader :size
70
+
50
71
  # @return [Boolean] whether this popup is modal. See {#initialize}.
51
72
  def modal? = @modal
52
73
 
53
74
  def focusable? = true
54
75
 
76
+ # Sets the popup's size and repositions it. Accepts a {Fraction}
77
+ # (resolved against the screen every layout pass, so it tracks resize) or
78
+ # an absolute {Size} (clamped to the screen). This is **authoritative**,
79
+ # not a preference: the screen applies exactly what you ask for (clamped),
80
+ # with no negotiation — a popup has no siblings to compete with.
81
+ # @param new_size [Size, Fraction]
82
+ # @return [void]
83
+ def size=(new_size)
84
+ @size = new_size
85
+ reposition
86
+ end
87
+
55
88
  # Reassigns the popup's rect, escalating to a full scene repaint when an
56
89
  # open popup shrinks or moves so its new rect no longer covers the cells
57
90
  # it previously painted. A popup overdraws the scene without clipping and
@@ -67,20 +100,21 @@ module Tuile
67
100
  screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
68
101
  end
69
102
 
70
- # Mounts this popup on the {Screen}. Recomputes the popup's size from
71
- # the current content first, so reopening a popup whose content has
72
- # grown or shrunk while closed picks up the new size.
103
+ # Mounts this popup on the {Screen}, re-resolving its {#size} against the
104
+ # current screen first.
73
105
  # @return [void]
74
106
  def open
75
- update_rect unless @content.nil?
107
+ reposition
76
108
  screen.add_popup(self)
77
109
  end
78
110
 
79
111
  # Constructs and opens a popup in one call.
80
112
  # @param content [Component, nil]
113
+ # @param modal [Boolean] see {#initialize}.
114
+ # @param size [Size, Fraction] see {#initialize}.
81
115
  # @return [Popup] the opened popup.
82
- def self.open(content: nil)
83
- Popup.new(content: content).tap(&:open)
116
+ def self.open(content: nil, modal: true, size: Fraction::HALF)
117
+ Popup.new(content: content, modal: modal, size: size).tap(&:open)
84
118
  end
85
119
 
86
120
  # Removes this popup from the {Screen}. No-op if not currently open.
@@ -94,44 +128,29 @@ module Tuile
94
128
  screen.has_popup?(self)
95
129
  end
96
130
 
97
- # Recenters the popup on the screen, preserving its current width/height.
98
- # Called automatically by the screen's layout pass and by {#content=}
99
- # when the popup is open.
131
+ # Re-resolves {#size} against the current screen and repositions the popup
132
+ # *itself* (this is not laying out content — the popup's own rect): a
133
+ # modal popup recenters; a non-modal overlay keeps its caller-assigned
134
+ # top-left (only its size follows the screen). Called on {#open}, on
135
+ # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
136
+ # SIGWINCH).
137
+ #
138
+ # The final rect is computed and assigned in one step rather than sizing
139
+ # at the origin and then centering: the intermediate origin rect rarely
140
+ # covers the previous one, which would make {#rect=}'s shrink/move
141
+ # detection fire a full repaint on every resize.
100
142
  # @return [void]
101
- def center
102
- self.rect = rect.centered(screen.size)
103
- end
104
-
105
- # @return [Integer] max height the popup will grow to fit its content.
106
- # Defers to the content's {Component#popup_max_height} advice when it
107
- # gives one, else defaults to 12. Override in a subclass to allow
108
- # taller popups regardless of content.
109
- def max_height = @content&.popup_max_height || 12
110
-
111
- # @return [Integer] min height the popup occupies even when its content
112
- # is shorter. Defers to the content's {Component#popup_min_height}
113
- # advice when it gives one, else defaults to 0 (size purely to
114
- # content) — so a {Component::LogWindow} stays readable while only a
115
- # few lines are in without callers wiring up a subclass. Override in a
116
- # subclass to keep any popup from collapsing to a couple of rows.
117
- # Capped at the same 4/5-of-screen ceiling {#update_rect} applies.
118
- def min_height = @content&.popup_min_height || 0
119
-
120
- # Sets the popup's content and auto-sizes the popup to fit.
121
- # @param new_content [Component, nil]
122
- def content=(new_content)
123
- super
124
- update_rect unless new_content.nil?
143
+ def reposition
144
+ size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
145
+ r = Rect.new(rect.left, rect.top, size.width, size.height)
146
+ r = r.centered(screen.size) if modal?
147
+ self.rect = r
125
148
  end
126
149
 
127
- # Re-sizes (and recenters, when open) whenever the wrapped content's
128
- # natural size changes — e.g. a {Label}'s `text=`, a {List}'s
129
- # `add_line`, or a nested {Window} whose own content grew (the window
130
- # recomputes its {Component#content_size} and the change bubbles here).
131
- # @param _child [Component]
150
+ # Recenters the popup on the screen, preserving its current width/height.
132
151
  # @return [void]
133
- def on_child_content_size_changed(_child)
134
- update_rect
152
+ def center
153
+ self.rect = rect.centered(screen.size)
135
154
  end
136
155
 
137
156
  # Hint for the status bar: own "q Close" plus the wrapped content's hint.
@@ -164,30 +183,6 @@ module Tuile
164
183
  def layout(content)
165
184
  content.rect = rect
166
185
  end
167
-
168
- private
169
-
170
- # Recompute width/height from {#content}'s natural size and recenter
171
- # if currently open. Called whenever content is (re)assigned.
172
- #
173
- # Computes the final (centered) rect and assigns it in one step rather
174
- # than positioning at the origin and then centering: the intermediate
175
- # origin rect rarely covers the previous one, which would make
176
- # {#rect=}'s shrink/move detection fire a full repaint on every resize.
177
- # @return [void]
178
- def update_rect
179
- ceiling = screen.size.height * 4 / 5
180
- size = @content.content_size.clamp_height(max_height)
181
- size = size.clamp(Size.new(screen.size.width * 4 / 5, ceiling))
182
- floor = min_height.clamp(0, ceiling)
183
- size = Size.new(size.width, floor) if size.height < floor
184
- # A non-modal overlay is positioned by the caller, so an open one keeps
185
- # its current top-left when its content resizes; a modal popup recenters.
186
- origin = open? && !modal? ? Point.new(rect.left, rect.top) : Point.new(0, 0)
187
- r = Rect.new(origin.x, origin.y, size.width, size.height)
188
- r = r.centered(screen.size) if open? && modal?
189
- self.rect = r
190
- end
191
186
  end
192
187
  end
193
188
  end
@@ -122,7 +122,6 @@ module Tuile
122
122
  rewrap
123
123
  update_top_line_if_auto_scroll
124
124
  invalidate
125
- self.content_size = compute_content_size
126
125
  end
127
126
 
128
127
  # Creates a new empty {Region} at the spatial tail of the document
@@ -191,7 +190,6 @@ module Tuile
191
190
  @text = nil
192
191
  update_top_line_if_auto_scroll
193
192
  invalidate
194
- self.content_size = compute_content_size
195
193
  end
196
194
 
197
195
  # Verbatim append, returning `self` for chainability (`view << a << b`).
@@ -266,7 +264,6 @@ module Tuile
266
264
  @top_line = top_line_max if @top_line > top_line_max
267
265
  update_top_line_if_auto_scroll
268
266
  invalidate
269
- self.content_size = compute_content_size
270
267
  end
271
268
 
272
269
  # Replaces a contiguous range of hard lines with the parsed content
@@ -327,7 +324,6 @@ module Tuile
327
324
  @top_line = top_line_max if @top_line > top_line_max
328
325
  update_top_line_if_auto_scroll
329
326
  invalidate
330
- self.content_size = compute_content_size
331
327
  end
332
328
 
333
329
  # Inserts `str` at hard-line index `at`. Equivalent to
@@ -386,13 +382,6 @@ module Tuile
386
382
 
387
383
  def tab_stop? = true
388
384
 
389
- # @return [Size] longest hard-line's display width × number of hard
390
- # lines. Reported on the *unwrapped* text — wrap-aware sizing would
391
- # be circular (width depends on width). Empty text returns
392
- # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
393
- # {#append}, so reads are O(1).
394
- attr_reader :content_size
395
-
396
385
  # @param key [String]
397
386
  # @return [Boolean]
398
387
  def handle_key(key)
@@ -544,7 +533,6 @@ module Tuile
544
533
  @top_line = top_line_max if @top_line > top_line_max
545
534
  update_top_line_if_auto_scroll
546
535
  invalidate
547
- self.content_size = compute_content_size
548
536
  end
549
537
 
550
538
  # Region-scoped {#replace}. Validates `range` against
@@ -570,7 +558,6 @@ module Tuile
570
558
  @top_line = top_line_max if @top_line > top_line_max
571
559
  update_top_line_if_auto_scroll
572
560
  invalidate
573
- self.content_size = compute_content_size
574
561
  end
575
562
 
576
563
  # Verbatim append into `region`.
@@ -610,7 +597,6 @@ module Tuile
610
597
  @top_line = top_line_max if @top_line > top_line_max
611
598
  update_top_line_if_auto_scroll
612
599
  invalidate
613
- self.content_size = compute_content_size
614
600
  end
615
601
 
616
602
  # Drops the last `n` hard lines from `region`'s tail via
@@ -632,7 +618,6 @@ module Tuile
632
618
  @top_line = top_line_max if @top_line > top_line_max
633
619
  update_top_line_if_auto_scroll
634
620
  invalidate
635
- self.content_size = compute_content_size
636
621
  end
637
622
 
638
623
  # Drops `region` from {@regions}: its hard lines are removed via
@@ -658,7 +643,6 @@ module Tuile
658
643
  @top_line = top_line_max if @top_line > top_line_max
659
644
  update_top_line_if_auto_scroll
660
645
  invalidate
661
- self.content_size = compute_content_size
662
646
  end
663
647
 
664
648
  # Adjusts region line counts after a {@hard_lines} splice that
@@ -832,13 +816,6 @@ module Tuile
832
816
  StyledString.new(spans)
833
817
  end
834
818
 
835
- # @return [Size] {#content_size} computed from {@hard_lines}.
836
- def compute_content_size
837
- return Size::ZERO if @hard_lines.empty?
838
-
839
- Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
840
- end
841
-
842
819
  # @return [Integer] column width available for wrapped text — viewport
843
820
  # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
844
821
  # width is non-positive, which yields a degenerate "no wrap" result.
@@ -20,38 +20,46 @@ module Tuile
20
20
  @border_right = 1
21
21
  @caption = caption
22
22
  @content = nil
23
- # Optional bottom-row chrome that overlays the bottom border (e.g. a
24
- # search field).
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
- @footer_sizing = Sizing::FILL
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 overlaying the bottom border
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 [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.
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
@@ -122,36 +130,6 @@ module Tuile
122
130
  def caption=(new_caption)
123
131
  @caption = new_caption
124
132
  invalidate
125
- update_content_size
126
- end
127
-
128
- # Sets the new content. Also recomputes the window's natural size.
129
- # @param new_content [Component, nil]
130
- def content=(new_content)
131
- super
132
- update_content_size
133
- end
134
-
135
- # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
136
- # size changes, and folds a content resize into the window's own
137
- # natural size (whose change then bubbles to the window's parent — e.g.
138
- # a {Popup} re-self-sizes). The footer deliberately does *not*
139
- # participate in the window's {#content_size}: it is decoration
140
- # overlaying the border, and must not drive the window's size — if it
141
- # doesn't fit, it is clipped to the inner width.
142
- # @param child [Component]
143
- # @return [void]
144
- def on_child_content_size_changed(child)
145
- if child.equal?(@footer)
146
- old_rect = @footer.rect
147
- layout_footer
148
- # Repaint on any footer geometry change: a shrinking footer vacates
149
- # border cells that must be re-dashed (a growing one merely
150
- # overdraws, but distinguishing isn't worth the code).
151
- invalidate if @footer.rect != old_rect
152
- else
153
- update_content_size
154
- end
155
133
  end
156
134
 
157
135
  # Fully repaints the window: both frame and contents.
@@ -178,7 +156,6 @@ module Tuile
178
156
  super
179
157
  # The shortcut key is shown in the caption — repaint.
180
158
  invalidate
181
- update_content_size
182
159
  end
183
160
 
184
161
  protected
@@ -213,7 +190,26 @@ module Tuile
213
190
  buf.set_char(left, top + dy, "│", bar)
214
191
  buf.set_char(left + w - 1, top + dy, "│", bar)
215
192
  end
216
- buf.set_line(left, top + h - 1, StyledString.styled("└#{"─" * inner_w}┘", fg: fg)) if h >= 2
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)
217
213
  end
218
214
 
219
215
  # The caption text as it appears in the rendered border, including the
@@ -226,28 +222,14 @@ module Tuile
226
222
 
227
223
  private
228
224
 
229
- # Recomputes the window's natural size: content's natural size (or the
230
- # caption, whichever is wider) plus the 2-character border. The footer
231
- # is deliberately excluded — see {#on_child_content_size_changed}. A
232
- # window with no content or caption sizes to `Size.new(2, 2)` (bare
233
- # border).
234
- # @return [void]
235
- def update_content_size
236
- inner_w = [content&.content_size&.width || 0, frame_caption.length].max
237
- inner_h = content&.content_size&.height || 0
238
- self.content_size = Size.new(inner_w + 2, inner_h + 2)
239
- end
240
-
241
- # Positions the footer over the bottom border row, with its width
242
- # resolved by {#footer_sizing} against the inner width. A
243
- # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
244
- # 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).
245
228
  # @return [void]
246
229
  def layout_footer
247
230
  return if @footer.nil? || rect.empty?
248
231
 
249
- available = [rect.width - 2, 0].max
250
- width = @footer_sizing.resolve(available, @footer.content_size.width)
232
+ width = [rect.width - 2, 0].max
251
233
  @footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
252
234
  end
253
235
  end
@@ -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
 
@@ -252,30 +251,6 @@ module Tuile
252
251
  end
253
252
  end
254
253
 
255
- # The {Size} big enough to show the entire component contents without
256
- # scrolling. Plain components have no intrinsic content and report
257
- # {Size::ZERO}; content-bearing components (e.g. {Label}, {List},
258
- # {TextView}, {Window}) maintain it eagerly via {#content_size=} from
259
- # their mutators, so reads are O(1). Used by callers like
260
- # {Component::Popup} to auto-size to whatever content was assigned,
261
- # regardless of its concrete type, and by {Sizing::WRAP_CONTENT} slots.
262
- # @return [Size]
263
- attr_reader :content_size
264
-
265
- # Called by a child component whose {#content_size} just changed (fired
266
- # from the child's {#content_size=}). Does nothing by default — a plain
267
- # container is not size-coupled to its children. Containers that derive
268
- # their own natural size or child layout from a child's natural size
269
- # override this (e.g. {Component::Window} re-lays-out a
270
- # {Sizing::WRAP_CONTENT} footer and recomputes its own size from content;
271
- # {Component::Popup} re-self-sizes). If the receiver's own
272
- # {#content_size} changes as a consequence, its {#content_size=} notifies
273
- # *its* parent in turn — so the event bubbles exactly as far as geometry
274
- # keeps changing, and stops where it doesn't.
275
- # @param child [Component] the resized direct child.
276
- # @return [void]
277
- def on_child_content_size_changed(child); end
278
-
279
254
  # Where the hardware terminal cursor should sit when this component is the
280
255
  # cursor owner. Returns `nil` to indicate the cursor should be hidden. The
281
256
  # {Screen} positions the hardware cursor after each repaint cycle by
@@ -288,49 +263,15 @@ module Tuile
288
263
  # topmost popup. Empty by default; override to advertise shortcuts.
289
264
  def keyboard_hint = ""
290
265
 
291
- # Advice to a wrapping {Component::Popup} on the minimum height this
292
- # component prefers to occupy when shown in a popup. `nil` (the default)
293
- # means no preference — the popup uses its own {Component::Popup#min_height}.
294
- # Override in a content component that should not collapse to a couple of
295
- # rows when sparse (e.g. {Component::LogWindow}).
296
- # @return [Integer, nil]
297
- def popup_min_height = nil
298
-
299
- # Advice to a wrapping {Component::Popup} on the maximum height this
300
- # component may grow to when shown in a popup. `nil` (the default) means
301
- # no preference — the popup uses its own {Component::Popup#max_height}.
302
- # @return [Integer, nil]
303
- def popup_max_height = nil
304
-
305
266
  protected
306
267
 
307
- # @param parent [Component, nil]
268
+ # @return [Component, nil]
308
269
  attr_writer :parent
309
270
 
310
271
  # Called whenever the component width changes. Does nothing by default.
311
272
  # @return [void]
312
273
  def on_width_changed; end
313
274
 
314
- # Memoizes the component's natural size and notifies {#parent} via
315
- # {#on_child_content_size_changed} when the value actually changed.
316
- # Subclasses call this from their content mutators (`text=`, `add_lines`,
317
- # `caption=`, …) instead of caching ad-hoc.
318
- #
319
- # Call this as the *last* step of a mutator: the parent hook may
320
- # reentrantly reposition this component (assign {#rect} — e.g. {Window}
321
- # re-laying-out a wrap-content footer, or {Popup} re-self-sizing), which
322
- # triggers {#on_width_changed} and {#repaint}-related recomputation, so
323
- # all internal state must already be consistent.
324
- # @param new_size [Size]
325
- # @return [void]
326
- def content_size=(new_size)
327
- raise TypeError, "expected Size, got #{new_size.inspect}" unless new_size.is_a?(Size)
328
- return if @content_size == new_size
329
-
330
- @content_size = new_size
331
- parent&.on_child_content_size_changed(self)
332
- end
333
-
334
275
  # Invalidates the component: {Screen} records this component as
335
276
  # needs-repaint and once all events are processed, will call {#repaint}.
336
277
  #
@@ -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 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.
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 (`fps: 0.5` ⇒ one tick every two seconds).
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 tick(fps, &block)
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
- Ticker.new(self, fps, block)
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 fps [Numeric] firings per second (positive).
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, fps, block)
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: 1.0 / fps) do
263
+ @timer = Concurrent::TimerTask.new(execution_interval: interval) do
243
264
  @event_queue.submit { fire }
244
265
  end
245
266
  @timer.execute