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
@@ -3,17 +3,23 @@
3
3
  module Tuile
4
4
  class Component
5
5
  # A component mounted on the {Screen}'s overlay stack rather than in the
6
- # tiled tree: it floats above the content at a rect the caller assigns, and
7
- # has an open/close lifecycle instead of a parent that lays it out.
6
+ # tiled tree: it floats above the content, and has an open/close lifecycle.
7
+ # It opens with a *placement* — where it wants to be — and the
8
+ # {ScreenPane}'s layout pass turns that into its rect, again on every
9
+ # resize:
8
10
  #
9
11
  # overlay = Component::Overlay.new(content: Component::Label.new("saved"))
10
- # overlay.rect = Rect.new(10, 4, 20, 1) # you place it — nothing else does
11
- # overlay.open # mounts it on the Screen
12
+ # overlay.open(Overlay::At[Rect.new(10, 4, 20, 1)]) # mounts it on the Screen
13
+ # overlay.placement = Overlay::At[Rect.new(10, 6, 20, 1)] # moves it
12
14
  # overlay.close
13
15
  #
14
16
  # That is the whole of it — floating, plus the lifecycle, {#on_close},
15
17
  # outside-click dismissal and {#owner}. {Component::Popup} is the subclass
16
- # that adds a declared size, self-centering, focus and key handling.
18
+ # that adds a declared size, centering, focus and key handling.
19
+ #
20
+ # Where it goes is a {Placement}: {At} a fixed rect, {Centered} and
21
+ # {TopRight} an overlay that declares its size ({#declared_size_in}),
22
+ # {ListDropdown::Anchored} a dropdown hanging off a field.
17
23
  #
18
24
  # The wrapped content fills the overlay's whole {#rect}; for a frame and a
19
25
  # caption, wrap a {Component::Window} and let it draw its own border.
@@ -30,18 +36,127 @@ module Tuile
30
36
  # non-modal overlay is therefore driven from its owner's key handler
31
37
  # ({Component::Select} forwarding to its dropdown) instead of claiming focus.
32
38
  #
33
- # **A derived position needs a {#reposition} override.** The default is a
34
- # no-op: the rect is whatever the caller last assigned. An overlay whose
35
- # position is computed — from the screen, or from an anchor — must recompute
36
- # it there, or it keeps a stale rect after a SIGWINCH and sits off-screen
37
- # entirely if the terminal narrowed. Closing on resize is equally legal
38
- # ({Component::MenuBar} drops its cascade from `rect=` rather than walking
39
- # every level to re-anchor it).
39
+ # **An overlay whose size changes calls {#reposition}**, which asks the pane
40
+ # to place it again; the placement itself stays put. That is how a growing
41
+ # {Component::Notification} or a re-measured {Component::ConfirmWindow}
42
+ # gets its new rect.
40
43
  #
41
44
  # UI-thread-confined, like every component (see {Screen}).
42
45
  class Overlay < Component
43
46
  include Component::HasContent
44
47
 
