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
@@ -2,27 +2,55 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A scrollable list of items with cursor support.
5
+ # A scrollable list of typed items, one row each, with cursor support.
6
6
  #
7
- # Items are modeled as {StyledString}s and painted directly into the
8
- # component's {#rect}. Lines wider than the viewport are ellipsized via
9
- # {StyledString#ellipsize} with span styles preserved across the cut.
10
- # Vertical scrolling is via {#top_line}; enable {#auto_scroll} to keep the
11
- # bottom in view.
7
+ # list = Component::List.new
8
+ # list.items = people
9
+ # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.hint(" #{p.email}") }
10
+ # list.cursor = List::Cursor.new # a bare list has none
11
+ # list.on_item_chosen = ->(index, person) { open(person) }
12
12
  #
13
- # Cursor is supported; call {#cursor=} to change cursor behavior. The
14
- # cursor responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the
15
- # list automatically. The cursor highlight overlays
16
- # {Theme#active_bg_color} while preserving each span's foreground color.
13
+ # The {#renderer} turns an item into one row; the default renders an item
14
+ # as itself, so a list of `String`s or {StyledString}s needs none — which
15
+ # is what {#lines=} and {#build_lines} are, items that are their own
16
+ # rendering (split on `\n`, one row per line).
17
+ #
18
+ # There are no appenders. The items are always assigned whole — an app that
19
+ # grows a list keeps its own array and re-assigns it (`list.items = mine`) —
20
+ # so a `List` stays a *snapshot of a collection*, which is the only shape a
21
+ # lazily-sourced provider could ever fill.
22
+ #
23
+ # Rows wider than the viewport are ellipsized via {StyledString#ellipsize}
24
+ # with span styles preserved across the cut. Vertical scrolling is via
25
+ # {#scroll_top_row}; enable {#auto_scroll} to keep the bottom in view. The cursor
26
+ # responds to arrows, `jk`, Home/End, Ctrl+U/D and scrolls the list
27
+ # automatically; its highlight overlays {Theme#active_bg_color} while
28
+ # preserving each span's foreground color.
29
+ #
30
+ # == Implementation details
31
+ # Rendering is lazy: only the rows in the viewport are rendered, each
32
+ # memoized until {#items=}, {#renderer=} or a width change drops the cache.
33
+ # So a renderer runs *at paint time*, on any frame — keep it pure and
34
+ # cheap; work that reaches a service belongs in the item, not in the
35
+ # renderer. {#select_next} deliberately renders without memoizing: one
36
+ # failed scan would otherwise cache a row per item.
17
37
  class List < Component
38
+ # The default {#renderer}: an item renders as itself. Every renderer's
39
+ # output is coerced the same way — a {StyledString} passes through, a
40
+ # `String` is parsed (so embedded ANSI is honored), anything else is
41
+ # `#to_s`'d first.
42
+ # @return [Proc]
43
+ DEFAULT_RENDERER = :itself.to_proc
44
+
18
45
  def initialize
19
46
  super
20
- @lines = []
21
- @padded_lines = []
22
- @blank_padded = StyledString::EMPTY
47
+ @items = []
48
+ @renderer = DEFAULT_RENDERER
49
+ @row_cache = {}
50
+ @blank_row = nil
23
51
  @auto_scroll = false
24
52
  @follow = true
25
- @top_line = 0
53
+ @scroll_top_row = 0
26
54
  @cursor = Cursor::None.new
27
55
  @scrollbar_visibility = :gone
28
56
  @show_cursor_when_inactive = false
@@ -32,37 +60,37 @@ module Tuile
32
60
  end
33
61
 
34
62
  # @return [Proc, nil] callback fired when an item is chosen — by pressing
35
- # Enter on the cursor's item, or by left-clicking an item. Called as
36
- # `proc.call(index, line)` with the chosen 0-based index and its
37
- # {StyledString} line. Never fires when the cursor's position is
38
- # outside the content (e.g. {Cursor::None}, or empty content).
63
+ # Enter on the cursor's item, or by left-clicking it. Called as
64
+ # `proc.call(index, item)` with the chosen 0-based index and the item
65
+ # itself. Never fires when the cursor's position is outside the content
66
+ # (e.g. {Cursor::None}, or empty content).
39
67
  attr_accessor :on_item_chosen
40
68
 
41
- # @return [Proc, nil] callback fired when the `(index, line)` tuple under
42
- # the cursor changes. Called as `proc.call(index, line)` where `line`
43
- # is the {StyledString} at the cursor, or `nil` when the cursor is
69
+ # @return [Proc, nil] callback fired when the `(index, item)` tuple under
70
+ # the cursor changes (items compared with `==`). Called as
71
+ # `proc.call(index, item)`, with `item` `nil` when the cursor is
44
72
  # off-content ({Cursor::None}, empty list, or `index` past the last
