Class: Tuile::Component::List

Inherits:
Component
  • Object
show all
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

Instance Method Summary collapse

Constructor Details

#initializeList

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_scrollBoolean

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

Returns:

  • (Boolean)


58
59
60
# File 'lib/tuile/component/list.rb', line 58

def auto_scroll
  @auto_scroll
end

#cursorCursor

@return — the list's cursor.

Returns:



70
71
72
# File 'lib/tuile/component/list.rb', line 70

def cursor
  @cursor
end

#on_cursor_changedProc?

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

Returns:

  • (Proc, nil)


51
52
53
# File 'lib/tuile/component/list.rb', line 51

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

Returns:

  • (Proc, nil)


41
42
43
# File 'lib/tuile/component/list.rb', line 41

def on_item_chosen
  @on_item_chosen
end

#scrollbar_visibilitySymbol

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

Returns:

  • (Symbol)


73
74
75
# File 'lib/tuile/component/list.rb', line 73

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)


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

def show_cursor_when_inactive
  @show_cursor_when_inactive
end

#top_lineInteger

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

Returns:

  • (Integer)


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

Parameters:



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.

Parameters:

  • lines (::Array[untyped])


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

Returns:

  • (Boolean)


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

def at_bottom? = @top_line == top_line_max

#content_widthInteger

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

Returns:

  • (Integer)


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

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 content line.

Returns:

  • (Boolean)


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.

Returns:



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_chosenvoid

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

Returns:

  • (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.

Returns:

  • (Boolean)


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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(-viewport_lines)
    true
  elsif key == Keys::PAGE_DOWN
    move_top_line_by(viewport_lines)
    true
  elsif key == Keys::ENTER && cursor_on_item?
    fire_item_chosen
    true
  elsif @cursor.handle_key(key, @lines.size, viewport_lines)
    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:



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.button == :scroll_down
    move_top_line_by(4)
  elsif event.button == :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)
      move_viewport_to_cursor
      notify_cursor_changed
      invalidate
    end
    fire_item_chosen if event.button == :left && line >= 0 && line < @lines.size && cursor_on_item?
  end
end

#linesvoid

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.

Parameters:

  • lines (::Array[untyped])


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.

Parameters:

  • delta (Integer)


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_cursorvoid

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 move_viewport_to_cursor
  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_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.



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_changedvoid

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

Parameters:

Returns:



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.

Parameters:

Returns:



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

def paintable_line(index, row_in_viewport, scrollbar)
  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(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 add_line "" is a no-op) — and trailing ASCII whitespace stripped on each resulting line.

@param entries

Parameters:

  • entries (::Array[untyped])

Returns:



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_linesvoid

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

#repaintvoid

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?

  scrollbar = if scrollbar_visible?
                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, scrollbar)
    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

Parameters:

Returns:



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.

Returns:

  • (Boolean)


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

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)


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)
  move_viewport_to_cursor
  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.

Parameters:

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

Returns:

  • (Boolean)


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.

Parameters:

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

Returns:

  • (Boolean)


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

Parameters:

  • entry (Object)

Returns:



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

Returns:

  • (Boolean)


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

def tab_stop? = true

#top_line_maxInteger

@return — the max value of #top_line.

Returns:

  • (Integer)


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_scrollvoid

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 - viewport_lines).clamp(0, nil)
  return unless @top_line != new_top_line

  self.top_line = new_top_line
end

#viewport_linesInteger

@return — the number of visible lines.

Returns:

  • (Integer)


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

def viewport_lines = rect.height