Module: Clacky::UI2::LineEditor

Included in:
Components::InlineInput, Components::InputArea
Defined in:
lib/clacky/ui2/line_editor.rb

Overview

LineEditor module provides single-line text editing functionality Shared by InputArea and InlineInput components

Constant Summary collapse

MAX_CONTENT_WIDTH_RATIO =

Maximum content width ratio (percentage of terminal width) Use 90% of terminal width for better readability on wide screens This dynamically adjusts based on terminal size

0.9

Instance Attribute Summary collapse

Instance Method Summary collapse

Instance Attribute Details

#cursor_positionObject (readonly)

Returns the value of attribute cursor_position.



15
16
17
# File 'lib/clacky/ui2/line_editor.rb', line 15

def cursor_position
  @cursor_position
end

Instance Method Details

#backspaceObject

Backspace - delete character before cursor



49
50
51
52
53
54
55
# File 'lib/clacky/ui2/line_editor.rb', line 49

def backspace
  return if @cursor_position == 0
  chars = @line.chars
  chars.delete_at(@cursor_position - 1)
  @line = chars.join
  @cursor_position -= 1
end

#calculate_display_width(text) ⇒ Integer

Calculate display width of a string, considering multi-byte characters East Asian Wide and Fullwidth characters (like Chinese) take 2 columns

Parameters:

  • text (String)

    UTF-8 encoded text

Returns:

  • (Integer)

    Display width in terminal columns



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/clacky/ui2/line_editor.rb', line 155

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

#char_display_width(char) ⇒ Integer

Calculate display width of a single character

Parameters:

  • char (String)

    Single character

Returns:

  • (Integer)

    Display width (1 or 2)



304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
# File 'lib/clacky/ui2/line_editor.rb', line 304

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

#clear_line_contentObject

Clear line



35
36
37
38
# File 'lib/clacky/ui2/line_editor.rb', line 35

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

#current_lineObject

Get current line content



24
25
26
# File 'lib/clacky/ui2/line_editor.rb', line 24

def current_line
  @line
end

#cursor_column(prompt = "") ⇒ Integer

Get cursor column position (considering multi-byte characters)

Parameters:

  • prompt (String) (defaults to: "")

    Prompt string before the line (may contain ANSI codes)

Returns:

  • (Integer)

    Column position for cursor



192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/clacky/ui2/line_editor.rb', line 192

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

#cursor_endObject

Move cursor to end of line



81
82
83
# File 'lib/clacky/ui2/line_editor.rb', line 81

def cursor_end
  @cursor_position = @line.chars.length
end

#cursor_homeObject

Move cursor to start of line



76
77
78
# File 'lib/clacky/ui2/line_editor.rb', line 76

def cursor_home
  @cursor_position = 0
end

#cursor_leftObject

Move cursor left



66
67
68
# File 'lib/clacky/ui2/line_editor.rb', line 66

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

#cursor_position_with_wrap(prompt = "", width = TTY::Screen.width, continuation_prompt = "> ") ⇒ Array<Integer>

Get cursor position considering line wrapping

Parameters:

  • prompt (String) (defaults to: "")

    Prompt string before the line (may contain ANSI codes)

  • width (Integer) (defaults to: TTY::Screen.width)

    Terminal width for wrapping

  • continuation_prompt (String) (defaults to: "> ")

    Prompt for continuation lines (default: “> ”)

Returns:

  • (Array<Integer>)

    Row and column position (0-indexed)



210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
# File 'lib/clacky/ui2/line_editor.rb', line 210

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

#cursor_rightObject

Move cursor right



71
72
73
# File 'lib/clacky/ui2/line_editor.rb', line 71

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

#delete_charObject

Delete character at cursor position



58
59
60
61
62
63
# File 'lib/clacky/ui2/line_editor.rb', line 58

def delete_char
  chars = @line.chars
  return if @cursor_position >= chars.length
  chars.delete_at(@cursor_position)
  @line = chars.join
end

#expand_placeholders(text, placeholders) ⇒ Object

Expand placeholders and normalize line endings



130
131
132
133
134
135
136
137
138
# File 'lib/clacky/ui2/line_editor.rb', line 130

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

#initialize_line_editorObject



17
18
19
20
21
# File 'lib/clacky/ui2/line_editor.rb', line 17

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

#insert_char(char) ⇒ Object

Insert character at cursor position



41
42
43
44
45
46
# File 'lib/clacky/ui2/line_editor.rb', line 41

def insert_char(char)
  chars = @line.chars
  chars.insert(@cursor_position, char)
  @line = chars.join
  @cursor_position += 1
end

#insert_text(text) ⇒ Object

Insert text at cursor position



119
120
121
122
123
124
125
126
127
# File 'lib/clacky/ui2/line_editor.rb', line 119

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

#kill_to_endObject

Kill from cursor to end of line (Ctrl+K)



86
87
88
89
# File 'lib/clacky/ui2/line_editor.rb', line 86

def kill_to_end
  chars = @line.chars
  @line = chars[0...@cursor_position].join
end

#kill_to_startObject

Kill from start to cursor (Ctrl+U)



92
93
94
95
96
# File 'lib/clacky/ui2/line_editor.rb', line 92

def kill_to_start
  chars = @line.chars
  @line = chars[@cursor_position..-1]&.join || ""
  @cursor_position = 0
end

#kill_wordObject

Kill word before cursor (Ctrl+W)



99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/clacky/ui2/line_editor.rb', line 99

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

#render_line_segment_with_cursor(line, segment_start, segment_end) ⇒ String

Render a segment of a line with cursor if cursor is in this segment

Parameters:

  • line (String)

    Full line text

  • segment_start (Integer)

    Start position of segment in line (char index)

  • segment_end (Integer)

    End position of segment in line (char index)

Returns:

  • (String)

    Rendered segment with cursor if applicable (without text color, only cursor highlight)



338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
# File 'lib/clacky/ui2/line_editor.rb', line 338

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

#render_line_with_cursorString

Render line with cursor highlight

Returns:

  • (String)

    Rendered line with cursor



142
143
144
145
146
147
148
149
# File 'lib/clacky/ui2/line_editor.rb', line 142

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

#set_line(text) ⇒ Object

Set line content



29
30
31
32
# File 'lib/clacky/ui2/line_editor.rb', line 29

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

#strip_ansi_codes(text) ⇒ String

Strip ANSI escape codes from a string

Parameters:

  • text (String)

    Text with ANSI codes

Returns:

  • (String)

    Text without ANSI codes



185
186
187
# File 'lib/clacky/ui2/line_editor.rb', line 185

def strip_ansi_codes(text)
  text.gsub(/\e\[[0-9;]*m/, '')
end

#wrap_line(line, max_width) ⇒ Array<Hash>

Wrap a line into multiple segments based on available width Considers display width of characters (multi-byte characters like Chinese)

Parameters:

  • line (String)

    The line to wrap

  • max_width (Integer)

    Maximum display width per wrapped line

Returns:

  • (Array<Hash>)

    Array of segment info: { text: String, start: Integer, end: Integer }



260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
# File 'lib/clacky/ui2/line_editor.rb', line 260

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