tuile 0.11.0 → 0.12.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +680 -8
  4. data/README.md +12 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +1 -1
  8. data/book/06-theming.md +1 -1
  9. data/book/07-components.md +97 -27
  10. data/examples/file_commander.rb +5 -4
  11. data/examples/sampler.rb +38 -1
  12. data/ideas/new-components.md +9 -4
  13. data/lib/tuile/buffer.rb +7 -7
  14. data/lib/tuile/component/button.rb +1 -1
  15. data/lib/tuile/component/checkbox.rb +1 -1
  16. data/lib/tuile/component/checkbox_group.rb +31 -26
  17. data/lib/tuile/component/combo_box.rb +10 -7
  18. data/lib/tuile/component/info_window.rb +1 -1
  19. data/lib/tuile/component/label.rb +14 -14
  20. data/lib/tuile/component/list.rb +291 -216
  21. data/lib/tuile/component/list_dropdown.rb +14 -7
  22. data/lib/tuile/component/notification.rb +317 -0
  23. data/lib/tuile/component/picker_window.rb +3 -3
  24. data/lib/tuile/component/popup.rb +8 -10
  25. data/lib/tuile/component/progress_bar.rb +1 -1
  26. data/lib/tuile/component/radio_group.rb +32 -30
  27. data/lib/tuile/component/select.rb +7 -7
  28. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  29. data/lib/tuile/component/text_area.rb +79 -273
  30. data/lib/tuile/component/text_field.rb +1 -1
  31. data/lib/tuile/component/text_view.rb +191 -177
  32. data/lib/tuile/component/window.rb +8 -8
  33. data/lib/tuile/component.rb +5 -5
  34. data/lib/tuile/screen.rb +1 -1
  35. data/lib/tuile/styled_string.rb +12 -12
  36. data/lib/tuile/version.rb +1 -1
  37. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  38. data/sig/tuile.rbs +788 -377
  39. metadata +4 -1
@@ -9,9 +9,10 @@ module Tuile
9
9
  # highlight, and reads the pick.
10
10
  #
11
11
  # drop = Component::ListDropdown.new
12
- # drop.on_item_chosen = ->(index, _line) { commit(index) } # caller commits
12
+ # drop.renderer = method(:label_for) # caller renders
13
+ # drop.on_item_chosen = ->(_index, item) { commit(item) } # caller commits
13
14
  # # …then, from the driver's key handler:
14
- # drop.lines = matches.map { |m| render(m) } # caller filters + renders
15
+ # drop.items = matches # caller filters
15
16
  # drop.anchor_to(rect, rows: matches.size) # below the driver, or flipped
16
17
  # drop.open
17
18
  # return true if drop.move(key) # Up/Down/PgUp/PgDn/^U/^D → list scroll
@@ -66,14 +67,20 @@ module Tuile
66
67
  self.bg_color = Theme.ref(:input_bg_color)
67
68
  end
68
69
 
69
- # @param lines [Array] the rows to show; see {List#lines=}.
70
+ # @param items [Array] the items to show, one row each; see {List#items=}.
70
71
  # @return [void]
71
- def lines=(lines)
72
- @list.lines = lines
72
+ def items=(items)
73
+ @list.items = items
73
74
  end
74
75
 
75
- # @return [Array<StyledString>] the current rows.
76
- def lines = @list.lines
76
+ # @return [Array] the items currently shown.
77
+ def items = @list.items
78
+
79
+ # @param proc [Proc, Method] item -> row; see {List#renderer}.
80
+ # @return [void]
81
+ def renderer=(proc)
82
+ @list.renderer = proc
83
+ end
77
84
 
78
85
  # @param proc [Proc, Method, nil] commit callback; see {List#on_item_chosen}.
79
86
  # @return [void]
