Class: Tuile::Component::TextArea

Inherits:
AbstractStringField 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, always on a grapheme-cluster boundary (AbstractStringField). 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).

Enter inserts a newline, as in a plain <textarea> or text editor; only AbstractStringField#on_change is wired. A pasted line break arrives as \n (Keys::CTRL_J) rather than the \r a typed Enter sends, so both are accepted — otherwise a multi-line paste would silently lose its newlines.

Implementation details

The same two axes TextField names apply, and the wrap straddles both: an index counts characters into AbstractStringField#text (AbstractStringField#caret, a row's start and length), a column counts terminal cells (#rect, a row's columns, #cursor_position, a MouseEvent). A row therefore carries both counts, and the wrap fills each row to a column budget while recording a character span. Everything crossing between them goes through the inherited columns_of and the private chars_for_column.

The wrap walks grapheme clusters, not characters — a combining mark must add no columns and must not be split from its base across a row break. Note "\r\n" is a single cluster, so a hard break tests end_with?("\n") rather than equality.

Instance Attribute Summary collapse

Attributes inherited from AbstractStringField

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

Attributes included from HasValue

#on_value_change

Instance Method Summary collapse

Methods inherited from AbstractStringField

#background, #clear, #cluster_boundary_after, #cluster_boundary_before, #columns_of, #default_on_escape, #delete_at_caret, #delete_before_caret, #empty?, #empty_value, #focusable?, #handle_key, #preprocess_text, #snap_to_cluster, #tab_stop?, #value, #value=, #word_left, #word_right

Methods included from HasValue

#clear, #empty?, #empty_value, #focusable?, #value, #value=

Constructor Details

#initializeTextArea

