Class: Tuile::Component::TextArea

Inherits:
TextInput
  • Object
show all
Defined in:
lib/tuile/component/text_area.rb,
sig/tuile.rbs

Overview

A multi-line, word-wrapping text input.

Sized by the caller — #rect is fixed; the area does not grow with content. Text is wrapped to Rect#width columns and any text that doesn't fit vertically is reached by scrolling: #top_display_row follows the caret so the line being edited stays visible. There is no horizontal scrolling.

The caret is a logical index in 0..text.length. When the caret falls inside a whitespace run that was absorbed by a soft wrap, it displays at the end of the previous row (which is visually identical to the start of the next row in nearly all cases).

Currently only Tuile::Component::TextInput#on_change is wired; Enter inserts a newline as in any plain <textarea> or text editor. A future on_enter/on_submit callback may opt out of that by consuming Enter instead.

Instance Attribute Summary collapse

Attributes inherited from TextInput

#caret, #on_change, #on_escape, #on_key, #text

Instance Method Summary collapse

Methods inherited from TextInput

#background, #default_on_escape, #delete_at_caret, #delete_before_caret, #empty?, #focusable?, #handle_key, #preprocess_text, #tab_stop?, #word_left, #word_right

Constructor Details

#initializeTextArea

Returns a new instance of TextArea.



22
23
24
25
26
27
28
29
30
31
32
# File 'lib/tuile/component/text_area.rb', line 22

def initialize
  super
  @top_display_row = 0
  # Lazy cache of the word-wrapped layout: an
  # `Array<Hash{Symbol=>Integer}>` whose entries are
  # `{start: <text-index>, length: <chars>}`, one per display row, built
  # by {#compute_display_rows}. `nil` means "stale, recompute on next
  # read". Reset to nil whenever {#text} mutates or the width changes;
  # see {#on_text_mutated} and {#on_width_changed}.
  @display_rows = nil
end

Instance Attribute Details

#top_display_rowInteger (readonly)

@return — index of the topmost display row currently visible.

Returns:

  • (Integer)


35
36
37
# File 'lib/tuile/component/text_area.rb', line 35

def top_display_row
  @top_display_row
end

Instance Method Details

#adjust_top_display_rowvoid

This method returns an undefined value.

Keeps the caret visible by scrolling vertically.



265
266
267
268
269
270
271
272
273
274
275
276
277
# File 'lib/tuile/component/text_area.rb', line 265

def adjust_top_display_row
  return if rect.empty?

  rows = display_rows
  cur_row, = caret_to_display(@caret)
  if cur_row < @top_display_row
    @top_display_row = cur_row
  elsif cur_row >= @top_display_row + rect.height
    @top_display_row = cur_row - rect.height + 1
  end
  max_top = (rows.size - rect.height).clamp(0, nil)
  @top_display_row = @top_display_row.clamp(0, max_top)
end

#caret_to_display(caret) ⇒ [Integer, Integer]

@param caret

@return[row_index, column] for caret.

Parameters:

  • caret (Integer)

Returns:

  • ([Integer, Integer])


210
211
212
213
214
215
216
217
218
219
220
# File 'lib/tuile/component/text_area.rb', line 210

def caret_to_display(caret)
  rows = display_rows
  rows.each_with_index do |r, i|
    next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
    next unless caret >= r[:start] && caret < next_start

    return [i, (caret - r[:start]).clamp(0, r[:length])]
  end
  r = rows.last
  [rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
end

#compute_display_rows::Array[::Hash[Symbol, Integer]]

Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed (not rendered on either row). A token longer than Rect#width hard- wraps inside the token. Newlines force a hard break and the wrap restarts on the next character.

Returns:

  • (::Array[::Hash[Symbol, Integer]])


139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
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
181
182
183
184
185
186
187
188
189
190
191
192
193
# File 'lib/tuile/component/text_area.rb', line 139

def compute_display_rows
  width = rect.width
  return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?

  rows = []
  pos = 0
  n = @text.length

  while pos < n
    row_start = pos
    row_chars = 0

    while pos < n
      c = @text[pos]
      break if c == "\n"

      if c.match?(/[ \t]/)
        if row_chars < width
          row_chars += 1
          pos += 1
        else
          row_chars = trim_trailing_whitespace(row_start, row_chars)
          pos += 1 while pos < n && @text[pos].match?(/[ \t]/)
          break
        end
      else
        word_end = pos
        word_end += 1 while word_end < n && !@text[word_end].match?(/\s/)
        word_len = word_end - pos

        if row_chars + word_len <= width
          row_chars += word_len
          pos = word_end
        elsif row_chars.zero?
          row_chars = width
          pos += width
          break
        else
          row_chars = trim_trailing_whitespace(row_start, row_chars)
          break
        end
      end
    end

    rows << { start: row_start, length: row_chars }

    if pos < n && @text[pos] == "\n"
      pos += 1
      rows << { start: pos, length: 0 } if pos == n
    end
  end

  rows << { start: 0, length: 0 } if rows.empty?
  rows
end

#cursor_positionPoint?

Returns:



38
39
40
41
42
43
44
45
46
47
48
49
50
# File 'lib/tuile/component/text_area.rb', line 38

def cursor_position
  return nil if rect.empty?

  row, col = caret_to_display(@caret)
  screen_row = row - @top_display_row
  return nil if screen_row.negative? || screen_row >= rect.height

  # Cap so the hardware cursor never lands at rect.left+rect.width
  # (one past the rect). Terminals with auto-wrap interpret that as
  # column 0 of the row below; capping pins the cursor on the last
  # visible cell instead.
  Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + screen_row)