45
- # line). Fires on cursor moves (key, mouse, search), on {#cursor=},
46
- # and on {#lines=}/{#add_lines} when the line at the cursor's index
73
+ # item). Fires on cursor moves (key, mouse, search), on {#cursor=},
74
+ # and on {#items=} when the item at the cursor's index
47
75
  # changes (or its in-range/out-of-range status flips). Useful for
48
76
  # keeping a details pane in sync with the highlighted row.
49
77
  attr_accessor :on_cursor_changed
50
78
 
51
- # @return [Boolean] if true and a line is added or new content is set,
52
- # auto-scrolls to the bottom — but only while the viewport is already
53
- # pinned to the last line (see {#following?}). Scroll up to read older
54
- # content and appends stop yanking you back down; scroll back to the
55
- # bottom and tailing resumes.
79
+ # @return [Boolean] if true and new content is set, auto-scrolls to the
80
+ # bottom — but only while the viewport is already pinned to the last
81
+ # row (see {#following?}). Scroll up to read older content and
82
+ # incoming rows stop yanking you back down; scroll back to the bottom
83
+ # and tailing resumes.
56
84
  attr_reader :auto_scroll
57
85
 
58
86
  # @return [Boolean] whether {#auto_scroll} is currently tailing. True
59
- # while the viewport sits at the last line; flips to false the moment
87
+ # while the viewport sits at the last row; flips to false the moment
60
88
  # the user scrolls up, and back to true once they scroll to the bottom
61
89
  # again. Only consulted when {#auto_scroll} is enabled.
62
90
  def following? = @follow
63
91
 
64
- # @return [Integer] top line of the viewport. 0 or positive.
65
- attr_reader :top_line
92
+ # @return [Integer] top row of the viewport. 0 or positive.
93
+ attr_reader :scroll_top_row
66
94
 
67
95
  # @return [Cursor] the list's cursor.
68
96
  attr_reader :cursor
@@ -91,7 +119,7 @@ module Tuile
91
119
  return if @scrollbar_visibility == value
92
120
 
93
121
  @scrollbar_visibility = value
94
- rebuild_padded_lines
122
+ drop_row_cache
95
123
  invalidate
96
124
  end
97
125
 
@@ -101,7 +129,7 @@ module Tuile
101
129
  def auto_scroll=(new_auto_scroll)
102
130
  @auto_scroll = new_auto_scroll
103
131
  @follow = true if new_auto_scroll
104
- update_top_line_if_auto_scroll
132
+ update_scroll_top_row_if_auto_scroll
105
133
  end
106
134
 
107
135
  # Sets a new cursor.
@@ -115,84 +143,106 @@ module Tuile
115
143
  notify_cursor_changed
116
144
  end
117
145
 
118
- # Sets the top line.
119
- # @param new_top_line [Integer] 0 or greater.
120
- def top_line=(new_top_line)
121
- raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
122
- raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
123
- return unless @top_line != new_top_line
146
+ # Sets the top row.
147
+ # @param new_row [Integer] 0 or greater.
148
+ def scroll_top_row=(new_row)
149
+ raise TypeError, "expected Integer, got #{new_row.inspect}" unless new_row.is_a? Integer
150
+ raise ArgumentError, "scroll_top_row must not be negative, got #{new_row}" if new_row.negative?
151
+ return unless @scroll_top_row != new_row
124
152
 
125
- @top_line = new_top_line
153
+ @scroll_top_row = new_row
126
154
  @follow = at_bottom?
127
155
  invalidate
128
156
  end
129
157
 
