module Clacky::UI2::LineEditor

def backspace

Backspace - delete character before cursor
def backspace
  return if @cursor_position == 0
  chars = @line.chars
  chars.delete_at(@cursor_position - 1)
  @line = chars.join
  @cursor_position -= 1
end

def calculate_display_width(text)

Returns:
  • (Integer) - Display width in terminal columns

Parameters:
  • text (String) -- UTF-8 encoded text
def calculate_display_width(text)
  width = 0
  text.each_char do |char|
    code = char.ord
    # East Asian Wide and Fullwidth characters
    # See: https://www.unicode.org/reports/tr11/
    if (code >= 0x1100 && code <= 0x115F) ||   # Hangul Jamo
       (code >= 0x2329 && code <= 0x232A) ||   # Left/Right-Pointing Angle Brackets
       (code >= 0x2E80 && code <= 0x303E) ||   # CJK Radicals Supplement .. CJK Symbols and Punctuation
       (code >= 0x3040 && code <= 0xA4CF) ||   # Hiragana .. Yi Radicals
       (code >= 0xAC00 && code <= 0xD7A3) ||   # Hangul Syllables
       (code >= 0xF900 && code <= 0xFAFF) ||   # CJK Compatibility Ideographs
       (code >= 0xFE10 && code <= 0xFE19) ||   # Vertical Forms
       (code >= 0xFE30 && code <= 0xFE6F) ||   # CJK Compatibility Forms .. Small Form Variants
       (code >= 0xFF00 && code <= 0xFF60) ||   # Fullwidth Forms
       (code >= 0xFFE0 && code <= 0xFFE6) ||   # Fullwidth Forms
       (code >= 0x1F300 && code <= 0x1F9FF) || # Emoticons, Symbols, etc.
       (code >= 0x20000 && code <= 0x2FFFD) || # CJK Unified Ideographs Extension B..F
       (code >= 0x30000 && code <= 0x3FFFD)    # CJK Unified Ideographs Extension G
      width += 2
    else
      width += 1
    end
  end
  width
end

def char_display_width(char)

Returns:
  • (Integer) - Display width (1 or 2)

Parameters:
  • char (String) -- Single character
def char_display_width(char)
  code = char.ord
  # East Asian Wide and Fullwidth characters take 2 columns
  if (code >= 0x1100 && code <= 0x115F) ||
     (code >= 0x2329 && code <= 0x232A) ||
     (code >= 0x2E80 && code <= 0x303E) ||
     (code >= 0x3040 && code <= 0xA4CF) ||
     (code >= 0xAC00 && code <= 0xD7A3) ||
     (code >= 0xF900 && code <= 0xFAFF) ||
     (code >= 0xFE10 && code <= 0xFE19) ||
     (code >= 0xFE30 && code <= 0xFE6F) ||
     (code >= 0xFF00 && code <= 0xFF60) ||
     (code >= 0xFFE0 && code <= 0xFFE6) ||
     (code >= 0x1F300 && code <= 0x1F9FF) ||
     (code >= 0x20000 && code <= 0x2FFFD) ||
     (code >= 0x30000 && code <= 0x3FFFD)
    2
  else
    1
  end
end

def clear_line_content

Clear line
def clear_line_content
  @line = ""
  @cursor_position = 0
end

def current_line

Get current line content
def current_line
  @line
end

def cursor_column(prompt = "")

Returns:
  • (Integer) - Column position for cursor

Parameters:
  • prompt (String) -- Prompt string before the line (may contain ANSI codes)
def cursor_column(prompt = "")
  # Strip ANSI codes from prompt to get actual display width
  visible_prompt = strip_ansi_codes(prompt)
  prompt_display_width = calculate_display_width(visible_prompt)
  # Calculate display width of text before cursor
  chars = @line.chars
  text_before_cursor = chars[0...@cursor_position].join
  text_display_width = calculate_display_width(text_before_cursor)
  prompt_display_width + text_display_width
end

def cursor_end

Move cursor to end of line
def cursor_end
  @cursor_position = @line.chars.length
end

def cursor_home