48
+ # Where an overlay wants to be. Include it to *be* a placement — the pane
49
+ # asks for a rect on every layout pass, and an app supplies its own by
50
+ # answering {#rect_for}:
51
+ #
52
+ # Bottom = Data.define do
53
+ # include Overlay::Placement
54
+ #
55
+ # def rect_for(overlay, screen_size, _anchor_rect)
56
+ # size = overlay.declared_size_in(screen_size)
57
+ # Rect.new(0, screen_size.height - size.height, size.width, size.height)
58
+ # end
59
+ # end
60
+ #
61
+ # overlay.open(Bottom[])
62
+ #
63
+ # A placement is a **rule, not a rect**: it is re-read on every resize and
64
+ # every settle, so re-running it moves nothing that stood still. Built in
65
+ # are {At}, {Centered}, {TopRight} and {ListDropdown::Anchored}.
66
+ #
67
+ # == Anchors
68
+ #
69
+ # A placement that hangs off something on screen — a field, a menu row —
70
+ # says so by answering {#anchor}; the pane resolves it and hands the rect
71
+ # to {#rect_for}:
72
+ #
73
+ # def anchor = field
74
+ #
75
+ # Resolving is deliberately the framework's job: a component becomes a
76
+ # screen rect through an ancestor-inclusive visibility walk, an
77
+ # {Component#attached?} test and {Component#absolute_extent_rect} (not
78
+ # {Component#absolute_rect}) — three invariants, one of them the trap
79
+ # {Component#walk_shown_tree} exists to prevent. {#anchor} therefore
80
+ # answers one of three things:
81
+ #
82
+ # - a {Component} — resolved while it is reachable, and **losable**: once
83
+ # detached or hidden there is no rect to compute, so the pane leaves the
84
+ # overlay where it was and warns its owner once rather than placing it
85
+ # against nothing. Closing it is the owner's job.
86
+ # - a {Rect} in screen coordinates — always resolves, never lost. Anchors to an absolute position.
87
+ # - `nil`, the default — not anchored; {#rect_for} is handed `nil`.
88
+ #
89
+ # An anchor also earns a **second settle pass**: the pane lays out before
90
+ # the content its anchor sits in does, so {Screen#flush_layout} re-reads
91
+ # every {#anchor} once the queue is empty and runs the pane again if any
92
+ # now resolves elsewhere.
93
+ module Placement
94
+ # What this placement hangs off, re-read on every pass — see the
95
+ # *Anchors* section above.
96
+ # @return [Component, Rect, nil] `nil`, the default, is not anchored.
97
+ def anchor = nil
98
+
99
+ # The rect this placement wants for `overlay`, in screen coordinates.
100
+ # The pane assigns it as-is — clamping it on screen is the placement's
101
+ # own job.
102
+ # @param overlay [Overlay] the overlay being placed.
103
+ # @param screen_size [Size] the whole terminal.
104
+ # @param anchor_rect [Rect, nil] {#anchor} resolved to screen
105
+ # coordinates; `nil` only for an unanchored placement, since a lost
106
+ # anchor is not placed at all.
107
+ # @raise [NotImplementedError] unless the includer overrides it.
108
+ # @return [Rect]
109
+ def rect_for(overlay, screen_size, anchor_rect)
110
+ raise(NotImplementedError, "#{self.class}#rect_for")
111
+ end
112
+ end
113
+
114
+ # Places the overlay at exactly `rect`, in screen coordinates.
115
+ #
116
+ # overlay.open(Overlay::At[Rect.new(10, 4, 20, 1)])
117
+ #
118
+ # @!attribute [r] rect
119
+ # @return [Rect]
120
+ At = Data.define(:rect) do
121
+ include Placement
122
+
123
+ # @param _overlay [Overlay]
124
+ # @param _screen_size [Size]
125
+ # @param _anchor_rect [Rect, nil] unused: this placement declares no {#anchor}.
126
+ # @return [Rect]
127
+ def rect_for(_overlay, _screen_size, _anchor_rect) = rect
128
+ end
129
+
130
+ # Centers the overlay on the screen at the size it declares
131
+ # ({Overlay#declared_size_in}) — {Popup}'s default.
132
+ Centered = Data.define do
133
+ include Placement
134
+
135
+ # @param overlay [Overlay]
136
+ # @param screen_size [Size]
137
+ # @param _anchor_rect [Rect, nil] unused: this placement declares no {#anchor}.
138
+ # @return [Rect]
139
+ def rect_for(overlay, screen_size, _anchor_rect)
140
+ size = overlay.declared_size_in(screen_size)
141
+ Rect.new(0, 0, size.width, size.height).centered(screen_size)
142
+ end
143
+ end
144
+
145
+ # Puts the overlay in the screen's top-right corner at the size it
146
+ # declares ({Overlay#declared_size_in}) — {Notification}'s default.
147
+ TopRight = Data.define do
148
+ include Placement
149
+
150
+ # @param overlay [Overlay]
151
+ # @param screen_size [Size]
152
+ # @param _anchor_rect [Rect, nil] unused: this placement declares no {#anchor}.
153
+ # @return [Rect]
154
+ def rect_for(overlay, screen_size, _anchor_rect)
155
+ size = overlay.declared_size_in(screen_size)
156
+ Rect.new([screen_size.width - size.width, 0].max, 0, size.width, size.height)
157
+ end
158
+ end
159
+
45
160
  # @param content [Component, nil] initial content; can be set later via
