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, the
#on_escape slot — 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.value = "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; #set_value'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: #set_value and #caret=
detect no-ops, mutate state, fire HasValue#on_value_change, and invalidate.
Typing, a paste and the deleting keys write with from_user: true, every
other write with false (HasValue::ValueChangeEvent#from_user?).
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 #set_value, 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).
Defined Under Namespace
Classes: EscapeEvent
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). -
#escape_clears_focus ⇒ Boolean
Whether ESC clears focus (
trueby default), so text entry visibly cancels instead of quitting the app. -
#text ⇒ String
readonly
@return — current text contents.
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.
-
#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 HasValue#on_value_change fires once, as the user's: every caller is a deleting key. -
#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 HasValue#on_value_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 / HasValue#on_value_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. -
#on_error_message_change ⇒ Listeners
Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set.
-
#on_escape ⇒ Listeners
Fired with an EscapeEvent when ESC is pressed, after #escape_clears_focus has had its say.
-
#on_value_change ⇒ Listeners
Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.
-
#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 #set_value.
-
#set_value(new_value, from_user:) ⇒ void
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS Replaces the text.
-
#shown_message ⇒ StyledString, ...
The message a consumer with cells of its own paints beside the field: the verdict here, and HasBadInput widens it to prefer the field's own report, which outranks a verdict computed a pass ago.
-
#snap_to_cluster(index) ⇒ Integer
@param
index— a #text index in0..text.length. - #tab_stop? ⇒ Boolean
-
#value ⇒ String
A text component's value is its text: #value reads the same buffer as #text, and #set_value is its one writer.
-
#value= ⇒ void
A programmatic write: #set_value with
from_user: false. -
#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.
Methods included from HasValue
Methods included from Listeners::Declare
Methods included from HasValidation
Constructor Details
#initialize ⇒ AbstractStringField
Returns a new instance of AbstractStringField.
87 88 89 90 91 92 93 94 95 96 97 |
# File 'lib/tuile/component/abstract_string_field.rb', line 87 def initialize super @text = +"" @caret = 0 @escape_clears_focus = true # Unconditional on purpose: a field used as the face of a composed one # ({ComboBox}, {IntegerField} …) is *told* to drop its well with # {ComponentBackground::INHERIT} — a second well would make the # composer's own bg_color inert over the very cells this field paints. bg.default_color = ComponentBackground::INPUT_WELL 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).
134 135 136 |
# File 'lib/tuile/component/abstract_string_field.rb', line 134 def caret @caret end |
#escape_clears_focus ⇒ Boolean
Whether ESC clears focus (true by default), so text entry visibly
cancels instead of quitting the app. It runs before #on_escape fires,
and it — not a listener an app must remove by identity — is the whole
representation of the default, so turning it off is the one way to give
ESC another meaning. See D_escape_opt_out.
163 164 165 |
# File 'lib/tuile/component/abstract_string_field.rb', line 163 def escape_clears_focus @escape_clears_focus end |
#text ⇒ String (readonly)
@return — current text contents.
100 101 102 |
# File 'lib/tuile/component/abstract_string_field.rb', line 100 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 #set_value that returns early on a
no-op set — is a clear that leaves the garbage on screen. A clear is
programmatic: its event says from_user? == false.
14150 |
# File 'sig/tuile.rbs', line 14150
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.
368 369 370 371 372 373 374 375 |
# File 'lib/tuile/component/abstract_string_field.rb', line 368 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.
353 354 355 356 357 358 359 360 361 362 363 |
# File 'lib/tuile/component/abstract_string_field.rb', line 353 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.
260 |
# File 'lib/tuile/component/abstract_string_field.rb', line 260 def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) } |
#delete_at_caret ⇒ void
This method returns an undefined value.
Removes the whole grapheme cluster at the caret.
328 329 330 331 332 333 334 |
# File 'lib/tuile/component/abstract_string_field.rb', line 328 def delete_at_caret return if @caret >= @text.length new_text = @text.dup new_text.slice!(@caret...cluster_boundary_after(@caret)) set_value(new_text, from_user: true) 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 HasValue#on_value_change fires once, as the
user's: every caller is a deleting key.
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.
316 317 318 319 320 321 322 323 324 |
# File 'lib/tuile/component/abstract_string_field.rb', line 316 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 set_value(new_text, from_user: true) 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).
306 |
# File 'lib/tuile/component/abstract_string_field.rb', line 306 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.
14141 |
# File 'sig/tuile.rbs', line 14141
def empty?: () -> bool
|
#empty_value ⇒ String
"" (not nil): a text field is empty when its buffer is blank.
130 |
# File 'lib/tuile/component/abstract_string_field.rb', line 130 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).
14200 |
# File 'sig/tuile.rbs', line 14200
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.
14205 |
# File 'sig/tuile.rbs', line 14205
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
14170 |
# File 'sig/tuile.rbs', line 14170
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
14180 |
# File 'sig/tuile.rbs', line 14180
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).
14157 |
# File 'sig/tuile.rbs', line 14157
def focusable?: () -> bool
|
#handle_caret_mutated ⇒ void
272 |
# File 'lib/tuile/component/abstract_string_field.rb', line 272 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
187 |
# File 'lib/tuile/component/abstract_string_field.rb', line 187 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 HasValue#on_value_change fires once for the whole paste rather than once per character. #preprocess_paste filters it first.
@param text
195 196 197 |
# File 'lib/tuile/component/abstract_string_field.rb', line 195 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.
284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 |
# File 'lib/tuile/component/abstract_string_field.rb', line 284 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 !@escape_clears_focus && on_escape.empty? screen.focused = nil if @escape_clears_focus on_escape.fire(EscapeEvent.new(source: self)) 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 / HasValue#on_value_change. Default no-op. Subclasses use this to invalidate caches (TextArea's wrap cache) and update derived state.
266 |
# File 'lib/tuile/component/abstract_string_field.rb', line 266 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).
#set_value does not pass through here: only user input is filtered,
so a programmatic write may still hold what no key types. The same line
decides the origin: an insertion is the user's, so it writes with
from_user: true.
@param str
@return — true if the text changed.
237 238 239 240 241 242 243 244 |
# File 'lib/tuile/component/abstract_string_field.rb', line 237 def insert_text(str) return false if str.empty? new_text = @text.dup.insert(@caret, str) @caret += str.length set_value(new_text, from_user: true) true end |
#inspect_details ⇒ ::Array[String]
Adds value=… to Tuile::Component#inspect, omitted while the value is nil.
14160 |
# File 'sig/tuile.rbs', line 14160
def inspect_details: () -> ::Array[String]
|
#on_error_message_change ⇒ Listeners
Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set. The container that paints the message registers here, and so may an app painting its own: the list takes both, which a single slot could not.
14166 |
# File 'sig/tuile.rbs', line 14166
def on_error_message_change: () -> Listeners
|
#on_escape ⇒ Listeners
Fired with an EscapeEvent when ESC is pressed, after #escape_clears_focus has had its say. Append to react as well as blur; turn that flag off first to react instead:
field.escape_clears_focus = false
field.on_escape << method(:close_search)
Empty means the field declines ESC — but only while #escape_clears_focus is off too, since a field that blurs has handled the key. With both, ESC bubbles to the parent, and on to the screen's ESC-to-quit.
155 |
# File 'lib/tuile/component/abstract_string_field.rb', line 155 listener :on_escape |
#on_value_change ⇒ Listeners
Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.
14127 |
# File 'sig/tuile.rbs', line 14127
def on_value_change: () -> Listeners
|
#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
209 |
# File 'lib/tuile/component/abstract_string_field.rb', line 209 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 #set_value. 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.
251 |
# File 'lib/tuile/component/abstract_string_field.rb', line 251 def preprocess_text(new_text) = new_text.to_s |
#set_value(new_value, from_user:) ⇒ void
This method returns an undefined value.
sord duck - #to_s looks like a duck type with an equivalent RBS interface, replacing with _ToS Replaces the text. Runs #preprocess_text first, then clamps the caret to the new text and snaps it onto a cluster boundary of it. Fires HasValue#on_value_change only on a real change — never on #caret=.
field.value = "admin"
field.caret = field.text.length # the caret is clamped, never moved to the end
@param new_value
@param from_user — see HasValue#set_value.
117 118 119 120 121 122 123 124 125 126 |
# File 'lib/tuile/component/abstract_string_field.rb', line 117 def set_value(new_value, from_user:) new_value = preprocess_text(new_value) return if @text == new_value @text = +new_value @caret = snap_to_cluster(@caret.clamp(0, @text.length)) handle_text_mutated invalidate on_value_change.fire(HasValue::ValueChangeEvent.new(source: self, value: @text, from_user:)) end |
#shown_message ⇒ StyledString, ...
The message a consumer with cells of its own paints beside the field: the verdict here, and HasBadInput widens it to prefer the field's own report, which outranks a verdict computed a pass ago.
field.on_error_message_change { message_label.caption = field.shown_message.to_s }
field.on_bad_input_change { message_label.caption = field.shown_message.to_s }
Register on both: two channels with two writers, either of which moves it.
@return — nil when there is nothing to show;
a plain String when it is the field's own bad-input report.
14193 |
# File 'sig/tuile.rbs', line 14193
def shown_message: () -> (StyledString | String)?
|
#snap_to_cluster(index) ⇒ Integer
@param index — a #text index in 0..text.length.
@return — the smallest grapheme-cluster boundary >= index.
340 341 342 343 344 345 346 347 348 |
# File 'lib/tuile/component/abstract_string_field.rb', line 340 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
165 |
# File 'lib/tuile/component/abstract_string_field.rb', line 165 def tab_stop? = true |
#value ⇒ String
A text component's value is its text: #value reads the same buffer as #text, and #set_value is its one writer.
105 |
# File 'lib/tuile/component/abstract_string_field.rb', line 105 def value = text |
#value= ⇒ void
This method returns an undefined value.
A programmatic write: #set_value with from_user: false. Defined
here once and never overridden — override #set_value instead, since a
setter has no call syntax for the keyword.
@param new_value
14134 |
# File 'sig/tuile.rbs', line 14134
def value=: (Object new_value) -> void
|
#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.
381 382 383 384 385 386 |
# File 'lib/tuile/component/abstract_string_field.rb', line 381 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.
392 393 394 395 396 397 |
# File 'lib/tuile/component/abstract_string_field.rb', line 392 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 |