Class: Tuile::Component::List

Inherits:
Component
  • Object
show all
Defined in:
lib/tuile/component/list.rb,
sig/tuile.rbs

Overview

A scrollable list of typed items, one row each, with cursor support.

list = Component::List.new
list.items    = people
list.renderer = ->(p) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
list.cursor   = List::Cursor.new                  # a bare list has none
list.on_item_chosen = ->(index, person) { open(person) }

The #renderer turns an item into one row; the default renders an item as itself, so a list of Strings or StyledStrings needs none — which is what #lines= and #build_lines are, items that are their own rendering (split on \n, one row per line).

There are no appenders. The items are always assigned whole — an app that grows a list keeps its own array and re-assigns it (list.items = mine) — so a List stays a snapshot of a collection, which is the only shape a lazily-sourced provider could ever fill.

Rows wider than the viewport are ellipsized via StyledString#ellipsize with span styles preserved across the cut. Vertical scrolling is via #scroll_top_row; enable #auto_scroll to keep the bottom in view. The cursor responds to arrows, jk, Home/End, Ctrl+U/D and scrolls the list automatically; its highlight overlays Theme#active_bg_color while preserving each span's foreground color.

Implementation details

Rendering is lazy: only the rows in the viewport are rendered, each memoized until #items=, #renderer= or a width change drops the cache. So a renderer runs at paint time, on any frame — keep it pure and cheap; work that reaches a service belongs in the item, not in the renderer. #select_next deliberately renders without memoizing: one failed scan would otherwise cache a row per item.

Direct Known Subclasses

Tuile::Component::ListDropdown::Menu

Defined Under Namespace

Classes: Cursor

Constant Summary collapse

DEFAULT_RENDERER =

The default #renderer: an item renders as itself. Every renderer's output is coerced the same way — a StyledString passes through, a String is parsed (so embedded ANSI is honored), anything else is #to_s'd first.

Returns:

  • (Proc)
:itself.to_proc

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeList

Returns a new instance of List.



45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
# File 'lib/tuile/component/list.rb', line 45

def initialize
  super
  @items = []
  @renderer = DEFAULT_RENDERER
  @row_cache = {}
  @blank_row = nil
  @auto_scroll = false
  @follow = true
  @scroll_top_row = 0
  @cursor = Cursor::None.new
  @scrollbar_visibility = :gone
  @show_cursor_when_inactive = false
  @on_item_chosen = nil
  @on_cursor_changed = nil
  @last_cursor_state = cursor_state
end

Instance Attribute Details

#auto_scrollBoolean

