tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
data/lib/tuile/screen.rb CHANGED
@@ -21,7 +21,7 @@ module Tuile
21
21
  #
22
22
  # Tuile draws no chrome of its own: there is no status bar and no reserved
23
23
  # row, so {#content} gets the whole terminal. An app that wants a status line
24
- # builds one into its own layout and drives it from {#on_focus_changed=}
24
+ # builds one into its own layout and drives it from {#on_focus_changed}
25
25
  # (`D_status_bar`).
26
26
  #
27
27
  # ## Repaint model
@@ -55,15 +55,32 @@ module Tuile
55
55
  # {FakeScreen} — which captures output in memory — is what
56
56
  # {Screen.instance} returns under test.
57
57
  class Screen
58
+ extend Listeners::Declare
59
+
58
60
  # Class variable (not class instance var) so the singleton survives
59
61
  # subclassing — `FakeScreen < Screen` and `Screen.instance` see the same slot.
60
62
  @@instance = nil # rubocop:disable Style/ClassVars
61
63
 
64
+ # What {#on_focus_changed} fires.
65
+ #
66
+ # @!attribute [r] source
67
+ # @return [Screen] the screen whose {#focused} changed; read {#focused}
68
+ # for what it changed to.
69
+ FocusChangedEvent = Data.define(:source) { include Tuile::Event }
70
+
62
71
  def initialize
63
72
  @@instance = self # rubocop:disable Style/ClassVars
64
73
  @event_queue = EventQueue.new
65
74
  @size = EventQueue::TTYSizeEvent.create.size
66
75
  @invalidated = Set.new
76
+ # Whether some container may owe a {Component#relayout} — the O(1) gate
77
+ # that lets a no-op {#flush_layout} skip the walk; the containers' own
78
+ # flags are the queue.
79
+ @layout_pending = false
80
+ # A {#focused=} made inside a pass whose scroll and notice wait for the
81
+ # drain, and what was focused before the first such call.
82
+ @focus_deferred = false
83
+ @focus_deferred_from = nil
67
84
  # Components being repainted right now. A component may invalidate its
68
85
  # children during its repaint phase; this prevents double-draw.
69
86
  @repainting = Set.new
@@ -77,23 +94,26 @@ module Tuile
77
94
  @color_depth = detect_color_depth
78
95
  @locale = detect_locale
79
96
  @theme_def = ThemeDef.default
80
- @theme = @theme_def.for(@color_scheme)
97
+ # The theme as assigned, derivation Procs and all; {#theme} is this
98
+ # resolved against {#background_color}, re-resolved when either changes.
99
+ @theme_source = @theme_def.for(@color_scheme)
100
+ @theme = @theme_source.resolve(@background_color)
81
101
  # Structural root of the component tree: holds tiled content and the
82
- # popup stack. Sized here rather than waiting for the first {#layout},
102
+ # popup stack. Sized here rather than waiting for the first {#resize},
83
103
  # for the same reason {#size} is seeded from {EventQueue::TTYSizeEvent}:
84
104
  # an empty pane rect is an *ancestor* empty rect, and {#repaint}'s drain
85
105
  # filter would take the whole tree with it.
86
106
  @pane = ScreenPane.new
87
- @pane.rect = Rect.new(0, 0, @size.width, @size.height)
107
+ size_pane
88
108
  @mouse_router = Mouse::Router.new(self)
89
- @on_error = ->(e) { raise e }
90
109
  # App-level keyboard shortcuts dispatched by {#handle_key?} before keys
91
110
  # reach the pane. See {#register_global_shortcut}.
92
111
  @global_shortcuts = {}
93
112
  # The back buffer components paint into. {#repaint} flushes its diff to
94
113
  # the terminal, so only changed cells are emitted (flicker-free on any
95
- # terminal). Sized to the current viewport; {#layout} resizes it.
114
+ # terminal). Sized to the current viewport; {#resize} resizes it.
96
115
  @buffer = Buffer.new(@size, color_depth: @color_depth)
116
+ @canvas = Canvas.new(@buffer)
97
117
  end
98
118
 
99
119
  # Entry in the global shortcut registry: the block to run, and whether it
@@ -142,25 +162,112 @@ module Tuile
142
162
  # ({Buffer#set_text} / {Buffer#fill} / {Buffer#set_char}).
143
163
  attr_reader :buffer
144
164
 
145
- # Handler invoked when a {StandardError} escapes an event handler inside
146
- # the event loop (e.g. a {Component::TextField}'s `on_change` raises).
165
+ # The untinted root canvas over {#buffer}, at the buffer's own origin,
166
+ # which {#canvas_for} derives every component's from. Nothing in `lib/`
167
+ # paints through it: a component paints onto the canvas it is *given*,
168
+ # never this one by name (`D_canvas`).
169
+ # @return [Canvas]
170
+ attr_reader :canvas
171
+
172
+ # The canvas `component` paints onto: one over {#buffer} carrying that
173
+ # component's resolved background, positioned where it sits on screen and
174
+ # bounded by what its ancestors allow — so it writes at `(0, 0)`, an
175
+ # inherited tint shows through and a scrolled-away row goes nowhere, with
176
+ # the component doing none of it. Summing the offsets is this method's job,
177
+ # which is what leaves every {Component#rect} parent-relative
178
+ # (`D_relative_rect`).
147
179
  #