130
- # Sets new lines. Each entry is coerced into a {StyledString} (a
131
- # `String` is parsed via {StyledString.parse}, so embedded ANSI is
132
- # honored; a {StyledString} is used as-is; anything else is stringified
133
- # via `#to_s` first), then split on `\n` into separate lines via
134
- # {StyledString#lines}, with trailing empty pieces dropped and trailing
135
- # ASCII whitespace stripped — symmetric with {#add_lines}, so the
136
- # stored `@lines` is always `Array<StyledString>`.
158
+ # @return [Array] the items, one row each.
159
+ attr_reader :items
160
+
161
+ # @return [Proc, Method] item -> row: a {StyledString}, a `String` (parsed,
162
+ # so embedded ANSI is honored), or anything with `#to_s`. Only the first
163
+ # line of a multi-line rendering is kept — one item is one row.
164
+ attr_reader :renderer
165
+
166
+ # Replaces the items, leaving the cursor where it is — a cursor left past
167
+ # the last item strands off-content: no highlight, a dead Enter, and a
168
+ # caller resolving `items[position]` gets `nil`. A caller that cares
169
+ # clamps it (`cursor.go_to_last(items.size)`).
170
+ # @param new_items [Array] one row each; rendered by {#renderer}.
171
+ # @raise [TypeError] unless `new_items` is an `Array`.
172
+ # @return [void]
173
+ def items=(new_items)
174
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a? Array
175
+
176
+ @items = new_items
177
+ drop_row_cache
178
+ update_scroll_top_row_if_auto_scroll
179
+ notify_cursor_changed
180
+ invalidate
181
+ end
182
+
183
+ # @param proc [Proc, Method] item -> row; see {#renderer}.
184
+ # @return [void]
185
+ def renderer=(proc)
186
+ @renderer = proc
187
+ drop_row_cache
188
+ invalidate
189
+ end
190
+
191
+ # Re-renders every row — for a {#renderer} whose *inputs* changed while
192
+ # it and {#items} stayed the same, e.g. one prefixing a marker read from
193
+ # a selection it closes over:
194
+ #
195
+ # def value=(new_value) # RadioGroup: the marked row moved
196
+ # super
197
+ # content.refresh_rows
198
+ # end
199
+ #
200
+ # @return [void]
201
+ def refresh_rows
202
+ drop_row_cache
203
+ invalidate
204
+ end
205
+
206
+ # Sets the items from line-flavored input: each entry is coerced into a
207
+ # {StyledString} (a `String` is parsed via {StyledString.parse}, so
208
+ # embedded ANSI is honored; a {StyledString} is used as-is; anything else
209
+ # is stringified via `#to_s` first), then split on `\n` into separate
210
+ # lines via {StyledString#lines}, with trailing empty pieces dropped and
211
+ # trailing ASCII whitespace stripped. The resulting {StyledString}s
212
+ # *are* the {#items}, so under the
213
+ # {DEFAULT_RENDERER} each is its own row.
137
214
  # @param lines [Array] entries are `String`, `StyledString`, or anything
138
215
  # that responds to `#to_s`.
139
216
  # @return [void]
140
217
  def lines=(lines)
141
218
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array
142
219
 
143
- @lines = parse_input_lines(lines)
144
- rebuild_padded_lines
145
- update_top_line_if_auto_scroll
146
- notify_cursor_changed
147
- invalidate
220
+ self.items = parse_input_lines(lines)
148
221
  end
149
222
 
150
- # Without a block, returns the current lines. With a block, fully
151
- # re-populates the list:
223
+ # Fully re-populates the list, line-flavored: yields a fresh buffer and
224
+ # assigns it through {#lines=}, so each entry is coerced, split and
225
+ # rstripped exactly as there. The buffer is a plain `Array`, which a
226
+ # builder can read mid-build — recording the row a {Cursor::Limited} may
227
+ # land on is the case this exists for:
152
228
  # ```ruby
153
- # list.lines do |buffer|
154
- # buffer << "Hello!"
229
+ # list.build_lines do |lines|
230
+ # cursor_positions << lines.size
231
+ # lines << format_overview(vm)
232
+ # lines << format_detail(vm)
155
233
  # end
156
234
  # ```
157
235
  # @yield [buffer]
158
236
  # @yieldparam buffer [Array] mutable buffer to push lines into. Each
159
237
  # entry is parsed the same way as the items passed to {#lines=}.
160
238
  # @yieldreturn [void]
161
- # @return [Array<StyledString>] current lines (when called without a
162
- # block).
163
- def lines
164
- return @lines unless block_given?
165
-
239
+ # @return [void]
240
+ def build_lines
166
241
  buffer = []
167
242
  yield buffer
168
243
  self.lines = buffer
169
244
  end
170
245
 
171
- # Adds a line.
172
- # @param line [String, StyledString, #to_s]
173
- # @return [void]
174
- def add_line(line)
175
- raise ArgumentError, "line is nil" if line.nil?
176
-
177
- add_lines [line]
178
- end
179
-
180
- # Appends given lines. Each entry is parsed the same way as in
181
- # {#lines=}: coerced to a {StyledString}, split on `\n`, with trailing
182
- # empty pieces dropped and trailing ASCII whitespace stripped.
183
- # @param lines [Array] entries are `String`, `StyledString`, or anything
184
- # that responds to `#to_s`.
185
- # @return [void]
186
- def add_lines(lines)
187
- screen.check_locked
188
- new_lines = parse_input_lines(lines)
189
- @lines += new_lines
190
- @padded_lines += new_lines.map { |line| pad_to_row(line) }
191
- update_top_line_if_auto_scroll
192
- notify_cursor_changed
193
- invalidate
194
- end
195
-
196
246
  def focusable? = true
197
247
 
198
248
  def tab_stop? = true
@@ -201,15 +251,15 @@ module Tuile
201
251
  # @return [Boolean] true if the key was handled.
202
252
  def handle_key(key)
203
253
  if key == Keys::PAGE_UP
204
- move_top_line_by(-viewport_lines)
254
+ move_scroll_top_row_by(-viewport_rows)
205
255
  true
206
256
  elsif key == Keys::PAGE_DOWN
207
- move_top_line_by(viewport_lines)
257
+ move_scroll_top_row_by(viewport_rows)
208
258
  true
