Scene

Orchestrates layout and rendering of the color picker UI.

Building a complete color picker UI involves layout calculation, widget composition, and coordinate tracking for hit testing. Keeping this logic scattered across the main app makes the app harder to read and test.

This object owns the layout logic. It orchestrates all sections. It calculates and caches rects for hit testing.

Use it to encapsulate complex UI composition.

Example

scene = Scene.new(tui)
scene.render(frame, input:, palette:, clipboard:, dialog:)

# For hit testing:
rect = scene.export_rect
if rect.contains?(x, y)
  # Handle click
end

Source Code

# frozen_string_literal: true

# SPDX-FileCopyrightText: 2025 Kerrick Long <me@kerricklong.com>
# SPDX-License-Identifier: AGPL-3.0-or-later

require_relative "input"
require_relative "palette"
require_relative "clipboard"
require_relative "copy_dialog"

# Orchestrates layout and rendering of the color picker UI.
#
# Building a complete color picker UI involves layout calculation, widget
# composition, and coordinate tracking for hit testing. Keeping this logic
# scattered across the main app makes the app harder to read and test.
#
# This object owns the layout logic. It orchestrates all sections. It calculates
# and caches rects for hit testing.
#
# Use it to encapsulate complex UI composition.
#
# === Example
#
#   scene = Scene.new(tui)
#   scene.render(frame, input:, palette:, clipboard:, dialog:)
#
#   # For hit testing:
#   rect = scene.export_rect
#   if rect.contains?(x, y)
#     # Handle click
#   end
class Scene
  def initialize(tui)
    @tui = tui
    @export_area_rect = nil
    @hotkey_style = @tui.style(modifiers: [:bold, :underlined])
  end

  # Renders the complete UI given all model objects.
  #
  # Calculates layout once per frame. Renders input, palette, export, controls,
  # and dialog sections. Caches the export area rect for hit testing.
  #
  # [frame] Frame object from RatatuiRuby.draw block
  # [input] Input object for text input display
  # [palette] Palette object for color display
  # [clipboard] Clipboard object for feedback message
  # [dialog] CopyDialog object for confirmation dialog
  #
  # === Example
  #
  #   scene.render(frame, input: @input, palette: @palette, clipboard: @clipboard, dialog: @dialog)
  def render(frame, input:, palette:, clipboard:, dialog:)
    input_area, rest = @tui.layout_split(
      frame.area,
      direction: :vertical,
      constraints: [
        @tui.constraint_length(3),
        @tui.constraint_fill(1),
      ]
    )

    color_area, control_area = @tui.layout_split(
      rest,
      direction: :vertical,
      constraints: [
        @tui.constraint_length(14),
        @tui.constraint_fill(1),
      ]
    )

    harmony_area, @export_area_rect = @tui.layout_split(
      color_area,
      direction: :vertical,
      constraints: [
        @tui.constraint_length(7),
        @tui.constraint_fill(1),
      ]
    )

    frame.render_widget(input.render(@tui), input_area)
    frame.render_widget(build_palette_section(palette, harmony_area), harmony_area)
    frame.render_widget(build_export_section(palette), @export_area_rect)
    frame.render_widget(build_controls_section(clipboard), control_area)

    if dialog.active?
      dialog_center = calculate_center_area(frame.area, 40, 8)
      frame.render_widget(@tui.clear, frame.area)
      frame.render_widget(dialog.render(@tui, dialog_center), dialog_center)
    end
  end

  # The cached rectangle of the export formats section, used for hit testing.
  #
  # Populated during #render. Use this to detect clicks on the export section.
  #
  # === Example
  #
  #   scene.render(frame, ...)
  #   if scene.export_rect.contains?(x, y)
  #     # Click on export section
  #   end
  def export_rect
    @export_area_rect
  end

  private def build_palette_section(palette, _harmony_area)
    if palette.main.nil?
      @tui.paragraph(text: "No color selected")
    else
      blocks = palette.as_blocks(@tui)
      @tui.layout(
        direction: :horizontal,
        constraints: Array.new(blocks.size) { @tui.constraint_fill(1) },
        children: blocks
      )
    end
  end

  private def build_export_section(palette)
    if palette.main.nil?
      @tui.block(
        title: "Export Formats",
        borders: [:all],
        children: [
          @tui.paragraph(
            text: @tui.text_line(spans: [
              @tui.text_span(content: "Enter a color to see formats"),
            ])
          ),
        ]
      )
    else
      color = palette.main
      hex = color.hex
      rgb = color.rgb
      hsl = color.hsl_string
      text_color = color.contrasting_text_color
      bg_style = @tui.style(bg: hex, fg: text_color)

      @tui.block(
        title: "Export Formats",
        borders: [:all],
        style: bg_style,
        children: [
          @tui.paragraph(
            text: [
              @tui.text_line(spans: [
                @tui.text_span(content: "HEX: ", style: bg_style),
                @tui.text_span(content: hex, style: @tui.style(bg: hex, fg: text_color, modifiers: [:underlined])),
              ]),
              @tui.text_line(spans: [
                @tui.text_span(content: "RGB: ", style: bg_style),
                @tui.text_span(content: rgb, style: @tui.style(bg: hex, fg: text_color, modifiers: [:underlined])),
              ]),
              @tui.text_line(spans: [
                @tui.text_span(content: "HSL: ", style: bg_style),
                @tui.text_span(content: hsl, style: @tui.style(bg: hex, fg: text_color, modifiers: [:underlined])),
              ]),
            ]
          ),
        ]
      )
    end
  end

  private def build_controls_section(clipboard)
    control_lines = [
      @tui.text_line(spans: [
        @tui.text_span(content: "a-z/0-9", style: @hotkey_style),
        @tui.text_span(content: ": Type  "),
        @tui.text_span(content: "enter", style: @hotkey_style),
        @tui.text_span(content: ": Parse  "),
        @tui.text_span(content: "bksp", style: @hotkey_style),
        @tui.text_span(content: ": Erase  "),
        @tui.text_span(content: "esc", style: @hotkey_style),
        @tui.text_span(content: ": Quit"),
      ]),
    ]

    unless clipboard.message.empty?
      control_lines << @tui.text_line(spans: [
        @tui.text_span(content: clipboard.message, style: @tui.style(fg: :green, modifiers: [:bold])),
      ])
    end

    @tui.block(
      title: "Controls",
      borders: [:all],
      children: [
        @tui.paragraph(text: control_lines),
      ]
    )
  end

  private def calculate_center_area(parent_area, width, height)
    x = (parent_area.width - width) / 2
    y = (parent_area.height - height) / 2
    @tui.rect(x:, y:, width:, height:)
  end
end