46
161
  # {#content=}. It fills the overlay's {#rect} and does not determine it.
47
162
  # @param close_on_outside_click [Boolean] true (default) to dismiss on a
@@ -50,7 +165,6 @@ module Tuile
50
165
  super()
51
166
  @close_on_outside_click = close_on_outside_click
52
167
  @owner = nil
53
- @on_close = nil
54
168
  @content = nil
55
169
  self.content = content unless content.nil?
56
170
  end
@@ -127,46 +241,74 @@ module Tuile
127
241
  # @return [Component, nil]
128
242
  attr_accessor :owner
129
243
 
130
- # A callback taking no arguments, fired once this overlay has left the
131
- # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
132
- # an outside click, or teardown via {Screen#close}. That unconditionality
133
- # is the point, so it hangs off {#handle_detached} rather than {#close}; a
134
- # driver keeping its own record of open overlays reconciles it here and
135
- # cannot drift ({Component::MenuBar::Cascade} is the worked example).
136
- #
137
- # It fires *after* the overlay is detached, so {#open?} is already false
138
- # and the usual {Component#handle_detached} caveats apply: release state, don't
139
- # inspect the tree, keep it trivial (it may run while the pane is mid-way
140
- # through closing a batch of overlays, and a raise propagates).
141
- # @return [Proc, nil]
142
- attr_accessor :on_close
143
-
144
- # Reassigns the overlay's rect, escalating to a full scene repaint when an
145
- # open overlay shrinks or moves so its new rect no longer covers the cells
146
- # it previously painted. An overlay overdraws the scene without clipping
147
- # and nothing clears underneath it, so {Screen#repaint}'s overlay-only fast
148
- # path would repaint into the new rect and leave the vacated cells showing
149
- # stale content. When the new rect fully covers the old one (the overlay
150
- # only grew), the fast path is correct and the full repaint is skipped.
151
- # @param new_rect [Rect]
244
+ # What {#on_close} fires.
245
+ #
246
+ # @!attribute [r] source
247
+ # @return [Overlay] the overlay that left the screen.
248
+ CloseEvent = Data.define(:source) { include Tuile::Event }
249
+
250
+ # @!method on_close
251
+ # Fired with a {CloseEvent} once this overlay has left the screen —
252
+ # **however it left**: {#close}, a direct {Screen#remove_popup}, an
253
+ # outside click, or teardown via {Screen#close}. That unconditionality is
254
+ # the point, so it hangs off {#handle_detached} rather than {#close}; a
255
+ # driver keeping its own record of open overlays reconciles it here and
256
+ # cannot drift ({Component::MenuBar::Cascade} is the worked example).
257
+ #
258
+ # It fires *after* the overlay is detached, so {#open?} is already false
259
+ # and the usual {Component#handle_detached} caveats apply: release state,
260
+ # don't inspect the tree, keep it trivial (it may run while the pane is
261
+ # mid-way through closing a batch of overlays, and a raise propagates).
262
+ # @return [Listeners]
263
+ listener :on_close
264
+
265
+ # The box this overlay asks for on a screen of `screen_size` — what
266
+ # {Centered} and {TopRight} place. A bare overlay declares none.
267
+ # @param _screen_size [Size]
268
+ # @raise [Tuile::Error] always, here; {Popup} and {Notification} answer.
269
+ # @return [Size]
270
+ def declared_size_in(_screen_size)
271
+ raise Tuile::Error, "#{self.class} declares no size — place it with Overlay::At[rect]"
272
+ end
273
+
274
+ # The placement a bare {#open} uses. A bare overlay has none, because
275
+ # nothing about it says where it goes.
276
+ # @raise [ArgumentError] always, here; {Popup} and {Notification} answer.
277
+ # @return [Placement]
278
+ def default_placement
279
+ raise ArgumentError, "#{self.class} has no default placement — open(Overlay::At[rect])"
280
+ end
281
+
282
+ # Escalates to a full scene repaint when an open overlay shrinks or moves
283
+ # so its new rect no longer covers the cells it previously painted. An
284
+ # overlay overdraws the scene without clipping and nothing clears
285
+ # underneath it, so {Screen#repaint}'s overlay-only fast path would repaint
286
+ # into the new rect and leave the vacated cells showing stale content.
287
+ # When the new rect fully covers the old one (the overlay only grew), the
288
+ # fast path is correct and the full repaint is skipped.
289
+ # @param old_rect [Rect]
152
290
  # @return [void]
