Class: 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. 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
-
#top_display_row ⇒ Integer
readonly
@return — index of the topmost display row currently visible.
Attributes inherited from TextInput
#caret, #on_change, #on_escape, #on_key, #text
Instance Method Summary collapse
-
#adjust_top_display_row ⇒ void
Keeps the caret visible by scrolling vertically.
-
#caret_to_display(caret) ⇒ [Integer, Integer]
@param
caret. -
#compute_display_rows ⇒ ::Array[::Hash[Symbol, Integer]]
Greedy word-wrap.
- #cursor_position ⇒ Point?
-
#display_rows ⇒ ::Array[::Hash[Symbol, Integer]]
@return — cached wrap of Tuile::Component::TextInput#text for the current Rect#width.
-
#handle_mouse(event) ⇒ void
@param
event. -
#handle_text_input_key(key) ⇒ Boolean
@param
key. -
#initialize ⇒ TextArea
constructor
A new instance of TextArea.
-
#insert_char(char) ⇒ Boolean
@param
char. - #move_caret_to_row_end ⇒ void
- #move_caret_to_row_start ⇒ void
-
#move_caret_vertical(delta) ⇒ void
@param
delta—+1for down,-1for up. - #on_caret_mutated ⇒ void
- #on_text_mutated ⇒ void
- #on_width_changed ⇒ void
- #repaint ⇒ void
-
#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.
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
#initialize ⇒ TextArea
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_row ⇒ Integer (readonly)
@return — index of the topmost display row currently visible.
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_row ⇒ void
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.
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.
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_position ⇒ Point?
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:}.
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
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. == :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
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.
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_end ⇒ void
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_start ⇒ void
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.
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_mutated ⇒ void
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_mutated ⇒ void
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_changed ⇒ void
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 |
#repaint ⇒ void
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.
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 |