Class: Tuile::Component::TextArea
- Inherits:
-
AbstractStringField
- Object
- Component
- AbstractStringField
- Tuile::Component::TextArea
- 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
-
#top_display_row ⇒ Integer
readonly
@return — index of the topmost display row currently visible.
Attributes inherited from AbstractStringField
#caret, #on_change, #on_escape, #on_key, #text
Attributes included from HasValue
Instance Method Summary collapse
-
#adjust_top_display_row ⇒ void
Keeps the caret visible by scrolling vertically.
-
#blank?(cluster) ⇒ Boolean
@param
cluster. -
#caret_column_in(row, caret) ⇒ Integer
@param
row. -
#caret_to_display(caret) ⇒ [Integer, Integer]
@param
caret. -
#chars_for_column(row, column) ⇒ Integer
@param
row. -
#cluster_table ⇒ ::Array[::Hash[Symbol, Object]]
@return — one entry per grapheme cluster of AbstractStringField#text:
{offset: <text-index>, text: <cluster>, width: <columns>}. -
#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.
- #cursor_position ⇒ Point?
-
#display_rows ⇒ ::Array[::Hash[Symbol, Integer]]
@return — cached wrap of AbstractStringField#text for the current Rect#width.
-
#handle_mouse(event) ⇒ void
@param
event. -
#handle_text_input_key(key) ⇒ Boolean
@param
key. -
#hard_wrap(clusters, index, width) ⇒ [Integer, Integer, Integer]
Splits a token too wide for a whole row, taking entire glyphs while they fit.
-
#initialize ⇒ TextArea
constructor
A new instance of TextArea.
-
#insert_char(char) ⇒ Boolean
@param
char. -
#measure_word(clusters, index) ⇒ [Integer, Integer, Integer]
@param
clusters. - #move_caret_to_row_end ⇒ void
- #move_caret_to_row_start ⇒ void
-
#move_caret_vertical(delta) ⇒ void
@param
delta—+1for down,-1for up. -
#newline?(cluster) ⇒ Boolean
@param
cluster. - #on_caret_mutated ⇒ void
- #on_text_mutated ⇒ void
- #on_width_changed ⇒ void
-
#padded_row(row) ⇒ String
@param
row. - #repaint ⇒ void
-
#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.
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
#initialize ⇒ TextArea
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_row ⇒ Integer (readonly)
@return — index of the topmost display row currently visible.
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_row ⇒ void
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).
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
@return — caret's column offset within row.
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.
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.
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.
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.
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_position ⇒ Point?
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:}.
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
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. == :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
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]
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.
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.
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_end ⇒ void
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_start ⇒ void
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.
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.
165 |
# File 'lib/tuile/component/text_area.rb', line 165 def newline?(cluster) = cluster[:text].end_with?("\n") |
#on_caret_mutated ⇒ void
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_mutated ⇒ void
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_changed ⇒ void
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.
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 |
#repaint ⇒ void
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]
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 |