Class: Tuile::Component::TextArea
- Inherits:
-
AbstractStringField
- Object
- Component
- AbstractStringField
- Tuile::Component::TextArea
- Defined in:
- lib/tuile/component/text_area.rb,
lib/tuile/component/text_area/wrapped_text.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: #scroll_top_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.
Up/Down move the caret between rows and, at the first/last row, snap to the start/end of the text. A subclass can claim the key at that edge instead — shell-style history recall is the motivating case — by asking #caret_row and #row_count before delegating:
class PromptArea < Component::TextArea
protected
def handle_text_input_key(key)
return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
super # anywhere else: the caret moves, and the edge still snaps
end
end
Both recalls return true to consume the key; app code that would rather
not subclass claims the same keys through AbstractStringField#on_key.
Implementation details
The wrap itself — and with it every conversion between a character
index and a row/column — lives in WrappedText, a
snapshot of (text, rect.width) this class caches and drops whenever
either changes. What stays here is the widget: keys, mouse, painting, and
the #scroll_top_row viewport, which WrappedText deliberately knows
nothing about (it is a pure function of text and width; the viewport is
stateful and needs Rect#height).
Defined Under Namespace
Classes: WrappedText
Instance Attribute Summary collapse
-
#scroll_top_row ⇒ Integer
readonly
@return — index of the topmost row currently visible.
Attributes inherited from AbstractStringField
#caret, #on_change, #on_escape, #on_key, #text
Attributes included from HasValue
Instance Method Summary collapse
-
#adjust_scroll_top_row ⇒ void
Keeps the caret visible by scrolling vertically.
-
#caret_row ⇒ Integer
The caret's row, counted from the text's first row — not from the top of the viewport (subtract #scroll_top_row for that).
- #cursor_position ⇒ Point?
-
#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
-
#row_count ⇒ Integer
@return — rows the wrapped text occupies at the current Rect#width; always
>= 1, since empty text still wraps to one (empty) row. -
#wrap ⇒ WrappedText
@return — the current wrap of AbstractStringField#text at Rect#width.
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.
54 55 56 57 58 59 60 |
# File 'lib/tuile/component/text_area.rb', line 54 def initialize super @scroll_top_row = 0 # Lazy cache; nil means "stale, rebuild on next read". Reset whenever # {#text} mutates or the width changes. @wrap = nil end |
Instance Attribute Details
#scroll_top_row ⇒ Integer (readonly)
@return — index of the topmost row currently visible.
63 64 65 |
# File 'lib/tuile/component/text_area.rb', line 63 def scroll_top_row @scroll_top_row end |
Instance Method Details
#adjust_scroll_top_row ⇒ void
This method returns an undefined value.
Keeps the caret visible by scrolling vertically.
196 197 198 199 200 201 202 203 204 205 206 207 |
# File 'lib/tuile/component/text_area.rb', line 196 def adjust_scroll_top_row return if rect.empty? cur_row = wrap.row_at(@caret) if cur_row < @scroll_top_row @scroll_top_row = cur_row elsif cur_row >= @scroll_top_row + rect.height @scroll_top_row = cur_row - rect.height + 1 end max_top = (wrap.row_count - rect.height).clamp(0, nil) @scroll_top_row = @scroll_top_row.clamp(0, max_top) end |
#caret_row ⇒ Integer
The caret's row, counted from the text's first row — not from the top of the viewport (subtract #scroll_top_row for that).
@return — a row index in 0...row_count.
68 |
# File 'lib/tuile/component/text_area.rb', line 68 def caret_row = wrap.row_at(@caret) |
#cursor_position ⇒ Point?
76 77 78 79 80 81 82 83 84 85 86 87 88 |
# File 'lib/tuile/component/text_area.rb', line 76 def cursor_position return nil if rect.empty? row, col = wrap.position_at(@caret) = row - @scroll_top_row return nil if .negative? || >= 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 + ) end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
@param event
92 93 94 95 96 97 98 99 100 101 102 |
# File 'lib/tuile/component/text_area.rb', line 92 def handle_mouse(event) super return unless event. == :left && rect.contains?(event.point) target_row = (event.y - rect.top) + @scroll_top_row self.caret = if target_row >= wrap.row_count @text.length else wrap.index_at(target_row, event.x - rect.left) end end |
#handle_text_input_key(key) ⇒ Boolean
@param key
129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 |
# File 'lib/tuile/component/text_area.rb', line 129 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 |
#insert_char(char) ⇒ Boolean
@param char
@return — always true.
187 188 189 190 191 192 |
# File 'lib/tuile/component/text_area.rb', line 187 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.
181 182 183 |
# File 'lib/tuile/component/text_area.rb', line 181 def move_caret_to_row_end self.caret = wrap.row_end(wrap.row_at(@caret)) end |
#move_caret_to_row_start ⇒ void
This method returns an undefined value.
176 177 178 |
# File 'lib/tuile/component/text_area.rb', line 176 def move_caret_to_row_start self.caret = wrap.row_start(wrap.row_at(@caret)) end |
#move_caret_vertical(delta) ⇒ void
This method returns an undefined value.
@param delta — +1 for down, -1 for up.
162 163 164 165 166 167 168 169 170 171 172 173 |
# File 'lib/tuile/component/text_area.rb', line 162 def move_caret_vertical(delta) cur_row, cur_col = wrap.position_at(@caret) new_row = (cur_row + delta).clamp(0, wrap.row_count - 1) if new_row == cur_row # Already at the top/bottom 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 self.caret = wrap.index_at(new_row, cur_col) end |
#on_caret_mutated ⇒ void
This method returns an undefined value.
123 124 125 |
# File 'lib/tuile/component/text_area.rb', line 123 def on_caret_mutated adjust_scroll_top_row end |
#on_text_mutated ⇒ void
This method returns an undefined value.
117 118 119 120 |
# File 'lib/tuile/component/text_area.rb', line 117 def on_text_mutated @wrap = nil adjust_scroll_top_row end |
#on_width_changed ⇒ void
This method returns an undefined value.
147 148 149 150 151 |
# File 'lib/tuile/component/text_area.rb', line 147 def on_width_changed super @wrap = nil adjust_scroll_top_row end |
#repaint ⇒ void
This method returns an undefined value.
105 106 107 108 109 110 111 112 |
# File 'lib/tuile/component/text_area.rb', line 105 def repaint return if rect.empty? (0...rect.height).each do || line = wrap.row_text( + @scroll_top_row) screen.buffer.set_text(rect.left, rect.top + , background(line)) end end |
#row_count ⇒ Integer
@return — rows the wrapped text occupies at the current
Rect#width; always >= 1, since empty text still wraps to one
(empty) row.
73 |
# File 'lib/tuile/component/text_area.rb', line 73 def row_count = wrap.row_count |
#wrap ⇒ WrappedText
@return — the current wrap of AbstractStringField#text at Rect#width.
156 157 158 |
# File 'lib/tuile/component/text_area.rb', line 156 def wrap @wrap ||= WrappedText.new(@text, rect.width) end |