209
259
  elsif key == Keys::ENTER && cursor_on_item?
210
260
  fire_item_chosen
211
261
  true
212
- elsif @cursor.handle_key(key, @lines.size, viewport_lines)
262
+ elsif @cursor.handle_key(key, @items.size, viewport_rows)
213
263
  move_viewport_to_cursor
214
264
  notify_cursor_changed
215
265
  invalidate
@@ -219,16 +269,16 @@ module Tuile
219
269
  end
220
270
  end
221
271
 
222
- # Moves the cursor to the next line whose text contains `query`
272
+ # Moves the cursor to the next item whose text contains `query`
223
273
  # (case-insensitive substring match). Search wraps around the end of the
224
- # list. Only lines reachable by the current {#cursor} are considered.
225
- # Matching uses the line's plain text — span styles do not affect the
274
+ # list. Only items reachable by the current {#cursor} are considered.
275
+ # Matching uses the rendered row's plain text — span styles do not affect the
226
276
  # match.
227
277
  #
228
278
  # @param query [String] substring to match. Empty query never matches.
229
279
  # @param include_current [Boolean] when true, the current cursor position
230
280
  # is eligible (useful when the query has just changed and the current
231
- # line may still match); when false, the search starts after the
281
+ # item may still match); when false, the search starts after the
232
282
  # current position (useful for "find next" key bindings that should
233
283
  # advance past the current).
234
284
  # @return [Boolean] true if a match was found.
@@ -249,28 +299,29 @@ module Tuile
249
299
  def handle_mouse(event)
250
300
  super
251
301
  if event.button == :scroll_down
252
- move_top_line_by(4)
302
+ move_scroll_top_row_by(4)
253
303
  elsif event.button == :scroll_up
254
- move_top_line_by(-4)
304
+ move_scroll_top_row_by(-4)
255
305
  else
256
306
  return unless rect.contains?(event.point)
257
307
 
258
- line = event.y - rect.top + top_line
259
- if @cursor.handle_mouse(line, event, @lines.size)
308
+ item_index = event.y - rect.top + scroll_top_row
309
+ if @cursor.handle_mouse(item_index, event, @items.size)
260
310
  move_viewport_to_cursor
261
311
  notify_cursor_changed
262
312
  invalidate
263
313
  end
264
- fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
314
+ fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
265
315
  end
266
316
  end
267
317
 
268
- # Paints the list items into {#rect}.
318
+ # Paints the visible items into {#rect}, rendering the ones not already
319
+ # cached.
269
320
  #
270
321
  # Skips the {Component#repaint} default's auto-clear: every row of
271
322
  # {#rect} is painted below (with blank padding past the last item),
272
323
  # so the parent contract — "fully draw over your rect" — is met
273
- # without an upfront wipe. Rows go through {Component#draw_line}, so
324
+ # without an upfront wipe. Rows go through {Component#draw_text}, so
274
325
  # content *and* blank filler inherit {Component#effective_bg_color}
275
326
  # (a {#bg_color} set here or on an ancestor); the cursor row's
276
327
  # {Theme#active_bg_color} highlight composes on top of it.
@@ -279,11 +330,10 @@ module Tuile
279
330
  return if rect.empty?
280
331
 
281
332
  scrollbar = if scrollbar_visible?
282
- VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
333
+ VerticalScrollBar.new(rect.height, row_count: @items.size, scroll_top_row: @scroll_top_row)
283
334
  end
284
335
  (0...rect.height).each do |row|
285
- line = paintable_line(row + @top_line, row, scrollbar)
286
- draw_line(rect.left, row + rect.top, line)
336
+ draw_text(rect.left, row + rect.top, paintable_row(row + @scroll_top_row, row, scrollbar))
287
337
  end
288
338
  end
289
339
 
@@ -304,24 +354,24 @@ module Tuile
304
354
  end
305
355
 
306
356
  # @param _key [String]
307
- # @param _line_count [Integer]
308
- # @param _viewport_lines [Integer]
357
+ # @param _item_count [Integer]
358
+ # @param _viewport_rows [Integer]
309
359
  # @return [Boolean]
310
- def handle_key(_key, _line_count, _viewport_lines)
360
+ def handle_key(_key, _item_count, _viewport_rows)
311
361
  false
312
362
  end
313
363
 
314
- # @param _line [Integer]
364
+ # @param _item_index [Integer]
315
365
  # @param _event [MouseEvent]
316
- # @param _line_count [Integer]
366
+ # @param _item_count [Integer]
317
367
  # @return [Boolean]
318
- def handle_mouse(_line, _event, _line_count)
368
+ def handle_mouse(_item_index, _event, _item_count)
319
369
  false
320
370
  end
321
371
 
322
- # @param _line_count [Integer]
372
+ # @param _item_count [Integer]
323
373
  # @return [Array<Integer>]
324
- def candidate_positions(_line_count)
374
+ def candidate_positions(_item_count)
325
375
  []
