tuile 0.16.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -6,14 +6,26 @@ module Tuile
6
6
  #
7
7
  # list = Component::List.new
8
8
  # list.items = people
9
- # list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
9
+ # list.renderer = ->(p, _w) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
10
10
  # list.cursor = List::Cursor.new # a bare list has none
11
- # list.on_item_chosen = ->(index, person) { open(person) }
11
+ # list.on_item_chosen { |e| open(e.item) }
12
12
  #
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).
13
+ # The {#renderer} turns an item and the columns it gets into one row; the
14
+ # default renders an item as itself and ignores the width, so a list of
15
+ # `String`s or {StyledString}s needs none — which is what {#lines=} and
16
+ # {#build_lines} are, items that are their own rendering (split on `\n`,
17
+ # one row per line).
18
+ #
19
+ # A renderer that *uses* the width lays out against it — an aligned
20
+ # right-hand column, a path elided from the left — neither of which a
21
+ # trailing ellipsis can express:
22
+ #
23
+ # counts_w = files.map { _1.counts.length }.max # per snapshot, not per row
24
+ # list.renderer = lambda do |file, w|
25
+ # name = StyledString.plain(file.path).ellipsize(w - counts_w - 1, at: :start)
26
+ # gap = " " * (w - counts_w - name.display_width)
27
+ # name + StyledString.plain(gap + file.counts)
28
+ # end
17
29
  #
18
30
  # There are no appenders. The items are always assigned whole — an app that
19
31
  # grows a list keeps its own array and re-assigns it (`list.items = mine`) —
@@ -27,26 +39,38 @@ module Tuile
27
39
  # automatically; its highlight overlays {Theme#active_bg_color} while
28
40
  # preserving each span's foreground color.
29
41
  #
42
+ # {#scrollbar_visibility} turns on a {VerticalScrollBar} in the rightmost
43
+ # column: a child component, so its handle drags (wanting
44
+ # `run_event_loop(capture_mouse: :drag)`) and a press on its track pages.
45
+ # Under `:visible` the column stays reserved whether anything scrolls or
46
+ # not, so a growing list re-flows nothing; under `:auto` the bar shows only
47
+ # while the items outnumber the rows, and hands its column back to the
48
+ # rows while they fit (`D_scrollbar_ink`).
49
+ #
30
50
  # == Implementation details
31
51
  # Rendering is lazy: only the rows in the viewport are rendered, each
32
- # memoized until {#items=}, {#renderer=} or a width change drops the cache.
52
+ # memoized until {#items=} or {#renderer=} drops the cache, or the row
53
+ # width moves off the one it was padded at.
33
54
  # So a renderer runs *at paint time*, on any frame — keep it pure and
34
55
  # 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.
56
+ # renderer, and a measurement over the whole snapshot (the example's widest
57
+ # counts column) is computed where {#items=} is assigned, or it is an
58
+ # O(items²) pass on every resize. {#select_next} deliberately renders
59
+ # without memoizing: one failed scan would otherwise cache a row per item.
37
60
  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.
61
+ # The default {#renderer}: an item renders as itself, whatever the width.
62
+ # Every renderer's output is coerced the same way — a {StyledString}
63
+ # passes through, a `String` is parsed (so embedded ANSI is honored),
64
+ # anything else is `#to_s`'d first.
42
65
  # @return [Proc]
43
- DEFAULT_RENDERER = :itself.to_proc
66
+ DEFAULT_RENDERER = ->(item, _text_width) { item }
44
67
 
45
68
  def initialize
46
69
  super
47
70
  @items = []
48
71
  @renderer = DEFAULT_RENDERER
49
72
  @row_cache = {}
73
+ @row_cache_width = nil
50
74
  @blank_row = nil
51
75
  @auto_scroll = false
52
76
  @follow = true
@@ -54,27 +78,52 @@ module Tuile
54
78
  @cursor = Cursor::None.new
55
79
  @scrollbar_visibility = :gone
56
80
  @show_cursor_when_inactive = false
57
- @on_item_chosen = nil
58
- @on_cursor_changed = nil
81
+ @interactive = true
59
82
  @last_cursor_state = cursor_state
83
+ @scrollbar = VerticalScrollBar.new
84
+ @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
85
+ add_child(@scrollbar) # chrome, appended: a List has no content children
60
86
  end
61
87
 