153
- def rect=(new_rect)
154
- old_rect = rect
291
+ def handle_rect_changed(old_rect)
155
292
  super
156
- screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
293
+ screen.needs_full_repaint if open? && !rect.contains_rect?(old_rect)
157
294
  end
158
295
 
159
- # Mounts this overlay on the {Screen}.
296
+ # Mounts this overlay on the {Screen} at `placement`; the pane assigns the
297
+ # rect on the next settle.
160
298
  #
161
- # overlay = Component::Overlay.new(content: label).open # construct and mount
299
+ # Component::Popup.new(content: window).open # its default: centered
300
+ # overlay.open(Overlay::At[Rect.new(10, 4, 20, 1)])
162
301
  #
163
302
  # There is deliberately no class-level `Overlay.open` factory — see
164
303
  # `design/decisions.md` `D_popup_open`; returning `self` is what keeps the
165
304
  # one-liner above available without one.
305
+ # @param placement [Placement] where it wants to be.
306
+ # @raise [ArgumentError] with no placement, for an overlay that has no
307
+ # {#default_placement}.
308
+ # @raise [TypeError] if `placement` does not include {Placement}.
166
309
  # @return [self]
167
- def open
168
- reposition
169
- screen.add_popup(self)
310
+ def open(placement = default_placement)
311
+ screen.add_popup(self, placement)
170
312
  self
171
313
  end
172
314
 
@@ -181,29 +323,45 @@ module Tuile
181
323
  screen.has_popup?(self)
182
324
  end
183
325
 
184
- # Recomputes this overlay's own rect (not its content's layout). A no-op
185
- # here — the rect is whatever the caller assigned — and the hook a subclass
186
- # with a *derived* position overrides; see the class docs. Called on
187
- # {#open} and by the screen's layout pass, so an override tracks SIGWINCH.
326
+ # Where this overlay wants to be, or `nil` while closed.
327
+ # @return [Placement, nil] the placement it was opened or moved with.
328
+ def placement = open? ? screen.pane.placement(self) : nil
329
+
330
+ # Moves the open overlay; it takes the new rect on the next settle.
331
+ # @param placement [Placement] see the class docs.
332
+ # @raise [Tuile::Error] unless open — a closed overlay takes its
333
+ # placement from {#open}.
334
+ # @raise [TypeError] if `placement` does not include {Placement}.
188
335
  # @return [void]
189
- def reposition; end
336
+ def placement=(placement)
337
+ raise Tuile::Error, "#{self} is not open — pass the placement to #open" unless open?
338
+
339
+ screen.pane.constrain(self, placement)
340
+ end
341
+
342
+ # Asks the pane to place this overlay again, because something its
343
+ # placement reads — its declared size, its row count — changed. A no-op
344
+ # while closed: {#open} places it anyway.
345
+ # @return [void]
346
+ def reposition
347
+ parent&.invalidate_layout
348
+ end
190
349
 
191
350
  # Fires {#on_close}. A subclass overriding this **must** call `super`, or
192
351
  # the overlay's driver never hears that it closed.
193
352
  # @return [void]
194
353
  def handle_detached
195
354
  super
