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, _w) { StyledString.plain(p.name) + screen.theme.fg(:muted, " #{p.email}") }
list.cursor   = List::Cursor.new                  # a bare list has none
list.on_item_chosen { |e| open(e.item) }

The #renderer turns an item and the columns it gets into one row; the default renders an item as itself and ignores the width, 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).

A renderer that uses the width lays out against it — an aligned right-hand column, a path elided from the left — neither of which a trailing ellipsis can express:

counts_w = files.map { _1.counts.length }.max   # per snapshot, not per row
list.renderer = lambda do |file, w|
name = StyledString.plain(file.path).ellipsize(w - counts_w - 1, at: :start)
gap  = " " * (w - counts_w - name.display_width)
name + StyledString.plain(gap + file.counts)
end

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.

#scrollbar_visibility turns on a VerticalScrollBar in the rightmost column: a child component, so its handle drags (wanting run_event_loop(capture_mouse: :drag)) and a press on its track pages. Under :visible the column stays reserved whether anything scrolls or not, so a growing list re-flows nothing; under :auto the bar shows only while the items outnumber the rows, and hands its column back to the rows while they fit (D_scrollbar_ink).

Implementation details

Rendering is lazy: only the rows in the viewport are rendered, each memoized until #items= or #renderer= drops the cache, or the row width moves off the one it was padded at. 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, and a measurement over the whole snapshot (the example's widest counts column) is computed where #items= is assigned, or it is an O(items²) pass on every resize. #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, CursorChangedEvent, ItemChosenEvent

Constant Summary collapse

DEFAULT_RENDERER =

The default #renderer: an item renders as itself, whatever the width. 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)
->(item, _text_width) { item }

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize ⇒ List

Returns a new instance of List.



68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/tuile/component/list.rb', line 68

def initialize
  super
  @items = []
  @renderer = DEFAULT_RENDERER
  @row_cache = {}
  @row_cache_width = nil
  @blank_row = nil
  @auto_scroll = false
  @follow = true
  @scroll_top_row = 0
  @cursor = Cursor::None.new
  @scrollbar_visibility = :gone
  @show_cursor_when_inactive = false
  @interactive = true
  @last_cursor_state = cursor_state
  @scrollbar = VerticalScrollBar.new
  @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
  add_child(@scrollbar) # chrome, appended: a List has no content children
end

Instance Attribute Details

#auto_scroll ⇒ Boolean

@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)


133
134
135
# File 'lib/tuile/component/list.rb', line 133

def auto_scroll
  @auto_scroll
end

#cursor ⇒ Cursor

@return — the list's cursor.

Returns:



145
146
147
# File 'lib/tuile/component/list.rb', line 145

def cursor
  @cursor
end

#items ⇒ ::Array[untyped]

@return — the items, one row each.

Returns:

  • (::Array[untyped])


238
239
240
# File 'lib/tuile/component/list.rb', line 238

def items
  @items
end

#renderer ⇒ Proc, Method

The width is passed whether or not a renderer wants it, so a one-argument callable raises when a row is rendered.

@return — (item, text_width) -> 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. text_width is the display columns the row body gets, gutters and scrollbar column already deducted; it is 0 before the list has a rect, and every change to it drops the row cache, so a width-dependent row is never left over from the old width.

Returns:

  • (Proc, Method)


249
250
251
# File 'lib/tuile/component/list.rb', line 249

def renderer
  @renderer
end

#scroll_top_row ⇒ Integer

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

Returns:

  • (Integer)


142
143
144
# File 'lib/tuile/component/list.rb', line 142

def scroll_top_row
  @scroll_top_row
end

#scrollbar_visibility ⇒ Symbol

@return — scrollbar visibility: :gone, :visible, or :auto — shown only while there are more #items than rows. :gone by default.

Returns:

  • (Symbol)


150
151
152
# File 'lib/tuile/component/list.rb', line 150

def scrollbar_visibility
  @scrollbar_visibility
end

#show_cursor_when_inactive ⇒ Boolean

@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)


155
156
157
# File 'lib/tuile/component/list.rb', line 155

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)


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

def at_bottom? = @scroll_top_row == scroll_top_row_max

#blank_row ⇒ StyledString

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

Returns:



917
918
919
# File 'lib/tuile/component/list.rb', line 917

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

#build_lines ⇒ void

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


326
327
328
329
330
# File 'lib/tuile/component/list.rb', line 326

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

#content_width ⇒ Integer

@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)


874
875
876
877
878
# File 'lib/tuile/component/list.rb', line 874

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)