148
- # The default re-raises, so the exception propagates out of
149
- # {#run_event_loop} and crashes the script with a stacktrace — unhandled
150
- # exceptions are bugs and should be surfaced loudly.
180
+ # label.repaint(screen.canvas_for(label)) # paint one component, as a spec does
151
181
  #
152
- # Replace it when the host has somewhere visible to put errors, e.g. a
153
- # {Component::LogWindow} wired to {Tuile.logger}:
182
+ # With `root:` it is positioned and bounded within that ancestor instead of
183
+ # the screen, so a subtree paints into a buffer of its own:
154
184
  #
155
- # screen.on_error = lambda do |e|
156
- # Tuile.logger.error("#{e.class}: #{e.message}\n#{e.backtrace&.join("\n")}")
157
- # end
185
+ # screen.canvas_for(list, backend: Buffer.new(window.rect.size), root: window)
186
+ #
187
+ # The **sole converter** into backend coordinates: {#clip_for} answers in
188
+ # the component's own space and the one `moved_by` here puts it where
189
+ # {Canvas} keeps it, beside the origin (`D_clip`).
190
+ # @param component [Component]
191
+ # @param backend [Canvas::Backend] where the cells land; the screen's
192
+ # {#buffer} unless given.
193
+ # @param root [Component, nil] `component` itself or one of its ancestors;
194
+ # `nil` is the screen.
195
+ # @return [Canvas]
196
+ def canvas_for(component, backend: @buffer, root: nil)
197
+ # Built rather than derived from #canvas, since {Canvas#with} is
198
+ # block-only. __send__ because Component#bg is protected: the
199
+ # framework paints with it, an app never asks (`D_bg_surface`).
200
+ origin = component.to_screen(Point::ZERO)
201
+ unless root.nil?
202
+ base = root.to_screen(Point::ZERO)
203
+ origin = Point.new(origin.x - base.x, origin.y - base.y)
204
+ end
205
+ Canvas.new(backend, bg_color: component.__send__(:bg).effective,
206
+ origin:,
207
+ clip: clip_for(component, root:).moved_by(origin))
208
+ end
209
+
210
+ # The cells `component` may write, in **its own** coordinates: its
211
+ # {Component#local_rect} intersected with every ancestor's, each folded in
212
+ # as the walk climbs.
213
+ #
214
+ # Every component is bounded, and a component cannot widen its own bounds —
215
+ # which is why this lives here rather than as a hook on {Component}. A
216
+ # container that wants to allow its children *less* than its own box has no
217
+ # way to say so yet; `clip_rect_for(child)` is the shape that would, and it
218
+ # is deferred with no caller (`D_clip`).
219
+ #
220
+ # Resolved per call and never cached, like the origin it rides beside: an
221
+ # ancestor may scroll between two frames. {#clipped?} skips the fold
222
+ # entirely in the common case, which is what keeps that affordable.
223
+ # @param component [Component]
224
+ # @param root [Component, nil] the last ancestor folded in — `component`
225
+ # itself or one of its ancestors; `nil` folds in every one.
226
+ # @return [Rect] never `nil`; {Rect#empty? empty} exactly when the component
227
+ # can show nothing — scrolled clean out of its viewport, collapsed, or
228
+ # under an ancestor allowing no cell. The own rect being folded in, that
229
+ # one predicate answers *is any of this visible*.
230
+ def clip_for(component, root: nil)
231
+ return component.local_rect unless clipped?(component, root)
232
+
233
+ clip = component.local_rect
234
+ x = 0
235
+ y = 0
236
+ node = component
237
+ while !node.equal?(root) && (up = node.parent)
238
+ # Intersection only ever shrinks, so the rest of the chain cannot change
239
+ # an empty answer. Worth the test: a scroller's off-screen children land
240
+ # here on every scroll, one per row it is not showing (`D_clip`).
241
+ return clip if clip.empty?
242
+
243
+ x -= node.rect.left
244
+ y -= node.rect.top
245
+ clip = clip.intersect(up.local_rect.moved_by(Point.new(x, y)))
246
+ node = up
247
+ end
248
+ clip
249
+ end
250
+
251
+ # @!method on_error
252
+ # Fired with an {EventQueue::ErrorEvent} when a {StandardError} escapes an
253
+ # event handler inside the event loop (e.g. a {Component::TextField}'s
254
+ # `on_value_change` listener raises). The one slot whose event carries no
255
+ # `source`: its listener wants `error`.
256
+ #
257
+ # **Empty means re-raise**, so the exception propagates out of
258
+ # {#run_event_loop} and crashes the script with a stacktrace — unhandled
259
+ # exceptions are bugs and should be surfaced loudly. Registering anything
260
+ # at all takes that over:
261
+ #
262
+ # screen.on_error do |e|
263
+ # Tuile.logger.error("#{e.error.class}: #{e.error.message}")
264
+ # end
158
265
  #