Move cursor to start of line
def cursor_home
  @cursor_position = 0
end

def cursor_left

Move cursor left
def cursor_left
  @cursor_position = [@cursor_position - 1, 0].max
end

def cursor_position_with_wrap(prompt = "", width = TTY::Screen.width, continuation_prompt = "> ")

Returns:
  • (Array) - Row and column position (0-indexed)

Parameters:
  • continuation_prompt (String) -- Prompt for continuation lines (default: "> ")
  • width (Integer) -- Terminal width for wrapping
  • prompt (String) -- Prompt string before the line (may contain ANSI codes)
def cursor_position_with_wrap(prompt = "", width = TTY::Screen.width, continuation_prompt = "> ")
  return [0, cursor_column(prompt)] if width <= 0
  prompt_width = calculate_display_width(strip_ansi_codes(prompt))
  available_width = width - prompt_width
  # Get wrapped segments for current line
  wrapped_segments = wrap_line(@line, available_width)
  # Find which segment contains cursor
  cursor_segment_idx = 0
  cursor_pos_in_segment = @cursor_position
  wrapped_segments.each_with_index do |segment, idx|
    if @cursor_position >= segment[:start] && @cursor_position < segment[:end]
      cursor_segment_idx = idx
      cursor_pos_in_segment = @cursor_position - segment[:start]
      break
    elsif @cursor_position >= segment[:end] && idx == wrapped_segments.size - 1
      cursor_segment_idx = idx
      cursor_pos_in_segment = segment[:end] - segment[:start]
      break
    end
  end
  # Calculate display width of text before cursor in this segment
  chars = @line.chars
  segment_start = wrapped_segments[cursor_segment_idx][:start]
  text_in_segment_before_cursor = chars[segment_start...(segment_start + cursor_pos_in_segment)].join
  display_width = calculate_display_width(text_in_segment_before_cursor)
  # Use appropriate prompt width based on which segment (row) we're on
  # First line uses original prompt, subsequent lines use continuation prompt
  actual_prompt_width = if cursor_segment_idx == 0
    prompt_width
  else
    calculate_display_width(strip_ansi_codes(continuation_prompt))
  end
  col = actual_prompt_width + display_width
  row = cursor_segment_idx
  [row, col]
end

def cursor_right

Move cursor right
def cursor_right
  @cursor_position = [@cursor_position + 1, @line.chars.length].min
end

def delete_char

Delete character at cursor position
def delete_char
  chars = @line.chars
  return if @cursor_position >= chars.length
  chars.delete_at(@cursor_position)
  @line = chars.join
end

def effective_content_width(screen_width)

Returns:
  • (Integer) - Effective content width to use

Parameters:
  • screen_width (Integer) -- Terminal screen width
def effective_content_width(screen_width)
n_width * MAX_CONTENT_WIDTH_RATIO).to_i

def expand_placeholders(text, placeholders)

Expand placeholders and normalize line endings
def expand_placeholders(text, placeholders)
  result = text.dup
  placeholders.each do |placeholder, actual_content|
    # Normalize line endings to \n
    normalized_content = actual_content.gsub(/\r\n|\r/, "\n")
    result.gsub!(placeholder, normalized_content)
  end
  result
end

def initialize_line_editor

def initialize_line_editor
  @line = ""
  @cursor_position = 0
  @pastel = Pastel.new
end

def insert_char(char)

Insert character at cursor position
def insert_char(char)
  chars = @line.chars
  chars.insert(@cursor_position, char)
  @line = chars.join
  @cursor_position += 1
end

def insert_text(text)

Insert text at cursor position
def insert_text(text)
  return if text.nil? || text.empty?
  chars = @line.chars
  text.chars.each_with_index do |c, i|
    chars.insert(@cursor_position + i, c)
  end
  @line = chars.join
  @cursor_position += text.length
end

def kill_to_end

Kill from cursor to end of line (Ctrl+K)
def kill_to_end
  chars = @line.chars
  @line = chars[0...@cursor_position].join
end

def kill_to_start

Kill from start to cursor (Ctrl+U)
def kill_to_start
  chars = @line.chars
  @line = chars[@cursor_position..-1]&.join || ""
  @cursor_position = 0
