CopyDialog

A self-contained modal dialog component for copying text to the clipboard.

Users click on content they want to copy. The app needs to confirm: “Are you sure?” This component owns dialog state, renders itself, and handles keyboard input.

Component Contract

Example

dialog = CopyDialog.new(clipboard)
dialog.open("#FF0000")

result = dialog.handle_event(event)
# result == :consumed when dialog handled the event

dialog.render(tui, frame, center_area)

Source Code

# frozen_string_literal: true

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

require_relative "clipboard"

# A self-contained modal dialog component for copying text to the clipboard.
#
# Users click on content they want to copy. The app needs to confirm: "Are you
# sure?" This component owns dialog state, renders itself, and handles keyboard
# input.
#
# === Component Contract
#
# - `render(tui, frame, area)`: Draws the dialog; stores `area`
# - `handle_event(event) -> Symbol | nil`: Returns `:consumed` when handled
# - `open(text)`: Opens the dialog with the text to copy
# - `close`: Closes the dialog
# - `active?`: True if the dialog is visible
#
# === Example
#
#   dialog = CopyDialog.new(clipboard)
#   dialog.open("#FF0000")
#
#   result = dialog.handle_event(event)
#   # result == :consumed when dialog handled the event
#
#   dialog.render(tui, frame, center_area)
class CopyDialog
  def initialize(clipboard)
    @clipboard = clipboard
    @text = ""
    @selected = :yes
    @active = false
    @area = nil
  end

  # The cached render area.
  attr_reader :area

  # Opens the dialog with text to copy.
  #
  # Initializes selection to <tt>:yes</tt> and sets active to true.
  #
  # [text] String text to show and copy
  #
  # === Example
  #
  #   dialog.open("#FF0000")
  #   dialog.active?  # => true
  def open(text)
    @text = text
    @selected = :yes
    @active = true
  end

  # Closes the dialog and deactivates it.
  def close
    @active = false
  end

  # True if the dialog is currently open and visible.
  def active?
    @active
  end

  # Renders the dialog into the given area.
  #
  # Shows the text to copy, Yes/No buttons with current selection highlighted,
  # and keyboard instructions.
  #
  # [tui] Session or TUI factory object
  # [frame] Frame object from RatatuiRuby.draw block
  # [area] Rect area to draw into
  #
  # === Example
  #
  #   dialog.render(tui, frame, center_area)
  def render(tui, frame, area)
    @area = area
    widget = build_widget(tui)
    frame.render_widget(widget, area)
  end

  # Processes a keyboard event and updates selection or closes the dialog.
  #
  # Returns:
  # - `:consumed` when the event was handled
  # - `nil` when the event was ignored or dialog is inactive
  #
  # [event] Event from RatatuiRuby.poll_event
  #
  # === Example
  #
  #   result = dialog.handle_event(event)
  def handle_event(event)
    return nil unless @active

    case event
    in { type: :key, code: "left" } | { type: :key, code: "h" }
      @selected = :yes
      :consumed
    in { type: :key, code: "right" } | { type: :key, code: "l" }
      @selected = :no
      :consumed
    in { type: :key, code: "enter" }
      if @selected == :yes
        @clipboard.copy(@text)
      end
      @active = false
      :consumed
    in { type: :key, code: "y" }
      @clipboard.copy(@text)
      @active = false
      :consumed
    in { type: :key, code: "n" }
      @active = false
      :consumed
    else
      nil
    end
  end

  private def build_widget(tui)
    yes_style = if @selected == :yes
      tui.style(bg: :cyan, fg: :black, modifiers: [:bold])
    else
      tui.style(fg: :gray)
    end

    no_style = if @selected == :no
      tui.style(bg: :cyan, fg: :black, modifiers: [:bold])
    else
      tui.style(fg: :gray)
    end

    tui.block(
      title: "Copy to Clipboard",
      borders: [:all],
      border_type: :rounded,
      style: tui.style(bg: :black, fg: :white),
      children: [
        tui.paragraph(
          text: [
            tui.text_line(spans: [
              tui.text_span(content: "Copy #{@text}?", style: tui.style(fg: :white)),
            ]),
            tui.text_line(spans: []),
            tui.text_line(spans: [
              tui.text_span(content: "[", style: tui.style(fg: :white)),
              tui.text_span(content: "Yes", style: yes_style),
              tui.text_span(content: "]  [", style: tui.style(fg: :white)),
              tui.text_span(content: "No", style: no_style),
              tui.text_span(content: "]", style: tui.style(fg: :white)),
            ]),
            tui.text_line(spans: [
              tui.text_span(content: "Use ←/→ or h/l to select, Enter to confirm", style: tui.style(fg: :gray, modifiers: [:italic])),
            ]),
          ]
        ),
      ]
    )
  end
end