159
- # The handler runs on the event-loop thread with the UI lock held.
160
- # Returning normally keeps the loop alive; raising from within the handler
161
- # tears the loop down and propagates out of {#run_event_loop}.
162
- # @return [Proc] one-arg callable receiving the {StandardError} instance.
163
- attr_accessor :on_error
266
+ # A listener runs on the event-loop thread with the UI lock held.
267
+ # Returning normally keeps the loop alive; raising tears the loop down and
268
+ # propagates out of {#run_event_loop}.
269
+ # @return [Listeners]
270
+ listener :on_error
164
271
 
165
272
  # @return [Screen] the singleton instance.
166
273
  def self.instance
@@ -185,7 +292,7 @@ module Tuile
185
292
  # pane to forward to, and NoMethodError-for-nil is a poor error.
186
293
  check_locked
187
294
  @pane.content = content
188
- layout
295
+ resize
189
296
  end
190
297
 
191
298
  # @return [Size] current screen size.
@@ -197,6 +304,9 @@ module Tuile
197
304
  # dark). While the event loop runs, terminals supporting mode 2031
198
305
  # push OS appearance changes ({EventQueue::ColorSchemeEvent}) and the
199
306
  # screen re-picks from {#theme_def}.
307
+ #
308
+ # Always resolved ({Theme#resolve}): a derived token is recomputed from
309
+ # {#background_color} whenever that changes, so no token here is a Proc.
200
310
  # @return [Theme]
201
311
  attr_reader :theme
202
312
 
@@ -212,20 +322,21 @@ module Tuile
212
322
  # The terminal's own background, as it reported it — for deriving a
213
323
  # color *from* the background rather than picking one against it. A pane
214
324
  # tinted a few percent off it sits right on any terminal, where a fixed
215
- # near-neutral only sits right near the one it was tuned on:
325
+ # near-neutral only sits right near the one it was tuned on. Declare such
326
+ # a color as a derived theme token (see {Theme}) and point a slot at it:
216
327
  #
217
- # bg = screen.background_color
218
- # sidebar.bg_color =
219
- # bg ? Color.rgb(*bg.value.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT
328
+ # pane_bg: ->(bg) { bg ? Color.rgb(*bg.rgb.map { (_1 + 10).clamp(0, 255) }) : FALLBACK_TINT }
329
+ # sidebar.bg_color = Theme.ref(:pane_bg)
220
330
  #
221
331
  # **Nil is the normal case, not an edge** — a terminal answering only
222
332
  # `COLORFGBG`, or neither probe, reports no RGB at all. Keep a fallback.
223
333
  #
224
334
  # Kept current across OS appearance flips, a frame behind: the flip
225
335
  # report carries light/dark only, so the screen re-probes and this
226
- # updates when the reply lands. A changed color then fires
227
- # {Component#handle_theme_changed} across the tree exactly as a theme swap
228
- # does — a background-derived tint *is* a theme-derived color.
336
+ # updates when the reply lands. A changed color re-resolves {#theme} and
337
+ # then fires {Component#handle_theme_changed} across the tree once, as a
338
+ # theme swap does — even when no token is derived, since a component may
339
+ # read this directly.
229
340
  # @return [Color, nil]
230
341
  attr_reader :background_color
231
342
 
@@ -245,22 +356,22 @@ module Tuile
245
356
 
246
357
  # Replaces the theme and restyles the whole UI: fires
247
358
  # {Component#handle_theme_changed} across the attached tree and invalidates
248
- # every attached component. No-op when `new_theme` equals the current theme.
359
+ # every attached component. No-op when `new_theme`, resolved, equals the
360
+ # current theme. A derived token keeps following {#background_color}.
249
361
  # This is a *transient* override — the next OS appearance flip re-picks from
250
362
  # {#theme_def}; assign {#theme_def=} for durable theming.
251
- # @param new_theme [Theme]
363
+ # @param new_theme [Theme] resolved or not.
252
364
  # @return [void]
253
365
  def theme=(new_theme)
254
366
  raise TypeError, "expected Theme, got #{new_theme.inspect}" unless new_theme.is_a?(Theme)
255
367
 
256
368
  check_locked
257
- return if @theme == new_theme
369
+ @theme_source = new_theme
370
+ resolved = new_theme.resolve(@background_color)
371
+ return if @theme == resolved
258
372
 
259
- @theme = new_theme
260
- # `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app
261
- # subclass may narrow it further (`D_hook_visibility`).
262
- @pane&.walk_tree { _1.__send__(:handle_theme_changed) }
263
- needs_full_repaint
373
+ @theme = resolved
374
+ restyle
264
375
  end
