WidgetRect

Rect Widget Showcase

Demonstrates the Rect class and the Cached Layout Pattern.

Rect is the fundamental geometry primitive for TUI layout. This example shows:

Controls:

←/→: Adjust sidebar width
↑/↓: Navigate menu items
Mouse: Click panels to test Rect#contains?
q: Quit

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"

# Rect Widget Showcase
#
# Demonstrates the Rect class and the Cached Layout Pattern.
#
# Rect is the fundamental geometry primitive for TUI layout. This example shows:
# - Rect attributes: x, y, width, height
# - Edge accessors: left, right, top, bottom
# - Geometry methods: area, empty?, union, inner, offset, clamp
# - Iterators: rows, columns, positions
# - Rect#contains? for hit testing mouse clicks
# - Layout.split returning cached rects for reuse
#
# Controls:
#   ←/→: Adjust sidebar width
#   ↑/↓: Navigate menu items
#   Mouse: Click panels to test Rect#contains?
#   q: Quit
class WidgetRect
  MENU_ITEMS = ["Dashboard", "Analytics", "Settings", "Logs", "Help"].freeze

  def initialize
    @sidebar_width = 20
    @selected_index = 0
    @last_action = "Click any panel to test Rect#contains?"
    @click_count = 0
    @sidebar_rect = nil
    @main_rect = nil
    @controls_rect = nil
  end

  def run
    RatatuiRuby.run do |tui|
      @tui = tui
      @hotkey_style = @tui.style(modifiers: [:bold, :underlined])
      @label_style = @tui.style(modifiers: [:bold])
      @dim_style = @tui.style(fg: :dark_gray)

      loop do
        render
        break if handle_input == :quit
      end
    end
  end

  private def render
    @tui.draw do |frame|
      @main_rect, @controls_rect = @tui.layout_split(
        frame.area,
        direction: :vertical,
        constraints: [
          @tui.constraint_fill(1),
          @tui.constraint_length(5),
        ]
      )

      @sidebar_rect, @content_rect = @tui.layout_split(
        @main_rect,
        direction: :horizontal,
        constraints: [
          @tui.constraint_length(@sidebar_width),
          @tui.constraint_fill(1),
        ]
      )

      render_sidebar(frame)
      render_content(frame)
      render_controls(frame)
    end
  end

  private def render_sidebar(frame)
    sidebar = @tui.list(
      items: MENU_ITEMS,
      selected_index: @selected_index,
      highlight_style: @tui.style(fg: :black, bg: :white, modifiers: [:bold]),
      highlight_symbol: "> ",
      block: @tui.block(title: "Menu", borders: [:all])
    )
    frame.render_widget(sidebar, @sidebar_rect)
  end

  private def render_content(frame)
    r = @content_rect
    inner_r = r.inner(2)
    offset_r = r.offset(3, 2)
    bounds = RatatuiRuby::Layout::Rect.new(x: 0, y: 0, width: 50, height: 20)
    clamped = r.clamp(bounds)
    union_r = r.union(@sidebar_rect)

    # Extract position and size from rect
    pos = r.position # => Position(x:, y:)
    size = r.size    # => Size(width:, height:)

    text_content = [
      @tui.text_line(spans: [
        @tui.text_span(content: "Active View: ", style: @label_style),
        @tui.text_span(content: MENU_ITEMS[@selected_index], style: @tui.style(fg: :green)),
      ]),
      @tui.text_line(spans: [
        @tui.text_span(content: "Rect Attributes ", style: @label_style),
        @tui.text_span(content: "(from Layout.split):", style: @dim_style),
      ]),
      "  x:#{r.x} y:#{r.y} width:#{r.width} height:#{r.height}",
      @tui.text_line(spans: [
        @tui.text_span(content: "Edge Accessors:", style: @label_style),
      ]),
      "  left:#{r.left} right:#{r.right} top:#{r.top} bottom:#{r.bottom}",
      @tui.text_line(spans: [
        @tui.text_span(content: "Conversion Methods ", style: @label_style),
        @tui.text_span(content: "(as_position/as_size):", style: @dim_style),
      ]),
      "  Position: x=#{pos.x} y=#{pos.y}  Size: #{size.width}x#{size.height}",
      @tui.text_line(spans: [
        @tui.text_span(content: "Geometry Transformations:", style: @label_style),
      ]),
      "  inner(2): x:#{inner_r.x} y:#{inner_r.y} w:#{inner_r.width} h:#{inner_r.height}",
      "  offset(3,2): x:#{offset_r.x} y:#{offset_r.y}  clamp: x:#{clamped.x} y:#{clamped.y}",
      "  union(sidebar): w:#{union_r.width} h:#{union_r.height}",
      @tui.text_line(spans: [
        @tui.text_span(content: "Hit Testing ", style: @label_style),
        @tui.text_span(content: "(Rect#contains?):", style: @dim_style),
      ]),
      "  Clicks: #{@click_count}  |  #{@last_action}",
    ]

    paragraph = @tui.paragraph(
      text: text_content,
      block: @tui.block(title: "Content", borders: [:all])
    )
    frame.render_widget(paragraph, @content_rect)
  end

  private def render_controls(frame)
    controls = @tui.block(
      title: "Controls",
      borders: [:all],
      children: [
        @tui.paragraph(
          text: [
            @tui.text_line(spans: [
              @tui.text_span(content: "←", style: @hotkey_style),
              @tui.text_span(content: "/"),
              @tui.text_span(content: "→", style: @hotkey_style),
              @tui.text_span(content: ": Sidebar width  "),
              @tui.text_span(content: "↑", style: @hotkey_style),
              @tui.text_span(content: "/"),
              @tui.text_span(content: "↓", style: @hotkey_style),
              @tui.text_span(content: ": Menu selection  "),
              @tui.text_span(content: "Click", style: @hotkey_style),
              @tui.text_span(content: ": Hit test  "),
              @tui.text_span(content: "q", style: @hotkey_style),
              @tui.text_span(content: ": Quit"),
            ]),
          ]
        ),
      ]
    )
    frame.render_widget(controls, @controls_rect)
  end

  private def handle_input
    case @tui.poll_event
    in { type: :key, code: "q" } | { type: :key, code: "c", modifiers: ["ctrl"] }
      :quit
    in type: :key, code: "left"
      @sidebar_width = [@sidebar_width - 2, 10].max
      @last_action = "Layout changed: sidebar_width=#{@sidebar_width}"
      nil
    in type: :key, code: "right"
      @sidebar_width = [@sidebar_width + 2, 40].min
      @last_action = "Layout changed: sidebar_width=#{@sidebar_width}"
      nil
    in type: :key, code: "up"
      @selected_index = (@selected_index - 1) % MENU_ITEMS.size
      @last_action = "Selected: #{MENU_ITEMS[@selected_index]}"
      nil
    in type: :key, code: "down"
      @selected_index = (@selected_index + 1) % MENU_ITEMS.size
      @last_action = "Selected: #{MENU_ITEMS[@selected_index]}"
      nil
    in type: :mouse, kind: "down", x: click_x, y: click_y
      handle_click(click_x, click_y)
      nil
    else
      nil
    end
  end

  private def handle_click(x, y)
    @click_count += 1

    if @sidebar_rect&.contains?(x, y)
      relative_y = y - @sidebar_rect.y - 1
      if relative_y >= 0 && relative_y < MENU_ITEMS.size
        old_item = MENU_ITEMS[@selected_index]
        @selected_index = relative_y
        new_item = MENU_ITEMS[@selected_index]
        @last_action = "sidebar.contains?(#{x},#{y})=true → #{old_item}→#{new_item}"
      else
        @last_action = "sidebar.contains?(#{x},#{y})=true (empty area)"
      end
    elsif @content_rect&.contains?(x, y)
      @last_action = "content.contains?(#{x},#{y})=true"
    elsif @controls_rect&.contains?(x, y)
      @last_action = "controls.contains?(#{x},#{y})=true"
    else
      @last_action = "No rect contains (#{x},#{y})"
    end
  end
end

WidgetRect.new.run if __FILE__ == $0