326
376
  end
327
377
 
@@ -336,46 +386,46 @@ module Tuile
336
386
  end
337
387
  end
338
388
 
339
- # @return [Integer] 0-based line index of the current cursor position.
389
+ # @return [Integer] 0-based item index of the current cursor position.
340
390
  attr_reader :position
341
391
 
342
- # @param line_count [Integer] number of lines in the list.
392
+ # @param item_count [Integer] number of items in the list.
343
393
  # @return [Array<Integer>] positions the cursor can land on, in
344
394
  # ascending order.
345
- def candidate_positions(line_count)
346
- (0...line_count).to_a
395
+ def candidate_positions(item_count)
396
+ (0...item_count).to_a
347
397
  end
348
398
 
349
399
  # @param key [String] pressed keyboard key.
350
- # @param line_count [Integer] number of lines in the list.
351
- # @param viewport_lines [Integer] number of visible lines.
400
+ # @param item_count [Integer] number of items in the list.
401
+ # @param viewport_rows [Integer] number of visible rows.
352
402
  # @return [Boolean] true if the cursor moved.
353
- def handle_key(key, line_count, viewport_lines)
403
+ def handle_key(key, item_count, viewport_rows)
354
404
  case key
355
405
  when *Keys::DOWN_ARROWS
356
- go_down_by(1, line_count)
406
+ go_down_by(1, item_count)
357
407
  when *Keys::UP_ARROWS
358
408
  go_up_by(1)
359
409
  when *Keys::HOMES
360
410
  go_to_first
361
411
  when *Keys::ENDS_
362
- go_to_last(line_count)
412
+ go_to_last(item_count)
363
413
  when Keys::CTRL_U
364
- go_up_by(viewport_lines / 2)
414
+ go_up_by(viewport_rows / 2)
365
415
  when Keys::CTRL_D
366
- go_down_by(viewport_lines / 2, line_count)
416
+ go_down_by(viewport_rows / 2, item_count)
367
417
  else
368
418
  false
369
419
  end
370
420
  end
371
421
 
372
- # @param line [Integer] cursor is hovering over this line.
422
+ # @param item_index [Integer] the item the cursor is hovering over.
373
423
  # @param event [MouseEvent] the event.
374
- # @param line_count [Integer] number of lines in the list.
424
+ # @param item_count [Integer] number of items in the list.
375
425
  # @return [Boolean] true if the event was handled.
376
- def handle_mouse(line, event, line_count)
426
+ def handle_mouse(item_index, event, item_count)
377
427
  if event.button == :left
378
- go(line.clamp(nil, line_count - 1))
428
+ go(item_index.clamp(nil, item_count - 1))
379
429
  else
380
430
  false
381
431
  end
@@ -393,27 +443,27 @@ module Tuile
393
443
  end
394
444
 
395
445
  # Moves the cursor to the last reachable position. For base {Cursor},
396
- # the last line; {Limited} clamps to the last allowed position; {None}
446
+ # the last item; {Limited} clamps to the last allowed position; {None}
397
447
  # is a no-op.
398
- # @param line_count [Integer] number of lines in the list.
448
+ # @param item_count [Integer] number of items in the list.
399
449
  # @return [Boolean] true if the position changed.
400
- def go_to_last(line_count)
401
- go(line_count - 1)
450
+ def go_to_last(item_count)
451
+ go(item_count - 1)
402
452
  end
403
453
 
404
454
  protected
405
455
 
406
- # @param lines [Integer]
407
- # @param line_count [Integer]
456
+ # @param count [Integer]
457
+ # @param item_count [Integer]
408
458
  # @return [Boolean]
409
- def go_down_by(lines, line_count)
410
- go((@position + lines).clamp(nil, line_count - 1))
459
+ def go_down_by(count, item_count)
460
+ go((@position + count).clamp(nil, item_count - 1))
411
461
  end
412
462
 
413
- # @param lines [Integer]
463
+ # @param count [Integer]
414
464
  # @return [Boolean]
415
- def go_up_by(lines)
416
- go(@position - lines)
465
+ def go_up_by(count)
466
+ go(@position - count)
417
467
  end
418
468
 
419
469
  # @return [Boolean]
@@ -421,7 +471,7 @@ module Tuile
421
471
  go(0)
422
472
  end
423
473
 
424
- # Cursor which can only land on specific allowed lines.
474
+ # Cursor which can only land on specific allowed items.
425
475
  class Limited < Cursor
426
476
  # @param positions [Array<Integer>] allowed positions. Must not be
427
477
  # empty.
@@ -434,13 +484,13 @@ module Tuile
434
484
  super(position: position)
435
485
  end
436
486
 
437
- # @param line [Integer]
487
+ # @param item_index [Integer]
438
488
  # @param event [MouseEvent]
439
- # @param _line_count [Integer]
489
+ # @param _item_count [Integer]
440
490
  # @return [Boolean]
