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
@@ -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.
@@ -244,33 +294,56 @@ module Tuile
244
294
  search_and_go(query, include_current: include_current, reverse: true)
245
295
  end
246
296
 
297
+ # Moves the cursor to the item at `index`, scrolling it into view and
298
+ # firing {#on_cursor_changed} — the positional member of the
299
+ # {#select_next} / {#select_prev} family, for a caller that already knows
300
+ # *which* item it wants:
301
+ #
302
+ # list.select(items.index(chosen))
303
+ #
304
+ # Refuses an index the current {#cursor} can't reach — out of range, or
305
+ # anything at all under {Cursor::None} — rather than stranding the cursor
306
+ # off-content.
307
+ # @param index [Integer]
308
+ # @return [Boolean] whether the cursor moved there.
309
+ def select(index)
310
+ return false unless @cursor.candidate_positions(@items.size).include?(index)
311
+
312
+ @cursor.go(index)
313
+ move_viewport_to_cursor
314
+ notify_cursor_changed
315
+ invalidate
316
+ true
317
+ end
318
+
247
319
  # @param event [MouseEvent]
248
320
  # @return [void]
249
321
  def handle_mouse(event)
250
322
  super
251
323
  if event.button == :scroll_down
252
- move_top_line_by(4)
324
+ move_scroll_top_row_by(4)
253
325
  elsif event.button == :scroll_up
254
- move_top_line_by(-4)
326
+ move_scroll_top_row_by(-4)
255
327
  else
256
328
  return unless rect.contains?(event.point)
257
329
 
258
- line = event.y - rect.top + top_line
259
- if @cursor.handle_mouse(line, event, @lines.size)
330
+ item_index = event.y - rect.top + scroll_top_row
331
+ if @cursor.handle_mouse(item_index, event, @items.size)
260
332
  move_viewport_to_cursor
261
333
  notify_cursor_changed
262
334
  invalidate
263
335
  end
264
- fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
336
+ fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
265
337
  end
266
338
  end
267
339
 
268
- # Paints the list items into {#rect}.
340
+ # Paints the visible items into {#rect}, rendering the ones not already
341
+ # cached.
269
342
  #
270
343
  # Skips the {Component#repaint} default's auto-clear: every row of
271
344
  # {#rect} is painted below (with blank padding past the last item),
272
345
  # so the parent contract — "fully draw over your rect" — is met
273
- # without an upfront wipe. Rows go through {Component#draw_line}, so
346
+ # without an upfront wipe. Rows go through {Component#draw_text}, so
274
347
  # content *and* blank filler inherit {Component#effective_bg_color}
275
348
  # (a {#bg_color} set here or on an ancestor); the cursor row's
276
349
  # {Theme#active_bg_color} highlight composes on top of it.
@@ -279,11 +352,10 @@ module Tuile
279
352
  return if rect.empty?
280
353
 
281
354
  scrollbar = if scrollbar_visible?
282
- VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line)
355
+ VerticalScrollBar.new(rect.height, row_count: @items.size, scroll_top_row: @scroll_top_row)
283
356
  end
284
357
  (0...rect.height).each do |row|
285
- line = paintable_line(row + @top_line, row, scrollbar)
286
- draw_line(rect.left, row + rect.top, line)
358
+ draw_text(rect.left, row + rect.top, paintable_row(row + @scroll_top_row, row, scrollbar))
287
359
  end
288
360
  end
289
361
 
@@ -304,24 +376,24 @@ module Tuile
304
376
  end
305
377
 
306
378
  # @param _key [String]
307
- # @param _line_count [Integer]
308
- # @param _viewport_lines [Integer]
379
+ # @param _item_count [Integer]
380
+ # @param _viewport_rows [Integer]
309
381
  # @return [Boolean]
310
- def handle_key(_key, _line_count, _viewport_lines)
382
+ def handle_key(_key, _item_count, _viewport_rows)
311
383
  false
312
384
  end
313
385
 
314
- # @param _line [Integer]
386
+ # @param _item_index [Integer]
315
387
  # @param _event [MouseEvent]
316
- # @param _line_count [Integer]
388
+ # @param _item_count [Integer]
317
389
  # @return [Boolean]
318
- def handle_mouse(_line, _event, _line_count)
390
+ def handle_mouse(_item_index, _event, _item_count)
319
391
  false
320
392
  end
321
393
 
322
- # @param _line_count [Integer]
394
+ # @param _item_count [Integer]
323
395
  # @return [Array<Integer>]