720
721
722
723
# File 'lib/tuile/component/list.rb', line 720

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]])


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

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

#drop_row_cache ⇒ void

This method returns an undefined value.

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



892
893
894
895
# File 'lib/tuile/component/list.rb', line 892

def drop_row_cache
  @row_cache.clear
  @blank_row = nil
end

#fire_item_chosen ⇒ void

This method returns an undefined value.

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



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

def fire_item_chosen
  pos = @cursor.position
  on_item_chosen.fire(ItemChosenEvent.new(source: self, position: pos, item: @items[pos]))
end

#focusable? ⇒ Boolean

@return — #interactive?.

Returns:

  • (Boolean)


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

def focusable? = @interactive

#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)


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

def following? = @follow

#handle_key?(key) ⇒ Boolean

@param key — a key.

@return — true if the key was handled.

Parameters:

  • key (String)

Returns:

  • (Boolean)


340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
# File 'lib/tuile/component/list.rb', line 340

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. Declines every press while not #interactive?.

@param event

Parameters:

Returns:

  • (Boolean)


412
413
414
415
416
417
418
419
420
421
422
423
# File 'lib/tuile/component/list.rb', line 412

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

  item_index = event.y + 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)


429
430
431
432
433
434
435
436
437
# File 'lib/tuile/component/list.rb', line 429

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

#interactive=(value) ⇒ void

This method returns an undefined value.

Turning it off while focus is inside the list hands focus to the parent, as hiding a focused component does.

@param value

Parameters:

  • value (Boolean)


180
181
182
183
184
185
186
187
# File 'lib/tuile/component/list.rb', line 180

def interactive=(value)
  value = value ? true : false
  return if @interactive == value

  @interactive = value
  repair_focus_after_hiding unless value
  invalidate
end

#interactive? ⇒ Boolean

Whether the list is a control: focusable, a tab stop, and a press on a row moves the cursor and fires #on_item_chosen. false makes it a display-only pane — a sidebar summary beside the field the user types in — which Tab skips, a click doesn't focus, and a press on a row bubbles past, so no capability is left reachable only by the mouse. The wheel and the scrollbar still scroll it, and the app still drives #cursor and #scroll_top_row. true by default.

Returns:

  • (Boolean)


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

def interactive? = @interactive

#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])


303
304
305
306
307
# File 'lib/tuile/component/list.rb', line 303

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, clamped at both ends. Goes through #scroll_top_row=, so a wheel notch or a PgUp re-evaluates #following? exactly as an assignment does.

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

Parameters:

  • delta (Integer)


829
830
831
# File 'lib/tuile/component/list.rb', line 829

def move_scroll_top_row_by(delta)
  self.scroll_top_row = (@scroll_top_row + delta).clamp(0, scroll_top_row_max)
end

#move_viewport_to_cursor ⇒ void

This method returns an undefined value.

Scrolls the viewport so the cursor is visible.



803
804
805
806
807
808
809
810
811
812
# File 'lib/tuile/component/list.rb', line 803

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_changed ⇒ void

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.



745
746
747
748
749
750
751
# File 'lib/tuile/component/list.rb', line 745

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

  @last_cursor_state = state
  on_cursor_changed.fire(CursorChangedEvent.new(source: self, position: state[0], item: state[1]))
end

#on_cursor_changed ⇒ Listeners

Fired with a CursorChangedEvent when the (position, item) tuple under the cursor changes (items compared with ==). 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:



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

listener :on_cursor_changed

#on_item_chosen ⇒ Listeners

Fired with an ItemChosenEvent when an item is chosen — by pressing Enter on the cursor's item, or by left-clicking it. Never fires when the cursor's position is outside the content (e.g. Tuile::Component::List::Cursor::None, or empty content).

Returns:



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

listener :on_item_chosen

#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



783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
# File 'lib/tuile/component/list.rb', line 783

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:



