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_value_change is wired. Keys::CTRL_J does the same, since that is the byte a terminal sends for a typed Ctrl+J. A pasted line break arrives through AbstractStringField#handle_paste instead and never as a key at all — so a subclass rebinding Enter to submit keeps working under a multi-line paste, which lands as one draft.

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.

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: a text mutation drops it, and a read at another width rebuilds it. 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, #escape_clears_focus, #text

Instance Method Summary collapse

Methods inherited from AbstractStringField

#clear, #cluster_boundary_after, #cluster_boundary_before, #columns_of, #delete_at_caret, #delete_back_to, #delete_before_caret, #empty?, #empty_value, #error_bg_color, #error_ink?, #error_message, #error_message=, #focusable?, #handle_key?, #handle_paste, #insert_text, #inspect_details, #on_error_message_change, #on_escape, #on_value_change, #preprocess_paste, #preprocess_text, #set_value, #shown_message, #snap_to_cluster, #tab_stop?, #value, #value=, #word_left, #word_right

Methods included from HasValue

#clear, #empty?, #empty_value, #error_bg_color, #error_ink?, #error_message, #error_message=, #focusable?, #inspect_details, listener, #on_error_message_change, #on_value_change, #set_value, #shown_message, #value, #value=

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

#error_bg_color, #error_ink?, #error_message, #error_message=, #inspect_details, listener, #on_error_message_change, #shown_message

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; {#wrap} notices a width change itself.
  @wrap = nil
end

Instance Attribute Details

#scroll_top_row ⇒ Integer (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_row ⇒ void

This method returns an undefined value.

Keeps the caret visible by scrolling vertically.



207
208
209
210
211
212
213
214
215
216
217
218
# File 'lib/tuile/component/text_area.rb', line 207

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.

Returns:

  • (Integer)


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

def caret_row = wrap.row_at(@caret)

#caret_row_start ⇒ Integer

@return — index of the first character of the caret's row.

Returns:

  • (Integer)


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

def caret_row_start = wrap.row_start(wrap.row_at(@caret))

#cursor_position ⇒ Point?

Returns:



76
77
78
79
80
81
82
83
84
85
86
87
# 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 one past the last column.
  # 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(col.clamp(0, rect.width - 1), row_in_viewport)
end

#handle_caret_mutated ⇒ void

This method returns an undefined value.



124
125
126
127
# File 'lib/tuile/component/text_area.rb', line 124

def handle_caret_mutated
  super
  adjust_scroll_top_row
end

#handle_mouse_down?(event) ⇒ Boolean

@param event

Parameters:

Returns:

  • (Boolean)


91
92
93
94
95
96
97
98
99
100
101
# File 'lib/tuile/component/text_area.rb', line 91

def handle_mouse_down?(event)
  return false unless event.button == :left

  target_row = event.y + @scroll_top_row
  self.caret = if target_row >= wrap.row_count
                 @text.length
               else
                 wrap.index_at(target_row, event.x)
               end
  true
end

#handle_text_input_key?(key) ⇒ Boolean

HOME/END and CTRL+U act on the caret's row — the wrapped one, not the \n line — so CTRL+U kills back to exactly where HOME would go.

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
# File 'lib/tuile/component/text_area.rb', line 133

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::CTRL_U then delete_back_to(caret_row_start)
  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

#handle_text_mutated ⇒ void

This method returns an undefined value.



117
118
119
120
121
# File 'lib/tuile/component/text_area.rb', line 117

def handle_text_mutated
  super
  @wrap = nil
  adjust_scroll_top_row
end

#insert_char(char) ⇒ Boolean

Routes a typed character (or the ENTER newline) through AbstractStringField#insert_text, the same mutation a paste lands on.

@param char

@return — always true — a rejected character is swallowed rather than declined, so typing never falls through to a scope-wide binding.

Parameters:

  • char (String)

Returns:

  • (Boolean)


200
201
202
203
# File 'lib/tuile/component/text_area.rb', line 200

def insert_char(char)
  insert_text(char)
  true
end

#move_caret_to_row_end ⇒ void

This method returns an undefined value.



191
192
193
# File 'lib/tuile/component/text_area.rb', line 191

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.



186
187
188
# File 'lib/tuile/component/text_area.rb', line 186

def move_caret_to_row_start
  self.caret = caret_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)


169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/tuile/component/text_area.rb', line 169

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

#relayout ⇒ void

This method returns an undefined value.

Re-clamps the viewport to the caret, since either axis of a new rect can scroll it away.



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

def relayout
  super
  adjust_scroll_top_row
end

#repaint(canvas) ⇒ void

This method returns an undefined value.

@param canvas — see Tuile::Component#repaint.

Parameters:



105
106
107
108
109
110
111
112
# File 'lib/tuile/component/text_area.rb', line 105

def repaint(canvas)
  return if rect.empty?

  (0...rect.height).each do |row_in_viewport|
    line = wrap.row_text(row_in_viewport + @scroll_top_row)
    canvas.set_text(0, row_in_viewport, StyledString.plain(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.

Returns:

  • (Integer)


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.

Returns:



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

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