324
- def candidate_positions(_line_count)
396
+ def candidate_positions(_item_count)
325
397
  []
326
398
  end
327
399
 
@@ -336,46 +408,46 @@ module Tuile
336
408
  end
337
409
  end
338
410
 
339
- # @return [Integer] 0-based line index of the current cursor position.
411
+ # @return [Integer] 0-based item index of the current cursor position.
340
412
  attr_reader :position
341
413
 
342
- # @param line_count [Integer] number of lines in the list.
414
+ # @param item_count [Integer] number of items in the list.
343
415
  # @return [Array<Integer>] positions the cursor can land on, in
344
416
  # ascending order.
345
- def candidate_positions(line_count)
346
- (0...line_count).to_a
417
+ def candidate_positions(item_count)
418
+ (0...item_count).to_a
347
419
  end
348
420
 
349
421
  # @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.
422
+ # @param item_count [Integer] number of items in the list.
423
+ # @param viewport_rows [Integer] number of visible rows.
352
424
  # @return [Boolean] true if the cursor moved.
353
- def handle_key(key, line_count, viewport_lines)
425
+ def handle_key(key, item_count, viewport_rows)
354
426
  case key
355
427
  when *Keys::DOWN_ARROWS
356
- go_down_by(1, line_count)
428
+ go_down_by(1, item_count)
357
429
  when *Keys::UP_ARROWS
358
430
  go_up_by(1)
359
431
  when *Keys::HOMES
360
432
  go_to_first
361
433
  when *Keys::ENDS_
362
- go_to_last(line_count)
434
+ go_to_last(item_count)
363
435
  when Keys::CTRL_U
364
- go_up_by(viewport_lines / 2)
436
+ go_up_by(viewport_rows / 2)
365
437
  when Keys::CTRL_D
366
- go_down_by(viewport_lines / 2, line_count)
438
+ go_down_by(viewport_rows / 2, item_count)
367
439
  else
368
440
  false
369
441
  end
370
442
  end
371
443
 
372
- # @param line [Integer] cursor is hovering over this line.
444
+ # @param item_index [Integer] the item the cursor is hovering over.
373
445
  # @param event [MouseEvent] the event.
374
- # @param line_count [Integer] number of lines in the list.
446
+ # @param item_count [Integer] number of items in the list.
375
447
  # @return [Boolean] true if the event was handled.
376
- def handle_mouse(line, event, line_count)
448
+ def handle_mouse(item_index, event, item_count)
377
449
  if event.button == :left
378
- go(line.clamp(nil, line_count - 1))
450
+ go(item_index.clamp(nil, item_count - 1))
379
451
  else
380
452
  false
381
453
  end
@@ -393,27 +465,27 @@ module Tuile
393
465
  end
394
466
 
395
467
  # Moves the cursor to the last reachable position. For base {Cursor},
396
- # the last line; {Limited} clamps to the last allowed position; {None}
468
+ # the last item; {Limited} clamps to the last allowed position; {None}
397
469
  # is a no-op.
398
- # @param line_count [Integer] number of lines in the list.
470
+ # @param item_count [Integer] number of items in the list.
399
471
  # @return [Boolean] true if the position changed.
400
- def go_to_last(line_count)
401
- go(line_count - 1)
472
+ def go_to_last(item_count)
473
+ go(item_count - 1)
402
474
  end
403
475
 
404
476
  protected
405
477
 
406
- # @param lines [Integer]
407
- # @param line_count [Integer]
478
+ # @param count [Integer]
479
+ # @param item_count [Integer]
408
480
  # @return [Boolean]
409
- def go_down_by(lines, line_count)
410
- go((@position + lines).clamp(nil, line_count - 1))
481
+ def go_down_by(count, item_count)
482
+ go((@position + count).clamp(nil, item_count - 1))
411
483
  end
412
484
 
413
- # @param lines [Integer]
485
+ # @param count [Integer]
414
486
  # @return [Boolean]
415
- def go_up_by(lines)
416
- go(@position - lines)
487
+ def go_up_by(count)
488
+ go(@position - count)
417
489
  end
418
490
 
419
491
  # @return [Boolean]
@@ -421,7 +493,7 @@ module Tuile
421
493
  go(0)
422
494
  end
423
495
 
424
- # Cursor which can only land on specific allowed lines.
496
+ # Cursor which can only land on specific allowed items.
425
497
  class Limited < Cursor
426
498
  # @param positions [Array<Integer>] allowed positions. Must not be
427
499
  # empty.
