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 items with cursor support.
Items are modeled as StyledStrings and painted directly into the component's #rect. Lines wider than the viewport are ellipsized via StyledString#ellipsize (span styles are preserved across the cut — unlike the older ANSI-as-bytes truncation, color does not get dropped on the surviving characters). Vertical scrolling is supported via #top_line; the list can also automatically scroll to the bottom if #auto_scroll is enabled.
Cursor is supported; call #cursor= to change cursor behavior. The
cursor responds to arrows, jk, Home/End, Ctrl+U/D and scrolls the
list automatically. The cursor highlight overlays
Theme#active_bg_color while preserving each span's foreground color.
Defined Under Namespace
Classes: Cursor
Instance Attribute Summary collapse
-
#auto_scroll ⇒ Boolean
@return — if true and a line is added or new content is set, auto-scrolls to the bottom — but only while the viewport is already pinned to the last line (see #following?).
-
#cursor ⇒ Cursor
@return — the list's cursor.
-
#on_cursor_changed ⇒ Proc?
@return — callback fired when the
(index, line)tuple under the cursor changes. -
#on_item_chosen ⇒ Proc?
@return — callback fired when an item is chosen — by pressing Enter on the cursor's item, or by left-clicking an item.
-
#scrollbar_visibility ⇒ Symbol
@return — scrollbar visibility:
:goneor:visible. -
#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).
-
#top_line ⇒ Integer
@return — top line of the viewport.
Instance Method Summary collapse
-
#add_line(line) ⇒ void
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS Adds a line.
-
#add_lines(lines) ⇒ void
Appends given lines.
-
#at_bottom? ⇒ Boolean
@return — whether the viewport is pinned to the last line.
-
#content_width ⇒ Integer
@return — column width available for line content (rect width minus the scrollbar gutter, when visible).
-
#cursor_on_item? ⇒ Boolean
@return — true if the cursor sits on a real content line.
-
#cursor_state ⇒ [[Integer, StyledString, NilClass]]
@return —
[position, line_at_position], withlinenil when the cursor is off-content. -
#fire_item_chosen ⇒ void
Calls #on_item_chosen with the cursor's current
(index, line). - #focusable? ⇒ Boolean
-
#following? ⇒ Boolean
@return — whether #auto_scroll is currently tailing.
-
#handle_key(key) ⇒ Boolean
@param
key— a key. -
#handle_mouse(event) ⇒ void
@param
event. -
#initialize ⇒ List
constructor
A new instance of List.
-
#lines ⇒ void
Without a block, returns the current lines.
-
#lines=(lines) ⇒ void
Sets new lines.
-
#move_top_line_by(delta) ⇒ void
Scrolls the list.
-
#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_width_changed ⇒ void
Rebuilds pre-padded lines when the wrap width changes.
-
#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(line) ⇒ StyledString
Pads
lineto one full row of the viewport (scrollbar gutter excluded). -
#paintable_line(index, row_in_viewport, scrollbar) ⇒ StyledString
@param
index— 0-based index into #lines. -
#parse_input_lines(entries) ⇒ ::Array[StyledString]
Coerces and flattens a list of input entries into trimmed StyledString lines.
-
#rebuild_padded_lines ⇒ void
Recomputes @padded_lines for the current rect width and scrollbar visibility.
-
#repaint ⇒ void
Paints the list items into #rect.
-
#rstrip_styled(line) ⇒ StyledString
Returns
linewith trailing ASCII whitespace (space/tab) dropped, preserving span styles on the surviving prefix. -
#scrollbar_visible? ⇒ Boolean
@return — whether the scrollbar should be drawn right now.
-
#search_and_go(query, include_current:, reverse:) ⇒ Boolean
@param
query. -
#select_next(query, include_current: false) ⇒ Boolean
Moves the cursor to the next line 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. - #tab_stop? ⇒ Boolean
-
#top_line_max ⇒ Integer
@return — the max value of #top_line.
-
#update_top_line_if_auto_scroll ⇒ void
If auto-scrolling, recalculate the top line and snap the cursor to the last reachable position.
-
#viewport_lines ⇒ Integer
@return — the number of visible lines.
Constructor Details
#initialize ⇒ List
Returns a new instance of List.
20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
# File 'lib/tuile/component/list.rb', line 20 def initialize super @lines = [] @padded_lines = [] @blank_padded = StyledString::EMPTY @auto_scroll = false @follow = true @top_line = 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_scroll ⇒ Boolean
@return — if true and a line is added or new content is set, auto-scrolls to the bottom — but only while the viewport is already pinned to the last line (see #following?). Scroll up to read older content and appends stop yanking you back down; scroll back to the bottom and tailing resumes.
58 59 60 |
# File 'lib/tuile/component/list.rb', line 58 def auto_scroll @auto_scroll end |
#cursor ⇒ Cursor
@return — the list's cursor.
70 71 72 |
# File 'lib/tuile/component/list.rb', line 70 def cursor @cursor end |
#on_cursor_changed ⇒ Proc?
@return — callback fired when the (index, line) tuple under
the cursor changes. Called as proc.call(index, line) where line
is the StyledString at the cursor, or nil when the cursor is
off-content (Tuile::Component::List::Cursor::None, empty list, or index past the last
line). Fires on cursor moves (key, mouse, search), on #cursor=,
and on #lines=/#add_lines when the line 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.
51 52 53 |
# File 'lib/tuile/component/list.rb', line 51 def on_cursor_changed @on_cursor_changed end |
#on_item_chosen ⇒ Proc?
@return — callback fired when an item is chosen — by pressing
Enter on the cursor's item, or by left-clicking an item. Called as
proc.call(index, line) with the chosen 0-based index and its
StyledString line. Never fires when the cursor's position is
outside the content (e.g. Tuile::Component::List::Cursor::None, or empty content).
41 42 43 |
# File 'lib/tuile/component/list.rb', line 41 def on_item_chosen @on_item_chosen end |
#scrollbar_visibility ⇒ Symbol
@return — scrollbar visibility: :gone or :visible.
73 74 75 |
# File 'lib/tuile/component/list.rb', line 73 def @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.
78 79 80 |
# File 'lib/tuile/component/list.rb', line 78 def show_cursor_when_inactive @show_cursor_when_inactive end |
#top_line ⇒ Integer
@return — top line of the viewport. 0 or positive.
67 68 69 |
# File 'lib/tuile/component/list.rb', line 67 def top_line @top_line end |
Instance Method Details
#add_line(line) ⇒ void
This method returns an undefined value.
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS Adds a line.
@param line
176 177 178 179 180 |
# File 'lib/tuile/component/list.rb', line 176 def add_line(line) raise ArgumentError, "line is nil" if line.nil? add_lines [line] end |
#add_lines(lines) ⇒ void
This method returns an undefined value.
Appends given lines. Each entry is parsed the same way as in
#lines=: coerced to a StyledString, split on \n, with trailing
empty pieces dropped and trailing ASCII whitespace stripped.
@param lines — entries are String, StyledString, or anything that responds to #to_s.
188 189 190 191 192 193 194 195 196 |
# File 'lib/tuile/component/list.rb', line 188 def add_lines(lines) screen.check_locked new_lines = parse_input_lines(lines) @lines += new_lines @padded_lines += new_lines.map { |line| pad_to_row(line) } update_top_line_if_auto_scroll notify_cursor_changed invalidate end |
#at_bottom? ⇒ Boolean
@return — whether the viewport is pinned to the last line. Drives #following?: re-evaluated on every #top_line=.
646 |
# File 'lib/tuile/component/list.rb', line 646 def at_bottom? = @top_line == top_line_max |
#content_width ⇒ Integer
@return — column width available for line content (rect width
minus the scrollbar gutter, when visible). 0 when Tuile::Component#rect's width
is non-positive.
698 699 700 701 702 |
# File 'lib/tuile/component/list.rb', line 698 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 content line.
547 548 549 550 |
# File 'lib/tuile/component/list.rb', line 547 def cursor_on_item? pos = @cursor.position pos >= 0 && pos < @lines.size end |
#cursor_state ⇒ [[Integer, StyledString, NilClass]]
@return — [position, line_at_position], with line nil when the cursor is
off-content.
563 564 565 566 567 |
# File 'lib/tuile/component/list.rb', line 563 def cursor_state pos = @cursor.position line = pos >= 0 && pos < @lines.size ? @lines[pos] : nil [pos, line] end |
#fire_item_chosen ⇒ void
This method returns an undefined value.
Calls #on_item_chosen with the cursor's current (index, line).
Caller must ensure #cursor_on_item?.
555 556 557 558 |
# File 'lib/tuile/component/list.rb', line 555 def fire_item_chosen pos = @cursor.position @on_item_chosen&.call(pos, @lines[pos]) end |
#focusable? ⇒ Boolean
198 |
# File 'lib/tuile/component/list.rb', line 198 def focusable? = true |
#following? ⇒ Boolean
@return — whether #auto_scroll is currently tailing. True while the viewport sits at the last line; 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.
64 |
# File 'lib/tuile/component/list.rb', line 64 def following? = @follow |
#handle_key(key) ⇒ Boolean
@param key — a key.
@return — true if the key was handled.
204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 |
# File 'lib/tuile/component/list.rb', line 204 def handle_key(key) if key == Keys::PAGE_UP move_top_line_by(-) true elsif key == Keys::PAGE_DOWN move_top_line_by() true elsif key == Keys::ENTER && cursor_on_item? fire_item_chosen true elsif @cursor.handle_key(key, @lines.size, ) notify_cursor_changed invalidate true else false end end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
@param event
251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 |
# File 'lib/tuile/component/list.rb', line 251 def handle_mouse(event) super if event. == :scroll_down move_top_line_by(4) elsif event. == :scroll_up move_top_line_by(-4) else return unless rect.contains?(event.point) line = event.y - rect.top + top_line if @cursor.handle_mouse(line, event, @lines.size) notify_cursor_changed invalidate end fire_item_chosen if event. == :left && line >= 0 && line < @lines.size && cursor_on_item? end end |
#lines ⇒ void
This method returns an undefined value.
Without a block, returns the current lines. With a block, fully re-populates the list:
list.lines do |buffer|
buffer << "Hello!"
end
@return — current lines (when called without a block).
165 166 167 168 169 170 171 |
# File 'lib/tuile/component/list.rb', line 165 def lines return @lines unless block_given? buffer = [] yield buffer self.lines = buffer end |
#lines=(lines) ⇒ void
This method returns an undefined value.
Sets new lines. 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 — symmetric with #add_lines, so the
stored @lines is always Array<StyledString>.
@param lines — entries are String, StyledString, or anything that responds to #to_s.
142 143 144 145 146 147 148 149 150 |
# File 'lib/tuile/component/list.rb', line 142 def lines=(lines) raise TypeError, "expected Array, got #{lines.inspect}" unless lines.is_a? Array @lines = parse_input_lines(lines) rebuild_padded_lines update_top_line_if_auto_scroll notify_cursor_changed invalidate end |
#move_top_line_by(delta) ⇒ void
This method returns an undefined value.
Scrolls the list.
@param delta — negative scrolls up, positive scrolls down.
654 655 656 657 658 659 660 |
# File 'lib/tuile/component/list.rb', line 654 def move_top_line_by(delta) new_top_line = (@top_line + delta).clamp(0, top_line_max) return if @top_line == new_top_line @top_line = new_top_line invalidate end |
#move_viewport_to_cursor ⇒ void
This method returns an undefined value.
Scrolls the viewport so the cursor is visible.
630 631 632 633 634 635 636 637 638 639 |
# File 'lib/tuile/component/list.rb', line 630 def pos = @cursor.position return unless pos >= 0 if @top_line > pos self.top_line = pos elsif pos > @top_line + rect.height - 1 self.top_line = 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.
572 573 574 575 576 577 578 |
# File 'lib/tuile/component/list.rb', line 572 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_changed ⇒ void
This method returns an undefined value.
Rebuilds pre-padded lines 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
appended while the rect was empty (e.g. a Popup-wrapped list got
add_line calls 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.
501 502 503 504 505 |
# File 'lib/tuile/component/list.rb', line 501 def on_width_changed super rebuild_padded_lines update_top_line_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
610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 |
# File 'lib/tuile/component/list.rb', line 610 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(line) ⇒ StyledString
Pads line to one full row of the viewport (scrollbar gutter
excluded). Lines wider than the content area are ellipsized via
StyledString#ellipsize (span styles survive the cut); shorter
lines are padded with default-styled spaces.
@param line
@return — exactly #content_width display columns wide (or StyledString::EMPTY when content_width is non-positive).
721 722 723 724 725 726 727 728 729 730 |
# File 'lib/tuile/component/list.rb', line 721 def pad_to_row(line) cw = content_width return StyledString::EMPTY if cw <= 0 return StyledString.plain(" " * cw) if cw < 2 text_width = cw - 2 body = line.ellipsize(text_width) fill = cw - 2 - body.display_width StyledString.plain(" ") + body + StyledString.plain(" " * (fill + 1)) end |
#paintable_line(index, row_in_viewport, scrollbar) ⇒ StyledString
@param index — 0-based index into #lines.
@param row_in_viewport — 0-based row within the viewport.
@param scrollbar — scrollbar instance, or nil if not shown.
@return — paintable line exactly rect.width columns wide;
highlighted if cursor is here.
738 739 740 741 742 743 744 |
# File 'lib/tuile/component/list.rb', line 738 def paintable_line(index, , ) base = index < @lines.size ? @padded_lines[index] : @blank_padded is_cursor = (active? || @show_cursor_when_inactive) && index < @lines.size && @cursor.position == index styled = is_cursor ? base.with_bg(screen.theme.active_bg_color) : base styled += StyledString.plain(.()) if 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 add_line "" is a no-op) — and trailing ASCII
whitespace stripped on each resulting line.
@param entries
518 519 520 |
# File 'lib/tuile/component/list.rb', line 518 def parse_input_lines(entries) entries.flat_map { |entry| split_to_lines(entry) } end |
#rebuild_padded_lines ⇒ void
This method returns an undefined value.
Recomputes @padded_lines for the current rect width and scrollbar visibility. Each line is ellipsized to fit and pre-padded with single-space gutters on each side, so #paintable_line only has to apply the cursor highlight (if any) and append the scrollbar glyph.
709 710 711 712 |
# File 'lib/tuile/component/list.rb', line 709 def rebuild_padded_lines @padded_lines = @lines.map { |line| pad_to_row(line) } @blank_padded = pad_to_row(StyledString::EMPTY) end |
#repaint ⇒ void
This method returns an undefined value.
Paints the list items into Tuile::Component#rect.
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.
277 278 279 280 281 282 283 284 285 286 287 |
# File 'lib/tuile/component/list.rb', line 277 def repaint return if rect.empty? = if VerticalScrollBar.new(rect.height, line_count: @lines.size, top_line: @top_line) end (0...rect.height).each do |row| line = paintable_line(row + @top_line, row, ) screen.buffer.set_line(rect.left, row + rect.top, line) 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
537 538 539 540 541 542 543 544 |
# File 'lib/tuile/component/list.rb', line 537 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 |
#scrollbar_visible? ⇒ Boolean
@return — whether the scrollbar should be drawn right now.
689 690 691 692 693 |
# File 'lib/tuile/component/list.rb', line 689 def return false if rect.empty? @scrollbar_visibility == :visible end |
#search_and_go(query, include_current:, reverse:) ⇒ Boolean
@param query
@param include_current
@param reverse
584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 |
# File 'lib/tuile/component/list.rb', line 584 def search_and_go(query, include_current:, reverse:) return false if query.empty? candidates = @cursor.candidate_positions(@lines.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| @lines[idx].to_s.downcase.include?(query_lc) } return false unless match @cursor.go(match) notify_cursor_changed invalidate true end |
#select_next(query, include_current: false) ⇒ Boolean
Moves the cursor to the next line whose text contains query
(case-insensitive substring match). Search wraps around the end of the
list. Only lines reachable by the current #cursor are considered.
Matching uses the line'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 line 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.
237 238 239 |
# File 'lib/tuile/component/list.rb', line 237 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.
245 246 247 |
# File 'lib/tuile/component/list.rb', line 245 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
524 525 526 527 528 529 |
# File 'lib/tuile/component/list.rb', line 524 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
200 |
# File 'lib/tuile/component/list.rb', line 200 def tab_stop? = true |
#top_line_max ⇒ Integer
@return — the max value of #top_line.
642 |
# File 'lib/tuile/component/list.rb', line 642 def top_line_max = (@lines.size - rect.height).clamp(0, nil) |
#update_top_line_if_auto_scroll ⇒ void
This method returns an undefined value.
If auto-scrolling, recalculate the top line 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 "lines minus viewport" formula yields @lines.size,
which would leave top_line 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 lines. #top_line= re-arms
@follow when the viewport returns to the bottom.
676 677 678 679 680 681 682 683 684 685 686 |
# File 'lib/tuile/component/list.rb', line 676 def update_top_line_if_auto_scroll return unless @auto_scroll && @follow return if rect.empty? notify_cursor_changed if @cursor.go_to_last(@lines.size) new_top_line = (@lines.size - ).clamp(0, nil) return unless @top_line != new_top_line self.top_line = new_top_line end |
#viewport_lines ⇒ Integer
@return — the number of visible lines.
649 |
# File 'lib/tuile/component/list.rb', line 649 def = rect.height |