@@ -0,0 +1,317 @@
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 dismisses ({#handle_mouse}); an app
38
+ # wanting a key registers a global shortcut and calls {#close}.
39
+ # - **Follow a theme flip.** A `Theme::Ref` `color:` is resolved once, when
40
+ # the message is added — a toast lives seconds, so there is no
41
+ # {Component#on_theme_changed} rebuild.
42
+ # - **Take a size.** {#size=} raises; the messages decide.
43
+ class Notification < Popup
44
+ # Most messages held at once, counting both the painted ones and any
45
+ # waiting for room. Chosen from reading time rather than geometry: the
46
+ # drain rate is one message per {DISPLAY_SECONDS}, so the queue length *is*
47
+ # a duration, and 5 × 3 s is about the longest a corner box should own the
48
+ # screen — and about as many short lines as anyone reads.
49
+ # @return [Integer]
50
+ MAX_MESSAGES = 5
51
+
52
+ # Rows a single message may occupy before it is ellipsized.
53
+ # @return [Integer]
54
+ MAX_ROWS_PER_MESSAGE = 3
55
+
56
+ # Seconds between retirements — how long the oldest message is held.
57
+ # @return [Float]
58
+ DISPLAY_SECONDS = 3.0
59
+
60
+ # Fraction of the screen width the box may not exceed (see {MIN_CAP_WIDTH}).
61
+ # @return [Float]
62
+ WIDTH_FRACTION = 0.4
63
+
64
+ # Fraction of the screen height the box may not exceed.
65
+ # @return [Float]
66
+ HEIGHT_FRACTION = 0.4
67
+
68
+ # Floor under the width cap, so 40 % of an 80-column terminal doesn't
69
+ # ellipsize every message down to five words.
70
+ # @return [Integer]
71
+ MIN_CAP_WIDTH = 34
72
+
73
+ # Separator for re-joining wrapped rows before ellipsizing.
74
+ # @return [StyledString]
75
+ SPACE = StyledString.parse(" ")
76
+
77
+ # Hard-line separator handed to {TextView#text=}.
78
+ # @return [StyledString]
79
+ ROW_BREAK = StyledString.parse("\n")
80
+ private_constant :SPACE, :ROW_BREAK
81
+
82
+ # Shows `text` in the corner, creating the box if none is open and
83
+ # appending to it if one is.
84
+ #
85
+ # @param text [String, StyledString, nil] the message. A `String` is parsed
86
+ # via {StyledString.parse}, so embedded ANSI is honored. `nil` and the
87
+ # empty string are no-ops (nothing is shown, nothing is created).
88
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
89
+ # applied to every span of the message via {StyledString#with_fg}. A
90
+ # {Theme::Ref} is resolved against the current theme *now* — see the
91
+ # class docs on theme following. `nil` leaves the message's own colors
92
+ # alone.
93
+ # @return [Notification, nil] the live notification, or `nil` when `text`
94
+ # was empty.
95
+ # @raise [Tuile::Error] when the screen is closed, or when called from a
96
+ # thread that doesn't currently own the UI — a background job raising a
97
+ # notification must marshal it: `screen.event_queue.submit { ... }`.
98
+ def self.show(text, color: nil)
99
+ Screen.instance.check_locked
100
+ return nil if StyledString.parse(text).empty?
101
+
102
+ live = Screen.instance.pane.popups.find { _1.is_a?(Notification) }
103
+ return live.tap { _1.add_message(text, color: color) } unless live.nil?
104
+
105
+ # Message first, so the box is sized before it is mounted: opening an
106
+ # empty 0×0 popup and then growing it would paint a frame of nothing.
107
+ new.tap do |notification|
108
+ notification.add_message(text, color: color)
109
+ notification.open
110
+ end
111
+ end
112
+
113
+ private_class_method :new
114
+
115
+ def initialize
116
+ # Built before `super`, because Popup#initialize assigns the content and
117
+ # calls #reposition, and our override reads every one of these.
118
+ @messages = []
119
+ @high_water = 0
120
+ @ticker = nil
121
+ @view = TextView.new
122
+ @window = Window.new
123
+ @window.content = @view
124
+ super(content: @window, modal: false)
125
+ end
126
+
127
+ # Load-bearing, not cosmetic: focus landing inside a non-modal popup sits
128
+ # outside the key-dispatch scope, where {ScreenPane#handle_key} delivers to
129
+ # nobody — every keystroke would go dead until the user pressed Tab.
130
+ # @return [Boolean] false.
131
+ def focusable? = false
132
+
133
+ # @return [Boolean] false — see {#focusable?}.
134
+ def tab_stop? = false
135
+
136
+ # Empty: a non-modal popup never owns the status bar, and {Popup}'s
137
+ # inherited `q Close` hint would be a lie here — no key ever reaches a
138
+ # notification.
139
+ # @return [String]
140
+ def keyboard_hint = ""
141
+
142
+ # Appends a message, dropping it (with a {Tuile.logger} warning) once
143
+ # {MAX_MESSAGES} are held. Public so a caller holding the instance can
144
+ # append without repeating {show}'s lookup.
145
+ # @param text [String, StyledString, nil] see {show}. Empty is a no-op.
146
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
147
+ # see {show}.
148
+ # @return [void]
149
+ # @raise [Tuile::Error] see {show}.
150
+ def add_message(text, color: nil)
151
+ # Explicit rather than inherited-through-invalidate: this appends to
152
+ # @messages before anything repaints, so a wrong-thread call has to fail
153
+ # before the message is recorded, not after.
154
+ screen.check_locked
155
+ message = build_message(text, color)
156
+ return if message.empty?
157
+
158
+ if @messages.size >= MAX_MESSAGES
159
+ Tuile.logger.warn("Notification: dropping #{message.to_s.inspect}, " \
160
+ "#{MAX_MESSAGES} messages already queued")
161
+ return
162
+ end
163
+
164
+ @messages << message
165
+ @high_water = [@high_water, natural_width(message)].max
166
+ reposition
167
+ sync_ticker
168
+ end
169
+
170
+ # Recomputes the box from its messages and re-anchors it to the screen's
171
+ # top-right corner — so a SIGWINCH re-wraps and re-anchors, where
172
+ # {Popup#reposition} would have kept the stale left column of a *derived*
173
+ # position (off-screen entirely if the terminal narrowed).
174
+ #
175
+ # Rebuilds the {TextView}'s text too, and every mutation routes through
176
+ # here, because the four are one computation: the wrap width *is* the box
177
+ # width, the height *is* the wrapped row count, the left edge *is* derived
178
+ # from the width.
179
+ # @return [void]
180
+ def reposition
181
+ if @messages.empty?
182
+ self.rect = Rect.new(0, 0, 0, 0)
183
+ return
184
+ end
185
+
186
+ width = box_width
187
+ rows = @messages.flat_map { |message| wrap_message(message, width - 2) }
188
+ height = [rows.size + 2, cap_height].min
189
+ @size = Size.new(width, height)
190
+ @view.text = join_rows(rows)
191
+ self.rect = Rect.new([screen.size.width - width, 0].max, 0, width, height)
192
+ end
193
+
194
+ # A notification is sized by its messages, so this always raises. Failing
195
+ # loudly beats accepting a size the next {#reposition} would discard.
196
+ # @param _new_size [Size, Fraction]
197
+ # @raise [Tuile::Error] always.
198
+ # @return [void]
199
+ def size=(_new_size)
200
+ raise Tuile::Error, "Notification sizes itself from its messages; #{self.class}#size= is not settable"
201
+ end
202
+
203
+ # A left click dismisses the whole box, every message with it. Other buttons
204
+ # are consumed and inert — including the scroll wheel, which would otherwise
205
+ # nuke the box on a stray spin.
206
+ #
207
+ # Deliberately *replaces* rather than augments: neither `super` nor
208
+ # {HasContent#handle_mouse} may run, since both end at a
209
+ # `screen.focused = …` inside this subtree (see {#focusable?}).
210
+ # @param event [MouseEvent]
211
+ # @return [void]
212
+ def handle_mouse(event)
213
+ close if event.button == :left
214
+ end
215
+
216
+ # @return [void]
217
+ def on_attached = sync_ticker
218
+
219
+ # @return [void]
220
+ def on_detached = sync_ticker
221
+
222
+ private
223
+
224
+ # Retires the oldest message, closing the box when it was the last. Runs on
225
+ # the event-loop thread, from the ticker.
226
+ # @return [void]
227
+ def retire_oldest
228
+ @messages.shift
229
+ @messages.empty? ? close : reposition
230
+ sync_ticker
231
+ end
232
+
233
+ # Syncs the retirement clock from the invariant "something to retire, and on
234
+ # screen" — the sole writer of `@ticker`. Four sites change whether it is
235
+ # wanted (append, a retirement that empties the queue, {#close}, detach),
236
+ # which is the 2×2 a start-in-{#on_attached} / cancel-in-{#on_detached} pair
237
+ # gets half wrong. The early return is also what keeps an append from
238
+ # *restarting* the clock and extending the oldest message's life.
239
+ # @return [void]
240
+ def sync_ticker
241
+ want = attached? && !@messages.empty?
242
+ return if want == !@ticker.nil?
243
+
244
+ if want
245
+ @ticker = screen.event_queue.tick(DISPLAY_SECONDS) { retire_oldest }
246
+ else
247
+ @ticker.cancel
248
+ @ticker = nil
249
+ end
250
+ end
251
+
252
+ # @param text [String, StyledString, nil]
253
+ # @param color [Color, Theme::Ref, Symbol, Integer, Array<Integer>, nil]
254
+ # @return [StyledString]
255
+ def build_message(text, color)
256
+ message = StyledString.parse(text)
257
+ return message if color.nil? || message.empty?
258
+
259
+ message.with_fg(color.is_a?(Theme::Ref) ? color.resolve(screen.theme) : color)
260
+ end
261
+
262
+ # Columns the message would like, ignoring wrapping — the *widest* of its
263
+ # hard lines, not the sum of its spans (which would add every line
264
+ # together for a message carrying `\n`).
265
+ # @param message [StyledString]
266
+ # @return [Integer]
267
+ def natural_width(message) = message.lines.map(&:display_width).max || 0
268
+
269
+ # Grow-only: the high-water mark is kept in *desired* columns and the cap
270
+ # is applied here, last. Storing the clamped value instead would let a
271
+ # SIGWINCH that narrows the terminal ratchet the box permanently down to
272
+ # the narrow cap, with nothing to restore it when the terminal widens.
273
+ # @return [Integer]
274
+ def box_width = [@high_water + 2, cap_width].min
275
+
276
+ # @return [Integer]
277
+ def cap_width
278
+ [[(screen.size.width * WIDTH_FRACTION).to_i, MIN_CAP_WIDTH].max, screen.size.width].min
279
+ end
280
+
281
+ # @return [Integer] at least 3: two border rows plus one row of message.
282
+ def cap_height
283
+ [[(screen.size.height * HEIGHT_FRACTION).to_i, 3].max, screen.size.height].min
284
+ end
285
+
286
+ # Wraps one message to `width` columns, capped at {MAX_ROWS_PER_MESSAGE}
287
+ # rows.
288
+ #
289
+ # The overflow is ellipsized from the *joined remainder*, not by
290
+ # ellipsizing the last kept row: that row usually already fits `width`, so
291
+ # {StyledString#ellipsize} would be a no-op and the message would be
292
+ # truncated with no `…` to say so.
293
+ # @param message [StyledString]
294
+ # @param width [Integer]
295
+ # @return [Array<StyledString>]
296
+ def wrap_message(message, width)
297
+ rows = message.wrap(width)
298
+ return rows if rows.size <= MAX_ROWS_PER_MESSAGE
299
+
300
+ kept = rows.take(MAX_ROWS_PER_MESSAGE - 1)
301
+ rest = rows[(MAX_ROWS_PER_MESSAGE - 1)..].inject { |joined, row| joined + SPACE + row }
302
+ kept + [rest.ellipsize(width)]
303
+ end
304
+
305
+ # Joins pre-wrapped rows into one {StyledString} with `\n` separators, so
306
+ # {TextView} takes them as hard lines and its own wrap is a no-op over
307
+ # them (each row already fits the width it will be painted at).
308
+ # @param rows [Array<StyledString>]
309
+ # @return [StyledString]
310
+ def join_rows(rows)
311
+ return StyledString::EMPTY if rows.empty?
312
+
313
+ rows.inject { |joined, row| joined + ROW_BREAK + row }
314
+ end
315
+ end
316
+ end
317
+ 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
@@ -83,7 +84,6 @@ module Tuile
83
84
  popup = Popup.new(content: picker)
84
85
  picker.on_pick = -> { popup.close }
85
86
  popup.open
86
- popup
87
87
  end
88
88
 
89
89
  protected
@@ -98,19 +98,17 @@ module Tuile
98
98
 
99
99
  # Mounts this popup on the {Screen}, re-resolving its {#size} against the
100
100
  # current screen first.
101
- # @return [void]
101
+ #
102
+ # popup = Component::Popup.new(content: window).open # construct and mount
103
+ #
104
+ # There is deliberately no class-level `Popup.open` factory — see
105
+ # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
106
+ # one-liner above available without one.
107
+ # @return [self]
102
108
  def open
103
109
  reposition
104
110
  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)
111
+ self
114
112
  end
115
113
 
116
114
  # Removes this popup from the {Screen}. No-op if not currently open.
@@ -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]
@@ -72,7 +72,8 @@ module Tuile
72
72
  @value = value