441
- def handle_mouse(line, event, _line_count)
491
+ def handle_mouse(item_index, event, _item_count)
442
492
  if event.button == :left
443
- prev_pos = @positions.reverse_each.find { _1 <= line }
493
+ prev_pos = @positions.reverse_each.find { _1 <= item_index }
444
494
  return go_to_first if prev_pos.nil?
445
495
 
446
496
  go(prev_pos)
@@ -449,34 +499,34 @@ module Tuile
449
499
  end
450
500
  end
451
501
 
452
- # @param line_count [Integer]
502
+ # @param item_count [Integer]
453
503
  # @return [Array<Integer>]
454
- def candidate_positions(line_count)
455
- @positions.select { _1 < line_count }
504
+ def candidate_positions(item_count)
505
+ @positions.select { _1 < item_count }
456
506
  end
457
507
 
458
- # @param _line_count [Integer]
508
+ # @param _item_count [Integer]
459
509
  # @return [Boolean]
460
- def go_to_last(_line_count)
510
+ def go_to_last(_item_count)
461
511
  go(@positions.last)
462
512
  end
463
513
 
464
514
  protected
465
515
 
466
- # @param lines [Integer]
467
- # @param line_count [Integer]
516
+ # @param count [Integer]
517
+ # @param item_count [Integer]
468
518
  # @return [Boolean]
469
- def go_down_by(lines, line_count)
470
- next_pos = @positions.find { _1 >= @position + lines }
471
- return go_to_last(line_count) if next_pos.nil?
519
+ def go_down_by(count, item_count)
520
+ next_pos = @positions.find { _1 >= @position + count }
521
+ return go_to_last(item_count) if next_pos.nil?
472
522
 
473
523
  go(next_pos)
474
524
  end
475
525
 
476
- # @param lines [Integer]
526
+ # @param count [Integer]
477
527
  # @return [Boolean]
478
- def go_up_by(lines)
479
- prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
528
+ def go_up_by(count)
529
+ prev_pos = @positions.reverse_each.find { _1 <= @position - count }
480
530
  return go_to_first if prev_pos.nil?
481
531
 
482
532
  go(prev_pos)
@@ -491,18 +541,18 @@ module Tuile
491
541
 
492
542
  protected
493
543
 
494
- # Rebuilds pre-padded lines when the wrap width changes. The wrap width
495
- # depends on {#rect}`.width` and the scrollbar gutter, both of which
496
- # trigger this hook. Also re-evaluates {#auto_scroll}: if items were
497
- # appended while the rect was empty (e.g. a {Popup}-wrapped list got
498
- # `add_line` calls before the popup was opened), the auto-scroll update
544
+ # Drops the rendered-row cache when the wrap width changes. The wrap
545
+ # width depends on {#rect}`.width` and the scrollbar gutter, both of
546
+ # which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
547
+ # assigned while the rect was empty (e.g. a {Popup}-wrapped list was
548
+ # populated before the popup was opened), the auto-scroll update
499
549
  # was skipped because there was no viewport — re-run it now that there
500
550
  # is one, so the list snaps to the bottom on first paint.
501
551
  # @return [void]
502
552
  def on_width_changed
503
553
  super
504
- rebuild_padded_lines
505
- update_top_line_if_auto_scroll
554
+ drop_row_cache
555
+ update_scroll_top_row_if_auto_scroll
506
556
  end
507
557
 
508
558
  private
@@ -512,7 +562,7 @@ module Tuile
512
562
  # via {StyledString.parse}, StyledString passed through, anything else
513
563
  # via `#to_s`), then split on `\n` via {StyledString#lines} — with
514
564
  # trailing empty pieces dropped (matching `String#split("\n")`'s
515
- # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
565
+ # default behavior, so a lone `""` entry adds no row) — and trailing ASCII
516
566
  # whitespace stripped on each resulting line.
517
567
  # @param entries [Array]
518
568
  # @return [Array<StyledString>]
@@ -544,27 +594,27 @@ module Tuile
544
594
  line.slice(0, line.display_width - trailing)
545
595
  end
546
596
 
547
- # @return [Boolean] true if the cursor sits on a real content line.
597
+ # @return [Boolean] true if the cursor sits on a real item.
548
598
  def cursor_on_item?
549
599
  pos = @cursor.position
550
- pos >= 0 && pos < @lines.size
600
+ pos >= 0 && pos < @items.size
551
601
  end
552
602
 
553
- # Calls {#on_item_chosen} with the cursor's current `(index, line)`.
603
+ # Calls {#on_item_chosen} with the cursor's current `(index, item)`.
554
604
  # Caller must ensure {#cursor_on_item?}.
555
605
  # @return [void]
556
606
  def fire_item_chosen
557
607
  pos = @cursor.position
558
- @on_item_chosen&.call(pos, @lines[pos])
608
+ @on_item_chosen&.call(pos, @items[pos])
559
609
  end
