Class: Tuile::Component::TextField
- Inherits:
-
AbstractStringField
- Object
- Component
- AbstractStringField
- Tuile::Component::TextField
- Includes:
- HasPlaceholder
- 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. An empty field paints its HasPlaceholder#placeholder instead, when it has one.
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, #cursor_position, a
Mouse::DownEvent, and the private horizontal scroll offset
left_column.
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
BigDecimalField::Field, FloatField::Field, IntegerField::Field, PasswordField
Instance Attribute Summary collapse
-
#left_column ⇒ Integer
readonly
Internal — the field's own scroll state, with no caller outside this class: the paint, the cursor and the hit test all read the ivar, and nothing above the field has a column to spend it on.
-
#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, #text
Attributes included from HasValue
Attributes included from HasValidation
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_caret_mutated ⇒ void
-
#handle_mouse_down?(event) ⇒ Boolean
Places the caret at the pressed column.
-
#handle_text_input_key?(key) ⇒ Boolean
CTRL+U kills back to the start of the field — with the caret at the end, "clear what I typed".
- #handle_text_mutated ⇒ void
- #handle_width_changed ⇒ void
-
#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
Routes a typed character through AbstractStringField#insert_text, the same mutation a paste lands on.
-
#inspect_details ⇒ ::Array[String]
Adds
placeholder="…"to #inspect, omitted while unset. -
#placeholder ⇒ String?
@return — the hint, or
nilwhen there is none (default). -
#placeholder= ⇒ void
Sets the hint and invalidates the component.
-
#placeholder_row ⇒ StyledString
#repaint does not call
super, so this padded row is the only thing that clears the rect: it is the well. -
#preprocess_paste(text) ⇒ String
Keeps the paste's first line and drops the rest, then trims what's left to what #max_text_length still allows:.
- #repaint ⇒ void
-
#show_placeholder? ⇒ Boolean
@return — true when the empty field should paint its HasPlaceholder#placeholder instead of its (blank) contents.
-
#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
#clear, #cluster_boundary_after, #cluster_boundary_before, #columns_of, #default_bg_color, #default_on_escape, #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, #preprocess_text, #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?, #value, #value=
Methods included from HasValidation
#error_bg_color, #error_ink?, #error_message, #error_message=
Constructor Details
#initialize ⇒ TextField
Returns a new instance of TextField.
46 47 48 49 50 51 52 53 54 |
# File 'lib/tuile/component/text_field.rb', line 46 def initialize super @placeholder = nil @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)
Internal — the field's own scroll state, with no caller outside this
class: the paint, the cursor and the hit test all read the ivar, and
nothing above the field has a column to spend it on. Specs assert the
scrolling through send.
@return — text column drawn in the field's leftmost cell — the horizontal scroll offset. Follows AbstractStringField#caret, always on a glyph boundary.
282 283 284 |
# File 'lib/tuile/component/text_field.rb', line 282 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).
62 63 64 |
# File 'lib/tuile/component/text_field.rb', line 62 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.
286 287 288 289 290 291 292 293 294 295 |
# File 'lib/tuile/component/text_field.rb', line 286 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.
222 |
# File 'lib/tuile/component/text_field.rb', line 222 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.
201 |
# File 'lib/tuile/component/text_field.rb', line 201 def display_text = @text |
#handle_caret_mutated ⇒ void
This method returns an undefined value.
184 185 186 187 |
# File 'lib/tuile/component/text_field.rb', line 184 def handle_caret_mutated super adjust_left_column end |
#handle_mouse_down?(event) ⇒ Boolean
Places the caret at the pressed column. A press 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_down?(event) return false unless event. == :left self.caret = index_at(event.x - rect.left + @left_column) true end |
#handle_text_input_key?(key) ⇒ Boolean
CTRL+U kills back to the start of the field — with the caret at the end, "clear what I typed".
@param key
132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/tuile/component/text_field.rb', line 132 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::CTRL_U then delete_back_to(0) 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 |
#handle_text_mutated ⇒ void
This method returns an undefined value.
178 179 180 181 |
# File 'lib/tuile/component/text_field.rb', line 178 def handle_text_mutated super adjust_left_column end |
#handle_width_changed ⇒ void
This method returns an undefined value.
190 191 192 193 |
# File 'lib/tuile/component/text_field.rb', line 190 def handle_width_changed super adjust_left_column 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.
227 228 229 230 231 232 233 234 235 236 237 238 |
# File 'lib/tuile/component/text_field.rb', line 227 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
Routes a typed character through AbstractStringField#insert_text, the same mutation a paste lands on.
@param char
@return — always true — a field that is at #max_text_length, or that rejected the character, swallows the key rather than declining it, so typing can never fall through to a scope-wide binding.
211 212 213 214 215 216 |
# File 'lib/tuile/component/text_field.rb', line 211 def insert(char) return true if @max_text_length && @text.length >= @max_text_length insert_text(char) true end |
#inspect_details ⇒ ::Array[String]
Adds placeholder="…" to Tuile::Component#inspect, omitted while unset.
8183 |
# File 'sig/tuile.rbs', line 8183
def inspect_details: () -> ::Array[String]
|
#placeholder ⇒ String?
@return — the hint, or nil when there is none (default).
8175 |
# File 'sig/tuile.rbs', line 8175
def placeholder: () -> String?
|
#placeholder= ⇒ void
This method returns an undefined value.
Sets the hint and invalidates the component. No-op when unchanged.
@param text — nil removes it.
8180 |
# File 'sig/tuile.rbs', line 8180
def placeholder=: (String? text) -> void
|
#placeholder_row ⇒ StyledString
#repaint does not call super, so this padded row is the only thing
that clears the rect: it is the well.
@return — the hint, ellipsized to rect.width and padded
back out to it.
248 249 250 251 |
# File 'lib/tuile/component/text_field.rb', line 248 def placeholder_row hint = StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width) hint + StyledString.plain(" " * [rect.width - hint.display_width, 0].max) end |
#preprocess_paste(text) ⇒ String
Keeps the paste's first line and drops the rest, then trims what's left to what #max_text_length still allows:
f.handle_paste("name\nstreet\ncity")
f.text # => "name"
Overshooting the cap trims rather than rejects, which is what typing the
same characters would have done. Why the first line, and not a
newline-to-space flattening: D_paste_newlines.
@param text
170 171 172 173 174 175 |
# File 'lib/tuile/component/text_field.rb', line 170 def preprocess_paste(text) first_line = super[/\A[^\n]*/] return first_line if @max_text_length.nil? first_line[0, [@max_text_length - @text.length, 0].max] || "" end |
#repaint ⇒ void
This method returns an undefined value.
118 119 120 121 122 123 124 |
# File 'lib/tuile/component/text_field.rb', line 118 def repaint return if rect.empty? return draw_text(rect.left, rect.top, placeholder_row) if show_placeholder? draw_text(rect.left, rect.top, StyledString.plain(visible_text)) end |
#show_placeholder? ⇒ Boolean
@return — true when the empty field should paint its HasPlaceholder#placeholder instead of its (blank) contents.
242 |
# File 'lib/tuile/component/text_field.rb', line 242 def show_placeholder? = @text.empty? && !placeholder.to_s.empty? |
#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.
306 307 308 309 310 311 312 313 314 |
# File 'lib/tuile/component/text_field.rb', line 306 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.
254 |
# File 'lib/tuile/component/text_field.rb', line 254 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.
259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/tuile/component/text_field.rb', line 259 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 |