Returns a new instance of TextArea.



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

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>, columns: <cols>}`, 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)


53
54
55
# File 'lib/tuile/component/text_area.rb', line 53

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.



389
390
391
392
393
394
395
396
397
398
399
400
401
# File 'lib/tuile/component/text_area.rb', line 389

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

#blank?(cluster) ⇒ Boolean

@param cluster

@return — true for a space or tab (each exactly one column).

Parameters:

  • cluster (::Hash[Symbol, Object])

Returns:

  • (Boolean)


160
# File 'lib/tuile/component/text_area.rb', line 160

def blank?(cluster) = cluster[:text].match?(/[ \t]/)

#caret_column_in(row, caret) ⇒ Integer

@param row

@param caret

@returncaret's column offset within row.

Parameters:

  • row (::Hash[Symbol, Integer])
  • caret (Integer)

Returns:

  • (Integer)


307
308
309
310
# File 'lib/tuile/component/text_area.rb', line 307

def caret_column_in(row, caret)
  chars = (caret - row[:start]).clamp(0, row[:length])
  columns_of(@text[row[:start], chars] || "").clamp(0, row[:columns])
end

#caret_to_display(caret) ⇒ [Integer, Integer]

@param caret

@return[row_index, column] for caret.

Parameters:

  • caret (Integer)

Returns:

  • ([Integer, Integer])


293
294
295
296
297
298
299
300
301
302
# File 'lib/tuile/component/text_area.rb', line 293

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_column_in(r, caret)]
  end
  [rows.size - 1, caret_column_in(rows.last, caret)]
end

#chars_for_column(row, column) ⇒ Integer

@param row

@param column — a column offset within row.

@return — characters from the row's start. A column landing in a wide glyph's right half resolves past it, as a click does in Tuile::Component::TextField.

Parameters:

  • row (::Hash[Symbol, Integer])
  • column (Integer)

Returns:

  • (Integer)


317
318
319
320
321
322
323
324
325
326
327
328
# File 'lib/tuile/component/text_area.rb', line 317

def chars_for_column(row, column)
  chars = 0
  col = 0
  (@text[row[:start], row[:length]] || "").each_grapheme_cluster do |g|
    w = Buffer.display_width(g)
    return chars if column < col + ((w + 1) / 2)

    col += w
    chars += g.length
  end
  chars
end

#cluster_table::Array[::Hash[Symbol, Object]]

@return — one entry per grapheme cluster of AbstractStringField#text: {offset: <text-index>, text: <cluster>, width: <columns>}. Rebuilt per wrap and discarded — the wrap is what's cached.

Returns:

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


149
150
151
152
153
154
155
156
# File 'lib/tuile/component/text_area.rb', line 149

def cluster_table
  offset = 0
  @text.each_grapheme_cluster.map do |g|
    entry = { offset: offset, text: g, width: Buffer.display_width(g) }
    offset += g.length
    entry
  end
end

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

Greedy word-wrap, filling each row to a column budget while recording the character span that produced it. Whitespace at a soft-wrap break point is absorbed (not rendered on either row). A token wider than Rect#width hard-wraps inside the token. Newlines force a hard break and the wrap restarts on the next cluster.

Returns:

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


173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
# File 'lib/tuile/component/text_area.rb', line 173

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

  cl = cluster_table
  rows = []
  i = 0
  n = cl.size

  while i < n
    start = cl[i][:offset]
    chars = 0
    cols = 0

    while i < n
      g = cl[i]
      break if newline?(g)

      if blank?(g)
        if cols < width
          chars += g[:text].length
          cols += g[:width]
          i += 1
        else
          chars, cols = trim_trailing_whitespace(start, chars, cols)
          i += 1 while i < n && blank?(cl[i])
          break
        end
      else
        word_chars, word_cols, word_end = measure_word(cl, i)

        if cols + word_cols <= width
          chars += word_chars
          cols += word_cols
          i = word_end
        elsif cols.zero?
          chars, cols, i = hard_wrap(cl, i, width)
          break
        else
          chars, cols = trim_trailing_whitespace(start, chars, cols)
          break
        end
      end
    end

    rows << { start: start, length: chars, columns: cols }

    next unless i < n && newline?(cl[i])

    i += 1
    rows << { start: @text.length, length: 0, columns: 0 } if i >= n
  end

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

#cursor_positionPoint?

Returns:



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

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 AbstractStringField#text for the current Rect#width. Each entry is {start:, length:}.

Returns:

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


142
143
144
# File 'lib/tuile/component/text_area.rb', line 142

def display_rows
  @display_rows ||= compute_display_rows
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



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

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] + chars_for_column(r, target_col)
  end
end

#handle_text_input_key(key) ⇒ Boolean

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
# File 'lib/tuile/component/text_area.rb', line 114

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, Keys::CTRL_J then insert_char("\n")
  else
    return insert_char(key) if Keys.printable?(key)

    return super
  end
  true
end

#hard_wrap(clusters, index, width) ⇒ [Integer, Integer, Integer]

Splits a token too wide for a whole row, taking entire glyphs while they fit. Consumes at least one glyph even when that single glyph is wider than the row — otherwise the wrap would not terminate (the row would stay empty and the same token be reconsidered forever). Such a row reports more columns than the rect holds and #padded_row drops the glyph; a 2-column glyph in a 1-column area is unpaintable either way.

@param clusters

@param index

@param width — column budget.

@return[chars, columns, next_index]

Parameters:

  • clusters (::Array[::Hash[Symbol, Object]])
  • index (Integer)
  • width (Integer)

Returns:

  • ([Integer, Integer, Integer])


255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
# File 'lib/tuile/component/text_area.rb', line 255

def hard_wrap(clusters, index, width)
  chars = 0
  cols = 0
  while index < clusters.size && cols + clusters[index][:width] <= width
    chars += clusters[index][:text].length
    cols += clusters[index][:width]
    index += 1
  end
  if chars.zero? && index < clusters.size
    chars = clusters[index][:text].length
    cols = clusters[index][:width]
    index += 1
  end
  [chars, cols, index]
end

#insert_char(char) ⇒ Boolean

@param char

@return — always true.

Parameters:

  • char (String)

Returns:

  • (Boolean)


380
381
382
383
384
385
# File 'lib/tuile/component/text_area.rb', line 380

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

#measure_word(clusters, index) ⇒ [Integer, Integer, Integer]

@param clusters

@param index — cluster index of the word's first glyph.

@return[chars, columns, next_index] for the run of non-whitespace starting at index.

Parameters:

  • clusters (::Array[::Hash[Symbol, Object]])
  • index (Integer)

Returns:

  • ([Integer, Integer, Integer])


234
235
236
237
238
239
240
241
242
243
# File 'lib/tuile/component/text_area.rb', line 234

def measure_word(clusters, index)
  chars = 0
  cols = 0
  while index < clusters.size && !blank?(clusters[index]) && !newline?(clusters[index])
    chars += clusters[index][:text].length
    cols += clusters[index][:width]
    index += 1
  end
  [chars, cols, index]
end

#move_caret_to_row_endvoid

This method returns an undefined value.



371
372
373
374
375
376
# File 'lib/tuile/component/text_area.rb', line 371

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.



364
365
366
367
368
# File 'lib/tuile/component/text_area.rb', line 364

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)


348
349
350
351
352
353
354
355
356
357
358
359
360
361
# File 'lib/tuile/component/text_area.rb', line 348

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] + chars_for_column(r, cur_col)
end

#newline?(cluster) ⇒ Boolean

@param cluster

@return — true for a hard line break. Tests the suffix rather than equality because "\r\n" is one grapheme cluster.

Parameters:

  • cluster (::Hash[Symbol, Object])

Returns:

  • (Boolean)


165
# File 'lib/tuile/component/text_area.rb', line 165

def newline?(cluster) = cluster[:text].end_with?("\n")

#on_caret_mutatedvoid

This method returns an undefined value.



108
109
110
# File 'lib/tuile/component/text_area.rb', line 108

def on_caret_mutated
  adjust_top_display_row
end

#on_text_mutatedvoid

This method returns an undefined value.



102
103
104
105
# File 'lib/tuile/component/text_area.rb', line 102

def on_text_mutated
  @display_rows = nil
  adjust_top_display_row
end

#on_width_changedvoid

This method returns an undefined value.



132
133
134
135
136
# File 'lib/tuile/component/text_area.rb', line 132

def on_width_changed
  super
  @display_rows = nil
  adjust_top_display_row
end

#padded_row(row) ⇒ String

@param row

@return — the row's text padded to rect.width columns. A glyph with no room left is dropped rather than half-painted.

Parameters:

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

Returns:

  • (String)


333
334
335
336
337
338
339
340
341
342
343
344
# File 'lib/tuile/component/text_area.rb', line 333

def padded_row(row)
  out = +""
  cols = 0
  (@text[row[:start], row[:length]] || "").each_grapheme_cluster do |g|
    w = Buffer.display_width(g)
    break if cols + w > rect.width

    out << g
    cols += w
  end
  out << (" " * (rect.width - cols))
end

#repaintvoid

This method returns an undefined value.



88
89
90
91
92
93
94
95
96
97
# File 'lib/tuile/component/text_area.rb', line 88

def repaint
  return if rect.empty?

  rows = display_rows
  (0...rect.height).each do |screen_row|
    row_idx = screen_row + @top_display_row
    line = row_idx >= rows.size ? " " * rect.width : padded_row(rows[row_idx])
    screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
  end
end

#trim_trailing_whitespace(row_start, row_chars, row_cols) ⇒ [Integer, 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.

Both counts drop by one per trimmed character: a space and a tab each measure exactly one column.

@param row_start

@param row_chars

@param row_cols

@return[row_chars, row_cols]

Parameters:

  • row_start (Integer)
  • row_chars (Integer)
  • row_cols (Integer)

Returns:

  • ([Integer, Integer])


283
284
285
286
287
288
289
# File 'lib/tuile/component/text_area.rb', line 283

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