945
946
947
948
949
950
951
# File 'lib/tuile/component/list.rb', line 945

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

  StyledString.plain(" ") + row.ellipsize(text_width).ljust(cw - 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:



912
913
914
# File 'lib/tuile/component/list.rb', line 912

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

#paintable_row(index) ⇒ StyledString

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

@return — paintable row exactly #content_width columns wide — the bar's column is the child's; highlighted if cursor is here.

Parameters:

  • index (Integer)

Returns:



956
957
958
959
960
# File 'lib/tuile/component/list.rb', line 956

def paintable_row(index)
  base = index < @items.size ? padded_row(index) : blank_row
  is_cursor = (active? || @show_cursor_when_inactive) && index < @items.size && @cursor.position == index
  is_cursor ? base.with_bg(screen.theme.active_bg_color) : base
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:



691
692
693
# File 'lib/tuile/component/list.rb', line 691

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

#refresh_rows ⇒ void

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 set_value(new_value, from_user:)   # RadioGroup: the marked row moved
super
list.refresh_rows
end


287
288
289
290
# File 'lib/tuile/component/list.rb', line 287

def refresh_rows
  drop_row_cache
  invalidate
end

#relayout ⇒ void

This method returns an undefined value.

The bar's whole state, pushed in one place: its column, and the two numbers it paints a handle from. #auto_scroll re-pins first, since a taller viewport moves the bottom, and a rect that was empty skipped the pin altogether.



673
674
675
676
677
678
# File 'lib/tuile/component/list.rb', line 673

def relayout
  update_scroll_top_row_if_auto_scroll
  @scrollbar.rect = scrollbar_visible? ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0)
  @scrollbar.row_count = @items.size
  @scrollbar.scroll_top_row = @scroll_top_row
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:



928
929
930
931
932
933
934
# File 'lib/tuile/component/list.rb', line 928

def render(item)
  rendered = @renderer.call(item, text_width)
  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

#repaint(canvas) ⇒ void

This method returns an undefined value.

Paints the visible items into Tuile::Component#rect, rendering the ones not already cached. The scrollbar's column is the child VerticalScrollBar's, reserved out of #content_width.

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 — but not Tuile::Component#invalidate_children, or the scrollbar goes stale under an ancestor's clear. Rows go through Tuile::Canvas#set_text, so content and blank filler inherit Tuile::ComponentBackground#effective (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.

@param canvas — see Tuile::Component#repaint.

Parameters:



454
455
456
457
458
459
460
461
462
# File 'lib/tuile/component/list.rb', line 454

def repaint(canvas)
  return if rect.empty?

  invalidate_children
  sync_row_cache_width
  (0...rect.height).each do |row|
    canvas.set_text(0, row, paintable_row(row + @scroll_top_row))
  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:



710
711
712
713
714
715
716
717
# File 'lib/tuile/component/list.rb', line 710

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_max ⇒ Integer

@return — the max value of #scroll_top_row.

Returns:

  • (Integer)


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

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

#scrollbar_visible? ⇒ Boolean

Derived on every read, never stored, so #content_width can't disagree with it.

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

Returns:

  • (Boolean)


861
862
863
864
865
866
867
868
869
# File 'lib/tuile/component/list.rb', line 861

def scrollbar_visible?
  return false if rect.empty?

  case @scrollbar_visibility
  when :visible then true
  when :auto then @items.size > rect.height
  else false
  end
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)


757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
# File 'lib/tuile/component/list.rb', line 757

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)


397
398
399
400
401
402
403
404
405
# File 'lib/tuile/component/list.rb', line 397

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)


373
374
375
# File 'lib/tuile/component/list.rb', line 373

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)


381
382
383
# File 'lib/tuile/component/list.rb', line 381

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:



697
698
699
700
701
702
# File 'lib/tuile/component/list.rb', line 697

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

#sync_row_cache_width ⇒ void

This method returns an undefined value.

Drops the row cache when #content_width has moved off the width it was padded at. The width is compared rather than announced because an :auto bar moves it on a height change too — a list shrinking below its item count takes the bar and the column with it.



902
903
904
905
906
907
# File 'lib/tuile/component/list.rb', line 902

def sync_row_cache_width
  return if @row_cache_width == content_width

  drop_row_cache
  @row_cache_width = content_width
end

#tab_stop? ⇒ Boolean

@return — #interactive?.

Returns:

  • (Boolean)


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

def tab_stop? = @interactive

#text_width ⇒ Integer

The budget handed to #renderer and the width #pad_to_row fits a row into: #content_width less the gutter either side. Clamped at 0 — a list with no rect still renders when #search_and_go scans it, and a renderer subtracting from a negative width would blow up at paint time.

@return — display columns available to the row body.

Returns:

  • (Integer)


885
886
887
# File 'lib/tuile/component/list.rb', line 885

def text_width
  [content_width - 2, 0].max
end

#update_scroll_top_row_if_auto_scroll ⇒ void

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. #relayout re-runs it once one does.

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.



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

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_rows ⇒ Integer

@return — the number of visible rows.

Returns:

  • (Integer)


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

def viewport_rows = rect.height