@return — if true and new content is set, auto-scrolls to the bottom — but only while the viewport is already pinned to the last row (see #following?). Scroll up to read older content and incoming rows stop yanking you back down; scroll back to the bottom and tailing resumes.

Returns:

  • (Boolean)


84
85
86
# File 'lib/tuile/component/list.rb', line 84

def auto_scroll
  @auto_scroll
end

#cursorCursor

@return — the list's cursor.

Returns:



96
97
98
# File 'lib/tuile/component/list.rb', line 96

def cursor
  @cursor
end

#items::Array[untyped]

@return — the items, one row each.

Returns:

  • (::Array[untyped])


159
160
161
# File 'lib/tuile/component/list.rb', line 159

def items
  @items
end

#on_cursor_changedProc?

@return — callback fired when the (index, item) tuple under the cursor changes (items compared with ==). Called as proc.call(index, item), with item nil when the cursor is off-content (Tuile::Component::List::Cursor::None, empty list, or index past the last item). Fires on cursor moves (key, mouse, search), on #cursor=, and on #items= when the item at the cursor's index changes (or its in-range/out-of-range status flips). Useful for keeping a details pane in sync with the highlighted row.

Returns:

  • (Proc, nil)


77
78
79
# File 'lib/tuile/component/list.rb', line 77

def on_cursor_changed
  @on_cursor_changed
end

#on_item_chosenProc?

@return — callback fired when an item is chosen — by pressing Enter on the cursor's item, or by left-clicking it. Called as proc.call(index, item) with the chosen 0-based index and the item itself. Never fires when the cursor's position is outside the content (e.g. Tuile::Component::List::Cursor::None, or empty content).

Returns:

  • (Proc, nil)


67
68
69
# File 'lib/tuile/component/list.rb', line 67

def on_item_chosen
  @on_item_chosen
end

#rendererProc, Method

@return — item -> row: a StyledString, a String (parsed, so embedded ANSI is honored), or anything with #to_s. Only the first line of a multi-line rendering is kept — one item is one row.

Returns:

  • (Proc, Method)


164
165
166
# File 'lib/tuile/component/list.rb', line 164

def renderer
  @renderer
end

#scroll_top_rowInteger

@return — top row of the viewport. 0 or positive.

Returns:

  • (Integer)


93
94
95
# File 'lib/tuile/component/list.rb', line 93

def scroll_top_row
  @scroll_top_row
end

#scrollbar_visibilitySymbol

@return — scrollbar visibility: :gone or :visible.

Returns:

  • (Symbol)


99
100
101
# File 'lib/tuile/component/list.rb', line 99

def scrollbar_visibility
  @scrollbar_visibility
end

#show_cursor_when_inactiveBoolean

@return — when true, the cursor highlight is painted even while the list is inactive (e.g. when focus is on a sibling search field). Defaults to false.

Returns:

  • (Boolean)


104
105
106
# File 'lib/tuile/component/list.rb', line 104

def show_cursor_when_inactive
  @show_cursor_when_inactive
end

Instance Method Details

#at_bottom?Boolean

@return — whether the viewport is pinned to the last row. Drives #following?: re-evaluated on every #scroll_top_row=.

Returns:

  • (Boolean)


729
# File 'lib/tuile/component/list.rb', line 729

def at_bottom? = @scroll_top_row == scroll_top_row_max

#blank_rowStyledString

@return — the blank row painted past the last item.

Returns:



803
804
805
# File 'lib/tuile/component/list.rb', line 803

def blank_row
  @blank_row ||= pad_to_row(StyledString::EMPTY)
end

#build_linesvoid

This method returns an undefined value.

Fully re-populates the list, line-flavored: yields a fresh buffer and assigns it through #lines=, so each entry is coerced, split and rstripped exactly as there. The buffer is a plain Array, which a builder can read mid-build — recording the row a Tuile::Component::List::Cursor::Limited may land on is the case this exists for:

list.build_lines do |lines|
  cursor_positions << lines.size
  lines << format_overview(vm)
  lines << format_detail(vm)
end


240
241
242
243
244
# File 'lib/tuile/component/list.rb', line 240

def build_lines
  buffer = []
  yield buffer
  self.lines = buffer
end

#content_widthInteger

@return — column width available for row content (rect width minus the scrollbar column, when visible). 0 when Tuile::Component#rect's width is non-positive.

Returns:

  • (Integer)


781
782
783
784
785
# File 'lib/tuile/component/list.rb', line 781

def content_width
  return 0 if rect.width <= 0

  rect.width - (scrollbar_visible? ? 1 : 0)
end

#cursor_on_item?Boolean

@return — true if the cursor sits on a real item.

Returns:

  • (Boolean)


630
631
632
633
# File 'lib/tuile/component/list.rb', line 630

def cursor_on_item?
  pos = @cursor.position
  pos >= 0 && pos < @items.size
end

#cursor_state[[Integer, Object, NilClass]]

@return[position, item_at_position], with the item nil when the cursor is off-content.

Returns:

  • ([[Integer, Object, NilClass]])


646
647
648
649
650
# File 'lib/tuile/component/list.rb', line 646

def cursor_state
  pos = @cursor.position
  item = pos >= 0 && pos < @items.size ? @items[pos] : nil
  [pos, item]
end

#drop_row_cachevoid

This method returns an undefined value.

Discards every rendered row, so the next paint re-renders the viewport against the current items, renderer and width.



790
791
792
793
# File 'lib/tuile/component/list.rb', line 790

def drop_row_cache
  @row_cache.clear
  @blank_row = nil
end

#fire_item_chosenvoid

This method returns an undefined value.

Calls #on_item_chosen with the cursor's current (index, item). Caller must ensure #cursor_on_item?.



638
639
640
641
# File 'lib/tuile/component/list.rb', line 638

def fire_item_chosen
  pos = @cursor.position
  @on_item_chosen&.call(pos, @items[pos])
end

#focusable?Boolean

Returns:

  • (Boolean)


246
# File 'lib/tuile/component/list.rb', line 246

def focusable? = true

#following?Boolean

@return — whether #auto_scroll is currently tailing. True while the viewport sits at the last row; flips to false the moment the user scrolls up, and back to true once they scroll to the bottom again. Only consulted when #auto_scroll is enabled.

Returns:

  • (Boolean)


90
# File 'lib/tuile/component/list.rb', line 90

def following? = @follow

#handle_key?(key) ⇒ Boolean

@param key — a key.

@return — true if the key was handled.

Parameters:

  • key (String)

Returns:

  • (Boolean)


252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
# File 'lib/tuile/component/list.rb', line 252

def handle_key?(key)
  if key == Keys::PAGE_UP
    move_scroll_top_row_by(-viewport_rows)
    true
  elsif key == Keys::PAGE_DOWN
    move_scroll_top_row_by(viewport_rows)
    true
  elsif key == Keys::ENTER && cursor_on_item?
    fire_item_chosen
    true
  elsif @cursor.handle_key?(key, @items.size, viewport_rows)
    move_viewport_to_cursor
    notify_cursor_changed
    invalidate
    true
  else
    false
  end
end

#handle_mouse_down?(event) ⇒ Boolean

Moves the cursor to the pressed row and fires #on_item_chosen; what each Cursor does with a press is its own.

@param event

Parameters:

Returns:

  • (Boolean)


323
324
325
326
327
328
329
330
331
332
333
334
# File 'lib/tuile/component/list.rb', line 323

def handle_mouse_down?(event)
  return false unless event.button == :left

  item_index = event.y - rect.top + scroll_top_row
  if @cursor.handle_mouse_down?(item_index, event, @items.size)
    move_viewport_to_cursor
    notify_cursor_changed
    invalidate
  end
  fire_item_chosen if item_index >= 0 && item_index < @items.size && cursor_on_item?
  true
end

#handle_mouse_scroll?(event) ⇒ Boolean

Scrolls four rows a notch, and declines — so the notch bubbles to an ancestor scroller — once this list is at that end of its items.

@param event

Parameters:

Returns:

  • (Boolean)


340
341
342
343
344
345
346
347
348
# File 'lib/tuile/component/list.rb', line 340

def handle_mouse_scroll?(event)
  before = scroll_top_row
  case event.direction
  when :down then move_scroll_top_row_by(4)
  when :up   then move_scroll_top_row_by(-4)
  else return false
  end
  scroll_top_row != before
end

#handle_width_changedvoid

This method returns an undefined value.

Drops the rendered-row cache when the wrap width changes. The wrap width depends on Tuile::Component#rect.width and the scrollbar column, both of which trigger this hook. Also re-evaluates #auto_scroll: if items were assigned while the rect was empty (e.g. a Popup-wrapped list was populated before the popup was opened), the auto-scroll update was skipped because there was no viewport — re-run it now that there is one, so the list snaps to the bottom on first paint.



584
585
586
587
588
# File 'lib/tuile/component/list.rb', line 584

def handle_width_changed
  super
  drop_row_cache
  update_scroll_top_row_if_auto_scroll
end

#lines=(lines) ⇒ void

This method returns an undefined value.

Sets the items from line-flavored input: each entry is coerced into a StyledString (a String is parsed via StyledString.parse, so embedded ANSI is honored; a StyledString is used as-is; anything else is stringified via #to_s first), then split on \n into separate lines via StyledString#lines, with trailing empty pieces dropped and trailing ASCII whitespace stripped. The resulting StyledStrings *are* the #items, so under the DEFAULT_RENDERER each is its own row.

@param lines — entries are String, StyledString, or anything that responds to #to_s.

Parameters:

  • lines (::Array[untyped])


217
218
219
220
221
# File 'lib/tuile/component/list.rb', line 217

def lines=(lines)
  raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array

  self.items = parse_input_lines(lines)
end

#move_scroll_top_row_by(delta) ⇒ void

This method returns an undefined value.

Scrolls the list.

@param delta — negative scrolls up, positive scrolls down.

Parameters:

  • delta (Integer)


737
738
739
740
741
742
743
# File 'lib/tuile/component/list.rb', line 737

def move_scroll_top_row_by(delta)
  new_scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
  return if @scroll_top_row == new_scroll_top_row

  @scroll_top_row = new_scroll_top_row
  invalidate
end

#move_viewport_to_cursorvoid

This method returns an undefined value.

Scrolls the viewport so the cursor is visible.



713
714
715
716
717
718
719
720
721
722
# File 'lib/tuile/component/list.rb', line 713

def move_viewport_to_cursor
  pos = @cursor.position
  return unless pos >= 0

  if @scroll_top_row > pos
    self.scroll_top_row = pos
  elsif pos > @scroll_top_row + rect.height - 1
    self.scroll_top_row = pos - rect.height + 1
  end
end

#notify_cursor_changedvoid

This method returns an undefined value.

Fires #on_cursor_changed if #cursor_state differs from the last fired state. Idempotent — safe to call after any mutation.



655
656
657
658
659
660
661
# File 'lib/tuile/component/list.rb', line 655

def notify_cursor_changed
  state = cursor_state
  return if state == @last_cursor_state

  @last_cursor_state = state
  @on_cursor_changed&.call(*state)
end

#order_for_search(candidates, current, include_current:, reverse:) ⇒ Object

Rotates candidates (sorted ascending) so iteration starts from the position appropriate for "find next" / "find prev" with optional inclusion of the current.

@param candidates

@param current

@param include_current

@param reverse



693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
# File 'lib/tuile/component/list.rb', line 693

def order_for_search(candidates, current, include_current:, reverse:)
  if reverse
    before, after = if include_current
                      [candidates.select { _1 <= current }, candidates.select { _1 > current }]
                    else
                      [candidates.select { _1 < current }, candidates.select { _1 >= current }]
                    end
    before.reverse + after.reverse
  else
    after, before = if include_current
                      [candidates.select { _1 >= current }, candidates.select { _1 < current }]
                    else
                      [candidates.select { _1 > current }, candidates.select { _1 <= current }]
                    end
    after + before
  end
end

#pad_to_row(row) ⇒ StyledString

Pads row to one full row of the viewport (scrollbar column excluded), including the one-column gutter either side — the trailing one is what keeps a row off the bar, as TextView#scrollbar_columns reserves separately. Rows wider than the content area are ellipsized via StyledString#ellipsize (span styles survive the cut); shorter ones are padded with default-styled spaces.

@param row

@return — exactly #content_width display columns wide (or StyledString::EMPTY when content_width is non-positive).

Parameters:

Returns:



831
832
833
834
835
836
837
838
839
840
# File 'lib/tuile/component/list.rb', line 831

def pad_to_row(row)
  cw = content_width
  return StyledString::EMPTY if cw <= 0
  return StyledString.plain(" " * cw) if cw < 2

  text_width = cw - 2
  body = row.ellipsize(text_width)
  fill = cw - 2 - body.display_width
  StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1))