265
376
 
266
377
  # The formatting conventions this session renders and parses by — date
@@ -323,6 +434,10 @@ module Tuile
323
434
  # screen.check_locked # from a worker: raises; wrap the work in
324
435
  # # screen.event_queue.submit { ... } instead
325
436
  #
437
+ # Both halves of the test below are load-bearing — is a loop running
438
+ # *anywhere*, and is it mine: the loop need not run on the thread that
439
+ # created the screen, and the gem's own specs rely on that.
440
+ #
326
441
  # @raise [Tuile::Error] if {#state} is `:closed`, or the calling thread
327
442
  # isn't the current owner.
328
443
  # @return [void]
@@ -360,6 +475,66 @@ module Tuile
360
475
  @invalidated << component unless @repainting.include? component
361
476
  end
362
477
 
478
+ # Marks a container as owing a {Component#relayout}, run by the next
479
+ # {#flush_layout}.
480
+ # @param component [Component]
481
+ # @return [void]
482
+ def invalidate_layout(component)
483
+ check_locked
484
+ raise TypeError, "expected Component, got #{component.inspect}" unless component.is_a? Component
485
+
486
+ @layout_pending = true
487
+ end
488
+
489
+ # Runs every pending {Component#relayout}, so rects are current again.
490
+ #
491
+ # form.add(field)
492
+ # screen.flush_layout
493
+ # field.rect # assigned, rather than whatever it had before
494
+ #
495
+ # The loop calls this after every event ({#dispatch}), which is enough for
496
+ # app code that mutates in one handler and reads in the next. Call it by
497
+ # hand only to read a rect in the *same* turn that dirtied it — that is what
498
+ # Swing's `validate()` and Tk's `update idletasks` are for.
499
+ #
500
+ # == Implementation details
501
+ #
502
+ # {LayoutPass.drain} over the pane, the same fixpoint a detached tree's
503
+ # {Component#flush_layout} runs. Then two follow-ups the pane's tree owes
504
+ # once it has settled: the pane places anchored popups before the content
505
+ # they hang from, so a moved anchor re-marks it and the drain runs again
506
+ # ({ScreenPane#remark_moved_anchors}); and the half of a {#focused=} that was
507
+ # deferred because it happened inside a pass — see there.
508
+ # @raise [Tuile::Error] when the tree has not settled after
509
+ # {LayoutPass::MAX_ROUNDS} rounds — a relayout feeding its own input — or
510
+ # when called from inside a {Component#relayout}, where the running pass
511
+ # has not yet assigned the rects a nested drain would read.
512
+ # @return [void]
513
+ def flush_layout
514
+ check_locked
515
+ LayoutPass.refuse_nested
516
+ return if @pane.nil?
517
+
518
+ rounds = 0
519
+ loop do
520
+ if @layout_pending
521
+ rounds = LayoutPass.drain(@pane, rounds)
522
+ # Only once the drain returns: a raise leaves the gate open, so the
523
+ # next settle reports the same cycle rather than skipping the walk.
524
+ @layout_pending = false
525
+ end
526
+ # One round shared with the drain: an anchor chain that never settles
527
+ # hits the same cap.
528
+ next if @pane.__send__(:remark_moved_anchors)
529
+ break unless @focus_deferred
530
+
531
+ # Last, so the scroll sees popups at their final place; and looped, so
532
+ # what an on_focus_changed callback marks is settled before we return.
533
+ @focus_deferred = false
534
+ follow_up_focus(@focus_deferred_from)
535
+ end
536
+ end
537
+
363
538
  # @return [Component, nil] currently focused component.
364
539
  attr_reader :focused
365
540
 
@@ -371,10 +546,25 @@ module Tuile
371
546
  # detached one does: focusing it would park the hardware cursor inside
372
547
  # whatever is painted over it and feed it every keystroke.
373
548
  #
374
- # Once the pointer and the active flags are settled, three notices fire in
375
- # order: {Component#handle_blur} on what lost focus, {Component#handle_focus} on
376
- # what took it, then {#on_focus_changed}. The outer two are edge-triggered
377
- # and `handle_focus` is not — see there.
549
+ # Once the pointer and the active flags are settled, four steps run in
550
+ # order: {Component#handle_blur} on what lost focus, {Component#handle_focus}
551
+ # on what took it, that component's {Component#scroll_to_visible} so a
552
+ # scroller shows what Tab just reached, then {#on_focus_changed}, which
553
+ # therefore reads settled geometry. The outer two are edge-triggered and the
554
+ # middle two are not — see `handle_focus`.
555
+ #
556
+ # **Inside a {Component#relayout} the last two wait for the layout** — a
557
+ # focus repair from a child hidden there, a menu closed from
558
+ # {Component#handle_rect_changed}. The pass has not placed its children yet,
559
+ # and a scroll decided now is latched, so {#flush_layout} runs them once the
560
+ # tree has settled: the final target is scrolled into view, and
561
+ # {#on_focus_changed} fires once if focus ended somewhere other than where
562
+ # the pass found it.
563
+ #
564
+ # A target whose {#clip_for} is still empty after that request — a stale
565
+ # {Component::Scroller#content_rows}, say — logs a warning to
566
+ # {Tuile.logger} rather than raising: a terminal shrunk to nothing causes it
567
+ # legitimately.
378
568
  # @param focused [Component, nil] the new component to be focused.
