Class: Tuile::Component::TextArea

Inherits:
AbstractStringField show all
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

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.



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_rowInteger (readonly)

@return — index of the topmost row currently visible.

Returns:

  • (Integer)


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_rowvoid

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_rowInteger

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.

Returns:

  • (Integer)


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

def caret_row = wrap.row_at(@caret)

#cursor_positionPoint?

Returns:



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_in_viewport = row - @scroll_top_row
  return nil if row_in_viewport.negative? || row_in_viewport >= 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 + row_in_viewport)
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



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.button == :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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • char (String)

Returns:

  • (Boolean)


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_endvoid

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_startvoid

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.

Parameters:

  • delta (Integer)


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_mutatedvoid

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_mutatedvoid

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_changedvoid

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

#repaintvoid

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 |row_in_viewport|
    line = wrap.row_text(row_in_viewport + @scroll_top_row)
    screen.buffer.set_text(rect.left, rect.top + row_in_viewport, background(line))
  end
end

#row_countInteger

@return — rows the wrapped text occupies at the current Rect#width; always >= 1, since empty text still wraps to one (empty) row.

Returns:

  • (Integer)


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

def row_count = wrap.row_count

#wrapWrappedText

@return — the current wrap of AbstractStringField#text at Rect#width.

Returns:



156
157
158
# File 'lib/tuile/component/text_area.rb', line 156

def wrap
  @wrap ||= WrappedText.new(@text, rect.width)
end