Class: Tuile::Component::TextField
- Inherits:
-
AbstractStringField
- Object
- Component
- AbstractStringField
- Tuile::Component::TextField
- Defined in:
- lib/tuile/component/text_field.rb,
sig/tuile.rbs
Overview
A single-line text input with a real hardware caret, scrolling horizontally to keep that caret in view:
f = TextField.new
f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
f.text = "hello world" # … eleven columns of text, so it scrolls
f.caret = 11 # paints "world " — left_column 6, cursor on the last column
f.caret = 0 # paints "hello " — left_column 0
The field's width never bounds its contents — #max_text_length does, and only for typing.
Implementation details
Two axes run through this class and are not interchangeable:
- an index counts characters into AbstractStringField#text — AbstractStringField#caret,
#max_text_length,
text[i], every edit; - a column counts terminal cells — #rect, #left_column, #cursor_position, a MouseEvent.
They coincide only while every glyph is one column wide. A fullwidth CJK
char is two columns and a combining mark zero, so index 3 of "日本語" is
column 6. Every crossing goes through the private column_at / index_at
pair; adding an index to a column anywhere else is the bug those two exist
to prevent.
Indices count characters while widths measure grapheme clusters, but the caret never falls between the two: AbstractStringField keeps it on a cluster boundary, so a column derived from it always names a real glyph edge.
What gets painted is #display_text, a third seam that is text itself
here and the mask in PasswordField. Every column measurement reads it, so
a subclass showing something else overrides that and never #repaint —
overriding the paint alone leaves the measurements on the buffer while the
cells show the substitute, and the two drift apart by a growing offset.
Direct Known Subclasses
Instance Attribute Summary collapse
-
#left_column ⇒ Integer
readonly
@return — text column drawn in the field's leftmost cell — the horizontal scroll offset.
-
#max_text_length ⇒ Integer?
Optional cap on AbstractStringField#text's length in characters — a wide glyph counts once.
-
#on_enter ⇒ Proc, ...
Optional callback fired when ENTER is pressed.
-
#on_key_down ⇒ Proc, ...
Optional callback fired when the DOWN arrow key is pressed.
-
#on_key_up ⇒ Proc, ...
Optional callback fired when the UP arrow key is pressed.
Attributes inherited from AbstractStringField
#caret, #on_change, #on_escape, #on_key, #text
Attributes included from HasValue
Instance Method Summary collapse
-
#adjust_left_column ⇒ void
Scrolls the minimum needed to keep the caret's column visible.
-
#column_at(index) ⇒ Integer
@param
index— a AbstractStringField#text index in0..text.length. - #cursor_position ⇒ Point?
-
#display_text ⇒ String
What the field paints in place of AbstractStringField#text: one display character per AbstractStringField#text character, in order.
-
#handle_mouse(event) ⇒ void
Places the caret at the clicked column.
-
#handle_text_input_key(key) ⇒ Boolean
@param
key. -
#index_at(column) ⇒ Integer
@param
column— a text column (0 is the first glyph). -
#initialize ⇒ TextField
constructor
A new instance of TextField.
-
#insert(char) ⇒ Boolean
@param
char. - #on_caret_mutated ⇒ void
- #on_text_mutated ⇒ void
- #on_width_changed ⇒ void
- #repaint ⇒ void
-
#snap_to_glyph_start(column) ⇒ Integer
Snapping right is the only safe direction, and not because it shows more: the caret's own column is always a glyph boundary, so the next boundary at or after
left_columncan never overshoot it. -
#text_columns ⇒ Integer
@return — total display width of AbstractStringField#text.
-
#visible_text ⇒ String
@return — the windowed text, padded with spaces to
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 ⇒ TextField
Returns a new instance of TextField.
43 44 45 46 47 48 49 50 |
# File 'lib/tuile/component/text_field.rb', line 43 def initialize super @left_column = 0 @max_text_length = nil @on_key_up = nil @on_key_down = nil @on_enter = nil end |
Instance Attribute Details
#left_column ⇒ Integer (readonly)
@return — text column drawn in the field's leftmost cell — the horizontal scroll offset. Follows AbstractStringField#caret, always on a glyph boundary.
73 74 75 |
# File 'lib/tuile/component/text_field.rb', line 73 def left_column @left_column end |
#max_text_length ⇒ Integer?
Optional cap on AbstractStringField#text's length in characters — a wide glyph counts once. Typing into a field already at the cap does nothing.
Deliberately does not police AbstractStringField#text=: lowering the cap under an existing value leaves that value intact rather than silently trimming it.
@return — maximum characters, or nil for unbounded (default).
58 59 60 |
# File 'lib/tuile/component/text_field.rb', line 58 def max_text_length @max_text_length end |
#on_enter ⇒ Proc, ...
Optional callback fired when ENTER is pressed. When set, ENTER is consumed by the field; when nil, ENTER falls through to the parent (default behavior).
@return — no-arg callable, or nil.
93 94 95 |
# File 'lib/tuile/component/text_field.rb', line 93 def on_enter @on_enter end |
#on_key_down ⇒ Proc, ...
Optional callback fired when the DOWN arrow key is pressed. When set,
DOWN is consumed by the field; when nil, DOWN falls through to the
parent (default behavior). Only triggered by Keys::DOWN_ARROW, not by
j, since j is a printable character inserted into AbstractStringField#text.
@return — no-arg callable, or nil.
87 88 89 |
# File 'lib/tuile/component/text_field.rb', line 87 def on_key_down @on_key_down end |
#on_key_up ⇒ Proc, ...
Optional callback fired when the UP arrow key is pressed. When set, UP
is consumed by the field; when nil, UP falls through to the parent
(default behavior). Only triggered by Keys::UP_ARROW, not by k,
since k is a printable character inserted into AbstractStringField#text.
@return — no-arg callable, or nil.
80 81 82 |
# File 'lib/tuile/component/text_field.rb', line 80 def on_key_up @on_key_up end |
Instance Method Details
#adjust_left_column ⇒ void
This method returns an undefined value.
Scrolls the minimum needed to keep the caret's column visible.
240 241 242 243 244 245 246 247 248 249 |
# File 'lib/tuile/component/text_field.rb', line 240 def adjust_left_column return unless rect.width.positive? col = column_at(@caret) @left_column = col if col < @left_column @left_column = col - rect.width + 1 if col > @left_column + rect.width - 1 # The caret may park one column past the last glyph, so the scrollable # range runs one column past the text. @left_column = snap_to_glyph_start(@left_column.clamp(0, [text_columns - rect.width + 1, 0].max)) end |
#column_at(index) ⇒ Integer
@param index — a AbstractStringField#text index in 0..text.length.
@return — the column it sits at. An index landing inside a grapheme cluster measures the whole cluster, putting the caret just past it.
197 |
# File 'lib/tuile/component/text_field.rb', line 197 def column_at(index) = columns_of(display_text[0, index] || "") |
#cursor_position ⇒ Point?
96 97 98 99 100 101 102 103 104 |
# File 'lib/tuile/component/text_field.rb', line 96 def cursor_position return nil unless rect.width.positive? # Scrolling already keeps the caret inside the rect, so the cap is a # guard rather than a policy: a cursor parked at rect.left + rect.width # reads as column 0 of the next row on an auto-wrapping terminal. offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1) Point.new(rect.left + offset, rect.top) end |
#display_text ⇒ String
What the field paints in place of AbstractStringField#text: one display character per
AbstractStringField#text character, in order. column_at measures display_text[0, i] as
the rendering of text[0, i], so an override that changes the character
count — or reorders — desynchronizes the caret from the display. Nothing
enforces it at runtime; a subclass pins it with a spec.
@return — AbstractStringField#text itself, unless a subclass substitutes.
176 |
# File 'lib/tuile/component/text_field.rb', line 176 def display_text = @text |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
Places the caret at the clicked column. A click on the right half of a wide glyph lands after it, as in any editor.
@param event
110 111 112 113 114 115 |
# File 'lib/tuile/component/text_field.rb', line 110 def handle_mouse(event) super return unless event. == :left && rect.contains?(event.point) self.caret = index_at(event.x - rect.left + @left_column) end |
#handle_text_input_key(key) ⇒ Boolean
@param key
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 |
# File 'lib/tuile/component/text_field.rb', line 128 def handle_text_input_key(key) case key when *Keys::HOMES then self.caret = 0 when *Keys::ENDS_ then self.caret = @text.length when *Keys::BACKSPACES then delete_before_caret when Keys::DELETE then delete_at_caret when Keys::UP_ARROW return false if @on_key_up.nil? @on_key_up.call when Keys::DOWN_ARROW return false if @on_key_down.nil? @on_key_down.call when Keys::ENTER return false if @on_enter.nil? @on_enter.call else return insert(key) if Keys.printable?(key) return super end true end |
#index_at(column) ⇒ Integer
@param column — a text column (0 is the first glyph).
@return — the nearest AbstractStringField#text index — a column falling in a wide glyph's right half resolves past it.
202 203 204 205 206 207 208 209 210 211 212 213 |
# File 'lib/tuile/component/text_field.rb', line 202 def index_at(column) col = 0 i = 0 display_text.each_grapheme_cluster do |g| w = Buffer.display_width(g) return i if column < col + ((w + 1) / 2) col += w i += g.length end i end |
#insert(char) ⇒ Boolean
@param char
@return — always true — a field at #max_text_length swallows the key rather than declining it, so typing can never fall through to a scope-wide binding.
184 185 186 187 188 189 190 191 |
# File 'lib/tuile/component/text_field.rb', line 184 def insert(char) return true if @max_text_length && @text.length >= @max_text_length new_text = @text.dup.insert(@caret, char) @caret += 1 self.text = new_text true end |
#on_caret_mutated ⇒ void
This method returns an undefined value.
160 161 162 |
# File 'lib/tuile/component/text_field.rb', line 160 def on_caret_mutated adjust_left_column end |
#on_text_mutated ⇒ void
This method returns an undefined value.
155 156 157 |
# File 'lib/tuile/component/text_field.rb', line 155 def on_text_mutated adjust_left_column end |
#on_width_changed ⇒ void
This method returns an undefined value.
165 166 167 168 |
# File 'lib/tuile/component/text_field.rb', line 165 def on_width_changed super adjust_left_column end |
#repaint ⇒ void
This method returns an undefined value.
118 119 120 121 122 |
# File 'lib/tuile/component/text_field.rb', line 118 def repaint return if rect.empty? screen.buffer.set_line(rect.left, rect.top, background(visible_text)) end |
#snap_to_glyph_start(column) ⇒ Integer
Snapping right is the only safe direction, and not because it shows
more: the caret's own column is always a glyph boundary, so the next
boundary at or after left_column can never overshoot it. Snapping left
instead pulls the window's right edge inward, which strands the caret
outside it whenever wide glyphs exactly fill a narrow field.
@param column
@return — the smallest glyph-boundary column >= column, so the
window never opens on a wide glyph's right half.
260 261 262 263 264 265 266 267 268 |
# File 'lib/tuile/component/text_field.rb', line 260 def snap_to_glyph_start(column) col = 0 display_text.each_grapheme_cluster do |g| return col if col >= column col += Buffer.display_width(g) end col end |
#text_columns ⇒ Integer
@return — total display width of AbstractStringField#text.
216 |
# File 'lib/tuile/component/text_field.rb', line 216 def text_columns = column_at(@text.length) |
#visible_text ⇒ String
@return — the windowed text, padded with spaces to rect.width.
A wide glyph straddling the right edge is dropped rather than painted
as a half glyph.
221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 |
# File 'lib/tuile/component/text_field.rb', line 221 def visible_text right = @left_column + rect.width visible = +"" width = 0 col = 0 display_text.each_grapheme_cluster do |g| start = col col += Buffer.display_width(g) next if start < @left_column break if col > right visible << g width = col - @left_column end visible << (" " * (rect.width - width)) end |