379
569
  def focused=(focused)
380
570
  unless focused.nil? || focused.is_a?(Component)
@@ -382,13 +572,21 @@ module Tuile
382
572
  end
383
573
 
384
574
  check_locked
575
+ # For the scroll-into-view request, whose answer is *latched* into a
576
+ # scroller's scroll_top_row and so is not re-derived by any later pass.
577
+ # Nil scrolls nothing, and skipping it there keeps {#close}'s teardown
578
+ # from tripping over a layout that never settles; inside a pass the
579
+ # request waits for the drain instead (#fire_focus_hooks).
580
+ flush_layout unless focused.nil? || LayoutPass.running?
385
581
  previous = @focused
386
582
  if focused.nil?
387
583
  @focused = nil
388
584
  @pane.walk_tree { _1.active = false }
389
585
  else
390
586
  raise Tuile::Error, "#{focused} is not attached to this screen" if focused.root != @pane
391
- raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor" if hidden?(focused)
587
+ unless ComponentUtil.effectively_visible?(focused)
588
+ raise Tuile::Error, "#{focused} is hidden, or sits under a hidden ancestor"
589
+ end
392
590
 
393
591
  @focused = focused
394
592
  active = Set[focused]
@@ -411,7 +609,7 @@ module Tuile
411
609
  # status bar and reserves no row: build a {Component::Label} into your own
412
610
  # layout and fill it here (`D_status_bar`).
413
611
  #
414
- # screen.on_focus_changed = -> { bar.text = hint_for(screen.focused) }
612
+ # screen.on_focus_changed { bar.text = hint_for(screen.focused) }
415
613
  #
416
614
  # **Edge-triggered**, like {Component#handle_attached}: re-assigning the
417
615
  # component that already has focus fires nothing, so a callback can be as
@@ -420,22 +618,27 @@ module Tuile
420
618
  # clears focus on every content swap, which on a level-triggered hook would
421
619
  # fire a nil→nil notification during assembly.
422
620
  #
423
- # It runs *after* the active-flag cascade and `handle_focus`, so the tree is
424
- # settled. Two things a callback must tolerate: {#focused} being `nil`, and
425
- # firing during {#close} — teardown clears focus, exactly as it fires
621
+ # It runs *after* the active-flag cascade, `handle_focus` and
622
+ # {Component#scroll_to_visible}, so the tree is settled. Two things a
623
+ # callback must tolerate: {#focused} being `nil`, and firing during
624
+ # {#close} — teardown clears focus, exactly as it fires
426
625
  # {Component#handle_detached}. A raising callback propagates out of {#focused=}
427
626
  # and leaves focus assigned; keep it trivial, as with the attach hooks.
428
- # @return [Proc, nil]
429
- attr_accessor :on_focus_changed
627
+ # @!method on_focus_changed
628
+ # Fired with a {FocusChangedEvent} after {#focused} changes.
629
+ # @return [Listeners]
630
+ listener :on_focus_changed
430
631
 
431
632
  # Internal — use {Component::Overlay#open} instead. Adds the overlay to
432
- # {#pane}; a {Component::Popup} is additionally centered and focused.
633
+ # {#pane} at `placement`; a {Component::Popup} is additionally focused.
433
634
  # @api private
434
635
  # @param window [Component::Overlay] any overlay, modal or not.
636
+ # @param placement [Component::Overlay::Placement, nil] where it wants to
637
+ # be; `nil` takes the overlay's default.
435
638
  # @return [void]
436
- def add_popup(window)
639
+ def add_popup(window, placement = nil)
437
640
  check_locked
438
- @pane.add_popup(window)
641
+ @pane.add_popup(window, placement)
439
642
  # No need to fully repaint the scene: a popup simply paints over the
440
643
  # current screen contents.
441
644
  end
@@ -614,8 +817,12 @@ module Tuile
614
817
  # redraws, so that test TTY is not painted over. {FakeScreen#initialize}
615
818
  # self-installs as the singleton, so subsequent {Screen.instance} calls
616
819
  # return the same object.
820
+ #
821
+ # before { Screen.fake(width: 40, height: 12) } # a narrow, short terminal
822
+ # @param width [Integer] the terminal's columns.
823
+ # @param height [Integer] the terminal's rows.
617
824
  # @return [FakeScreen]
618
- def self.fake = FakeScreen.new
825
+ def self.fake(width: 160, height: 50) = FakeScreen.new(width: width, height: height)
619
826
 
