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

Direct Known Subclasses

TextArea, TextField

Instance Attribute Summary collapse

Attributes included from HasValue

#on_value_change

Instance Method Summary collapse

Constructor Details

#initializeAbstractStringField

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

#caretInteger

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

Returns:

  • (Integer)


82
83
84
# File 'lib/tuile/component/abstract_string_field.rb', line 82

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)


88
89
90
# File 'lib/tuile/component/abstract_string_field.rb', line 88

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)


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

def on_escape
  @on_escape
end

#on_keyProc, ...

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.

Returns:

  • (Proc, Method, nil)


102
103
104
# File 'lib/tuile/component/abstract_string_field.rb', line 102

def on_key
  @on_key
end

#textString

@return — current text contents.

Returns:

  • (String)


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.

Parameters:

  • text (String)

Returns:



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

#clearvoid

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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

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


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



290
291
292
# File 'lib/tuile/component/abstract_string_field.rb', line 290

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.



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



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.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_valueString

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

Returns:

  • (String)


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

Returns:

  • (Boolean)


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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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



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

def on_caret_mutated; end

#on_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.



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.

Parameters:

  • new_text (String)

Returns:

  • (String)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

Returns:

  • (Boolean)


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

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)


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

Parameters:

  • new_value (String, _ToS)


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


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


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