@@ -434,13 +506,13 @@ module Tuile
434
506
  super(position: position)
435
507
  end
436
508
 
437
- # @param line [Integer]
509
+ # @param item_index [Integer]
438
510
  # @param event [MouseEvent]
439
- # @param _line_count [Integer]
511
+ # @param _item_count [Integer]
440
512
  # @return [Boolean]
441
- def handle_mouse(line, event, _line_count)
513
+ def handle_mouse(item_index, event, _item_count)
442
514
  if event.button == :left
443
- prev_pos = @positions.reverse_each.find { _1 <= line }
515
+ prev_pos = @positions.reverse_each.find { _1 <= item_index }
444
516
  return go_to_first if prev_pos.nil?
445
517
 
446
518
  go(prev_pos)
@@ -449,34 +521,34 @@ module Tuile
449
521
  end
450
522
  end
451
523
 
452
- # @param line_count [Integer]
524
+ # @param item_count [Integer]
453
525
  # @return [Array<Integer>]
454
- def candidate_positions(line_count)
455
- @positions.select { _1 < line_count }
526
+ def candidate_positions(item_count)
527
+ @positions.select { _1 < item_count }
456
528
  end
457
529
 
458
- # @param _line_count [Integer]
530
+ # @param _item_count [Integer]
459
531
  # @return [Boolean]
460
- def go_to_last(_line_count)
532
+ def go_to_last(_item_count)
461
533
  go(@positions.last)
462
534
  end
463
535
 
464
536
  protected
465
537
 
466
- # @param lines [Integer]
467
- # @param line_count [Integer]
538
+ # @param count [Integer]
539
+ # @param item_count [Integer]
468
540
  # @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?
541
+ def go_down_by(count, item_count)
542
+ next_pos = @positions.find { _1 >= @position + count }
543
+ return go_to_last(item_count) if next_pos.nil?
472
544
 
473
545
  go(next_pos)
474
546
  end
475
547
 
476
- # @param lines [Integer]
548
+ # @param count [Integer]
477
549
  # @return [Boolean]
478
- def go_up_by(lines)
479
- prev_pos = @positions.reverse_each.find { _1 <= @position - lines }
550
+ def go_up_by(count)
551
+ prev_pos = @positions.reverse_each.find { _1 <= @position - count }
480
552
  return go_to_first if prev_pos.nil?
481
553
 
482
554
  go(prev_pos)
@@ -491,18 +563,18 @@ module Tuile
491
563
 
492
564
  protected
493
565
 
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
566
+ # Drops the rendered-row cache when the wrap width changes. The wrap
567
+ # width depends on {#rect}`.width` and the scrollbar gutter, both of
568
+ # which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
569
+ # assigned while the rect was empty (e.g. a {Popup}-wrapped list was
570
+ # populated before the popup was opened), the auto-scroll update
499
571
  # was skipped because there was no viewport — re-run it now that there
500
572
  # is one, so the list snaps to the bottom on first paint.
501
573
  # @return [void]
502
574
  def on_width_changed
503
575
  super
504
- rebuild_padded_lines
505
- update_top_line_if_auto_scroll
576
+ drop_row_cache
577
+ update_scroll_top_row_if_auto_scroll
506
578
  end
507
579
 
508
580
  private
@@ -512,7 +584,7 @@ module Tuile
512
584
  # via {StyledString.parse}, StyledString passed through, anything else
513
585
  # via `#to_s`), then split on `\n` via {StyledString#lines} — with
514
586
  # trailing empty pieces dropped (matching `String#split("\n")`'s
515
- # default behavior, so `add_line ""` is a no-op) — and trailing ASCII
587
+ # default behavior, so a lone `""` entry adds no row) — and trailing ASCII
516
588
  # whitespace stripped on each resulting line.
517
589
  # @param entries [Array]
518
590
  # @return [Array<StyledString>]
@@ -544,27 +616,27 @@ module Tuile
544
616
  line.slice(0, line.display_width - trailing)
545
617
  end
546
618
 
547
- # @return [Boolean] true if the cursor sits on a real content line.
619
+ # @return [Boolean] true if the cursor sits on a real item.
548
620
  def cursor_on_item?
549
621
  pos = @cursor.position
550
- pos >= 0 && pos < @lines.size
622
+ pos >= 0 && pos < @items.size
551
623
  end
552
624
 
553
- # Calls {#on_item_chosen} with the cursor's current `(index, line)`.
625
+ # Calls {#on_item_chosen} with the cursor's current `(index, item)`.
554
626
  # Caller must ensure {#cursor_on_item?}.