620
827
  # Tears the screen down and vacates the singleton slot, moving {#state} to
621
828
  # the terminal `:closed`. Unmounts the tree first, so every component gets
@@ -692,9 +899,14 @@ module Tuile
692
899
  # of {#buffer}. Called once per event-loop tick (on {EventQueue::EmptyQueueEvent});
693
900
  # components should {Component#invalidate} and let the loop coalesce rather
694
901
  # than call this directly.
902
+ #
903
+ # Settles the layout first: painting is the heaviest reader of rects there
904
+ # is, and a pending {#flush_layout} would have it paint children where they
905
+ # no longer are.
695
906
  # @return [void]
696
907
  def repaint
697
908
  check_locked
909
+ flush_layout
698
910
  # The one site that runs after every mutation, so a component hidden,
699
911
  # detached or reparented while hovered gets its exit exactly once.
700
912
  @mouse_router.sync_hover
@@ -777,7 +989,7 @@ module Tuile
777
989
  @repainting = repaint.to_set
778
990
  @invalidated.clear
779
991
 
780
- repaint.each(&:repaint)
992
+ repaint.each { _1.repaint(canvas_for(_1)) }
781
993
  @repainting.clear
782
994
  end
783
995
  return unless did_paint
@@ -787,12 +999,27 @@ module Tuile
787
999
  emit("#{Ansi::SYNC_BEGIN}#{@buffer.flush}#{cursor_sequence}#{Ansi::SYNC_END}")
788
1000
  end
789
1001
 
790
- # Returns the absolute screen coordinates where the hardware cursor should
791
- # sit, or nil if it should be hidden. Only the {#focused} component owns
792
- # the cursor: there can be multiple active components (the focus path),
793
- # but only one focused.
1002
+ # Where the hardware cursor should sit in **screen** coordinates, or nil if
1003
+ # it should be hidden. Only the {#focused} component owns the cursor: there
1004
+ # can be multiple active components (the focus path), but only one focused.
1005
+ #
1006
+ # A component answers {Component#cursor_position} in its own coordinates and
1007
+ # this converts — the same split as painting, so a caret is a column and a
1008
+ # row and nothing more (`D_relative_rect`).
1009
+ #
1010
+ # A caret an ancestor clips away is **hidden**, not parked at a coordinate
1011
+ # nobody can see: the cursor is the one thing on screen that no clip can
1012
+ # reach, since the terminal draws it itself (`D_clip`).
794
1013
  # @return [Point, nil]
795
- def cursor_position = @focused&.cursor_position
1014
+ def cursor_position
1015
+ focused = @focused
1016
+ local = focused&.cursor_position
1017
+ return nil if local.nil?
1018
+
1019
+ return nil unless clip_for(focused).contains?(local)
1020
+
1021
+ focused.to_screen(local)
1022
+ end
796
1023
 
797
1024
  # Routes one mouse event into the tree ({Mouse::Router}) — what the event
798
1025
  # loop does with every report the terminal sends, and how a spec drives the
@@ -818,22 +1045,48 @@ module Tuile
818
1045
 
819
1046
  private
820
1047
 
821
- # Whether `component` is out of the user's reach because it or an ancestor
822
- # is {Component#visible? hidden}.
1048
+ # Gives the pane the whole screen — the one rect no parent's pass assigns,
1049
+ # so the screen places it itself.
1050
+ # @return [void]
1051
+ def size_pane
1052
+ LayoutPass.run(self) { @pane.__send__(:rect=, Rect.new(0, 0, @size.width, @size.height)) }
1053
+ end
1054
+
1055
+ # Whether anything on `component`'s ancestor chain actually cuts it — the
1056
+ # test that lets {#clip_for} answer {Component#local_rect} outright.
1057
+ #
1058
+ # If every node sits inside the box its parent gave it, then by induction
1059
+ # every ancestor's box contains `component`'s rect, the fold can remove
1060
+ # nothing, and the answer is its own `local_rect` — which still bounds the
1061
+ # component, so the short-circuit gives up no part of the guarantee.
823
1062
  #
824
- # Private, not a `Component#shown?`, for the reason `D_empty_ancestor`
825
- # declined a `Component#paintable?`: it reads as a component-level concept
826
- # and is really this class's question. A *walk* needs no such predicate —
827
- # it prunes at the hidden subtree's root ({Component#walk_shown_tree}).
1063
+ # **Allocation-free on purpose, and that is the whole optimization.**
1064
+ # `local_rect`, `moved_by` and `intersect` each build a `Rect`; comparing
1065
+ # `node.rect` against the ancestor's stored *dimensions* builds nothing.
1066
+ # Spelled the obvious way, `up.local_rect.contains_rect?(node.rect)`, it
1067
+ # reads better and measures as no gain at all (`D_clip`).
828
1068
  # @param component [Component]
1069
+ # @param root [Component, nil] see {#clip_for}.
829
1070
  # @return [Boolean]