62
- # @return [Proc, nil] callback fired when an item is chosen — by pressing
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).
67
- attr_accessor :on_item_chosen
68
-
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
72
- # off-content ({Cursor::None}, empty list, or `index` past the last
73
- # item). Fires on cursor moves (key, mouse, search), on {#cursor=},
74
- # and on {#items=} when the item at the cursor's index
75
- # changes (or its in-range/out-of-range status flips). Useful for
76
- # keeping a details pane in sync with the highlighted row.
77
- attr_accessor :on_cursor_changed
88
+ # What {#on_item_chosen} fires.
89
+ #
90
+ # @!attribute [r] source
91
+ # @return [List] the list the item was chosen in.
92
+ # @!attribute [r] position
93
+ # @return [Integer] the chosen item's 0-based index.
94
+ # @!attribute [r] item
95
+ # @return [Object] the chosen item itself.
96
+ ItemChosenEvent = Data.define(:source, :position, :item) { include Tuile::Event }
97
+
98
+ # What {#on_cursor_changed} fires.
99
+ #
100
+ # @!attribute [r] source
101
+ # @return [List] the list whose cursor moved.
102
+ # @!attribute [r] position
103
+ # @return [Integer] the cursor's 0-based index.
104
+ # @!attribute [r] item
105
+ # @return [Object, nil] the item there, `nil` when the cursor is
106
+ # off-content ({Cursor::None}, empty list, or `position` past the last
107
+ # item).
108
+ CursorChangedEvent = Data.define(:source, :position, :item) { include Tuile::Event }
109
+
110
+ # @!method on_item_chosen
111
+ # Fired with an {ItemChosenEvent} when an item is chosen — by pressing
112
+ # Enter on the cursor's item, or by left-clicking it. Never fires when
113
+ # the cursor's position is outside the content (e.g. {Cursor::None}, or
114
+ # empty content).
115
+ # @return [Listeners]
116
+ listener :on_item_chosen
117
+
118
+ # @!method on_cursor_changed
119
+ # Fired with a {CursorChangedEvent} when the `(position, item)` tuple
120
+ # under the cursor changes (items compared with `==`). Fires on cursor
121
+ # moves (key, mouse, search), on {#cursor=}, and on {#items=} when the
122
+ # item at the cursor's index changes (or its in-range/out-of-range status
123
+ # flips). Useful for keeping a details pane in sync with the highlighted
124
+ # row.
125
+ # @return [Listeners]
126
+ listener :on_cursor_changed
78
127
 
79
128
  # @return [Boolean] if true and new content is set, auto-scrolls to the
80
129
  # bottom — but only while the viewport is already pinned to the last
@@ -95,7 +144,9 @@ module Tuile
95
144
  # @return [Cursor] the list's cursor.
96
145
  attr_reader :cursor
97
146
 
98
- # @return [Symbol] scrollbar visibility: `:gone` or `:visible`.
147
+ # @return [Symbol] scrollbar visibility: `:gone`, `:visible`, or `:auto`
148
+ # — shown only while there are more {#items} than rows. `:gone` by
149
+ # default.
99
150
  attr_reader :scrollbar_visibility
100
151
 
101
152
  # @return [Boolean] when true, the cursor highlight is painted even while
@@ -112,15 +163,42 @@ module Tuile
112
163
  invalidate
113
164
  end
114
165
 
166
+ # Whether the list is a control: focusable, a tab stop, and a press on a
167
+ # row moves the cursor and fires {#on_item_chosen}. `false` makes it a
168
+ # display-only pane — a sidebar summary beside the field the user types
169
+ # in — which Tab skips, a click doesn't focus, and a press on a row
170
+ # bubbles past, so no capability is left reachable only by the mouse.
171
+ # The wheel and the scrollbar still scroll it, and the app still drives
172
+ # {#cursor} and {#scroll_top_row}. `true` by default.
173
+ # @return [Boolean]
174
+ def interactive? = @interactive
175
+
176
+ # Turning it off while focus is inside the list hands focus to the
177
+ # parent, as hiding a focused component does.
178
+ # @param value [Boolean]
179
+ # @return [void]
180
+ def interactive=(value)
181
+ value = value ? true : false
182
+ return if @interactive == value
183
+
184
+ @interactive = value
185
+ repair_focus_after_hiding unless value
186
+ invalidate
187
+ end
188
+
115
189
  # Sets the scrollbar visibility.
116
- # @param value [Symbol] `:gone` or `:visible`.
190
+ # @param value [Symbol] `:gone`, `:visible` or `:auto`.
191
+ # @raise [ArgumentError] on any other value.
192
+ # @return [void]
117
193
  def scrollbar_visibility=(value)