560
610
 
561
- # @return [Array((Integer, StyledString, nil))]
562
- # `[position, line_at_position]`, with `line` nil when the cursor is
611
+ # @return [Array((Integer, Object, nil))]
612
+ # `[position, item_at_position]`, with the item nil when the cursor is
563
613
  # off-content.
564
614
  def cursor_state
565
615
  pos = @cursor.position
566
- line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
567
- [pos, line]
616
+ item = pos >= 0 && pos < @items.size ? @items[pos] : nil
617
+ [pos, item]
568
618
  end
569
619
 
570
620
  # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
@@ -585,12 +635,12 @@ module Tuile
585
635
  def search_and_go(query, include_current:, reverse:)
586
636
  return false if query.empty?
587
637
 
588
- candidates = @cursor.candidate_positions(@lines.size)
638
+ candidates = @cursor.candidate_positions(@items.size)
589
639
  return false if candidates.empty?
590
640
 
591
641
  ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
592
642
  query_lc = query.downcase
593
- match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
643
+ match = ordered.find { |idx| render(@items[idx]).to_s.downcase.include?(query_lc) }
594
644
  return false unless match
595
645
 
596
646
  @cursor.go(match)
@@ -632,58 +682,58 @@ module Tuile
632
682
  pos = @cursor.position
633
683
  return unless pos >= 0
634
684
 
635
- if @top_line > pos
636
- self.top_line = pos
637
- elsif pos > @top_line + rect.height - 1
638
- self.top_line = pos - rect.height + 1
685
+ if @scroll_top_row > pos
686
+ self.scroll_top_row = pos
687
+ elsif pos > @scroll_top_row + rect.height - 1
688
+ self.scroll_top_row = pos - rect.height + 1
639
689
  end
640
690
  end
641
691
 
642
- # @return [Integer] the max value of {#top_line}.
643
- def top_line_max = (@lines.size - rect.height).clamp(0, nil)
692
+ # @return [Integer] the max value of {#scroll_top_row}.
693
+ def scroll_top_row_max = (@items.size - rect.height).clamp(0, nil)
644
694
 
645
- # @return [Boolean] whether the viewport is pinned to the last line.
646
- # Drives {#following?}: re-evaluated on every {#top_line=}.
647
- def at_bottom? = @top_line == top_line_max
695
+ # @return [Boolean] whether the viewport is pinned to the last row.
696
+ # Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
697
+ def at_bottom? = @scroll_top_row == scroll_top_row_max
648
698
 
649
- # @return [Integer] the number of visible lines.
650
- def viewport_lines = rect.height
699
+ # @return [Integer] the number of visible rows.
700
+ def viewport_rows = rect.height
651
701
 
652
702
  # Scrolls the list.
653
703
  # @param delta [Integer] negative scrolls up, positive scrolls down.
654
704
  # @return [void]
655
- def move_top_line_by(delta)
656
- new_top_line = (@top_line + delta).clamp(0, top_line_max)
657
- return if @top_line == new_top_line
705
+ def move_scroll_top_row_by(delta)
706
+ new_scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
707
+ return if @scroll_top_row == new_scroll_top_row
658
708
 
659
- @top_line = new_top_line
709
+ @scroll_top_row = new_scroll_top_row
660
710
  invalidate
661
711
  end
662
712
 
663
- # If auto-scrolling, recalculate the top line and snap the cursor to the
713
+ # If auto-scrolling, recalculate the top row and snap the cursor to the
664
714
  # last reachable position. Without the cursor snap the viewport gets
665
715
  # yanked back to wherever the cursor sat on the next arrow press,
666
716
  # negating the auto-scroll. Skipped when {#rect} is empty: without a
667
- # viewport the "lines minus viewport" formula yields `@lines.size`,
668
- # which would leave `top_line` past the last item once a real rect
717
+ # viewport the "items minus viewport" formula yields `@items.size`,
718
+ # which would leave `scroll_top_row` past the last item once a real rect
669
719
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
670
720
  # the snap-to-bottom intent is preserved.
671
721
  #
672
722
  # Gated on {#following?}: once the user scrolls up off the bottom the
673
723
  # cursor snap and viewport pin are both skipped, so reading older
674
- # content is not interrupted by incoming lines. {#top_line=} re-arms
724
+ # content is not interrupted by incoming items. {#scroll_top_row=} re-arms
675
725
  # `@follow` when the viewport returns to the bottom.
676
726
  # @return [void]
677
- def update_top_line_if_auto_scroll
727
+ def update_scroll_top_row_if_auto_scroll
678
728
  return unless @auto_scroll && @follow
679
729
  return if rect.empty?
680
730
 
681
- notify_cursor_changed if @cursor.go_to_last(@lines.size)
731
+ notify_cursor_changed if @cursor.go_to_last(@items.size)
682
732
 