555
627
  # @return [void]
556
628
  def fire_item_chosen
557
629
  pos = @cursor.position
558
- @on_item_chosen&.call(pos, @lines[pos])
630
+ @on_item_chosen&.call(pos, @items[pos])
559
631
  end
560
632
 
561
- # @return [Array((Integer, StyledString, nil))]
562
- # `[position, line_at_position]`, with `line` nil when the cursor is
633
+ # @return [Array((Integer, Object, nil))]
634
+ # `[position, item_at_position]`, with the item nil when the cursor is
563
635
  # off-content.
564
636
  def cursor_state
565
637
  pos = @cursor.position
566
- line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil
567
- [pos, line]
638
+ item = pos >= 0 && pos < @items.size ? @items[pos] : nil
639
+ [pos, item]
568
640
  end
569
641
 
570
642
  # Fires {#on_cursor_changed} if {#cursor_state} differs from the last
@@ -585,12 +657,12 @@ module Tuile
585
657
  def search_and_go(query, include_current:, reverse:)
586
658
  return false if query.empty?
587
659
 
588
- candidates = @cursor.candidate_positions(@lines.size)
660
+ candidates = @cursor.candidate_positions(@items.size)
589
661
  return false if candidates.empty?
590
662
 
591
663
  ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
592
664
  query_lc = query.downcase
593
- match = ordered.find { |idx| @lines[idx].to_s.downcase.include?(query_lc) }
665
+ match = ordered.find { |idx| render(@items[idx]).to_s.downcase.include?(query_lc) }
594
666
  return false unless match
595
667
 
596
668
  @cursor.go(match)
@@ -632,58 +704,58 @@ module Tuile
632
704
  pos = @cursor.position
633
705
  return unless pos >= 0
634
706
 
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
707
+ if @scroll_top_row > pos
708
+ self.scroll_top_row = pos
709
+ elsif pos > @scroll_top_row + rect.height - 1
710
+ self.scroll_top_row = pos - rect.height + 1
639
711
  end
640
712
  end
641
713
 
642
- # @return [Integer] the max value of {#top_line}.
643
- def top_line_max = (@lines.size - rect.height).clamp(0, nil)
714
+ # @return [Integer] the max value of {#scroll_top_row}.
715
+ def scroll_top_row_max = (@items.size - rect.height).clamp(0, nil)
644
716
 
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
717
+ # @return [Boolean] whether the viewport is pinned to the last row.
718
+ # Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
719
+ def at_bottom? = @scroll_top_row == scroll_top_row_max
648
720
 
649
- # @return [Integer] the number of visible lines.
650
- def viewport_lines = rect.height
721
+ # @return [Integer] the number of visible rows.
722
+ def viewport_rows = rect.height
651
723
 
652
724
  # Scrolls the list.
653
725
  # @param delta [Integer] negative scrolls up, positive scrolls down.
654
726
  # @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
727
+ def move_scroll_top_row_by(delta)
728
+ new_scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
729
+ return if @scroll_top_row == new_scroll_top_row
658
730
 
659
- @top_line = new_top_line
731
+ @scroll_top_row = new_scroll_top_row
660
732
  invalidate
661
733
  end
662
734
 
663
- # If auto-scrolling, recalculate the top line and snap the cursor to the
735
+ # If auto-scrolling, recalculate the top row and snap the cursor to the
664
736
  # last reachable position. Without the cursor snap the viewport gets
665
737
  # yanked back to wherever the cursor sat on the next arrow press,
666
738
  # 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
739
+ # viewport the "items minus viewport" formula yields `@items.size`,
740
+ # which would leave `scroll_top_row` past the last item once a real rect
669
741
  # arrives. {#on_width_changed} re-runs this hook when the rect grows so
670
742
  # the snap-to-bottom intent is preserved.
671
743
  #
672
744
  # Gated on {#following?}: once the user scrolls up off the bottom the
673
745
  # cursor snap and viewport pin are both skipped, so reading older
674
- # content is not interrupted by incoming lines. {#top_line=} re-arms
746
+ # content is not interrupted by incoming items. {#scroll_top_row=} re-arms
675
747
  # `@follow` when the viewport returns to the bottom.
676
748
  # @return [void]
677
- def update_top_line_if_auto_scroll
749
+ def update_scroll_top_row_if_auto_scroll
678
750
  return unless @auto_scroll && @follow
679
751
  return if rect.empty?
680
752
 