118
- raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
194
+ unless %i[gone visible auto].include?(value)
195
+ raise ArgumentError, "expected :gone, :visible or :auto, got #{value.inspect}"
196
+ end
119
197
  return if @scrollbar_visibility == value
120
198
 
121
199
  @scrollbar_visibility = value
122
- drop_row_cache
123
200
  invalidate
201
+ invalidate_layout
124
202
  end
125
203
 
126
204
  # Sets the new auto_scroll. If true, re-engages tailing and immediately
@@ -153,14 +231,21 @@ module Tuile
153
231
  @scroll_top_row = new_row
154
232
  @follow = at_bottom?
155
233
  invalidate
234
+ invalidate_layout
156
235
  end
157
236
 
158
237
  # @return [Array] the items, one row each.
159
238
  attr_reader :items
160
239
 
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.
240
+ # The width is passed whether or not a renderer wants it, so a
241
+ # one-argument callable raises when a row is rendered.
242
+ # @return [Proc, Method] `(item, text_width) -> row`: a {StyledString}, a
243
+ # `String` (parsed, so embedded ANSI is honored), or anything with
244
+ # `#to_s`. Only the first line of a multi-line rendering is kept — one
245
+ # item is one row. `text_width` is the display columns the row body
246
+ # gets, gutters and scrollbar column already deducted; it is `0` before
247
+ # the list has a rect, and every change to it drops the row cache, so a
248
+ # width-dependent row is never left over from the old width.
164
249
  attr_reader :renderer
165
250
 
166
251
  # Replaces the items, leaving the cursor where it is — a cursor left past
@@ -178,9 +263,10 @@ module Tuile
178
263
  update_scroll_top_row_if_auto_scroll
179
264
  notify_cursor_changed
180
265
  invalidate
266
+ invalidate_layout # the bar's row_count follows @items.size
181
267
  end
182
268
 
183
- # @param proc [Proc, Method] item -> row; see {#renderer}.
269
+ # @param proc [Proc, Method] `(item, text_width) -> row`; see {#renderer}.
184
270
  # @return [void]
185
271
  def renderer=(proc)
186
272
  @renderer = proc
@@ -192,9 +278,9 @@ module Tuile
192
278
  # it and {#items} stayed the same, e.g. one prefixing a marker read from
193
279
  # a selection it closes over:
194
280
  #
195
- # def value=(new_value) # RadioGroup: the marked row moved
281
+ # def set_value(new_value, from_user:) # RadioGroup: the marked row moved
196
282
  # super
197
- # content.refresh_rows
283
+ # list.refresh_rows
198
284
  # end
199
285
  #
200
286
  # @return [void]
@@ -243,9 +329,11 @@ module Tuile
243
329
  self.lines = buffer
244
330
  end
245
331
 
246
- def focusable? = true
332
+ # @return [Boolean] {#interactive?}.
333
+ def focusable? = @interactive
247
334
 
248
- def tab_stop? = true
335
+ # @return [Boolean] {#interactive?}.
336
+ def tab_stop? = @interactive
249
337
 
250
338
  # @param key [String] a key.
251
339
  # @return [Boolean] true if the key was handled.
@@ -317,13 +405,14 @@ module Tuile
317
405
  end
318
406
 
319
407
  # Moves the cursor to the pressed row and fires {#on_item_chosen}; what
320
- # each {Cursor} does with a press is its own.
408
+ # each {Cursor} does with a press is its own. Declines every press while
409
+ # not {#interactive?}.
321
410
  # @param event [Mouse::DownEvent]
322
411
  # @return [Boolean]
323
412
  def handle_mouse_down?(event)
324
- return false unless event.button == :left
413
+ return false unless @interactive && event.button == :left
325
414
 
326
- item_index = event.y - rect.top + scroll_top_row
415
+ item_index = event.y + scroll_top_row
327
416
  if @cursor.handle_mouse_down?(item_index, event, @items.size)
328
417
  move_viewport_to_cursor
329
418
  notify_cursor_changed
@@ -348,24 +437,27 @@ module Tuile
348
437
  end
349
438
 
350
439
  # Paints the visible items into {#rect}, rendering the ones not already
351
- # cached.
440
+ # cached. The scrollbar's column is the child {VerticalScrollBar}'s,
441
+ # reserved out of {#content_width}.
352
442
  #
353
443
  # Skips the {Component#repaint} default's auto-clear: every row of
354
444
  # {#rect} is painted below (with blank padding past the last item),
355
445
  # so the parent contract — "fully draw over your rect" — is met