end

#padded_row(index) ⇒ StyledString

@param index — 0-based index into #items.

@return — the item's padded row, rendered on first use and memoized until #drop_row_cache.

Parameters:

  • index (Integer)

Returns:



798
799
800
# File 'lib/tuile/component/list.rb', line 798

def padded_row(index)
  @row_cache[index] ||= pad_to_row(render(@items[index]))
end

#paintable_row(index, row_in_viewport, scrollbar) ⇒ StyledString

@param index — 0-based index into #items.

@param row_in_viewport — 0-based row within the viewport.

@param scrollbar — scrollbar instance, or nil if not shown.

@return — paintable row exactly rect.width columns wide; highlighted if cursor is here.

Parameters:

Returns:



848
849
850
851
852
853
854
855
856
857
# File 'lib/tuile/component/list.rb', line 848

def paintable_row(index, row_in_viewport, scrollbar)
  base = index < @items.size ? padded_row(index) : blank_row
  is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
  styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
  if scrollbar
    styled += StyledString.styled(scrollbar.scrollbar_char(row_in_viewport),
                                  fg: screen.theme.scrollbar_color)
  end
  styled
end

#parse_input_lines(entries) ⇒ ::Array[StyledString]

Coerces and flattens a list of input entries into trimmed StyledString lines. Each entry becomes a StyledString (String via StyledString.parse, StyledString passed through, anything else via #to_s), then split on \n via StyledString#lines — with trailing empty pieces dropped (matching String#split("\n")'s default behavior, so a lone "" entry adds no row) — and trailing ASCII whitespace stripped on each resulting line.

@param entries

Parameters:

  • entries (::Array[untyped])

Returns:



601
602
603
# File 'lib/tuile/component/list.rb', line 601

def parse_input_lines(entries)
  entries.flat_map { |entry| split_to_lines(entry) }
end

#refresh_rowsvoid

This method returns an undefined value.

Re-renders every row — for a #renderer whose inputs changed while it and #items stayed the same, e.g. one prefixing a marker read from a selection it closes over:

def value=(new_value)   # RadioGroup: the marked row moved
super
content.refresh_rows
end


201
202
203
204
# File 'lib/tuile/component/list.rb', line 201

def refresh_rows
  drop_row_cache
  invalidate
end

#render(item) ⇒ StyledString

Renders one item, without populating the row cache — #search_and_go scans with this, and caching a failed scan would grow the cache to one row per item.

@param item

@return — one row: the #renderer's output coerced to a StyledString, cut to its first line since a \n reaching the buffer would corrupt the frame.

Parameters:

  • item (Object)

Returns:



814
815
816
817
818
819
820
# File 'lib/tuile/component/list.rb', line 814

def render(item)
  rendered = @renderer.call(item)
  rendered = StyledString.parse(rendered.to_s) unless rendered.is_a?(StyledString)
  return rendered unless rendered.spans.any? { _1.text.include?("\n") }

  rendered.lines.first
end

#repaintvoid

This method returns an undefined value.

Paints the visible items into Tuile::Component#rect, rendering the ones not already cached.

Skips the Tuile::Component#repaint default's auto-clear: every row of Tuile::Component#rect is painted below (with blank padding past the last item), so the parent contract — "fully draw over your rect" — is met without an upfront wipe. Rows go through Tuile::Component#draw_text, so content and blank filler inherit Tuile::Component#effective_bg_color (a Tuile::Component#bg_color set here or on an ancestor); the cursor row's Theme#active_bg_color highlight composes on top of it.



361
362
363
364
365
366
367
368
369
370
# File 'lib/tuile/component/list.rb', line 361

def repaint
  return if rect.empty?

  scrollbar = if scrollbar_visible?
                VerticalScrollBar.new(rect.height, row_count: @items.size, scroll_top_row: @scroll_top_row)
              end
  (0...rect.height).each do |row|
    draw_text(rect.left, row + rect.top, paintable_row(row + @scroll_top_row, row, scrollbar))
  end
end

#rstrip_styled(line) ⇒ StyledString

Returns line with trailing ASCII whitespace (space/tab) dropped, preserving span styles on the surviving prefix. Whitespace chars are all single-column ASCII, so byte-count delta equals column-count delta and StyledString#slice can do the cut.

@param line

Parameters:

Returns:



620
621
622
623
624
625
626
627
# File 'lib/tuile/component/list.rb', line 620

def rstrip_styled(line)
  plain = line.to_s
  trailing = plain.length - plain.rstrip.length
  return line if trailing.zero?
  return StyledString::EMPTY if trailing == plain.length

  line.slice(0, line.display_width - trailing)
end

#scroll_top_row_maxInteger

@return — the max value of #scroll_top_row.

Returns:

  • (Integer)


725
# File 'lib/tuile/component/list.rb', line 725

def scroll_top_row_max = (@items.size - rect.height).clamp(0, nil)

#scrollbar_visible?Boolean

@return — whether the scrollbar should be drawn right now.

Returns:

  • (Boolean)


772
773
774
775
776
# File 'lib/tuile/component/list.rb', line 772

def scrollbar_visible?
  return false if rect.empty?

  @scrollbar_visibility == :visible
end

#search_and_go(query, include_current:, reverse:) ⇒ Boolean

@param query

@param include_current

@param reverse

Parameters:

  • query (String)
  • include_current: (Boolean)
  • reverse: (Boolean)

Returns:

  • (Boolean)


667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
# File 'lib/tuile/component/list.rb', line 667

def search_and_go(query, include_current:, reverse:)
  return false if query.empty?

  candidates = @cursor.candidate_positions(@items.size)
  return false if candidates.empty?

  ordered = order_for_search(candidates, @cursor.position, include_current: include_current, reverse: reverse)
  query_lc = query.downcase
  match = ordered.find { |idx| render(@items[idx]).to_s.downcase.include?(query_lc) }
  return false unless match

  @cursor.go(match)
  move_viewport_to_cursor
  notify_cursor_changed
  invalidate
  true
end

#select(index) ⇒ Boolean

Moves the cursor to the item at index, scrolling it into view and firing #on_cursor_changed — the positional member of the #select_next / #select_prev family, for a caller that already knows which item it wants:

list.select(items.index(chosen))

Refuses an index the current #cursor can't reach — out of range, or anything at all under Tuile::Component::List::Cursor::None — rather than stranding the cursor off-content.

@param index

@return — whether the cursor moved there.

Parameters:

  • index (Integer)

Returns:

  • (Boolean)


309
310
311
312
313
314
315
316
317
# File 'lib/tuile/component/list.rb', line 309

def select(index)
  return false unless @cursor.candidate_positions(@items.size).include?(index)

  @cursor.go(index)
  move_viewport_to_cursor
  notify_cursor_changed
  invalidate
  true
end

#select_next(query, include_current: false) ⇒ Boolean

Moves the cursor to the next item whose text contains query (case-insensitive substring match). Search wraps around the end of the list. Only items reachable by the current #cursor are considered. Matching uses the rendered row's plain text — span styles do not affect the match.

@param query — substring to match. Empty query never matches.

@param include_current — when true, the current cursor position is eligible (useful when the query has just changed and the current item may still match); when false, the search starts after the current position (useful for "find next" key bindings that should advance past the current).

@return — true if a match was found.

Parameters:

  • query (String)
  • include_current: (Boolean) (defaults to: false)

Returns:

  • (Boolean)


285
286
287
# File 'lib/tuile/component/list.rb', line 285

def select_next(query, include_current: false)
  search_and_go(query, include_current: include_current, reverse: false)
end

#select_prev(query, include_current: false) ⇒ Boolean

Mirror of #select_next that walks the list backwards.

@param query

@param include_current

@return — true if a match was found.

Parameters:

  • query (String)
  • include_current: (Boolean) (defaults to: false)

Returns:

  • (Boolean)


293
294
295
# File 'lib/tuile/component/list.rb', line 293

def select_prev(query, include_current: false)
  search_and_go(query, include_current: include_current, reverse: true)
end

#split_to_lines(entry) ⇒ ::Array[StyledString]

@param entry

Parameters:

  • entry (Object)

Returns:



607
608
609
610
611
612
# File 'lib/tuile/component/list.rb', line 607

def split_to_lines(entry)
  styled = entry.is_a?(StyledString) ? entry : StyledString.parse(entry.to_s)
  parts = styled.lines
  parts.pop while parts.last && parts.last.empty?
  parts.map { |line| rstrip_styled(line) }
end

#tab_stop?Boolean

Returns:

  • (Boolean)


248
# File 'lib/tuile/component/list.rb', line 248

def tab_stop? = true

#update_scroll_top_row_if_auto_scrollvoid

This method returns an undefined value.

If auto-scrolling, recalculate the top row and snap the cursor to the last reachable position. Without the cursor snap the viewport gets yanked back to wherever the cursor sat on the next arrow press, negating the auto-scroll. Skipped when Tuile::Component#rect is empty: without a viewport the "items minus viewport" formula yields @items.size, which would leave scroll_top_row past the last item once a real rect arrives. #handle_width_changed re-runs this hook when the rect grows so the snap-to-bottom intent is preserved.

Gated on #following?: once the user scrolls up off the bottom the cursor snap and viewport pin are both skipped, so reading older content is not interrupted by incoming items. #scroll_top_row= re-arms @follow when the viewport returns to the bottom.



759
760
761
762
763
764
765
766
767
768
769
# File 'lib/tuile/component/list.rb', line 759

def update_scroll_top_row_if_auto_scroll
  return unless @auto_scroll && @follow
  return if rect.empty?

  notify_cursor_changed if @cursor.go_to_last(@items.size)

  new_scroll_top_row = (@items.size - viewport_rows).clamp(0, nil)
  return unless @scroll_top_row != new_scroll_top_row

  self.scroll_top_row = new_scroll_top_row
end

#viewport_rowsInteger

@return — the number of visible rows.

Returns:

  • (Integer)


732
# File 'lib/tuile/component/list.rb', line 732

def viewport_rows = rect.height