681
- notify_cursor_changed if @cursor.go_to_last(@lines.size)
753
+ notify_cursor_changed if @cursor.go_to_last(@items.size)
682
754
 
683
- new_top_line = (@lines.size - viewport_lines).clamp(0, nil)
684
- return unless @top_line != new_top_line
755
+ new_scroll_top_row = (@items.size - viewport_rows).clamp(0, nil)
756
+ return unless @scroll_top_row != new_scroll_top_row
685
757
 
686
- self.top_line = new_top_line
758
+ self.scroll_top_row = new_scroll_top_row
687
759
  end
688
760
 
689
761
  # @return [Boolean] whether the scrollbar should be drawn right now.
@@ -693,7 +765,7 @@ module Tuile
693
765
  @scrollbar_visibility == :visible
694
766
  end
695
767
 
696
- # @return [Integer] column width available for line content (rect width
768
+ # @return [Integer] column width available for row content (rect width
697
769
  # minus the scrollbar gutter, when visible). `0` when {#rect}'s width
698
770
  # is non-positive.
699
771
  def content_width
@@ -702,43 +774,68 @@ module Tuile
702
774
  rect.width - (scrollbar_visible? ? 1 : 0)
703
775
  end
704
776
 
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.
777
+ # Discards every rendered row, so the next paint re-renders the viewport
778
+ # against the current items, renderer and width.
709
779
  # @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)
780
+ def drop_row_cache
781
+ @row_cache.clear
782
+ @blank_row = nil
783
+ end
784
+
785
+ # @param index [Integer] 0-based index into {#items}.
786
+ # @return [StyledString] the item's padded row, rendered on first use and
787
+ # memoized until {#drop_row_cache}.
788
+ def padded_row(index)
789
+ @row_cache[index] ||= pad_to_row(render(@items[index]))
713
790
  end
714
791
 
715
- # Pads `line` to one full row of the viewport (scrollbar gutter
716
- # excluded). Lines wider than the content area are ellipsized via
792
+ # @return [StyledString] the blank row painted past the last item.
793
+ def blank_row
794
+ @blank_row ||= pad_to_row(StyledString::EMPTY)
795
+ end
796
+
797
+ # Renders one item, *without* populating the row cache — {#search_and_go}
798
+ # scans with this, and caching a failed scan would grow the cache to one
799
+ # row per item.
800
+ # @param item [Object]
801
+ # @return [StyledString] one row: the {#renderer}'s output coerced to a
802
+ # {StyledString}, cut to its first line since a `\n` reaching the buffer
803
+ # would corrupt the frame.
804
+ def render(item)
805
+ rendered = @renderer.call(item)
806
+ rendered = StyledString.parse(rendered.to_s) unless rendered.is_a?(StyledString)
807
+ return rendered unless rendered.spans.any? { _1.text.include?("\n") }
808
+
809
+ rendered.lines.first
810
+ end
811
+
812
+ # Pads `row` to one full row of the viewport (scrollbar gutter
813
+ # excluded). Rows wider than the content area are ellipsized via
717
814
  # {StyledString#ellipsize} (span styles survive the cut); shorter
718
- # lines are padded with default-styled spaces.
719
- # @param line [StyledString]
815
+ # ones are padded with default-styled spaces.
816
+ # @param row [StyledString]
720
817
  # @return [StyledString] exactly {#content_width} display columns wide
721
818
  # (or {StyledString::EMPTY} when content_width is non-positive).
722
- def pad_to_row(line)
819
+ def pad_to_row(row)
723
820
  cw = content_width
724
821
  return StyledString::EMPTY if cw <= 0
725
822
  return StyledString.plain(" " * cw) if cw < 2
726
823
 
727
824
  text_width = cw - 2
728
- body = line.ellipsize(text_width)
825
+ body = row.ellipsize(text_width)
729
826
  fill = cw - 2 - body.display_width
730
827
  StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
731
828
  end
732
829
 
733
- # @param index [Integer] 0-based index into {#lines}.
830
+ # @param index [Integer] 0-based index into {#items}.
734
831
  # @param row_in_viewport [Integer] 0-based row within the viewport.
735
832
  # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
736
833
  # if not shown.
737
- # @return [StyledString] paintable line exactly `rect.width` columns wide;
834
+ # @return [StyledString] paintable row exactly `rect.width` columns wide;
738
835
  # 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
836
+ def paintable_row(index, row_in_viewport, scrollbar)
837
+ base = index < @items.size ? padded_row(index) : blank_row
838
+ is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
742
839
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
743
840
  styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
744
841
  styled