end

#display_rows::Array[::Hash[Symbol, Integer]]

@return — cached wrap of Tuile::Component::TextInput#text for the current Rect#width. Each entry is {start:, length:}.

Returns:

  • (::Array[::Hash[Symbol, Integer]])


130
131
132
# File 'lib/tuile/component/text_area.rb', line 130

def display_rows
  @display_rows ||= compute_display_rows
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



54
55
56
57
58
59
60
61
62
63
64
65
66
67
# File 'lib/tuile/component/text_area.rb', line 54

def handle_mouse(event)
  super
  return unless event.button == :left && rect.contains?(event.point)

  target_row = (event.y - rect.top) + @top_display_row
  target_col = event.x - rect.left
  rows = display_rows
  if target_row >= rows.size
    self.caret = @text.length
  else
    r = rows[target_row]
    self.caret = r[:start] + target_col.clamp(0, r[:length])
  end
end

#handle_text_input_key(key) ⇒ Boolean

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
# File 'lib/tuile/component/text_area.rb', line 102

def handle_text_input_key(key)
  case key
  when Keys::UP_ARROW then move_caret_vertical(-1)
  when Keys::DOWN_ARROW then move_caret_vertical(1)
  when *Keys::HOMES then move_caret_to_row_start
  when *Keys::ENDS_ then move_caret_to_row_end
  when *Keys::BACKSPACES then delete_before_caret
  when Keys::DELETE then delete_at_caret
  when Keys::ENTER then insert_char("\n")
  else
    return insert_char(key) if Keys.printable?(key)

    return super
  end
  true
end

#insert_char(char) ⇒ Boolean

@param char

@return — always true.

Parameters:

  • char (String)

Returns:

  • (Boolean)


256
257
258
259
260
261
# File 'lib/tuile/component/text_area.rb', line 256

def insert_char(char)
  new_text = @text.dup.insert(@caret, char)
  @caret += char.length
  self.text = new_text
  true
end

#move_caret_to_row_endvoid

This method returns an undefined value.



247
248
249
250
251
252
# File 'lib/tuile/component/text_area.rb', line 247

def move_caret_to_row_end
  rows = display_rows
  cur_row, = caret_to_display(@caret)
  r = rows[cur_row]
  self.caret = r[:start] + r[:length]
end

#move_caret_to_row_startvoid

This method returns an undefined value.



240
241
242
243
244
# File 'lib/tuile/component/text_area.rb', line 240

def move_caret_to_row_start
  rows = display_rows
  cur_row, = caret_to_display(@caret)
  self.caret = rows[cur_row][:start]
end

#move_caret_vertical(delta) ⇒ void

This method returns an undefined value.

@param delta+1 for down, -1 for up.

Parameters:

  • delta (Integer)


224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/tuile/component/text_area.rb', line 224

def move_caret_vertical(delta)
  rows = display_rows
  cur_row, cur_col = caret_to_display(@caret)
  new_row = (cur_row + delta).clamp(0, rows.size - 1)
  if new_row == cur_row
    # Already at the top/bottom display row. Snap to the absolute
    # start/end of the text so the user has a quick way to reach it.
    self.caret = delta.positive? ? @text.length : 0
    return
  end

  r = rows[new_row]
  self.caret = r[:start] + cur_col.clamp(0, r[:length])
end

#on_caret_mutatedvoid

This method returns an undefined value.



96
97
98
# File 'lib/tuile/component/text_area.rb', line 96

def on_caret_mutated
  adjust_top_display_row
end

#on_text_mutatedvoid

This method returns an undefined value.



90
91
92
93
# File 'lib/tuile/component/text_area.rb', line 90

def on_text_mutated
  @display_rows = nil
  adjust_top_display_row
end

#on_width_changedvoid

This method returns an undefined value.



120
121
122
123
124
# File 'lib/tuile/component/text_area.rb', line 120

def on_width_changed
  super
  @display_rows = nil
  adjust_top_display_row
end

#repaintvoid

This method returns an undefined value.



70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/tuile/component/text_area.rb', line 70

def repaint
  return if rect.empty?

  rows = display_rows
  (0...rect.height).each do |screen_row|
    row_idx = screen_row + @top_display_row
    line = if row_idx >= rows.size
             " " * rect.width
           else
             r = rows[row_idx]
             chunk = @text[r[:start], r[:length]] || ""
             chunk + (" " * (rect.width - r[:length]))
           end
    screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
  end
end

#trim_trailing_whitespace(row_start, row_chars) ⇒ Integer

Trims trailing space/tab characters off a row's visible length so the whitespace at a soft-wrap point is absorbed (not rendered) rather than left at the end of the row. Without this, soft-wrapping "foo bar" to width 4 would yield row 0 length 4 ("foo ") and the natural end-of-row caret position would coincide with row 1's start.

@param row_start

@param row_chars

@return — new row_chars.

Parameters:

  • row_start (Integer)
  • row_chars (Integer)

Returns:

  • (Integer)


203
204
205
206
# File 'lib/tuile/component/text_area.rb', line 203

def trim_trailing_whitespace(row_start, row_chars)
  row_chars -= 1 while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
  row_chars
end