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, 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.
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 two protected hooks:
- #preprocess_text — input filter (e.g. TextField truncates to
fit
rect.width - 1). - #preprocess_paste — the same for #handle_paste, which lands a whole clipboard at the caret in one mutation.
- #on_text_mutated / #on_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.
-
#on_key ⇒ Proc, ...
Optional interceptor consulted before the input's own key handling.
-
#text ⇒ String
@return — current text contents.
Attributes included from HasValue
Instance Method Summary collapse
-
#background(text) ⇒ StyledString
Renders
texton the field's background well, looked up from the current Screen#theme at paint time: Theme#active_bg_color when this input is on the active (focus) chain, Theme#input_bg_color otherwise — visibly a field either way, distinctly highlighted when active. -
#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_on_escape ⇒ void
Default #on_escape action: clear focus.
-
#delete_at_caret ⇒ void
Removes the whole grapheme cluster at the caret.
-
#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
@return — true iff #value equals #empty_value.
-
#empty_value ⇒ String
""(notnil): a text field is empty when its buffer is blank. -
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. -
#handle_key(key) ⇒ Boolean
Handles a key.
-
#handle_paste(text) ⇒ Boolean
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.
-
#initialize ⇒ AbstractStringField
constructor
A new instance of AbstractStringField.
-
#insert_text(str) ⇒ Boolean
Inserts
strat the caret, leaving the caret behind it. -
#on_caret_mutated ⇒ void
Hook called after #caret has been mutated, before invalidation.
-
#on_text_mutated ⇒ void
Hook called after #text has been mutated, before invalidation / #on_change.
-
#preprocess_paste(text) ⇒ String
Input filter for #handle_paste, the paste-side counterpart of #preprocess_text.
-
#preprocess_text(new_text) ⇒ String
Input filter for #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.
53 54 55 56 57 58 59 60 61 |
# File 'lib/tuile/component/abstract_string_field.rb', line 53 def initialize super @text = +"" @caret = 0 @on_change = nil @on_value_change = nil @on_key = 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).
84 85 86 |
# File 'lib/tuile/component/abstract_string_field.rb', line 84 def caret @caret end |
#on_change ⇒ Proc, ...
90 91 92 |
# File 'lib/tuile/component/abstract_string_field.rb', line 90 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.
112 113 114 |
# File 'lib/tuile/component/abstract_string_field.rb', line 112 def on_escape @on_escape end |
#on_key ⇒ Proc, ...
Optional interceptor consulted before the input's own key handling. Receives the pressed key; return a truthy value to consume it (the input then ignores that key), falsy to let normal editing proceed.
The keyboard analog of #on_change: it lets app code layer behavior onto an input without subclassing. The motivating case is an autocomplete / slash-command overlay (a non-modal Popup): while it is open the interceptor claims Up/Down/Enter/ESC and forwards them to the overlay's list, but lets ordinary characters fall through so typing keeps editing the field (and #on_change keeps refilling the list).
@return — one-arg callable, or nil.
104 105 106 |
# File 'lib/tuile/component/abstract_string_field.rb', line 104 def on_key @on_key end |
#text ⇒ String
@return — current text contents.
64 65 66 |
# File 'lib/tuile/component/abstract_string_field.rb', line 64 def text @text end |
Instance Method Details
#background(text) ⇒ StyledString
Renders text on the field's background well, looked up from the
current Screen#theme at paint time: Theme#active_bg_color when this
input is on the active (focus) chain, Theme#input_bg_color otherwise —
visibly a field either way, distinctly highlighted when active.
@param text
@return — text on the field's background well.
202 203 204 |
# File 'lib/tuile/component/abstract_string_field.rb', line 202 def background(text) StyledString.styled(text, bg: active? ? screen.theme.active_bg_color : screen.theme.input_bg_color) end |
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
7010 |
# File 'sig/tuile.rbs', line 7010
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.
314 315 316 317 318 319 320 321 |
# File 'lib/tuile/component/abstract_string_field.rb', line 314 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.
299 300 301 302 303 304 305 306 307 308 309 |
# File 'lib/tuile/component/abstract_string_field.rb', line 299 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.
219 |
# File 'lib/tuile/component/abstract_string_field.rb', line 219 def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) } |
#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.
326 327 328 |
# File 'lib/tuile/component/abstract_string_field.rb', line 326 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.
274 275 276 277 278 279 280 |
# File 'lib/tuile/component/abstract_string_field.rb', line 274 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_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).
262 263 264 265 266 267 268 269 270 |
# File 'lib/tuile/component/abstract_string_field.rb', line 262 def delete_before_caret return if @caret.zero? start = cluster_boundary_before(@caret) new_text = @text.dup new_text.slice!(start...@caret) @caret = start self.text = new_text end |
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
7007 |
# File 'sig/tuile.rbs', line 7007
def empty?: () -> bool
|
#empty_value ⇒ String
"" (not nil): a text field is empty when its buffer is blank.
80 |
# File 'lib/tuile/component/abstract_string_field.rb', line 80 def empty_value = "" |
#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 (DECISIONS.md
D-integer-field).
7017 |
# File 'sig/tuile.rbs', line 7017
def focusable?: () -> bool
|
#handle_key(key) ⇒ Boolean
Handles a key. An #on_key interceptor (if set) gets first refusal — a truthy return consumes the key — otherwise it delegates to #handle_text_input_key. 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
154 155 156 157 158 |
# File 'lib/tuile/component/abstract_string_field.rb', line 154 def handle_key(key) return true if @on_key&.call(key) handle_text_input_key(key) end |
#handle_paste(text) ⇒ Boolean
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
@return — always true — a field consumes every paste, an empty one included.
166 167 168 169 |
# File 'lib/tuile/component/abstract_string_field.rb', line 166 def handle_paste(text) insert_text(preprocess_paste(text)) true end |
#handle_text_input_key(key) ⇒ Boolean
Dispatch hook for #handle_key. Handles ESC and the navigation 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. Subclasses
override to add their own keys (HOME/END, UP/DOWN, ENTER, BACKSPACE/
DELETE, printable insertion) and call super to fall back to the
common navigation handling.
@param key
@return — true if the key was handled.
242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 |
# File 'lib/tuile/component/abstract_string_field.rb', line 242 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::ESC return false if @on_escape.nil? @on_escape.call else return false end true end |
#insert_text(str) ⇒ Boolean
Inserts str at the caret, leaving the caret behind it. The bulk
counterpart of a subclass's per-key insert.
@param str
@return — true if the text changed.
187 188 189 190 191 192 193 194 |
# File 'lib/tuile/component/abstract_string_field.rb', line 187 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 |
#on_caret_mutated ⇒ void
231 |
# File 'lib/tuile/component/abstract_string_field.rb', line 231 def on_caret_mutated; end |
#on_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.
225 |
# File 'lib/tuile/component/abstract_string_field.rb', line 225 def on_text_mutated; end |
#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
181 |
# File 'lib/tuile/component/abstract_string_field.rb', line 181 def preprocess_paste(text) = text.tr("\t", " ").gsub(/[\x00-\x09\x0b-\x1f\x7f]/, "") |
#preprocess_text(new_text) ⇒ String
Input filter for #text=. Subclasses override to truncate or reject invalid input. Default coerces to String.
@param new_text
@return — possibly transformed text.
210 |
# File 'lib/tuile/component/abstract_string_field.rb', line 210 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.
286 287 288 289 290 291 292 293 294 |
# File 'lib/tuile/component/abstract_string_field.rb', line 286 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
114 |
# File 'lib/tuile/component/abstract_string_field.rb', line 114 def tab_stop? = true |
#value ⇒ String
70 |
# File 'lib/tuile/component/abstract_string_field.rb', line 70 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
74 75 76 |
# File 'lib/tuile/component/abstract_string_field.rb', line 74 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.
334 335 336 337 338 339 |
# File 'lib/tuile/component/abstract_string_field.rb', line 334 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.
345 346 347 348 349 350 |
# File 'lib/tuile/component/abstract_string_field.rb', line 345 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 |