683
- new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
684
- return unless @top_line != new_top_line
733
+ new_scroll_top_row = (@items.size - viewport_rows).clamp(0, nil)
734
+ return unless @scroll_top_row != new_scroll_top_row
685
735
 
686
- self.top_line = new_top_line
736
+ self.scroll_top_row = new_scroll_top_row
687
737
  end
688
738
 
689
739
  # @return [Boolean] whether the scrollbar should be drawn right now.
@@ -693,7 +743,7 @@ module Tuile
693
743
  @scrollbar_visibility == :visible
694
744
  end
695
745
 
696
- # @return [Integer] column width available for line content (rect width
746
+ # @return [Integer] column width available for row content (rect width
697
747
  # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
698
748
  # is non-positive.
699
749
  def content_width
@@ -702,43 +752,68 @@ module Tuile
702
752
  rect.width - (scrollbar_visible? ? 1 : 0)
703
753
  end
704
754
 
705
- # Recomputes {@padded_lines} for the current rect width and scrollbar
706
- # visibility. Each line is ellipsized to fit and pre-padded with
707
- # single-space gutters on each side, so {#paintable_line} only has to
708
- # apply the cursor highlight (if any) and append the scrollbar glyph.
755
+ # Discards every rendered row, so the next paint re-renders the viewport
756
+ # against the current items, renderer and width.
709
757
  # @return [void]
710
- def rebuild_padded_lines
711
- @padded_lines = @lines.map { |line| pad_to_row(line) }
712
- @blank_padded = pad_to_row(StyledString::EMPTY)
758
+ def drop_row_cache
759
+ @row_cache.clear
760
+ @blank_row = nil
713
761
  end
714
762
 
715
- # Pads `line` to one full row of the viewport (scrollbar gutter
716
- # excluded). Lines wider than the content area are ellipsized via
763
+ # @param index [Integer] 0-based index into {#items}.
764
+ # @return [StyledString] the item's padded row, rendered on first use and
765
+ # memoized until {#drop_row_cache}.
766
+ def padded_row(index)
767
+ @row_cache[index] ||= pad_to_row(render(@items[index]))
768
+ end
769
+
770
+ # @return [StyledString] the blank row painted past the last item.
771
+ def blank_row
772
+ @blank_row ||= pad_to_row(StyledString::EMPTY)
773
+ end
774
+
775
+ # Renders one item, *without* populating the row cache — {#search_and_go}
776
+ # scans with this, and caching a failed scan would grow the cache to one
777
+ # row per item.
778
+ # @param item [Object]
779
+ # @return [StyledString] one row: the {#renderer}'s output coerced to a
780
+ # {StyledString}, cut to its first line since a `\n` reaching the buffer
781
+ # would corrupt the frame.
782
+ def render(item)
783
+ rendered = @renderer.call(item)
784
+ rendered = StyledString.parse(rendered.to_s) unless rendered.is_a?(StyledString)
785
+ return rendered unless rendered.spans.any? { _1.text.include?("\n") }
786
+
787
+ rendered.lines.first
788
+ end
789
+
790
+ # Pads `row` to one full row of the viewport (scrollbar gutter
791
+ # excluded). Rows wider than the content area are ellipsized via
717
792
  # {StyledString#ellipsize} (span styles survive the cut); shorter
718
- # lines are padded with default-styled spaces.
719
- # @param line [StyledString]
793
+ # ones are padded with default-styled spaces.
794
+ # @param row [StyledString]
720
795
  # @return [StyledString] exactly {#content_width} display columns wide
721
796
  # (or {StyledString::EMPTY} when content_width is non-positive).
722
- def pad_to_row(line)
797
+ def pad_to_row(row)
723
798
  cw = content_width
724
799
  return StyledString::EMPTY if cw <= 0
725
800
  return StyledString.plain(" " * cw) if cw < 2
726
801
 
727
802
  text_width = cw - 2
728
- body = line.ellipsize(text_width)
803
+ body = row.ellipsize(text_width)
729
804
  fill = cw - 2 - body.display_width
730
805
  StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
731
806
  end
732
807
 
733
- # @param index [Integer] 0-based index into {#lines}.
808
+ # @param index [Integer] 0-based index into {#items}.
734
809
  # @param row_in_viewport [Integer] 0-based row within the viewport.
735
810
  # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
736
811
  # if not shown.
737
- # @return [StyledString] paintable line exactly `rect.width` columns wide;
812
+ # @return [StyledString] paintable row exactly `rect.width` columns wide;
738
813
  # highlighted if cursor is here.
739
- def paintable_line(index, row_in_viewport, scrollbar)
740
- base = index < @lines.size ? @padded_lines[index] : @blank_padded
741
- is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index
814
+ def paintable_row(index, row_in_viewport, scrollbar)
815
+ base = index < @items.size ? padded_row(index) : blank_row
816
+ is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
742
817
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
743
818
  styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
744
819
  styled