end

def kill_word

Kill word before cursor (Ctrl+W)
def kill_word
  chars = @line.chars
  pos = @cursor_position - 1
  # Skip whitespace
  while pos >= 0 && chars[pos] =~ /\s/
    pos -= 1
  end
  # Delete word characters
  while pos >= 0 && chars[pos] =~ /\S/
    pos -= 1
  end
  delete_start = pos + 1
  chars.slice!(delete_start...@cursor_position)
  @line = chars.join
  @cursor_position = delete_start
end

def render_line_segment_with_cursor(line, segment_start, segment_end)

Returns:
  • (String) - Rendered segment with cursor if applicable (without text color, only cursor highlight)

Parameters:
  • segment_end (Integer) -- End position of segment in line (char index)
  • segment_start (Integer) -- Start position of segment in line (char index)
  • line (String) -- Full line text
def render_line_segment_with_cursor(line, segment_start, segment_end)
  chars = line.chars
  segment_chars = chars[segment_start...segment_end]
  # Check if cursor is in this segment
  if @cursor_position >= segment_start && @cursor_position < segment_end
    # Cursor is in this segment
    cursor_pos_in_segment = @cursor_position - segment_start
    before_cursor = segment_chars[0...cursor_pos_in_segment].join
    cursor_char = segment_chars[cursor_pos_in_segment] || " "
    after_cursor = segment_chars[(cursor_pos_in_segment + 1)..-1]&.join || ""
    # Only apply cursor highlight, let subclasses apply text color
    "#{before_cursor}#{@pastel.on_white(@pastel.black(cursor_char))}#{after_cursor}"
  elsif @cursor_position == segment_end && segment_end == line.length
    # Cursor is at the very end of the line, show it in last segment
    segment_text = segment_chars.join
    "#{segment_text}#{@pastel.on_white(@pastel.black(' '))}"
  else
    # Cursor is not in this segment, return plain text without color
    segment_chars.join
  end
end

def render_line_with_cursor

Returns:
  • (String) - Rendered line with cursor
def render_line_with_cursor
  chars = @line.chars
  before_cursor = chars[0...@cursor_position].join
  cursor_char = chars[@cursor_position] || " "
  after_cursor = chars[(@cursor_position + 1)..-1]&.join || ""
  "#{@pastel.white(before_cursor)}#{@pastel.on_white(@pastel.black(cursor_char))}#{@pastel.white(after_cursor)}"
end

def set_line(text)

Set line content
def set_line(text)
  @line = text
  @cursor_position = [@cursor_position, @line.chars.length].min
end

def strip_ansi_codes(text)

Returns:
  • (String) - Text without ANSI codes

Parameters:
  • text (String) -- Text with ANSI codes
def strip_ansi_codes(text)
  text.gsub(/\e\[[0-9;]*m/, '')
end

def wrap_line(line, max_width)

Returns:
  • (Array) - Array of segment info: { text: String, start: Integer, end: Integer }

Parameters:
  • max_width (Integer) -- Maximum display width per wrapped line
  • line (String) -- The line to wrap
def wrap_line(line, max_width)
  return [{ text: "", start: 0, end: 0 }] if line.empty?
  return [{ text: line, start: 0, end: line.length }] if max_width <= 0
  segments = []
  chars = line.chars
  segment_start = 0
  current_width = 0
  current_end = 0
  chars.each_with_index do |char, idx|
    char_width = char_display_width(char)
    # If adding this character exceeds max width, complete current segment
    if current_width + char_width > max_width && current_end > segment_start
      segments << {
        text: chars[segment_start...current_end].join,
        start: segment_start,
        end: current_end
      }
      segment_start = idx
      current_end = idx + 1
      current_width = char_width
    else
      current_end = idx + 1
      current_width += char_width
    end
  end
  # Add the last segment
  if current_end > segment_start
    segments << {
      text: chars[segment_start...current_end].join,
      start: segment_start,
      end: current_end
    }
  end
  segments.empty? ? [{ text: "", start: 0, end: 0 }] : segments
end