356
- # without an upfront wipe. Rows go through {Component#draw_text}, so
357
- # content *and* blank filler inherit {Component#effective_bg_color}
446
+ # without an upfront wipe — but not {Component#invalidate_children}, or
447
+ # the scrollbar goes stale under an ancestor's clear. Rows go through
448
+ # {Canvas#set_text}, so
449
+ # content *and* blank filler inherit {ComponentBackground#effective}
358
450
  # (a {#bg_color} set here or on an ancestor); the cursor row's
359
451
  # {Theme#active_bg_color} highlight composes on top of it.
452
+ # @param canvas [Canvas] see {Component#repaint}.
360
453
  # @return [void]
361
- def repaint
454
+ def repaint(canvas)
362
455
  return if rect.empty?
363
456
 
364
- scrollbar = if scrollbar_visible?
365
- VerticalScrollBar.new(rect.height, row_count: @items.size, scroll_top_row: @scroll_top_row)
366
- end
457
+ invalidate_children
458
+ sync_row_cache_width
367
459
  (0...rect.height).each do |row|
368
- draw_text(rect.left, row + rect.top, paintable_row(row + @scroll_top_row, row, scrollbar))
460
+ canvas.set_text(0, row, paintable_row(row + @scroll_top_row))
369
461
  end
370
462
  end
371
463
 
@@ -573,18 +665,16 @@ module Tuile
573
665
 
574
666
  protected
575
667
 
576
- # Drops the rendered-row cache when the wrap width changes. The wrap
577
- # width depends on {#rect}`.width` and the scrollbar column, both of
578
- # which trigger this hook. Also re-evaluates {#auto_scroll}: if items were
579
- # assigned while the rect was empty (e.g. a {Popup}-wrapped list was
580
- # populated before the popup was opened), the auto-scroll update
581
- # was skipped because there was no viewport — re-run it now that there
582
- # is one, so the list snaps to the bottom on first paint.
668
+ # The bar's whole state, pushed in one place: its column, and the two
669
+ # numbers it paints a handle from. {#auto_scroll} re-pins first, since a
670
+ # taller viewport moves the bottom, and a rect that was empty skipped the
671
+ # pin altogether.
583
672
  # @return [void]
584
- def handle_width_changed
585
- super
586
- drop_row_cache
673
+ def relayout
587
674
  update_scroll_top_row_if_auto_scroll
675
+ @scrollbar.rect = scrollbar_visible? ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0)
676
+ @scrollbar.row_count = @items.size
677
+ @scrollbar.scroll_top_row = @scroll_top_row
588
678
  end
589
679
 
590
680
  private
@@ -637,7 +727,7 @@ module Tuile
637
727
  # @return [void]
638
728
  def fire_item_chosen
639
729
  pos = @cursor.position
640
- @on_item_chosen&.call(pos, @items[pos])
730
+ on_item_chosen.fire(ItemChosenEvent.new(source: self, position: pos, item: @items[pos]))
641
731
  end
642
732
 
643
733
  # @return [Array((Integer, Object, nil))]
@@ -657,7 +747,7 @@ module Tuile
657
747
  return if state == @last_cursor_state
658
748
 
659
749
  @last_cursor_state = state
660
- @on_cursor_changed&.call(*state)
750
+ on_cursor_changed.fire(CursorChangedEvent.new(source: self, position: state[0], item: state[1]))
661
751
  end
662
752
 
663
753
  # @param query [String]
@@ -731,15 +821,13 @@ module Tuile
731
821
  # @return [Integer] the number of visible rows.
732
822
  def viewport_rows = rect.height
733
823
 
734
- # Scrolls the list.
824
+ # Scrolls the list, clamped at both ends. Goes through
825
+ # {#scroll_top_row=}, so a wheel notch or a PgUp re-evaluates
826
+ # {#following?} exactly as an assignment does.
735
827
  # @param delta [Integer] negative scrolls up, positive scrolls down.
736
828
  # @return [void]
737
829
  def move_scroll_top_row_by(delta)
738
- new_scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
739
- return if @scroll_top_row == new_scroll_top_row
740
-
741
- @scroll_top_row = new_scroll_top_row
742
- invalidate
830
+ self.scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
743
831
  end
744
832
 
745
833
  # If auto-scrolling, recalculate the top row and snap the cursor to the
@@ -748,8 +836,7 @@ module Tuile
748
836
  # negating the auto-scroll. Skipped when {#rect} is empty: without a
749
837
  # viewport the "items minus viewport" formula yields `@items.size`,
750
838
  # which would leave `scroll_top_row` past the last item once a real rect
