tuile 0.11.0 → 0.13.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A transient message in the screen's top-right corner — the TTY toast:
6
+ #
7
+ # Component::Notification.show("Saved")
8
+ # Component::Notification.show("Disk full", color: Theme.ref(:error))
9
+ #
10
+ # ┌─────────┐ ← flush: row 0, right edge at the last column
11
+ # │Saved │ ← oldest on top, retires in 3 s
12
+ # │Disk full│ ← then this one, 3 s after that
13
+ # └─────────┘
14
+ #
15
+ # {show} is the only entry point ({new} is private): it finds the live
16
+ # notification and appends to it, so a burst stacks as entries in one box
17
+ # instead of opening five overlapping ones.
18
+ #
19
+ # One repeating ticker retires the **oldest** entry every {DISPLAY_SECONDS}
20
+ # and closes the box when the last one goes — five messages raised together
21
+ # appear at once and drain over fifteen seconds. A message arriving mid-cycle
22
+ # waits its turn and does *not* restart the clock, so the bottom entry of a
23
+ # full box is visible for about `N × DISPLAY_SECONDS`. Past {MAX_MESSAGES} a
24
+ # message is dropped and reported to {Tuile.logger}; an app notifying faster
25
+ # than that wants a {Component::LogWindow}.
26
+ #
27
+ # The box is flush to the corner, at most {WIDTH_FRACTION} of the screen wide
28
+ # (floor {MIN_CAP_WIDTH}) and {HEIGHT_FRACTION} tall, and **grows but never
29
+ # shrinks** while it lives; a long message wraps to {MAX_ROWS_PER_MESSAGE}
30
+ # rows and is then ellipsized, and entries past the height cap wait unpainted.
31
+ # `DECISIONS.md` `D-notification` has why each of those is what it is.
32
+ #
33
+ # Three things it deliberately doesn't do:
34
+ #
35
+ # - **Take focus, or receive keys.** A non-modal popup sits off the
36
+ # key-dispatch scope ({ScreenPane#handle_key}), so not even {Popup}'s
37
+ # `q`/ESC arrives here. A left click *on the box* dismisses
38
+ # ({#handle_mouse}); an app wanting a key registers a global shortcut and
39
+ # calls {#close}. A click *elsewhere* does not — this is the one popup
40
+ # with {Popup#close_on_outside_click?} false, since a toast is timed and
41
+ # an unrelated click is not about it.
42
+ # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
43
+ # the message is added — a toast lives seconds, so there is no
44
+ # {Component#on_theme_changed} rebuild.
45
+ # - **Take a size.** {#size=} raises; the messages decide.
46
+ class Notification < Popup
47
+ # Most messages held at once, counting both the painted ones and any
48
+ # waiting for room. Chosen from reading time rather than geometry: the
49
+ # drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
50
+ # a duration, and 5 × 3 s is about the longest a corner box should own the
51
+ # screen — and about as many short lines as anyone reads.
52
+ # @return [Integer]
53
+ MAX_MESSAGES = 5
54
+
55
+ # Rows a single message may occupy before it is ellipsized.
56
+ # @return [Integer]
57
+ MAX_ROWS_PER_MESSAGE = 3
58
+
59
+ # Seconds between retirements — how long the oldest message is held.
60
+ # @return [Float]
61
+ DISPLAY_SECONDS = 3.0
62
+
63
+ # Fraction of the screen width the box may not exceed (see {MIN_CAP_WIDTH}).
64
+ # @return [Float]
65
+ WIDTH_FRACTION = 0.4
66
+
67
+ # Fraction of the screen height the box may not exceed.
68
+ # @return [Float]
69
+ HEIGHT_FRACTION = 0.4
70
+
71
+ # Floor under the width cap, so 40 % of an 80-column terminal doesn't
72
+ # ellipsize every message down to five words.
73
+ # @return [Integer]
74
+ MIN_CAP_WIDTH = 34
75
+
76
+ # Separator for re-joining wrapped rows before ellipsizing.
77
+ # @return [StyledString]
78
+ SPACE = StyledString.parse(" ")
79
+
80
+ # Hard-line separator handed to {TextView#text=}.
81
+ # @return [StyledString]
82
+ ROW_BREAK = StyledString.parse("\n")
83
+ private_constant :SPACE, :ROW_BREAK
84
+
85
+ # Shows `text` in the corner, creating the box if none is open and
86
+ # appending to it if one is.
87
+ #
88
+ # @param text [String, StyledString, nil] the message. A `String` is parsed
89
+ # via {StyledString.parse}, so embedded ANSI is honored. `nil` and the
90
+ # empty string are no-ops (nothing is shown, nothing is created).
91
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
92
+ # applied to every span of the message via {StyledString#with_fg}. A
93
+ # {Theme::Ref} is resolved against the current theme *now* — see the
94
+ # class docs on theme following. `nil` leaves the message's own colors
95
+ # alone.
96
+ # @return [Notification, nil] the live notification, or `nil` when `text`
97
+ # was empty.
98
+ # @raise [Tuile::Error] when the screen is closed, or when called from a
99
+ # thread that doesn't currently own the UI — a background job raising a
100
+ # notification must marshal it: `screen.event_queue.submit { ... }`.
101
+ def self.show(text, color: nil)
102
+ Screen.instance.check_locked
103
+ return nil if StyledString.parse(text).empty?
104
+
105
+ live = Screen.instance.pane.popups.find { _1.is_a?(Notification) }
106
+ return live.tap { _1.add_message(text, color: color) } unless live.nil?
107
+
108
+ # Message first, so the box is sized before it is mounted: opening an
109
+ # empty 0×0 popup and then growing it would paint a frame of nothing.
110
+ new.tap do |notification|
111
+ notification.add_message(text, color: color)
112
+ notification.open
113
+ end
114
+ end
115
+
116
+ private_class_method :new
117
+
118
+ def initialize
119
+ # Built before `super`, because Popup#initialize assigns the content and
120
+ # calls #reposition, and our override reads every one of these.
121
+ @messages = []
122
+ @high_water = 0
123
+ @ticker = nil
124
+ @view = TextView.new
125
+ @window = Window.new
126
+ @window.content = @view
127
+ super(content: @window, modal: false, close_on_outside_click: false)
128
+ end
129
+
130
+ # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
131
+ # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
132
+ # nobody — every keystroke would go dead until the user pressed Tab.
133
+ # @return [Boolean] false.
134
+ def focusable? = false
135
+
136
+ # @return [Boolean] false — see {#focusable?}.
137
+ def tab_stop? = false
138
+
139
+ # Appends a message, dropping it (with a {Tuile.logger} warning) once
140
+ # {MAX_MESSAGES} are held. Public so a caller holding the instance can
141
+ # append without repeating {show}'s lookup.
142
+ # @param text [String, StyledString, nil] see {show}. Empty is a no-op.
143
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
144
+ # see {show}.
145
+ # @return [void]
146
+ # @raise [Tuile::Error] see {show}.
147
+ def add_message(text, color: nil)
148
+ # Explicit rather than inherited-through-invalidate: this appends to
149
+ # @messages before anything repaints, so a wrong-thread call has to fail
150
+ # before the message is recorded, not after.
151
+ screen.check_locked
152
+ message = build_message(text, color)
153
+ return if message.empty?
154
+
155
+ if @messages.size >= MAX_MESSAGES
156
+ Tuile.logger.warn("Notification: dropping #{message.to_s.inspect}, " \
157
+ "#{MAX_MESSAGES} messages already queued")
158
+ return
159
+ end
160
+
161
+ @messages << message
162
+ @high_water = [@high_water, natural_width(message)].max
163
+ reposition
164
+ sync_ticker
165
+ end
166
+
167
+ # Recomputes the box from its messages and re-anchors it to the screen's
168
+ # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
169
+ # {Popup#reposition} would have kept the stale left column of a *derived*
170
+ # position (off-screen entirely if the terminal narrowed).
171
+ #
172
+ # Rebuilds the {TextView}'s text too, and every mutation routes through
173
+ # here, because the four are one computation: the wrap width *is* the box
174
+ # width, the height *is* the wrapped row count, the left edge *is* derived
175
+ # from the width.
176
+ # @return [void]
177
+ def reposition
178
+ if @messages.empty?
179
+ self.rect = Rect.new(0, 0, 0, 0)
180
+ return
181
+ end
182
+
183
+ width = box_width
184
+ rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
185
+ height = [rows.size + 2, cap_height].min
186
+ @size = Size.new(width, height)
187
+ @view.text = join_rows(rows)
188
+ self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
189
+ end
190
+
191
+ # A notification is sized by its messages, so this always raises. Failing
192
+ # loudly beats accepting a size the next {#reposition} would discard.
193
+ # @param _new_size [Size, Fraction]
194
+ # @raise [Tuile::Error] always.
195
+ # @return [void]
196
+ def size=(_new_size)
197
+ raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
198
+ end
199
+
200
+ # A left click dismisses the whole box, every message with it. Other buttons
201
+ # are consumed and inert — including the scroll wheel, which would otherwise
202
+ # nuke the box on a stray spin.
203
+ #
204
+ # Deliberately *replaces* rather than augments: neither `super` nor
205
+ # {HasContent#handle_mouse} may run, since both end at a
206
+ # `screen.focused = …` inside this subtree (see {#focusable?}).
207
+ # @param event [MouseEvent]
208
+ # @return [void]
209
+ def handle_mouse(event)
210
+ close if event.button == :left
211
+ end
212
+
213
+ # @return [void]
214
+ def on_attached
215
+ super
216
+ sync_ticker
217
+ end
218
+
219
+ # @return [void]
220
+ def on_detached
221
+ super
222
+ sync_ticker
223
+ end
224
+
225
+ private
226
+
227
+ # Retires the oldest message, closing the box when it was the last. Runs on
228
+ # the event-loop thread, from the ticker.
229
+ # @return [void]
230
+ def retire_oldest
231
+ @messages.shift
232
+ @messages.empty? ? close : reposition
233
+ sync_ticker
234
+ end
235
+
236
+ # Syncs the retirement clock from the invariant "something to retire, and on
237
+ # screen" — the sole writer of `@ticker`. Four sites change whether it is
238
+ # wanted (append, a retirement that empties the queue, {#close}, detach),
239
+ # which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
240
+ # gets half wrong. The early return is also what keeps an append from
241
+ # *restarting* the clock and extending the oldest message's life.
242
+ # @return [void]
243
+ def sync_ticker
244
+ want = attached? && !@messages.empty?
245
+ return if want == !@ticker.nil?
246
+
247
+ if want
248
+ @ticker = screen.event_queue.tick(DISPLAY_SECONDS) { retire_oldest }
249
+ else
250
+ @ticker.cancel
251
+ @ticker = nil
252
+ end
253
+ end
254
+
255
+ # @param text [String, StyledString, nil]
256
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
257
+ # @return [StyledString]
258
+ def build_message(text, color)
259
+ message = StyledString.parse(text)
260
+ return message if color.nil? || message.empty?
261
+
262
+ message.with_fg(color.is_a?(Theme::Ref) ? color.resolve(screen.theme) : color)
263
+ end
264
+
265
+ # Columns the message would like, ignoring wrapping — the *widest* of its
266
+ # hard lines, not the sum of its spans (which would add every line
267
+ # together for a message carrying `\n`).
268
+ # @param message [StyledString]
269
+ # @return [Integer]
270
+ def natural_width(message) = message.lines.map(&:display_width).max || 0
271
+
272
+ # Grow-only: the high-water mark is kept in *desired* columns and the cap
273
+ # is applied here, last. Storing the clamped value instead would let a
274
+ # SIGWINCH that narrows the terminal ratchet the box permanently down to
275
+ # the narrow cap, with nothing to restore it when the terminal widens.
276
+ # @return [Integer]
277
+ def box_width = [@high_water + 2, cap_width].min
278
+
279
+ # @return [Integer]
280
+ def cap_width
281
+ [[(screen.size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen.size.width].min
282
+ end
283
+
284
+ # @return [Integer] at least 3: two border rows plus one row of message.
285
+ def cap_height
286
+ [[(screen.size.height * HEIGHT_FRACTION).to_i, 3].max, screen.size.height].min
287
+ end
288
+
289
+ # Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
290
+ # rows.
291
+ #
292
+ # The overflow is ellipsized from the *joined remainder*, not by
293
+ # ellipsizing the last kept row: that row usually already fits `width`, so
294
+ # {StyledString#ellipsize} would be a no-op and the message would be
295
+ # truncated with no `…` to say so.
296
+ # @param message [StyledString]
297
+ # @param width [Integer]
298
+ # @return [Array<StyledString>]
299
+ def wrap_message(message, width)
300
+ rows = message.wrap(width)
301
+ return rows if rows.size <= MAX_ROWS_PER_MESSAGE
302
+
303
+ kept = rows.take(MAX_ROWS_PER_MESSAGE - 1)
304
+ rest = rows[(MAX_ROWS_PER_MESSAGE - 1)..].inject { |joined, row| joined + SPACE + row }
305
+ kept + [rest.ellipsize(width)]
306
+ end
307
+
308
+ # Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
309
+ # {TextView} takes them as hard lines and its own wrap is a no-op over
310
+ # them (each row already fits the width it will be painted at).
311
+ # @param rows [Array<StyledString>]
312
+ # @return [StyledString]
313
+ def join_rows(rows)
314
+ return StyledString::EMPTY if rows.empty?
315
+
316
+ rows.inject { |joined, row| joined + ROW_BREAK + row }
317
+ end
318
+ end
319
+ end
320
+ end
@@ -37,9 +37,10 @@ module Tuile
37
37
  @options = options.map { Option.new(_1[0], _1[1]) }
38
38
  @block = block
39
39
  list = Component::List.new
40
- list.lines = @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }
40
+ list.renderer = ->(option) { "#{option.key} #{screen.theme.hint(option.caption)}" }
41
+ list.items = @options
41
42
  list.cursor = Component::List::Cursor.new
42
- list.on_item_chosen = ->(index, _line) { select_option(@options[index].key) }
43
+ list.on_item_chosen = ->(_index, option) { select_option(option.key) }
43
44
  self.content = list
44
45
  # Optional hook for a containing Popup to dismiss itself after a pick.
45
46
  @on_pick = nil
@@ -65,11 +66,6 @@ module Tuile
65
66
  end
66
67
  end
67
68
 
68
- # @return [String]
69
- def keyboard_hint
70
- @options.map { "#{_1.key} #{screen.theme.hint(_1.caption)}" }.join(" ")
71
- end
72
-
73
69
  # Opens a picker as a popup. Picking an option fires `block`, then
74
70
  # closes the popup; ESC / `q` close without firing `block`.
75
71
  # @param caption [String]
@@ -83,7 +79,6 @@ module Tuile
83
79
  popup = Popup.new(content: picker)
84
80
  picker.on_pick = -> { popup.close }
85
81
  popup.open
86
- popup
87
82
  end
88
83
 
89
84
  protected
@@ -40,6 +40,10 @@ module Tuile
40
40
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
41
41
  # nested {Component::TextField} doesn't dismiss the popup: the field
42
42
  # consumes it first.
43
+ #
44
+ # A left click *outside* the popup closes it too, modal or not — see
45
+ # {#close_on_outside_click?} for the exact contract and {#on_close=} for
46
+ # the notice a driver hears when it happens.
43
47
  class Popup < Component
44
48
  include Component::HasContent
45
49
 
@@ -52,10 +56,16 @@ module Tuile
52
56
  # @param size [Size, Fraction] the popup's size, applied top-down. A
53
57
  # {Fraction} is resolved against the screen each layout pass; a {Size}
54
58
  # is clamped to the screen. Defaults to {Fraction::HALF}.
55
- def initialize(content: nil, modal: true, size: Fraction::HALF)
59
+ # @param close_on_outside_click [Boolean] true (default) to dismiss on a
60
+ # left click that misses this popup. See {#close_on_outside_click?}.
61
+ def initialize(content: nil, modal: true, size: Fraction::HALF,
62
+ close_on_outside_click: true)
56
63
  super()
57
64
  @modal = modal
58
65
  @size = size
66
+ @close_on_outside_click = close_on_outside_click
67
+ @owner = nil
68
+ @on_close = nil
59
69
  @content = nil
60
70
  self.content = content unless content.nil?
61
71
  reposition
@@ -67,6 +77,63 @@ module Tuile
67
77
  # @return [Boolean] whether this popup is modal. See {#initialize}.
68
78
  def modal? = @modal
69
79
 
80
+ # Whether a left click outside this popup closes it (default true, modal or
81
+ # not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
82
+ # the open popups *before* routing the click and closes the dismissable
83
+ # ones *after*, so a widget that toggles its own overlay from a click on
84
+ # its face (a {Component::Select}, a {Component::MenuBar} title) still
85
+ # toggles correctly: the delivered click closes the overlay and the
86
+ # dismissal then no-ops on it, rather than closing and reopening it. Only
87
+ # `:left` dismisses; scroll and right clicks never do.
88
+ #
89
+ # **"Outside" spans the {#owner} chain, not just this rect.** A click
90
+ # counts as inside this popup when it lands in its rect *or* in any popup
91
+ # that belongs to it — so a dialog is not dismissed by a click on a
92
+ # dropdown its own field opened, and a menu cascade is not dismissed by a
93
+ # click on one of its deeper panels. Popups with no owner relationship are
94
+ # independent: clicking one dismisses the other, which is what a
95
+ # window-like overlay should do. A popup that must survive unrelated
96
+ # clicks entirely ({Component::Notification}) sets this false.
97
+ #
98
+ # Every dismissable popup closes, not just the topmost, and stacking order
99
+ # plays no part: a {Component::MenuBar} cascade must vanish whole on one
100
+ # click on the background, not peel one panel per click.
101
+ # @return [Boolean]
102
+ def close_on_outside_click? = @close_on_outside_click
103
+
104
+ # @return [Boolean] see {#close_on_outside_click?}.
105
+ attr_writer :close_on_outside_click
106
+
107
+ # The component this overlay is *part of*, or `nil` (the default) when it
108
+ # is an overlay in its own right. It exists for outside-click dismissal:
109
+ # a click inside this popup also counts as inside whatever popup encloses
110
+ # its owner, so the host is not dismissed by a click on a panel it put
111
+ # there. See {#close_on_outside_click?}.
112
+ #
113
+ # Set it to the *driver* — {Component::ComboBox} hands its dropdown
114
+ # `self` — rather than to the enclosing popup: the driver knows what it
115
+ # is, while the popup above it is a tree relationship the pane resolves
116
+ # at click time (so it cannot go stale). Any {Component} is accepted, and
117
+ # a `Popup` resolves to itself, which is how a
118
+ # {Component::MenuBar::Cascade} chains each panel to the one it dropped
119
+ # out of.
120
+ # @return [Component, nil]
121
+ attr_accessor :owner
122
+
123
+ # A callback taking no arguments, fired once this popup has left the
124
+ # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
125
+ # an outside click, or teardown via {Screen#close}. That unconditionality
126
+ # is the point, so it hangs off {#on_detached} rather than {#close}; a
127
+ # driver keeping its own record of open popups reconciles it here and
128
+ # cannot drift ({Component::MenuBar::Cascade} is the worked example).
129
+ #
130
+ # It fires *after* the popup is detached, so {#open?} is already false and
131
+ # the usual {Component#on_detached} caveats apply: release state, don't
132
+ # inspect the tree, keep it trivial (it may run while the pane is mid-way
133
+ # through closing a batch of popups, and a raise propagates).
134
+ # @return [Proc, nil]
135
+ attr_accessor :on_close
136
+
70
137
  def focusable? = true
71
138
 
72
139
  # Sets the popup's size and repositions it. Accepts a {Fraction}
@@ -98,19 +165,17 @@ module Tuile
98
165
 
99
166
  # Mounts this popup on the {Screen}, re-resolving its {#size} against the
100
167
  # current screen first.
101
- # @return [void]
168
+ #
169
+ # popup = Component::Popup.new(content: window).open # construct and mount
170
+ #
171
+ # There is deliberately no class-level `Popup.open` factory — see
172
+ # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
173
+ # one-liner above available without one.
174
+ # @return [self]
102
175
  def open
103
176
  reposition
104
177
  screen.add_popup(self)
105
- end
106
-
107
- # Constructs and opens a popup in one call.
108
- # @param content [Component, nil]
109
- # @param modal [Boolean] see {#initialize}.
110
- # @param size [Size, Fraction] see {#initialize}.
111
- # @return [Popup] the opened popup.
112
- def self.open(content: nil, modal: true, size: Fraction::HALF)
113
- Popup.new(content: content, modal: modal, size: size).tap(&:open)
178
+ self
114
179
  end
115
180
 
116
181
  # Removes this popup from the {Screen}. No-op if not currently open.
@@ -149,14 +214,6 @@ module Tuile
149
214
  self.rect = rect.centered(screen.size)
150
215
  end
151
216
 
152
- # Hint for the status bar: own "q Close" plus the wrapped content's hint.
153
- # @return [String]
154
- def keyboard_hint
155
- prefix = "q #{screen.theme.hint("Close")}"
156
- child_hint = @content&.keyboard_hint.to_s
157
- child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
158
- end
159
-
160
217
  # `q` and ESC close the popup. The popup sits on the focus chain of
161
218
  # whatever it wraps, so the key reaches here by bubbling up from the
162
219
  # focused content after that content declined to handle it.
@@ -171,6 +228,13 @@ module Tuile
171
228
  end
172
229
  end
173
230
 
231
+ # Fires {#on_close}. A subclass overriding this **must** call `super`, or
232
+ # the popup's driver never hears that it closed.
233
+ # @return [void]
234
+ def on_detached
235
+ @on_close&.call
236
+ end
237
+
174
238
  protected
175
239
 
176
240
  # Content fills the popup's full rect — Popup has no border to subtract.
@@ -207,7 +207,7 @@ module Tuile
207
207
  def repaint
208
208
  return if rect.empty?
209
209
 
210
- draw_line(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
210
+ draw_text(rect.left, rect.top, StyledString.styled(glyphs(rect.width), fg: resolved_bar_color))
211
211
  clear_background(Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)) if rect.height > 1
212
212
  end
213
213
 
@@ -21,12 +21,13 @@ module Tuile
21
21
  # initial state, and assigning it is the only way back, since Space on the
22
22
  # already-selected row is a no-op rather than a deselect.
23
23
  #
24
- # Composes rather than subclasses, like {ComboBox}: a {List} is its single
25
- # {HasContent} child, which is where the cursor, scrolling, the scrollbar
26
- # and per-row mouse hit-testing come from. `content` is that list, so an app
27
- # can tune it (`scrollbar_visibility`, `show_cursor_when_inactive`, …). Rows
28
- # beyond {#rect}'s height scroll; the inner list is the tab stop, not the
29
- # group.
24
+ # Composes rather than subclasses, like {ComboBox}: a {List} of the items
25
+ # is its single {HasContent} child, which is where the cursor, scrolling,
26
+ # the scrollbar and per-row mouse hit-testing come from — the group only
27
+ # supplies the {List#renderer} that puts the marker in front of the label.
28
+ # `content` is that list, so an app can tune it (`scrollbar_visibility`,
29
+ # `show_cursor_when_inactive`, …). Rows beyond {#rect}'s height scroll; the
30
+ # inner list is the tab stop, not the group.
30
31
  #
31
32
  # == The cursor is chrome
32
33
  # The cursor and the selection are two independent things, as in
@@ -50,7 +51,7 @@ module Tuile
50
51
  # == Implementation details
51
52
  # Two `==`-equal items share one selection, so selecting either marks both
52
53
  # rows; two *distinct* items that merely render the same label stay
53
- # independent, because a row resolves to an item by index.
54
+ # independent, because a row resolves to its own item, never to its label.
54
55
  #
55
56
  # Rows are `(*) `/`( ) ` literals, mirroring {Checkbox}'s convention rather
56
57
  # than importing constants from it. ASCII deliberately: `(•)` would measure
@@ -69,7 +70,6 @@ module Tuile
69
70
  # doesn't matter to a form helper.
70
71
  def initialize(items: [], value: nil)
71
72
  super()
72
- @items = items.to_a
73
73
  @item_label = :to_s.to_proc
74
74
  @value = value
75
75
  @on_value_change = nil
@@ -77,13 +77,14 @@ module Tuile
77
77
  list = List.new
78
78
  # A List has no cursor at all by default (Cursor::None, position -1).
79
79
  list.cursor = List::Cursor.new
80
- list.on_item_chosen = ->(index, _line) { select_at(index) }
80
+ list.renderer = method(:render_row)
81
+ list.on_item_chosen = ->(_index, item) { self.value = item }
82
+ list.items = items.to_a
81
83
  self.content = list
82
- rebuild_rows
83
84
  end
84
85
 
85
86
  # @return [Array] the presented items.
86
- attr_reader :items
87
+ def items = content.items
87
88
 
88
89
  # @return [Proc, Method] item -> row label (a `String`, {StyledString}, or
89
90
  # anything with `#to_s`); `:to_s` by default.
@@ -97,18 +98,17 @@ module Tuile
97
98
  def items=(new_items)
98
99
  raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
99
100
 
100
- @items = new_items
101
- # Before the rebuild, so the single {List#on_cursor_changed} that
102
- # {List#lines=} fires reports the final row rather than a stale one.
103
- clamp_cursor
104
- rebuild_rows
101
+ # Before the items land, so the single {List#on_cursor_changed} that
102
+ # {List#items=} fires reports the final row rather than a stale one.
103
+ clamp_cursor(new_items.size)
104
+ content.items = new_items
105
105
  end
106
106
 
107
107
  # @param proc [Proc, Method] item -> row label.
108
108
  # @return [void]
109
109
  def item_label=(proc)
110
110
  @item_label = proc
111
- rebuild_rows
111
+ content.refresh_rows
112
112
  end
113
113
 
114
114
  # Selects `new_value`, firing {HasValue#on_value_change} when it really
@@ -122,7 +122,7 @@ module Tuile
122
122
  return if value == new_value
123
123
 
124
124
  super
125
- rebuild_rows
125
+ content.refresh_rows
126
126
  end
127
127
 
128
128
  # Selects the cursor row on Space. Nothing else is claimed: the composed
@@ -147,33 +147,35 @@ module Tuile
147
147
 
148
148
  private
149
149
 
150
- # Selects the item on row `index`; an index outside {#items} is ignored.
150
+ # Selects the item on row `index`; an index outside {#items} is ignored —
151
+ # {List::Cursor::None}'s `-1` would otherwise select the *last* item.
151
152
  # @param index [Integer]
152
153
  # @return [void]
153
154
  def select_at(index)
154
- return unless index.between?(0, @items.size - 1)
155
+ return unless index.between?(0, items.size - 1)
155
156
 
156
- self.value = @items[index]
157
+ self.value = items[index]
157
158
  end
158
159
 
159
- # Re-renders every row from the current items, labels and selection.
160
- # @return [void]
161
- def rebuild_rows
162
- content.lines = @items.map do |item|
163
- StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
164
- end
160
+ # @param item [Object]
161
+ # @return [StyledString] the item's row: its label behind a selection
162
+ # marker. The {List} calls this at paint time, so the marker tracks
163
+ # {#value} without re-rendering anything but the visible rows.
164
+ def render_row(item)
165
+ StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
165
166
  end
166
167
 
167
168
  # Pulls an over-range cursor back onto the last row (row 0 when there are
168
- # none). {List#lines=} leaves a stale cursor alone, which would strand it
169
+ # none). {List#items=} leaves a stale cursor alone, which would strand it
169
170
  # off-content: no highlight, a dead Enter, and a Space that resolves to
170
171
  # `nil` and silently clears the selection.
172
+ # @param item_count [Integer] size of the incoming item list.
171
173
  # @return [void]
172
- def clamp_cursor
174
+ def clamp_cursor(item_count)
173
175
  cursor = content.cursor
174
176
  # go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
175
177
  # items list floors at 0 instead of going negative.
176
- cursor.go_to_last(@items.size) if cursor.position >= @items.size
178
+ cursor.go_to_last(item_count) if cursor.position >= item_count
177
179
  end
178
180
 
179
181
  # @param item [Object]