196
- @on_close&.call
355
+ on_close.fire(CloseEvent.new(source: self))
197
356
  end
198
357
 
199
358
  protected
200
359
 
201
360
  # Content fills the overlay's full rect — an Overlay has no border to
202
361
  # subtract.
203
- # @param content [Component]
204
362
  # @return [void]
205
- def layout(content)
206
- content.rect = rect
363
+ def relayout
364
+ content&.rect = local_rect
207
365
  end
208
366
  end
209
367
  end
@@ -51,10 +51,7 @@ module Tuile
51
51
  # one-glyph-per-character contract {TextField#display_text} rests on,
52
52
  # a wide one the column axis.
53
53
  def mask_char=(char)
54
- raise TypeError, "expected String, got #{char.inspect}" unless char.is_a?(String)
55
- raise ArgumentError, "expected one grapheme cluster, got #{char.inspect}" unless single_cluster?(char)
56
- raise ArgumentError, "expected a 1-column glyph, got #{char.inspect}" unless Buffer.display_width(char) == 1
57
-
54
+ char = StyledString.validate_glyph(char, :mask_char)
58
55
  return if @mask_char == char
59
56
 
60
57
  @mask_char = char
@@ -91,10 +88,6 @@ module Tuile
91
88
 
92
89
  private
93
90
 
94
- # @param char [String]
95
- # @return [Boolean]
96
- def single_cluster?(char) = char.each_grapheme_cluster.take(2).size == 1
97
-
98
91
  # @return [Integer] caret target for CTRL+LEFT: the start, while masked.
99
92
  def word_left = revealed? ? super : 0
100
93
 
@@ -56,19 +56,24 @@ module Tuile
56
56
  @options = options.map { Option.new(_1[0], StyledString.parse(_1[1])) }
57
57
  @block = block
58
58
  list = Component::List.new
59
- list.renderer = ->(option) { StyledString.plain("#{option.key} ") + option.caption }
59
+ list.renderer = ->(option, _text_width) { StyledString.plain("#{option.key} ") + option.caption }
60
60
  list.items = @options
61
61
  list.cursor = Component::List::Cursor.new
62
- list.on_item_chosen = ->(_index, option) { select_option(option.key) }
62
+ list.on_item_chosen { |e| select_option(e.item.key) }
63
63
  self.content = list
64
- # Optional hook for a containing Popup to dismiss itself after a pick.
65
- @on_pick = nil
66
64
  end
67
65
 
68
- # Callback invoked after the user picks an option (after the block
69
- # fires). The {Popup} returned by {.open} sets this to its own `close`.
70
- # @return [Proc, nil]
71
- attr_accessor :on_pick
66
+ # What {#on_pick} fires.
67
+ #
68
+ # @!attribute [r] source
69
+ # @return [PickerWindow] the picker whose option was chosen.
70
+ PickEvent = Data.define(:source) { include Tuile::Event }
71
+
72
+ # @!method on_pick
73
+ # Fired with a {PickEvent} after the user picks an option, after the
74
+ # block fires. The {Popup} returned by {.open} registers its own `close`.
75
+ # @return [Listeners]
76
+ listener :on_pick
72
77
 
73
78
  # Handles an option-key press. Reached by bubbling: the inner {List}
74
79
  # (the focused component) sees the key first and handles cursor/Enter
@@ -96,7 +101,7 @@ module Tuile
96
101
  def self.open(caption, options, &block)
97
102
  picker = PickerWindow.new(caption, options, &block)
98
103
  popup = Popup.new(content: picker)
99
- picker.on_pick = -> { popup.close }
104
+ picker.on_pick { popup.close }
100
105
  popup.open
101
106
  end
102
107
 
@@ -106,7 +111,7 @@ module Tuile
106
111
  # @return [void]
107
112
  def select_option(key)
108
113
  @block.call(key)
109
- @on_pick&.call
114
+ on_pick.fire(PickEvent.new(source: self))
110
115
  end
111
116
  end
112
117
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A modal dialog: an {Component::Overlay} that centers itself on the screen,
5
+ # A modal dialog: an {Component::Overlay} centered on the screen, which
6
6
  # grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