830
- def hidden?(component)
831
- cursor = component
832
- cursor = cursor.parent while cursor&.visible?
833
- !cursor.nil?
1071
+ def clipped?(component, root)
1072
+ node = component
1073
+ while !node.equal?(root) && (up = node.parent)
1074
+ r = node.rect
1075
+ # An empty rect covers no cells, so it is trivially inside — matching
1076
+ # {Rect#contains_rect?}, whose place this takes.
1077
+ unless r.empty? || (r.left >= 0 && r.top >= 0 &&
1078
+ r.left + r.width <= up.rect.width &&
1079
+ r.top + r.height <= up.rect.height)
1080
+ return true
1081
+ end
1082
+
1083
+ node = up
1084
+ end
1085
+ false
834
1086
  end
835
1087
 
836
- # The tail of {#focused=}: blur, then focus, then the app notice.
1088
+ # The tail of {#focused=}: blur, then focus, then the scroll-into-view
1089
+ # request, then the app notice.
837
1090
  #
838
1091
  # A hook may reassign {#focused}; that nested call has already run this whole
839
1092
  # sequence for the target it chose, so this one stops rather than announcing
@@ -842,12 +1095,44 @@ module Tuile
842
1095
  # @param focused [Component, nil] what the assignment asked for.
843
1096
  # @return [void]
844
1097
  def fire_focus_hooks(previous, focused)
1098
+ deferred = LayoutPass.running?
1099
+ # Recorded before the hooks, which may reassign focus themselves: the
1100
+ # first deferral in a drain keeps its `previous`, so the one notice fired
1101
+ # later compares against where focus stood before any of them.
1102
+ if deferred && !@focus_deferred
1103
+ @focus_deferred = true
1104
+ @focus_deferred_from = previous
1105
+ end
845
1106
  unless focused.equal?(previous)
846
1107
  previous&.__send__(:handle_blur)
847
1108
  return unless @focused.equal?(focused)
848
1109
  end
849
1110
  @focused&.__send__(:handle_focus)
850
- @on_focus_changed&.call unless @focused.equal?(previous)
1111
+ follow_up_focus(previous) unless deferred
1112
+ end
1113
+
1114
+ # The geometry half of {#focused=}: scroll the target into view, warn if it
1115
+ # still shows nothing, then fire the notice.
1116
+ # @param previous [Component, nil] what was focused before the change.
1117
+ # @return [void]
1118
+ def follow_up_focus(previous)
1119
+ # Level-triggered like `handle_focus`, not edge-triggered like the notice
1120
+ # below: re-focusing what already has focus is how an app says "bring it
1121
+ # back into view", and an already-satisfied request scrolls by zero.
1122
+ @focused&.scroll_to_visible
1123
+ warn_if_unseen(@focused) unless @focused.nil?
1124
+ on_focus_changed.fire(FocusChangedEvent.new(source: self)) unless @focused.equal?(previous)
1125
+ end
1126
+
1127
+ # Unguarded on purpose: a container forwarding focus re-enters {#focused=},
1128
+ # so a target may be reported twice.
1129
+ # @param component [Component]
1130
+ # @return [void]
1131
+ def warn_if_unseen(component)
1132
+ return unless clip_for(component).empty?
1133
+
1134
+ Tuile.logger.warn("Screen: focused #{component} shows nothing, even after scroll_to_visible " \
1135
+ "(a stale Scroller#content_rows? Fixed[0] meant as visible = false?)")
851
1136
  end
852
1137
 
853
1138
  # The startup background probe, seeding {#theme} and
@@ -892,17 +1177,28 @@ module Tuile
892
1177
  print TerminalBackground::QUERY
893
1178
  end
894
1179
 
895
- # The re-probe answered: adopt the color and restyle, since an app's
896
- # background-derived tints are now a scheme behind. Deliberately keeps
897
- # the previous color until the reply lands rather than blanking it on
898
- # the flip — a terminal that reports mode-2031 flips but not OSC 11
899
- # would otherwise lose the color it gave us at startup, permanently.
1180
+ # The re-probe answered: adopt the color, re-derive the theme from it and
1181
+ # restyle, since an app's background-derived tints are now a scheme
1182
+ # behind. Deliberately keeps the previous color until the reply lands
1183
+ # rather than blanking it on the flip — a terminal that reports mode-2031
1184
+ # flips but not OSC 11 would otherwise lose the color it gave us at
1185
+ # startup, permanently.
900
1186
  # @param color [Color]
901
1187
  # @return [void]
902
1188
  def handle_background_color(color)
903
1189
  return if @background_color == color
904
1190
 
905
1191
  @background_color = color
1192
+ @theme = @theme_source.resolve(color)
1193
+ restyle
1194
+ end
1195
+
1196
+ # Fires {Component#handle_theme_changed} across the attached tree and
1197
+ # repaints everything, after {#theme} or {#background_color} changed.
1198
+ # @return [void]
1199
+ def restyle
1200
+ # `__send__`, not `&:handle_theme_changed`: the hook is protected, and an app
1201
+ # subclass may narrow it further (`D_hook_visibility`).
906
1202
  @pane&.walk_tree { _1.__send__(:handle_theme_changed) }
