A self-contained text input component for color entry.
Users type color values. They make mistakes—typos, invalid formats. The app needs to validate their input and show helpful error messages.
This component encapsulates rendering, state, and event handling. It draws itself into the provided area, caches that area for hit testing, and handles keyboard events internally.
Component Contract
-
‘render(tui, frame, area)`: Draws the input field; stores `area` for hit testing
-
‘handle_event(event) -> Symbol | nil`: Returns `:consumed`, `:submitted`, or `nil`
Example
input = Input.new input.render(tui, frame, area) result = input.handle_event(event) case result when :submitted palette.update_color(input.parsed_color) end
Source Code
# frozen_string_literal: true #-- # SPDX-FileCopyrightText: 2026 Kerrick Long <me@kerricklong.com> # SPDX-License-Identifier: AGPL-3.0-or-later #++ require_relative "color" # A self-contained text input component for color entry. # # Users type color values. They make mistakes—typos, invalid formats. The app # needs to validate their input and show helpful error messages. # # This component encapsulates rendering, state, and event handling. It draws # itself into the provided area, caches that area for hit testing, and handles # keyboard events internally. # # === Component Contract # # - `render(tui, frame, area)`: Draws the input field; stores `area` for hit testing # - `handle_event(event) -> Symbol | nil`: Returns `:consumed`, `:submitted`, or `nil` # # === Example # # input = Input.new # input.render(tui, frame, area) # # result = input.handle_event(event) # case result # when :submitted # palette.update_color(input.parsed_color) # end class Input PRINTABLE_PATTERN = /[\w#,().\s%]/ # Creates a new Input with an optional initial value. # # [initial_value] String initial color input (default: <tt>"#F96302"</tt>) def initialize(initial_value = "#F96302") @value = initial_value @error = "" @parsed_color = nil @area = nil end # Current input string. attr_reader :value # Error message from the last failed parse, or empty string. attr_reader :error # The last successfully parsed Color, or nil. attr_reader :parsed_color # The cached render area, for hit testing. attr_reader :area # Clears the current error message. def clear_error @error = "" end # Renders the input widget into the given area. # # Caches `area` for hit testing. Shows the current input value and positions # the terminal's blinking cursor at the end of the text using # `frame.set_cursor_position`. Displays the error message in red if set. # # [tui] Session or TUI factory object # [frame] Frame object from RatatuiRuby.draw block # [area] Rect area to draw into # # === Example # # input.render(tui, frame, input_area) def render(tui, frame, area) @area = area widget = build_widget(tui) frame.render_widget(widget, area) # Position real blinking cursor at end of input text cursor_x, cursor_y = cursor_position_in(area) frame.set_cursor_position(cursor_x, cursor_y) end # Processes a keyboard event and updates internal state. # # Returns: # - `:submitted` when Enter is pressed (caller should read `parsed_color`) # - `:consumed` when the event was handled (typing, backspace) # - `nil` when the event was ignored # # [event] Event from RatatuiRuby.poll_event # # === Example # # result = input.handle_event(event) # if result == :submitted # palette.update_color(input.parsed_color) # end def handle_event(event) case event in { type: :key, code: "enter" } parse :submitted in { type: :key, code: "backspace" } delete_char :consumed in { type: :paste, content: } set(content) parse :submitted in { type: :key, code: code } append_char(code) :consumed else nil end end private def append_char(char) @value += char if char.length == 1 && char.match?(PRINTABLE_PATTERN) end private def delete_char @value = @value[0...-1] end private def set(text) @value = text end private def parse color = Color.parse(@value) if color clear_error @parsed_color = color else @error = "Invalid color format. Try: #ff0000, rgb(255,0,0), red" @parsed_color = nil end end private def build_widget(tui) input_lines = [ tui.text_line(spans: [ tui.text_span(content: @value), ]), ] unless @error.empty? input_lines << tui.text_line(spans: [ tui.text_span(content: @error, style: tui.style(fg: :red)), ]) end tui.block( title: "Color Input", borders: [:all], children: [ tui.paragraph(text: input_lines), ] ) end # Calculates cursor position within the input area. # # Accounts for block border (1 cell) and current text length. private def cursor_position_in(area) # Border takes 1 cell on left, cursor goes after last character x = area.x + 1 + @value.length y = area.y + 1 # First line inside border [x, y] end end