73
73
  @on_value_change = nil
74
74
  @overlay = ListDropdown.new
75
- @overlay.on_item_chosen = ->(index, _line) { commit(index) }
75
+ @overlay.renderer = method(:label_for)
76
+ @overlay.on_item_chosen = ->(_index, item) { commit(item) }
76
77
  end
77
78
 
78
79
  # @return [Array] the options.
@@ -171,7 +172,7 @@ module Tuile
171
172
 
172
173
  tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
173
174
  clear_background(tail) unless tail.empty?
174
- draw_line(rect.left, rect.top, face_row)
175
+ draw_text(rect.left, rect.top, face_row)
175
176
  end
176
177
 
177
178
  private
@@ -196,7 +197,7 @@ module Tuile
196
197
  return
197
198
  end
198
199
 
199
- @overlay.lines = @items.map { |item| label_for(item) }
200
+ @overlay.items = @items
200
201
  @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
201
202
  @overlay.open unless @overlay.open?
202
203
  anchor
@@ -208,11 +209,10 @@ module Tuile
208
209
  # @return [void]
209
210
  def close_menu = (@overlay.close if @overlay.open?)
210
211
 
211
- # Adopts the item on row `index` as {#value} and closes the dropdown.
212
- # @param index [Integer]
212
+ # Adopts the chosen item as {#value} and closes the dropdown.
213
+ # @param item [Object]
213
214
  # @return [void]
214
- def commit(index)
215
- item = @items[index]
215
+ def commit(item)
216
216
  close_menu
217
217
  self.value = item
218
218
  end