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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +51 -24
  4. data/book/01-first-app.md +186 -0
  5. data/book/02-repaint.md +177 -0
  6. data/book/03-layout.md +379 -0
  7. data/book/04-event-loop.md +209 -0
  8. data/book/05-focus.md +188 -0
  9. data/book/06-theming.md +250 -0
  10. data/book/07-components.md +231 -0
  11. data/book/08-testing.md +186 -0
  12. data/book/README.md +79 -0
  13. data/examples/hello_world.rb +1 -2
  14. data/examples/sampler.rb +109 -0
  15. data/lib/tuile/ansi.rb +16 -0
  16. data/lib/tuile/buffer.rb +481 -0
  17. data/lib/tuile/component/button.rb +3 -14
  18. data/lib/tuile/component/has_content.rb +0 -6
  19. data/lib/tuile/component/info_window.rb +4 -2
  20. data/lib/tuile/component/label.rb +15 -23
  21. data/lib/tuile/component/layout.rb +0 -21
  22. data/lib/tuile/component/list.rb +10 -37
  23. data/lib/tuile/component/log_window.rb +6 -5
  24. data/lib/tuile/component/picker_window.rb +4 -2
  25. data/lib/tuile/component/popup.rb +85 -55
  26. data/lib/tuile/component/text_area.rb +1 -1
  27. data/lib/tuile/component/text_field.rb +1 -1
  28. data/lib/tuile/component/text_input.rb +25 -9
  29. data/lib/tuile/component/text_view.rb +6 -30
  30. data/lib/tuile/component/window.rb +77 -112
  31. data/lib/tuile/component.rb +16 -71
  32. data/lib/tuile/event_queue.rb +31 -10
  33. data/lib/tuile/fake_event_queue.rb +21 -5
  34. data/lib/tuile/fake_screen.rb +14 -1
  35. data/lib/tuile/fraction.rb +46 -0
  36. data/lib/tuile/screen.rb +98 -105
  37. data/lib/tuile/screen_pane.rb +82 -19
  38. data/lib/tuile/styled_string.rb +40 -30
  39. data/lib/tuile/version.rb +1 -1
  40. data/sig/tuile.rbs +653 -282
  41. metadata +13 -3
  42. 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 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
@@ -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
- frame = build_frame(frame_caption)
206
- frame = screen.theme.active_border(frame) if active?
207
- screen.print frame
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
- # Recomputes the window's natural size: content's natural size (or the
247
- # caption, whichever is wider) plus the 2-character border. The footer
248
- # is deliberately excluded — see {#on_child_content_size_changed}. A
249
- # window with no content or caption sizes to `Size.new(2, 2)` (bare
250
- # border).
251
- # @return [void]
252
- def update_content_size
253
- inner_w = [content&.content_size&.width || 0, frame_caption.length].max
254
- inner_h = content&.content_size&.height || 0
255
- self.content_size = Size.new(inner_w + 2, inner_h + 2)
256
- end
257
-
258
- # Positions the footer over the bottom border row, with its width
259
- # resolved by {#footer_sizing} against the inner width. A
260
- # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
261
- # rect — i.e. it is invisible, as if never assigned.
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
- available = [rect.width - 2, 0].max
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
@@ -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
- # Also called for inactive components. Inactive component should just return
87
- # false.
88
- #
89
- # Default implementation searches for a component with {#key_shortcut} and
90
- # focuses it. The shortcut search is suppressed while the focused component
91
- # owns the hardware cursor (e.g. a {Component::TextField} the user is
92
- # typing into) so that hotkeys don't steal printable keys from the editor.
93
- # @param key [String] a key.
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(key)
96
- return false unless screen.cursor_position.nil?
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
- # @param parent [Component, nil]
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
- return if rect.empty?
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
@@ -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
@@ -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 `fps` argument is
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 fps [Numeric] firings per second, must be positive. Validated
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(fps, &block)
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
- FakeTicker.new(block).tap { |t| @tickers << t }
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
@@ -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} printed so far.
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