Class: Tuile::Component::AbstractStringField
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::AbstractStringField
- Includes:
- HasValue
- Defined in:
- lib/tuile/component/abstract_string_field.rb,
sig/tuile.rbs
Overview
Abstract base for the String-valued editable text components
(TextField, TextArea): a field whose HasValue#value is its text.
A field whose value is a different type (an Integer, a domain object)
composes one of these rather than subclassing it — subclassing would
drag this String-typed text/value seam onto its face alongside the
real typed one.
Holds the shared state — a mutable #text buffer, a #caret index,
#on_change and #on_escape callbacks — and the keyboard machinery
that single-line and multi-line inputs both need: ESC handling,
LEFT/RIGHT caret movement, CTRL+LEFT/CTRL+RIGHT word jumps, CTRL+W
word-delete, and the tab_stop? flag (focusable? comes from
HasValue).
#caret counts characters into #text but may only sit between grapheme clusters — the glyphs a terminal draws. Both write sites snap it forward onto the enclosing cluster's end, and every edit steps by a whole cluster:
f.text = "e\u{0301}x" # a decomposed e-acute then "x": 3 chars, 2 glyphs
f.caret = 1 # into the middle of the e-acute …
f.caret # => 2, its end — where the caret already drew
f.handle_key?(Keys::BACKSPACE)
f.text # => "x": the whole glyph went, not its accent
Insertion stays character-native, so String#insert merges a typed
combining mark into its base; #text='s snap covers the case where that
re-segments the text around the caret.
Subclasses implement the layout-specific pieces (#cursor_position,
#repaint) and add their own keys (HOME/END, ENTER, UP/DOWN,
printable insertion) by overriding the protected
#handle_text_input_key? hook — super falls through to the common
navigation handling.
Customizing a field is subclassing it, and there are two seams
To change what keys do, override #handle_text_input_key?; to constrain what the buffer may hold, override #insert_text, which every insertion runs through — typed, pasted, or the ENTER newline:
class HexField < TextField
protected
# ENTER submits instead of falling through to the parent.
def handle_text_input_key?(key)
return super unless key == Keys::ENTER
submit(text)
true
end
# Hex digits only — and a paste of "12zz" lands nothing, not "12".
def insert_text(str)
return false unless @text.dup.insert(@caret, str).match?(/\A\h*\z/)
super
end
end
Both compose through super, which is why they are overrides rather than
the callback slot this class carried until 0.15.0: two behaviors could not
share one slot, and a filter written on a key callback let the same
characters in through a paste (D_input_filters, book ch7).
The mutation pipeline is a template method: #text= and #caret= detect no-ops, mutate state, fire #on_change, and invalidate. Subclasses inject their own behavior via four protected hooks:
- #insert_text — the one filter seam: every insertion runs through it, typed or pasted, so what the buffer may hold is decided here.
- #preprocess_text — filter for a whole assignment to #text=, which insertion does not pass through.
- #preprocess_paste — sanitizer for #handle_paste, run before the clipboard reaches #insert_text (TextField keeps its first line).
- #handle_text_mutated / #handle_caret_mutated — post-mutation side effects (e.g. TextArea invalidates its wrap cache and scrolls to keep the caret visible).
Instance Attribute Summary collapse
-
#caret ⇒ Integer
@return — caret index in
0..text.length, counting characters and always on a grapheme-cluster boundary (see the class doc). -
#on_change ⇒ Proc, ...
Optional callback fired whenever #text changes.
-
#on_escape ⇒ Proc, ...
Callback fired when ESC is pressed.
-
#text ⇒ String
@return — current text contents.
Attributes included from HasValue
Attributes included from HasValidation
Instance Method Summary collapse
-
#clear ⇒ void
Resets #value to #empty_value.
-
#cluster_boundary_after(index) ⇒ Integer
@param
index. -
#cluster_boundary_before(index) ⇒ Integer
@param
index. -
#columns_of(str) ⇒ Integer
The one measurement primitive both inputs share: a caret index counts characters, but every rect, cursor and click counts columns, and only this converts between them.
-
#default_bg_color ⇒ Color
The field's background well, looked up from the current Screen#theme at paint time: Theme#active_bg_color while this input is on the active (focus) chain, Theme#input_bg_color otherwise — visibly a field either way, distinctly highlighted when focused.
-
#default_on_escape ⇒ void
Default #on_escape action: clear focus.
-
#delete_at_caret ⇒ void
Removes the whole grapheme cluster at the caret.
-
#delete_back_to(index) ⇒ void
Removes the text between
indexand the caret, leaving the caret atindex— one mutation, so #on_change fires once. -
#delete_before_caret ⇒ void
Removes the whole grapheme cluster before the caret — one press, one glyph, whatever it is built from (a ZWJ emoji family and a three-jamo Hangul syllable each go whole).
-
#empty? ⇒ Boolean
Empty of value: a field whose parse is partial reports
truewhile the user is looking at glyphs it could not use, so ask HasBadInput#bad_input? first. -
#empty_value ⇒ String
""(notnil): a text field is empty when its buffer is blank. -
#error_bg_color ⇒ Color?
The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides #error_bg_color).
-
#error_ink? ⇒ Boolean
Whether to paint the invalid well right now.
-
#error_message ⇒ StyledString?
@return — why the field is invalid, or
nilwhen it is not;niluntil something sets it. -
#error_message= ⇒ void
Sets the verdict and repaints the field in Theme#error_color;
nilclears it. -
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. -
#handle_caret_mutated ⇒ void
Hook called after #caret has been mutated, before invalidation.
-
#handle_key?(key) ⇒ Boolean
Handles a key, by delegating to the #handle_text_input_key? hook a subclass overrides.
-
#handle_paste(text) ⇒ void
Inserts pasted text at the caret as one mutation, so #on_change fires once for the whole paste rather than once per character.
-
#handle_text_input_key?(key) ⇒ Boolean
Dispatch hook for #handle_key?.
-
#handle_text_mutated ⇒ void
Hook called after #text has been mutated, before invalidation / #on_change.
-
#initialize ⇒ AbstractStringField
constructor
A new instance of AbstractStringField.
-
#insert_text(str) ⇒ Boolean
Inserts
strat the caret, leaving the caret behind it. -
#inspect_details ⇒ ::Array[String]
Adds
value=…to #inspect, omitted while the value is nil. -
#preprocess_paste(text) ⇒ String
Input filter for #handle_paste, the paste-side counterpart of #preprocess_text.
-
#preprocess_text(new_text) ⇒ String
Input filter for a whole assignment to #text=.
-
#snap_to_cluster(index) ⇒ Integer
@param
index— a #text index in0..text.length. - #tab_stop? ⇒ Boolean
- #value ⇒ String
-
#value=(new_value) ⇒ void
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS @param
new_value. -
#word_left ⇒ Integer
Caret target for ctrl+left: skip whitespace going left, then a run of non-whitespace.
-
#word_right ⇒ Integer
Caret target for ctrl+right: skip non-whitespace going right, then a run of whitespace.
Constructor Details
#initialize ⇒ AbstractStringField
Returns a new instance of AbstractStringField.
85 86 87 88 89 90 91 92 |
# File 'lib/tuile/component/abstract_string_field.rb', line 85 def initialize super @text = +"" @caret = 0 @on_change = nil @on_value_change = nil @on_escape = method(:default_on_escape) end |
Instance Attribute Details
#caret ⇒ Integer
@return — caret index in 0..text.length, counting characters
and always on a grapheme-cluster boundary (see the class doc).
115 116 117 |
# File 'lib/tuile/component/abstract_string_field.rb', line 115 def caret @caret end |
#on_change ⇒ Proc, ...
121 122 123 |
# File 'lib/tuile/component/abstract_string_field.rb', line 121 def on_change @on_change end |
#on_escape ⇒ Proc, ...
Callback fired when ESC is pressed. Defaults to a closure that clears
focus (screen.focused = nil) so ESC visibly cancels text entry instead
of bubbling to the parent — and, in particular, instead of reaching the
screen's default ESC-to-quit handler. Set to nil to let ESC fall through
to the parent again; set to any other callable to replace the default.
@return — no-arg callable, or nil.
129 130 131 |
# File 'lib/tuile/component/abstract_string_field.rb', line 129 def on_escape @on_escape end |
#text ⇒ String
@return — current text contents.
95 96 97 |
# File 'lib/tuile/component/abstract_string_field.rb', line 95 def text @text end |
Instance Method Details
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
An includer whose input can outrun its value (HasBadInput) must clear
the input: a field holding bad input already reads empty_value, so
inheriting this default — over a #value= that returns early on a no-op
set — is a clear that leaves the garbage on screen.
11063 |
# File 'sig/tuile.rbs', line 11063
def clear: () -> void
|
#cluster_boundary_after(index) ⇒ Integer
@param index
@return — the smallest grapheme-cluster boundary > index, or
text.length at the end of the text.
360 361 362 363 364 365 366 367 |
# File 'lib/tuile/component/abstract_string_field.rb', line 360 def cluster_boundary_after(index) offset = 0 @text.each_grapheme_cluster do |g| offset += g.length return offset if offset > index end offset end |
#cluster_boundary_before(index) ⇒ Integer
@param index
@return — the greatest grapheme-cluster boundary < index, or
0 at the start of the text.
345 346 347 348 349 350 351 352 353 354 355 |
# File 'lib/tuile/component/abstract_string_field.rb', line 345 def cluster_boundary_before(index) last = 0 offset = 0 @text.each_grapheme_cluster do |g| offset += g.length return last if offset >= index last = offset end last end |
#columns_of(str) ⇒ Integer
The one measurement primitive both inputs share: a caret index counts characters, but every rect, cursor and click counts columns, and only this converts between them.
@param str
@return — str's width in terminal columns, measured per
grapheme cluster — so a combining mark adds nothing and a fullwidth
glyph adds two.
254 |
# File 'lib/tuile/component/abstract_string_field.rb', line 254 def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) } |
#default_bg_color ⇒ Color
The field's background well, looked up from the current Screen#theme at paint time: Theme#active_bg_color while this input is on the active (focus) chain, Theme#input_bg_color otherwise — visibly a field either way, distinctly highlighted when focused. An app overrides the pair by setting Tuile::Component#bg_color, which wins over this.
Unconditional on purpose. A field used as the face of a composed one (ComboBox, IntegerField …) is told to drop its well — that widget assigns BG_INHERIT at construction, since it owns the surface and a second well would make its own Tuile::Component#bg_color inert over the very cells this field paints.
238 |
# File 'lib/tuile/component/abstract_string_field.rb', line 238 def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color |
#default_on_escape ⇒ void
This method returns an undefined value.
Default #on_escape action: clear focus. Component deactivates; user can re-focus by clicking or tabbing back in.
372 373 374 |
# File 'lib/tuile/component/abstract_string_field.rb', line 372 def default_on_escape screen.focused = nil end |
#delete_at_caret ⇒ void
This method returns an undefined value.
Removes the whole grapheme cluster at the caret.
320 321 322 323 324 325 326 |
# File 'lib/tuile/component/abstract_string_field.rb', line 320 def delete_at_caret return if @caret >= @text.length new_text = @text.dup new_text.slice!(@caret...cluster_boundary_after(@caret)) self.text = new_text end |
#delete_back_to(index) ⇒ void
This method returns an undefined value.
Removes the text between index and the caret, leaving the caret at
index — one mutation, so #on_change fires once.
index is snapped forward onto a grapheme-cluster boundary, so a
caller may compute it by counting characters.
@param index — a #text index; clamped to 0..caret.
308 309 310 311 312 313 314 315 316 |
# File 'lib/tuile/component/abstract_string_field.rb', line 308 def delete_back_to(index) start = snap_to_cluster(index.clamp(0, @caret)) return if start == @caret new_text = @text.dup new_text.slice!(start...@caret) @caret = start self.text = new_text end |
#delete_before_caret ⇒ void
This method returns an undefined value.
Removes the whole grapheme cluster before the caret — one press, one glyph, whatever it is built from (a ZWJ emoji family and a three-jamo Hangul syllable each go whole).
299 |
# File 'lib/tuile/component/abstract_string_field.rb', line 299 def delete_before_caret = delete_back_to(cluster_boundary_before(@caret)) |
#empty? ⇒ Boolean
Empty of value: a field whose parse is partial reports true while the
user is looking at glyphs it could not use, so ask
HasBadInput#bad_input? first.
@return — true iff #value equals #empty_value.
11055 |
# File 'sig/tuile.rbs', line 11055
def empty?: () -> bool
|
#empty_value ⇒ String
"" (not nil): a text field is empty when its buffer is blank.
111 |
# File 'lib/tuile/component/abstract_string_field.rb', line 111 def empty_value = "" |
#error_bg_color ⇒ Color?
The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides Tuile::Component#error_bg_color).
11094 |
# File 'sig/tuile.rbs', line 11094
def error_bg_color: () -> Color?
|
#error_ink? ⇒ Boolean
Whether to paint the invalid well right now. Its own hook because HasBadInput widens it: a field holding input its value cannot represent is invalid on the face too, even with no verdict written.
11099 |
# File 'sig/tuile.rbs', line 11099
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
11077 |
# File 'sig/tuile.rbs', line 11077
def error_message: () -> StyledString?
|
#error_message= ⇒ void
This method returns an undefined value.
Sets the verdict and repaints the field in Theme#error_color; nil
clears it. No-op (no repaint, no listener) when unchanged. A String is
parsed via StyledString.parse, as HasCaption#caption= does.
Safe on a detached field — an app validates a form it assembled but has not mounted, and Tuile::Component#invalidate is already a no-op there.
@param new_message
11087 |
# File 'sig/tuile.rbs', line 11087
def error_message=: ((String | StyledString)? new_message) -> void
|
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides Tuile::Component#focusable?);
a read-only display field could override back to false. Only
focusable? lives here — tab_stop? diverges between leaf fields and
composing wrappers, so it stays per-class (design/decisions.md
D_integer_field).
11070 |
# File 'sig/tuile.rbs', line 11070
def focusable?: () -> bool
|
#handle_caret_mutated ⇒ void
266 |
# File 'lib/tuile/component/abstract_string_field.rb', line 266 def handle_caret_mutated; end |
#handle_key?(key) ⇒ Boolean
Handles a key, by delegating to the #handle_text_input_key? hook a subclass overrides. Dispatch (ScreenPane#handle_key?) only routes keys here when this input is on the focus chain, so there is no Tuile::Component#active? gate.
@param key
170 |
# File 'lib/tuile/component/abstract_string_field.rb', line 170 def handle_key?(key) = handle_text_input_key?(key) |
#handle_paste(text) ⇒ void
This method returns an undefined value.
Inserts pasted text at the caret as one mutation, so #on_change fires once for the whole paste rather than once per character. #preprocess_paste filters it first.
@param text
177 178 179 |
# File 'lib/tuile/component/abstract_string_field.rb', line 177 def handle_paste(text) insert_text(preprocess_paste(text)) end |
#handle_text_input_key?(key) ⇒ Boolean
Dispatch hook for #handle_key?. Handles ESC and the editing keys that
have identical semantics in single-line and multi-line inputs:
LEFT/RIGHT arrows (one grapheme cluster per press, so a press always
moves), CTRL+LEFT/CTRL+RIGHT for word jumps, and CTRL+W, which deletes
exactly what CTRL+LEFT would have skipped over (readline's
unix-word-rubout). Subclasses override to add their own keys (HOME/END,
UP/DOWN, ENTER, CTRL+U, BACKSPACE/DELETE, printable insertion) and call
super to fall back to the common handling.
@param key
@return — true if the key was handled.
278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 |
# File 'lib/tuile/component/abstract_string_field.rb', line 278 def handle_text_input_key?(key) case key when Keys::LEFT_ARROW then self.caret = cluster_boundary_before(@caret) when Keys::RIGHT_ARROW then self.caret = cluster_boundary_after(@caret) when Keys::CTRL_LEFT_ARROW then self.caret = word_left when Keys::CTRL_RIGHT_ARROW then self.caret = word_right when Keys::CTRL_W then delete_back_to(word_left) when Keys::ESC return false if @on_escape.nil? @on_escape.call else return false end true end |
#handle_text_mutated ⇒ void
This method returns an undefined value.
Hook called after #text has been mutated, before invalidation / #on_change. Default no-op. Subclasses use this to invalidate caches (TextArea's wrap cache) and update derived state.
260 |
# File 'lib/tuile/component/abstract_string_field.rb', line 260 def handle_text_mutated; end |
#insert_text(str) ⇒ Boolean
Inserts str at the caret, leaving the caret behind it.
Every insertion lands here — a typed character, the ENTER newline and a whole pasted clipboard alike — so a field constrains its contents by overriding this, and one override covers typing and pasting both:
def insert_text(str) # hex digits only, in a TextField subclass
return false unless @text.dup.insert(@caret, str).match?(/\A\h*\z/)
super
end
Test the whole resulting buffer, as above, and not the fragment being
inserted: sieving per character turns a pasted "1,5" into the
plausible, wrong "15", where an all-or-nothing test drops it — which
is also what typing the comma does. Filtering at all works only for a
grammar every valid value can be typed through; one where it can't
(a date — "2020-13-45" is well-formed at every character) reports bad
input rather than filtering it (D_input_filters, book ch7).
#text= does not pass through here: only user input is filtered, so a programmatic HasValue#value= may still write what no key types.
@param str
@return — true if the text changed.
217 218 219 220 221 222 223 224 |
# File 'lib/tuile/component/abstract_string_field.rb', line 217 def insert_text(str) return false if str.empty? new_text = @text.dup.insert(@caret, str) @caret += str.length self.text = new_text true end |
#inspect_details ⇒ ::Array[String]
Adds value=… to Tuile::Component#inspect, omitted while the value is nil.
11073 |
# File 'sig/tuile.rbs', line 11073
def inspect_details: () -> ::Array[String]
|
#preprocess_paste(text) ⇒ String
Input filter for #handle_paste, the paste-side counterpart of
#preprocess_text. Strips the C0 control characters a text buffer
cannot hold — a raw \e or \t reaching Buffer would move the real
terminal cursor mid-frame — keeping \n, and turning a tab into a
single space so pasted code keeps its word gaps. TextField narrows it
further; an app wanting tab expansion overrides #handle_paste.
@param text
191 |
# File 'lib/tuile/component/abstract_string_field.rb', line 191 def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "") |
#preprocess_text(new_text) ⇒ String
Input filter for a whole assignment to #text=. Nothing overrides it today; a subclass that does is filtering the programmatic setter, not user input — that is #insert_text.
@param new_text
@return — possibly transformed text; the default coerces to String.
245 |
# File 'lib/tuile/component/abstract_string_field.rb', line 245 def preprocess_text(new_text) = new_text.to_s |
#snap_to_cluster(index) ⇒ Integer
@param index — a #text index in 0..text.length.
@return — the smallest grapheme-cluster boundary >= index.
332 333 334 335 336 337 338 339 340 |
# File 'lib/tuile/component/abstract_string_field.rb', line 332 def snap_to_cluster(index) offset = 0 @text.each_grapheme_cluster do |g| return offset if offset >= index offset += g.length end offset end |
#tab_stop? ⇒ Boolean
131 |
# File 'lib/tuile/component/abstract_string_field.rb', line 131 def tab_stop? = true |
#value ⇒ String
101 |
# File 'lib/tuile/component/abstract_string_field.rb', line 101 def value = text |
#value=(new_value) ⇒ void
This method returns an undefined value.
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS
@param new_value
105 106 107 |
# File 'lib/tuile/component/abstract_string_field.rb', line 105 def value=(new_value) self.text = new_value.to_s end |
#word_left ⇒ Integer
Caret target for ctrl+left: skip whitespace going left, then a run of non-whitespace. Lands at the beginning of the current word, or the beginning of the previous word if already there.
380 381 382 383 384 385 |
# File 'lib/tuile/component/abstract_string_field.rb', line 380 def word_left c = @caret c -= 1 while c.positive? && @text[c - 1].match?(/\s/) c -= 1 while c.positive? && !@text[c - 1].match?(/\s/) c end |
#word_right ⇒ Integer
Caret target for ctrl+right: skip non-whitespace going right, then a run of whitespace. Lands at the beginning of the next word, or at the end of the text if no further word exists.
391 392 393 394 395 396 |
# File 'lib/tuile/component/abstract_string_field.rb', line 391 def word_right c = @caret c += 1 while c < @text.length && !@text[c].match?(/\s/) c += 1 while c < @text.length && @text[c].match?(/\s/) c end |