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.



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

#caretInteger

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

Returns:

  • (Integer)


84
85
86
# File 'lib/tuile/component/abstract_string_field.rb', line 84

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)


90
91
92
# File 'lib/tuile/component/abstract_string_field.rb', line 90

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)


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

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)


104
105
106
# File 'lib/tuile/component/abstract_string_field.rb', line 104

def on_key
  @on_key
end

#textString

@return — current text contents.

Returns:

  • (String)


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.

Parameters:

  • text (String)

Returns:



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

#clearvoid

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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

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


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



326
327
328
# File 'lib/tuile/component/abstract_string_field.rb', line 326

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.



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



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.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_valueString

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

Returns:

  • (String)


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

Returns:

  • (Boolean)


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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • text (String)

Returns:

  • (Boolean)


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • str (String)

Returns:

  • (Boolean)


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



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

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.



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

Parameters:

  • text (String)

Returns:

  • (String)


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.

Parameters:

  • new_text (String)

Returns:

  • (String)


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.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


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

Returns:

  • (Boolean)


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

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)


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

Parameters:

  • new_value (String, _ToS)


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


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


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