907
1203
  needs_full_repaint
908
1204
  end
@@ -916,7 +1212,7 @@ module Tuile
916
1212
  # @return [Boolean] true if focus moved.
917
1213
  def cycle_focus(forward:)
918
1214
  check_locked
919
- scope = @pane.modal_popup || @pane.content
1215
+ scope = @pane.key_scope
920
1216
  return false if scope.nil?
921
1217
 
922
1218
  stops = []
@@ -956,13 +1252,14 @@ module Tuile
956
1252
  # Resizes {#buffer} and {#pane} to the current {#size}, invalidates the
957
1253
  # whole tree and repaints. Run whenever the terminal size changes (the
958
1254
  # {EventQueue::TTYSizeEvent} path) and once at startup via the first
959
- # {#content=}.
1255
+ # {#content=}. Not layout in the {Component#relayout} sense: assigning the
1256
+ # pane a rect only *marks* it, and the {#repaint} below settles the pass.
960
1257
  # @return [void]
961
- def layout
1258
+ def resize
962
1259
  check_locked
963
1260
  @buffer.resize(size) unless @buffer.size == size
964
1261
  needs_full_repaint
965
- @pane.rect = Rect.new(0, 0, size.width, size.height)
1262
+ size_pane
966
1263
  repaint
967
1264
  end
968
1265
 
@@ -1016,21 +1313,36 @@ module Tuile
1016
1313
  # @return [void]
1017
1314
  def handle_paste(text) = @pane.handle_paste(text)
1018
1315
 
1019
- # @return [void]
1020
- def event_loop
1021
- @event_queue.run_loop do |event|
1316
+ # Routes one event to its handler, then {#settle}s.
1317
+ #
1318
+ # The single seam every event passes through, which is why {FakeScreen}'s
1319
+ # gesture helpers drive it rather than calling {#handle_mouse} themselves: a
1320
+ # spec's `click` then reaches components exactly as the loop's own report
1321
+ # does, settle included.
1322
+ #
1323
+ # The `rescue` stays in {#event_loop}: only a *loop* has an `on_error` slot
1324
+ # to divert into, and a spec driving one event wants the raise.
1325
+ # @param event [Object] a {EventQueue::KeyEvent}, {Mouse::Event},
1326
+ # {EventQueue::PasteEvent}, {EventQueue::TTYSizeEvent},
1327
+ # {EventQueue::ColorSchemeEvent}, {EventQueue::BackgroundColorEvent},
1328
+ # {EventQueue::EmptyQueueEvent}, or a `Proc` from {EventQueue#submit}.
1329
+ # @return [Object] what the handler returned — a paste's is the Boolean
1330
+ # {ScreenPane#handle_paste} answers with; most are unspecified.
1331
+ def dispatch(event)
1332
+ result =
1022
1333
  case event
1023
1334
  when EventQueue::KeyEvent
1024
1335
  key = event.key
1025
1336
  handled = handle_key?(key)
1026
1337
  @event_queue.stop if !handled && ["q", Keys::ESC].include?(key)
1338
+ handled
1027
1339
  when EventQueue::PasteEvent
1028
1340
  handle_paste(event.text)
1029
1341
  when Mouse::Event
1030
1342
  handle_mouse(event)
1031
1343
  when EventQueue::TTYSizeEvent
1032
1344
  @size = event.size
1033
- layout
1345
+ resize
1034
1346
  when EventQueue::ColorSchemeEvent
1035
1347
  handle_color_scheme(event.scheme)
1036
1348
  when EventQueue::BackgroundColorEvent
@@ -1040,8 +1352,31 @@ module Tuile
1040
1352
  when Proc
1041
1353
  event.call
1042
1354
  end
1355
+ settle
1356
+ result
1357
+ end
1358
+
1359
+ # Brings deferred work up to date, once per {#dispatch}ed event.
1360
+ #
1361
+ # The sequence point between "a handler mutated the tree" and "the next
1362
+ # event reads it": whatever a handler *marks*, this is where it is *done*,
1363
+ # so a queued mouse press never routes against what the key before it left
1364
+ # half-finished.
1365
+ # @return [void]
1366
+ def settle
1367
+ flush_layout
1368
+ end
1369
+
1370
+ # @return [void]
1371
+ def event_loop
1372
+ @event_queue.run_loop do |event|
1373
+ dispatch(event)
1043
1374
  rescue StandardError => e
1044
- @on_error.call(e)
1375
+ # Empty means re-raise: the generic fire cannot know that, so the one
1376
+ # slot with an executable empty branch carries it here.
1377
+ raise e if on_error.empty?
1378
+
1379
+ on_error.fire(EventQueue::ErrorEvent.new(error: e))
1045
1380
  end
1046
1381
  end
1047
1382
  end