Class: Tuile::Component::List
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::List
- 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
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
Stringis parsed (so embedded ANSI is honored), anything else is#to_s'd first. ->(item, _text_width) { item }
Instance Attribute Summary collapse
-
#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?).
-
#cursor ⇒ Cursor
@return — the list's cursor.
-
#items ⇒ ::Array[untyped]
@return — the items, one row each.
-
#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.
-
#scroll_top_row ⇒ Integer
@return — top row of the viewport.
-
#scrollbar_visibility ⇒ Symbol
@return — scrollbar visibility:
:gone,:visible, or:auto— shown only while there are more #items than rows. -
#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).
Instance Method Summary collapse
-
#at_bottom? ⇒ Boolean
@return — whether the viewport is pinned to the last row.
-
#blank_row ⇒ StyledString
@return — the blank row painted past the last item.
-
#build_lines ⇒ void
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.
-
#content_width ⇒ Integer
@return — column width available for row content (rect width minus the scrollbar column, when visible).
-
#cursor_on_item? ⇒ Boolean
@return — true if the cursor sits on a real item.
-
#cursor_state ⇒ [[Integer, Object, NilClass]]
@return —
[position, item_at_position], with the item nil when the cursor is off-content. -
#drop_row_cache ⇒ void
Discards every rendered row, so the next paint re-renders the viewport against the current items and renderer.
-
#fire_item_chosen ⇒ void
Calls #on_item_chosen with the cursor's current
(index, item). -
#focusable? ⇒ Boolean
@return — #interactive?.
-
#following? ⇒ Boolean
@return — whether #auto_scroll is currently tailing.
-
#handle_key?(key) ⇒ Boolean
@param
key— a key. -
#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.
-
#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.
-
#initialize ⇒ List
constructor
A new instance of List.
-
#interactive=(value) ⇒ void
Turning it off while focus is inside the list hands focus to the parent, as hiding a focused component does.
-
#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.
-
#lines=(lines) ⇒ void
Sets the items from line-flavored input: each entry is coerced into a StyledString (a
Stringis parsed via StyledString.parse, so embedded ANSI is honored; a StyledString is used as-is; anything else is stringified via#to_sfirst), then split on\ninto separate lines via StyledString#lines, with trailing empty pieces dropped and trailing ASCII whitespace stripped. -
#move_scroll_top_row_by(delta) ⇒ void
Scrolls the list, clamped at both ends.
-
#move_viewport_to_cursor ⇒ void
Scrolls the viewport so the cursor is visible.
-
#notify_cursor_changed ⇒ void
Fires #on_cursor_changed if #cursor_state differs from the last fired state.
-
#on_cursor_changed ⇒ Listeners
Fired with a CursorChangedEvent when the
(position, item)tuple under the cursor changes (items compared with==). -
#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.
-
#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. -
#pad_to_row(row) ⇒ StyledString
Pads
rowto 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. -
#padded_row(index) ⇒ StyledString
@param
index— 0-based index into #items. -
#paintable_row(index) ⇒ StyledString
@param
index— 0-based index into #items. -
#parse_input_lines(entries) ⇒ ::Array[StyledString]
Coerces and flattens a list of input entries into trimmed StyledString lines.
- #refresh_rows ⇒ void
-
#relayout ⇒ void
The bar's whole state, pushed in one place: its column, and the two numbers it paints a handle from.
-
#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.
-
#repaint(canvas) ⇒ void
Paints the visible items into #rect, rendering the ones not already cached.
-
#rstrip_styled(line) ⇒ StyledString
Returns
linewith trailing ASCII whitespace (space/tab) dropped, preserving span styles on the surviving prefix. -
#scroll_top_row_max ⇒ Integer
@return — the max value of #scroll_top_row.
-
#scrollbar_visible? ⇒ Boolean
Derived on every read, never stored, so #content_width can't disagree with it.
-
#search_and_go(query, include_current:, reverse:) ⇒ Boolean
@param
query. -
#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:. -
#select_next(query, include_current: false) ⇒ Boolean
Moves the cursor to the next item whose text contains
query(case-insensitive substring match). -
#select_prev(query, include_current: false) ⇒ Boolean
Mirror of #select_next that walks the list backwards.
-
#split_to_lines(entry) ⇒ ::Array[StyledString]
@param
entry. -
#sync_row_cache_width ⇒ void
Drops the row cache when #content_width has moved off the width it was padded at.
-
#tab_stop? ⇒ Boolean
@return — #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.
-
#update_scroll_top_row_if_auto_scroll ⇒ void
If auto-scrolling, recalculate the top row and snap the cursor to the last reachable position.
-
#viewport_rows ⇒ Integer
@return — the number of visible rows.
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 = :gone @show_cursor_when_inactive = false @interactive = true @last_cursor_state = cursor_state = VerticalScrollBar.new .on_scroll_request { self.scroll_top_row = _1.scroll_top_row } add_child() # 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.
133 134 135 |
# File 'lib/tuile/component/list.rb', line 133 def auto_scroll @auto_scroll end |
#cursor ⇒ Cursor
@return — the list's cursor.
145 146 147 |
# File 'lib/tuile/component/list.rb', line 145 def cursor @cursor end |
#items ⇒ ::Array[untyped]
@return — the items, one row each.
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.
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.
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.
150 151 152 |
# File 'lib/tuile/component/list.rb', line 150 def 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.
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=.
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.
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.
874 875 876 877 878 |
# File 'lib/tuile/component/list.rb', line 874 def content_width return 0 if rect.width <= 0 rect.width - ( ? 1 : 0) end |
#cursor_on_item? ⇒ Boolean
@return — true if the cursor sits on a real item.
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.
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?.
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.
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.
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(-) true elsif key == Keys::PAGE_DOWN move_scroll_top_row_by() true elsif key == Keys::ENTER && cursor_on_item? fire_item_chosen true elsif @cursor.handle_key?(key, @items.size, ) 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
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. == :left item_index = event.y + scroll_top_row if @cursor.handle_mouse_down?(item_index, event, @items.size) 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
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
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.
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.
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.
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 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.
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).
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).
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.
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.
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
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 .rect = ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0) .row_count = @items.size .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.
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.
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
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.
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.
861 862 863 864 865 866 867 868 869 |
# File 'lib/tuile/component/list.rb', line 861 def return false if rect.empty? case 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
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) 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.
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) 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.
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.
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
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?.
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.
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 - ).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.
822 |
# File 'lib/tuile/component/list.rb', line 822 def = rect.height |