Class: Tuile::Component::AbstractStringField

Inherits:
Component
  • Object
show all
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:

Direct Known Subclasses

TextArea, TextField

Instance Attribute Summary collapse

Attributes included from HasValue

#on_value_change

Attributes included from HasValidation

#on_error_message_change

Instance Method Summary collapse

Constructor Details

#initializeAbstractStringField

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

#caretInteger

@return — caret index in 0..text.length, counting characters and always on a grapheme-cluster boundary (see the class doc).

Returns:

  • (Integer)


115
116
117
# File 'lib/tuile/component/abstract_string_field.rb', line 115

def caret
  @caret
end

#on_changeProc, ...

Optional callback fired whenever #text changes. Receives the new text as a single argument. Not fired by #caret= (text unchanged) and not fired when a setter is a no-op.

@return — one-arg callable, or nil.

Returns:

  • (Proc, Method, nil)


121
122
123
# File 'lib/tuile/component/abstract_string_field.rb', line 121

def on_change
  @on_change
end

#on_escapeProc, ...

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.

Returns:

  • (Proc, Method, nil)


129
130
131
# File 'lib/tuile/component/abstract_string_field.rb', line 129

def on_escape
  @on_escape
end

#textString

@return — current text contents.

Returns:

  • (String)


95
96
97
# File 'lib/tuile/component/abstract_string_field.rb', line 95

def text
  @text
end

Instance Method Details

#clearvoid

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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

@returnstr's width in terminal columns, measured per grapheme cluster — so a combining mark adds nothing and a fullwidth glyph adds two.

Parameters:

  • str (String)

Returns:

  • (Integer)


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_colorColor

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.

Returns:



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_escapevoid

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_caretvoid

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.

Parameters:

  • index (Integer)


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_caretvoid

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.

Returns:

  • (Boolean)


11055
# File 'sig/tuile.rbs', line 11055

def empty?: () -> bool

#empty_valueString

"" (not nil): a text field is empty when its buffer is blank.

Returns:

  • (String)


111
# File 'lib/tuile/component/abstract_string_field.rb', line 111

def empty_value = ""

#error_bg_colorColor?

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).

Returns:



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.

Returns:

  • (Boolean)


11099
# File 'sig/tuile.rbs', line 11099

def error_ink?: () -> bool

#error_messageStyledString?

@return — why the field is invalid, or nil when it is not; nil until something sets it.

Returns:



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

Parameters:



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).

Returns:

  • (Boolean)


11070
# File 'sig/tuile.rbs', line 11070

def focusable?: () -> bool

#handle_caret_mutatedvoid

This method returns an undefined value.

Hook called after #caret has been mutated, before invalidation. Default no-op. Subclasses use this to keep the caret visible (TextArea's vertical scroll).



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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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

Parameters:

  • text (String)


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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_mutatedvoid

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.

Parameters:

  • str (String)

Returns:

  • (Boolean)


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.

Returns:

  • (::Array[String])


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

Parameters:

  • text (String)

Returns:

  • (String)


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.

Parameters:

  • new_text (String)

Returns:

  • (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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

Returns:

  • (Boolean)


131
# File 'lib/tuile/component/abstract_string_field.rb', line 131

def tab_stop? = true

#valueString

A text component's value is its text: #value/#value= are the HasValue seam over the same buffer as #text/#text=, so a form can drive it alongside typed fields. text stays the text-native name.

Returns:

  • (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

Parameters:

  • new_value (String, _ToS)


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_leftInteger

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.

Returns:

  • (Integer)


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_rightInteger

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.

Returns:

  • (Integer)


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