7
7
  # closes on ESC or `q`.
8
8
  #
@@ -34,7 +34,7 @@ module Tuile
34
34
  #
35
35
  # A left click *outside* the popup closes it too — see
36
36
  # {Overlay#close_on_outside_click?} for the exact contract and
37
- # {Overlay#on_close=} for the notice a driver hears when it happens.
37
+ # {Overlay#on_close} for the notice a driver hears when it happens.
38
38
  #
39
39
  # UI-thread-confined, like every component (see {Screen}).
40
40
  class Popup < Overlay
@@ -50,7 +50,6 @@ module Tuile
50
50
  def initialize(content: nil, declared_size: Fraction::HALF, close_on_outside_click: true)
51
51
  super(content: content, close_on_outside_click: close_on_outside_click)
52
52
  @declared_size = declared_size
53
- reposition
54
53
  end
55
54
 
56
55
  # The box this popup asks for, *as set* — a {Fraction} comes back
@@ -69,9 +68,9 @@ module Tuile
69
68
  # and focus repair falls back to it when its subtree has no tab stop.
70
69
  def focusable? = true
71
70
 
72
- # Sets the popup's box and repositions it. Accepts a {Fraction} (resolved
73
- # against the screen every layout pass, so it tracks resize) or an
74
- # absolute {Size} (clamped to the screen). This is **authoritative**, not
71
+ # Sets the popup's box; an open popup takes it on the next settle.
72
+ # Accepts a {Fraction} (resolved against the screen every layout pass, so
73
+ # it tracks resize) or an absolute {Size} (clamped to the screen). This is **authoritative**, not
75
74
  # a preference: the screen applies exactly what you ask for (clamped),
76
75
  # with no negotiation — a popup has no siblings to compete with.
77
76
  # @param new_size [Size, Fraction]
@@ -81,26 +80,16 @@ module Tuile
81
80
  reposition
82
81
  end
83
82
 
84
- # Re-resolves {#declared_size} against the current screen and recenters the popup
85
- # *itself* (this is not laying out content — the popup's own rect). Called
86
- # on {Overlay#open}, on {#declared_size=}, and by the screen's layout pass,
87
- # so a {Fraction} tracks SIGWINCH.
88
- #
89
- # The final rect is computed and assigned in one step rather than sizing at
90
- # the origin and then centering: the intermediate origin rect rarely covers
91
- # the previous one, which would make {Overlay#rect=}'s shrink/move
92
- # detection fire a full repaint on every resize.
93
- # @return [void]
94
- def reposition
95
- size = @declared_size.is_a?(Fraction) ? @declared_size.resolve(screen.size) : @declared_size.clamp(screen.size)
96
- self.rect = Rect.new(rect.left, rect.top, size.width, size.height).centered(screen.size)
83
+ # {#declared_size} resolved against `screen_size`: a {Fraction} scaled, a
84
+ # {Size} clamped.
85
+ # @param screen_size [Size]
86
+ # @return [Size]
87
+ def declared_size_in(screen_size)
88
+ @declared_size.is_a?(Fraction) ? @declared_size.resolve(screen_size) : @declared_size.clamp(screen_size)
97
89
  end
98
90
 
99
- # Recenters the popup on the screen, preserving its current width/height.
100
- # @return [void]
101
- def center
102
- self.rect = rect.centered(screen.size)
103
- end
91
+ # @return [Overlay::Centered] a popup opens centered.
92
+ def default_placement = Centered[]
104
93
 
105
94
  # `q` and ESC close the popup. The popup sits on the focus chain of
106
95
  # whatever it wraps, so the key reaches here by bubbling up from the
@@ -12,10 +12,9 @@ module Tuile
12
12
  # add(bar)
13
13
  # add(label)
14
14
  #
