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.hint(" #{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)


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

def at_bottom? = @scroll_top_row == scroll_top_row_max

#blank_rowStyledString

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

Returns:



771
772
773
# File 'lib/tuile/component/list.rb', line 771

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 gutter, when visible). 0 when Tuile::Component#rect's width is non-positive.

Returns:

  • (Integer)


749
750
751
752
753
# File 'lib/tuile/component/list.rb', line 749

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)


598
599
600
601
# File 'lib/tuile/component/list.rb', line 598

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


614
615
616
617
618
# File 'lib/tuile/component/list.rb', line 614

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.



758
759
760
761
# File 'lib/tuile/component/list.rb', line 758

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?.



606
607
608
609
# File 'lib/tuile/component/list.rb', line 606

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(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
# File 'lib/tuile/component/list.rb', line 299

def handle_mouse(event)
  super
  if event.button == :scroll_down
    move_scroll_top_row_by(4)
  elsif event.button == :scroll_up
    move_scroll_top_row_by(-4)
  else
    return unless rect.contains?(event.point)

    item_index = event.y - rect.top + scroll_top_row
    if @cursor.handle_mouse(item_index, event, @items.size)
      move_viewport_to_cursor
      notify_cursor_changed
      invalidate
    end
    fire_item_chosen if event.button == :left && item_index >= 0 && item_index < @items.size && cursor_on_item?
  end
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)


705
706
707
708
709
710
711
# File 'lib/tuile/component/list.rb', line 705

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.



681
682
683
684
685
686
687
688
689
690
# File 'lib/tuile/component/list.rb', line 681

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.



623
624
625
626
627
628
629
# File 'lib/tuile/component/list.rb', line 623

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

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

#on_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 gutter, 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.



552
553
554
555
556
# File 'lib/tuile/component/list.rb', line 552

def on_width_changed
  super
  drop_row_cache
  update_scroll_top_row_if_auto_scroll
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



661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
# File 'lib/tuile/component/list.rb', line 661

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 gutter excluded). 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:



797
798
799
800
801
802
803
804
805
806
# File 'lib/tuile/component/list.rb', line 797

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:



766
767
768
# File 'lib/tuile/component/list.rb', line 766

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:



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

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
  styled += StyledString.plain(scrollbar.scrollbar_char(row_in_viewport)) if scrollbar
  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:



569
570
571
# File 'lib/tuile/component/list.rb', line 569

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:



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

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.



329
330
331
332
333
334
335
336
337
338
# File 'lib/tuile/component/list.rb', line 329

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:



588
589
590
591
592
593
594
595
# File 'lib/tuile/component/list.rb', line 588

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)


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

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)


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

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)


635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
# File 'lib/tuile/component/list.rb', line 635

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_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:



575
576
577
578
579
580
# File 'lib/tuile/component/list.rb', line 575

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. #on_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.



727
728
729
730
731
732
733
734
735
736
737
# File 'lib/tuile/component/list.rb', line 727

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)


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

def viewport_rows = rect.height