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). - #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_text_input_key(key) ⇒ Boolean
Dispatch hook for #handle_key.
-
#initialize ⇒ AbstractStringField
constructor
A new instance of AbstractStringField.
-
#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_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.
51 52 53 54 55 56 57 58 59 |
# File 'lib/tuile/component/abstract_string_field.rb', line 51 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).
82 83 84 |
# File 'lib/tuile/component/abstract_string_field.rb', line 82 def caret @caret end |
#on_change ⇒ Proc, ...
88 89 90 |
# File 'lib/tuile/component/abstract_string_field.rb', line 88 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.
110 111 112 |
# File 'lib/tuile/component/abstract_string_field.rb', line 110 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.
102 103 104 |
# File 'lib/tuile/component/abstract_string_field.rb', line 102 def on_key @on_key end |
#text ⇒ String
@return — current text contents.
62 63 64 |
# File 'lib/tuile/component/abstract_string_field.rb', line 62 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.
166 167 168 |
# File 'lib/tuile/component/abstract_string_field.rb', line 166 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.
4511 |
# File 'sig/tuile.rbs', line 4511
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.
278 279 280 281 282 283 284 285 |
# File 'lib/tuile/component/abstract_string_field.rb', line 278 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.
263 264 265 266 267 268 269 270 271 272 273 |
# File 'lib/tuile/component/abstract_string_field.rb', line 263 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.
183 |
# File 'lib/tuile/component/abstract_string_field.rb', line 183 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.
290 291 292 |
# File 'lib/tuile/component/abstract_string_field.rb', line 290 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.
238 239 240 241 242 243 244 |
# File 'lib/tuile/component/abstract_string_field.rb', line 238 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).
226 227 228 229 230 231 232 233 234 |
# File 'lib/tuile/component/abstract_string_field.rb', line 226 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.
4508 |
# File 'sig/tuile.rbs', line 4508
def empty?: () -> bool
|
#empty_value ⇒ String
"" (not nil): a text field is empty when its buffer is blank.
78 |
# File 'lib/tuile/component/abstract_string_field.rb', line 78 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).
4518 |
# File 'sig/tuile.rbs', line 4518
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
152 153 154 155 156 |
# File 'lib/tuile/component/abstract_string_field.rb', line 152 def handle_key(key) return true if @on_key&.call(key) handle_text_input_key(key) 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.
206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/tuile/component/abstract_string_field.rb', line 206 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 |
#on_caret_mutated ⇒ void
195 |
# File 'lib/tuile/component/abstract_string_field.rb', line 195 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.
189 |
# File 'lib/tuile/component/abstract_string_field.rb', line 189 def on_text_mutated; end |
#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.
174 |
# File 'lib/tuile/component/abstract_string_field.rb', line 174 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.
250 251 252 253 254 255 256 257 258 |
# File 'lib/tuile/component/abstract_string_field.rb', line 250 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
112 |
# File 'lib/tuile/component/abstract_string_field.rb', line 112 def tab_stop? = true |
#value ⇒ String
68 |
# File 'lib/tuile/component/abstract_string_field.rb', line 68 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
72 73 74 |
# File 'lib/tuile/component/abstract_string_field.rb', line 72 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.
298 299 300 301 302 303 |
# File 'lib/tuile/component/abstract_string_field.rb', line 298 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.
309 310 311 312 313 314 |
# File 'lib/tuile/component/abstract_string_field.rb', line 309 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 |