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, 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:

Direct Known Subclasses

TextArea, TextField

Defined Under Namespace

Classes: EscapeEvent

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from HasValue

listener

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

listener

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

Returns:

  • (Integer)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (String)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • str (String)

Returns:

  • (Integer)


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.

Parameters:

  • index (Integer)


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.

Returns:

  • (Boolean)


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.

Returns:

  • (String)


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

Returns:



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.

Returns:

  • (Boolean)


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.

Returns:



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

Parameters:



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

Returns:

  • (Boolean)


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

def focusable?: () -> bool

#handle_caret_mutated ⇒ void

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



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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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

Parameters:

  • text (String)


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • str (String)

Returns:

  • (Boolean)


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.

Returns:

  • (::Array[String])


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.

Returns:



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.

Returns:



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.

Returns:



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

Parameters:

  • text (String)

Returns:

  • (String)


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.

Parameters:

  • new_text (String)

Returns:

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

Parameters:

  • new_value (String, _ToS)
  • from_user: (Boolean)


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.

Returns:



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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

Returns:

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

Returns:

  • (String)


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

Parameters:

  • new_value (Object)


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.

Returns:

  • (Integer)


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.

Returns:

  • (Integer)


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