751
- # arrives. {#handle_width_changed} re-runs this hook when the rect grows so
752
- # the snap-to-bottom intent is preserved.
839
+ # arrives. {#relayout} re-runs it once one does.
753
840
  #
754
841
  # Gated on {#following?}: once the user scrolls up off the bottom the
755
842
  # cursor snap and viewport pin are both skipped, so reading older
@@ -768,11 +855,17 @@ module Tuile
768
855
  self.scroll_top_row = new_scroll_top_row
769
856
  end
770
857
 
858
+ # Derived on every read, never stored, so {#content_width} can't disagree
859
+ # with it.
771
860
  # @return [Boolean] whether the scrollbar should be drawn right now.
772
861
  def scrollbar_visible?
773
862
  return false if rect.empty?
774
863
 
775
- @scrollbar_visibility == :visible
864
+ case @scrollbar_visibility
865
+ when :visible then true
866
+ when :auto then @items.size > rect.height
867
+ else false
868
+ end
776
869
  end
777
870
 
778
871
  # @return [Integer] column width available for row content (rect width
@@ -784,14 +877,35 @@ module Tuile
784
877
  rect.width - (scrollbar_visible? ? 1 : 0)
785
878
  end
786
879
 
880
+ # The budget handed to {#renderer} and the width {#pad_to_row} fits a row
881
+ # into: {#content_width} less the gutter either side. Clamped at `0` — a
882
+ # list with no rect still renders when {#search_and_go} scans it, and a
883
+ # renderer subtracting from a negative width would blow up at paint time.
884
+ # @return [Integer] display columns available to the row body.
885
+ def text_width
886
+ [content_width - 2, 0].max
887
+ end
888
+
787
889
  # Discards every rendered row, so the next paint re-renders the viewport
788
- # against the current items, renderer and width.
890
+ # against the current items and renderer.
789
891
  # @return [void]
790
892
  def drop_row_cache
791
893
  @row_cache.clear
792
894
  @blank_row = nil
793
895
  end
794
896
 
897
+ # Drops the row cache when {#content_width} has moved off the width it
898
+ # was padded at. The width is compared rather than announced because an
899
+ # `:auto` bar moves it on a *height* change too — a list shrinking below
900
+ # its item count takes the bar and the column with it.
901
+ # @return [void]
902
+ def sync_row_cache_width
903
+ return if @row_cache_width == content_width
904
+
905
+ drop_row_cache
906
+ @row_cache_width = content_width
907
+ end
908
+
795
909
  # @param index [Integer] 0-based index into {#items}.
796
910
  # @return [StyledString] the item's padded row, rendered on first use and
797
911
  # memoized until {#drop_row_cache}.
@@ -812,7 +926,7 @@ module Tuile
812
926
  # {StyledString}, cut to its first line since a `\n` reaching the buffer
813
927
  # would corrupt the frame.
814
928
  def render(item)
815
- rendered = @renderer.call(item)
929
+ rendered = @renderer.call(item, text_width)
816
930
  rendered = StyledString.parse(rendered.to_s) unless rendered.is_a?(StyledString)
817
931
  return rendered unless rendered.spans.any? { _1.text.include?("\n") }
818
932
 
@@ -833,27 +947,16 @@ module Tuile
833
947
  return StyledString::EMPTY if cw <= 0
834
948
  return StyledString.plain(" " * cw) if cw < 2
835
949
 
836
- text_width = cw - 2
837
- body = row.ellipsize(text_width)
838
- fill = cw - 2 - body.display_width
839
- StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
950
+ StyledString.plain(" ") + row.ellipsize(text_width).ljust(cw - 1)
840
951
  end
841
952
 
842
953
  # @param index [Integer] 0-based index into {#items}.
843
- # @param row_in_viewport [Integer] 0-based row within the viewport.
844
- # @param scrollbar [VerticalScrollBar, nil] scrollbar instance, or nil
845
- # if not shown.
846
- # @return [StyledString] paintable row exactly `rect.width` columns wide;
847
- # highlighted if cursor is here.
848
- def paintable_row(index, row_in_viewport, scrollbar)
954
+ # @return [StyledString] paintable row exactly {#content_width} columns
955
+ # wide — the bar's column is the child's; highlighted if cursor is here.
956
+ def paintable_row(index)
849
957
  base = index < @items.size ? padded_row(index) : blank_row
850
958
  is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
851
- styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
852
- if scrollbar
853
- styled += StyledString.styled(scrollbar.scrollbar_char(row_in_viewport),
854
- fg: screen.theme.scrollbar_color)
855
- end
856
- styled
959
+ is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
857
960
  end
858
961
  end
859
962
  end