15
- # def rect=(new_rect) # the enclosing Layout positions both
16
- # super
17
- # bar.rect = Rect.new(rect.left, rect.top, rect.width, 1)
18
- # label.rect = Rect.new(rect.left, rect.top + 1, rect.width, 1)
15
+ # private def relayout # the enclosing Layout positions both
16
+ # bar.rect = Rect.new(0, 0, width, 1)
17
+ # label.rect = Rect.new(0, 1, width, 1)
19
18
  # end
20
19
  #
21
20
  # bar.value = done
@@ -209,12 +208,13 @@ module Tuile
209
208
  # *whole* rect, which dirties every cell of the bar's own row before it is
210
209
  # painted over — so {Buffer#flush} re-emits the entire row every frame
211
210
  # instead of the one or two cells that actually moved.
211
+ # @param canvas [Canvas] see {Component#repaint}.
212
212
  # @return [void]
213
- def repaint
213
+ def repaint(canvas)
214
214
  return if rect.empty?
215
215
 
216
- draw_text(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
217
- clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
216
+ canvas.set_text(0, 0, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
217
+ canvas.fill(Rect.new(0, 1, rect.width, rect.height - 1)) if rect.height > 1
218
218
  end
219
219
 
220
220
  private
@@ -12,7 +12,7 @@ module Tuile
12
12
  #
13
13
  # rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
14
14
  # rg.value = "Descending" # or seed it via the ctor
15
- # rg.on_value_change = ->(order) { resort(order) }
15
+ # rg.on_value_change { |e| resort(e.value) }
16
16
  # rg.value # => "Descending"
17
17
  # rg.item_label = ->(o) { o.title } # default :to_s
18
18
  #
@@ -72,13 +72,12 @@ module Tuile
72
72
  super()
73
73
  @item_label = :to_s.to_proc
74
74
  @value = value
75
- @on_value_change = nil
76
75
 
77
76
  list = List.new
78
77
  # A List has no cursor at all by default (Cursor::None, position -1).
79
78
  list.cursor = List::Cursor.new
80
79
  list.renderer = method(:render_row)
81
- list.on_item_chosen = ->(_index, item) { self.value = item }
80
+ list.on_item_chosen { |e| set_value(e.item, from_user: true) }
82
81
  list.items = items.to_a
83
82
  @list = list
84
83
  add_child(list, at: 0)
@@ -93,12 +92,8 @@ module Tuile
93
92
  # @return [List]
94
93
  attr_reader :list
95
94
 
96
- # @param new_rect [Rect]
97
95
  # @return [void]
98
- def rect=(new_rect)
99
- super
100
- list.rect = rect
101
- end
96
+ def relayout = list.rect = local_rect
102
97
 
103
98
  # @return [void]
104
99
  def handle_focus
@@ -140,9 +135,10 @@ module Tuile
140
135
  # changed. The cursor stays where it is.
141
136
  # @param new_value [Object, nil] `nil` selects nothing; an item outside
142
137
  # {#items} is kept but renders no marked row.
138
+ # @param from_user [Boolean] see {HasValue#set_value}.
143
139
  # @return [void]
144
- def value=(new_value)
145
- # HasValue#value= no-ops on an unchanged value; this guard is what also
140
+ def set_value(new_value, from_user:)
141
+ # HasValue#set_value no-ops on an unchanged value; this guard is what also
146
142
  # skips the row rebuild.
147
143
  return if value == new_value
148
144
 
@@ -172,14 +168,16 @@ module Tuile
172
168
  def select_at(index)
173
169
  return unless index.between?(0, items.size - 1)
174
170
 
175
- self.value = items[index]
171
+ set_value(items[index], from_user: true)
176
172
  end
177
173
 
178
174
  # @param item [Object]
175
+ # @param _text_width [Integer] unused: a marker plus a label is as wide as
176
+ # it is, and {List} ellipsizes what will not fit.
179
177
  # @return [StyledString] the item's row: its label behind a selection
180
178
  # marker. The {List} calls this at paint time, so the marker tracks
181
179
  # {#value} without re-rendering anything but the visible rows.
182
- def render_row(item)
180
+ def render_row(item, _text_width)
183
181
  StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
184
182
  end
185
183