WidgetList

Demonstrates a selectable list of items with interactive attribute cycling.

Users need to browse and select from collections of data. Lists are fundamental to terminal interfaces, but managing selection state, scrolling, and styling can be complex.

This demo showcases the List widget. It provides an interactive playground where you can cycle through different configurations, styles, and behaviors in real-time.

Use it to understand how to implement menus, file browsers, or any selectable collection of items.

Examples

Run the demo from the terminal:

ruby examples/widget_list/app.rb

Source Code

# frozen_string_literal: true

#--
# SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com>
# SPDX-License-Identifier: MIT-0
#++

$LOAD_PATH.unshift File.expand_path("../../lib", __dir__)
require "ratatui_ruby"
require "faker" # Use Faker for large, realistic datasets

# Demonstrates a selectable list of items with interactive attribute cycling.
#
# Users need to browse and select from collections of data. Lists are fundamental to terminal interfaces, but managing selection state, scrolling, and styling can be complex.
#
# This demo showcases the <tt>List</tt> widget. It provides an interactive playground where you can cycle through different configurations, styles, and behaviors in real-time.
#
# Use it to understand how to implement menus, file browsers, or any selectable collection of items.
#
# === Examples
#
# Run the demo from the terminal:
#
#   ruby examples/widget_list/app.rb
#
# rdoc-image:/doc/images/widget_list.png
class WidgetList
  # Initializes the demo with example data and default configuration.
  def initialize
    Faker::Config.random = Random.new(12345)
    @selected_index = 6 # Start at C# to avoid highlighting the rich text examples
    @tui_for_setup = nil

    @item_sets = [
      {
        name: "Programming",
        items: [
          :ruby_styled, # Will be replaced with rich text in run()
          :rust_styled, # Will be replaced with rich text in run()
          :python_styled, # Will be replaced with rich text in run()
          :javascript_styled, # Will be replaced with rich text in run()
          "Go",
          "C++",
          "C#",
          "Java",
          "Kotlin",
          "Swift",
          "Objective-C",
          "PHP",
          "TypeScript",
          "Perl",
          "Lua",
          "R",
          "Scala",
          "Haskell",
          "Elixir",
          "Clojure",
          "Groovy",
          "Closure",
          "VB.NET",
          "F#",
          "Erlang",
          "Lisp",
          "Scheme",
          "Prolog",
          "Fortran",
          "COBOL",
          "Pascal",
          "Delphi",
          "Ada",
          "Bash",
          "Sh",
          "Tcl",
          "Awk",
          "sed",
          "Vim Script",
          "PowerShell",
          "Batch",
          "Assembly",
          "Wasm",
          "WebAssembly",
          "Julia",
          "Matlab",
          "Octave",
          "BASIC",
        ],
      },
      {
        name: "Large List",
        items: (1..200).map { |i| "Item #{i}" },
      },
      {
        name: "Colors",
        items: begin
          Faker::Color.unique.clear
          Array.new(100) { Faker::Color.color_name }
        end,
      },
      {
        name: "Fruits",
        items: begin
          Faker::Food.unique.clear
          Array.new(100) { Faker::Food.fruits }
        end,
      },
    ]
    @item_set_index = 0

    @highlight_symbol_names = [">> ", "▶ ", "→ ", "• ", "★ "]
    @highlight_symbol_index = 0

    @direction_configs = [
      { name: "Top to Bottom", direction: :top_to_bottom },
      { name: "Bottom to Top", direction: :bottom_to_top },
    ]
    @direction_index = 0

    @highlight_spacing_configs = [
      { name: "When Selected", spacing: :when_selected },
      { name: "Always", spacing: :always },
      { name: "Never", spacing: :never },
    ]
    @highlight_spacing_index = 1

    @repeat_modes = [
      { name: "Off", repeat: false },
      { name: "On", repeat: true },
    ]
    @repeat_index = 0

    @scroll_padding_configs = [
      { name: "None", padding: nil },
      { name: "1 item", padding: 1 },
      { name: "2 items", padding: 2 },
    ]
    @scroll_padding_index = 1

    # Offset mode configurations to demonstrate offset + selection interaction
    @offset_modes = [
      { name: "Auto (No Offset)", offset: nil, allow_selection: true },
      { name: "Offset Only", offset: 10, allow_selection: false },
      { name: "Selection + Offset (Conflict)", offset: 0, allow_selection: true },
    ]
    @offset_mode_index = 0
  end

  # Runs the demo application.
  #
  # This method enters the terminal alternate screen, starts the main loop, and handles cleanup on exit.
  def run
    RatatuiRuby.run do |tui|
      @tui = tui

      # Create rich text for "Ruby" - each letter with a different red style
      ruby_line = @tui.text_line(spans: [
        @tui.text_span(content: "R", style: @tui.style(fg: :red, modifiers: [:underlined])),
        @tui.text_span(content: "u", style: @tui.style(fg: :light_red, modifiers: [:bold])),
        @tui.text_span(content: "b", style: @tui.style(fg: :red, modifiers: [:italic])),
        @tui.text_span(content: "y", style: @tui.style(fg: :light_red, modifiers: [:reversed])),
      ])

      # Create rich text for "Rust" - single styled Span
      rust_span = @tui.text_span(
        content: "Rust",
        style: @tui.style(fg: :magenta, modifiers: [:bold, :underlined])
      )

      # Create ListItem for "Python" - demonstrates content + row background
      python_item = @tui.list_item(
        content: @tui.text_span(content: "Python", style: @tui.style(fg: :yellow)),
        style: @tui.style(bg: :dark_gray)
      )

      # Create ListItem for "JavaScript" - demonstrates styled text with row background
      javascript_item = @tui.list_item(
        content: @tui.text_line(spans: [
          @tui.text_span(content: "Java", style: @tui.style(fg: :yellow, modifiers: [:bold])),
          @tui.text_span(content: "Script", style: @tui.style(fg: :light_yellow, modifiers: [:italic])),
        ]),
        style: @tui.style(bg: :blue)
      )

      # Replace the styled placeholders
      @item_sets[0][:items][0] = ruby_line
      @item_sets[0][:items][1] = rust_span
      @item_sets[0][:items][2] = python_item
      @item_sets[0][:items][3] = javascript_item

      # Initialize styles that require @tui
      @highlight_styles = [
        { name: "Blue on White Bold", style: @tui.style(fg: :blue, bg: :white, modifiers: [:bold]) },
        { name: "Blue Bold", style: @tui.style(fg: :blue, modifiers: [:bold]) },
        { name: "Yellow on Black", style: @tui.style(fg: :yellow, bg: :black) },
        { name: "Green Italic", style: @tui.style(fg: :green, modifiers: [:italic]) },
        { name: "White Reversed", style: @tui.style(fg: :white, modifiers: [:reversed]) },
        { name: "Cyan Bold", style: @tui.style(fg: :cyan, modifiers: [:bold]) },
      ]
      @highlight_style_index = 0

      @base_styles = [
        { name: "None", style: nil },
        { name: "Dark Gray", style: @tui.style(fg: :dark_gray) },
        { name: "White on Black", style: @tui.style(fg: :white, bg: :black) },
      ]
      @base_style_index = 0

      @hotkey_style = @tui.style(modifiers: [:bold, :underlined])

      loop do
        render
        break if handle_input == :quit

        sleep 0.05
      end
    end
  end

  private def render # :nodoc:
    items = @item_sets[@item_set_index][:items]
    direction_config = @direction_configs[@direction_index]
    spacing_config = @highlight_spacing_configs[@highlight_spacing_index]
    repeat_config = @repeat_modes[@repeat_index]
    highlight_style_config = @highlight_styles[@highlight_style_index]
    highlight_symbol = @highlight_symbol_names[@highlight_symbol_index]
    base_style_config = @base_styles[@base_style_index]
    scroll_padding_config = @scroll_padding_configs[@scroll_padding_index]
    offset_mode_config = @offset_modes[@offset_mode_index]

    # Determine selection/offset based on mode
    effective_selection = offset_mode_config[:allow_selection] ? @selected_index : nil
    effective_offset = offset_mode_config[:offset]

    @tui.draw do |frame|
      # Split into main content and control panel
      main_area, control_area = @tui.layout_split(
        frame.area,
        direction: :vertical,
        constraints: [
          @tui.constraint_fill(1),
          @tui.constraint_length(8),
        ]
      )

      # Split main content into title and list
      title_area, list_area = @tui.layout_split(
        main_area,
        direction: :vertical,
        constraints: [
          @tui.constraint_length(1),
          @tui.constraint_fill(1),
        ]
      )

      # Render title
      title = @tui.paragraph(text: "List Widget - Interactive Attribute Cycling")
      frame.render_widget(title, title_area)

      # Build list first to demonstrate query methods:
      # - List#len (with #length, #size aliases)
      # - List#selection (alias for #selected_index)
      # - List#selected_item (returns item at selection, or nil)
      base_list = @tui.list(
        items:,
        selected_index: effective_selection,
        offset: effective_offset,
        style: base_style_config[:style],
        highlight_style: highlight_style_config[:style],
        highlight_symbol:,
        repeat_highlight_symbol: repeat_config[:repeat],
        highlight_spacing: spacing_config[:spacing],
        direction: direction_config[:direction],
        scroll_padding: scroll_padding_config[:padding]
      )

      # Demonstrate query methods: len, selected_index, selected_item
      item_count = base_list.len
      current_index = base_list.selected_index  # Explicit name - returns index
      current_item = base_list.selected_item    # Explicit name - returns item

      # Format the selected item for display (handle rich text objects)
      item_preview = case current_item
      when nil then "none"
      when String then (current_item.length > 12) ? "#{current_item[0..11]}…" : current_item
      else current_item.class.name.split("::").last # Show type for rich text
      end

      list = base_list.with(
        block: @tui.block(
          title: "#{@item_sets[@item_set_index][:name]} (len: #{item_count}) | index: #{current_index.inspect} → #{item_preview}",
          borders: [:all]
        )
      )
      frame.render_widget(list, list_area)

      # Render control panel
      control_panel = @tui.block(
        title: "Controls",
        borders: [:all],
        children: [
          @tui.paragraph(
            text: [
              @tui.text_line(spans: [
                @tui.text_span(content: "i", style: @hotkey_style),
                @tui.text_span(content: ": Items  "),
                @tui.text_span(content: "↑/↓", style: @hotkey_style),
                @tui.text_span(content: ": Navigate  "),
                @tui.text_span(content: "x", style: @hotkey_style),
                @tui.text_span(content: ": Select  "),
                @tui.text_span(content: "h", style: @hotkey_style),
                @tui.text_span(content: ": Highlight (#{highlight_style_config[:name]})"),
              ]),
              @tui.text_line(spans: [
                @tui.text_span(content: "y", style: @hotkey_style),
                @tui.text_span(content: ": Symbol (#{highlight_symbol})  "),
                @tui.text_span(content: "d", style: @hotkey_style),
                @tui.text_span(content: ": Direction (#{direction_config[:name]})"),
              ]),
              @tui.text_line(spans: [
                @tui.text_span(content: "s", style: @hotkey_style),
                @tui.text_span(content: ": Spacing (#{spacing_config[:name]})  "),
                @tui.text_span(content: "p", style: @hotkey_style),
                @tui.text_span(content: ": Scroll Padding (#{scroll_padding_config[:name]})"),
              ]),
              @tui.text_line(spans: [
                @tui.text_span(content: "b", style: @hotkey_style),
                @tui.text_span(content: ": Base (#{base_style_config[:name]})  "),
                @tui.text_span(content: "r", style: @hotkey_style),
                @tui.text_span(content: ": Repeat (#{repeat_config[:name]})"),
              ]),
              @tui.text_line(spans: [
                @tui.text_span(content: "o", style: @hotkey_style),
                @tui.text_span(content: ": Offset Mode (#{offset_mode_config[:name]})  "),
                @tui.text_span(content: "q", style: @hotkey_style),
                @tui.text_span(content: ": Quit"),
              ]),
            ]
          ),
        ]
      )
      frame.render_widget(control_panel, control_area)
    end
  end

  private def handle_input # :nodoc:
    case @tui.poll_event
    in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
      :quit
    in type: :key, code: "i"
      @item_set_index = (@item_set_index + 1) % @item_sets.size
      @selected_index = nil
    in type: :key, code: "up"
      items = @item_sets[@item_set_index][:items]
      @selected_index = (@selected_index || 0) - 1
      @selected_index = items.size - 1 if @selected_index.negative?
    in type: :key, code: "down"
      items = @item_sets[@item_set_index][:items]
      @selected_index = ((@selected_index || -1) + 1) % items.size
    in type: :key, code: "x"
      @selected_index = @selected_index.nil? ? 0 : nil
    in type: :key, code: "h"
      @highlight_style_index = (@highlight_style_index + 1) % @highlight_styles.size
    in type: :key, code: "y"
      @highlight_symbol_index = (@highlight_symbol_index + 1) % @highlight_symbol_names.size
    in type: :key, code: "d"
      @direction_index = (@direction_index + 1) % @direction_configs.size
    in type: :key, code: "s"
      @highlight_spacing_index = (@highlight_spacing_index + 1) % @highlight_spacing_configs.size
    in type: :key, code: "b"
      @base_style_index = (@base_style_index + 1) % @base_styles.size
    in type: :key, code: "r"
      @repeat_index = (@repeat_index + 1) % @repeat_modes.size
    in type: :key, code: "p"
      @scroll_padding_index = (@scroll_padding_index + 1) % @scroll_padding_configs.size
    in type: :key, code: "o"
      @offset_mode_index = (@offset_mode_index + 1) % @offset_modes.size
    else
      nil
    end
  end
end

WidgetList.new.run if __FILE__ == $PROGRAM_NAME