Class: Tuile::Component::TextField

Inherits:
AbstractStringField show all
Defined in:
lib/tuile/component/text_field.rb,
sig/tuile.rbs

Overview

A single-line text input with a real hardware caret, scrolling horizontally to keep that caret in view:

f = TextField.new
f.rect  = Rect.new(0, 0, 6, 1)   # six columns wide …
f.text  = "hello world"          # … eleven columns of text, so it scrolls
f.caret = 11                     # paints "world " — left_column 6, cursor on the last column
f.caret = 0                      # paints "hello " — left_column 0

The field's width never bounds its contents — #max_text_length does, and only for typing.

Implementation details

Two axes run through this class and are not interchangeable:

They coincide only while every glyph is one column wide. A fullwidth CJK char is two columns and a combining mark zero, so index 3 of "日本語" is column 6. Every crossing goes through the private column_at / index_at pair; adding an index to a column anywhere else is the bug those two exist to prevent.

Indices count characters while widths measure grapheme clusters, but the caret never falls between the two: AbstractStringField keeps it on a cluster boundary, so a column derived from it always names a real glyph edge.

What gets painted is #display_text, a third seam that is text itself here and the mask in PasswordField. Every column measurement reads it, so a subclass showing something else overrides that and never #repaint — overriding the paint alone leaves the measurements on the buffer while the cells show the substitute, and the two drift apart by a growing offset.

Direct Known Subclasses

PasswordField

Instance Attribute Summary collapse

Attributes inherited from AbstractStringField

#caret, #on_change, #on_escape, #on_key, #text

Attributes included from HasValue

#on_value_change

Instance Method Summary collapse

Methods inherited from AbstractStringField

#background, #clear, #cluster_boundary_after, #cluster_boundary_before, #columns_of, #default_on_escape, #delete_at_caret, #delete_before_caret, #empty?, #empty_value, #focusable?, #handle_key, #preprocess_text, #snap_to_cluster, #tab_stop?, #value, #value=, #word_left, #word_right

Methods included from HasValue

#clear, #empty?, #empty_value, #focusable?, #value, #value=

Constructor Details

#initializeTextField

Returns a new instance of TextField.



43
44
45
46
47
48
49
50
# File 'lib/tuile/component/text_field.rb', line 43

def initialize
  super
  @left_column = 0
  @max_text_length = nil
  @on_key_up = nil
  @on_key_down = nil
  @on_enter = nil
end

Instance Attribute Details

#left_columnInteger (readonly)

@return — text column drawn in the field's leftmost cell — the horizontal scroll offset. Follows AbstractStringField#caret, always on a glyph boundary.

Returns:

  • (Integer)


73
74
75
# File 'lib/tuile/component/text_field.rb', line 73

def left_column
  @left_column
end

#max_text_lengthInteger?

Optional cap on AbstractStringField#text's length in characters — a wide glyph counts once. Typing into a field already at the cap does nothing.

Deliberately does not police AbstractStringField#text=: lowering the cap under an existing value leaves that value intact rather than silently trimming it.

@return — maximum characters, or nil for unbounded (default).

Returns:

  • (Integer, nil)


58
59
60
# File 'lib/tuile/component/text_field.rb', line 58

def max_text_length
  @max_text_length
end

#on_enterProc, ...

Optional callback fired when ENTER is pressed. When set, ENTER is consumed by the field; when nil, ENTER falls through to the parent (default behavior).

@return — no-arg callable, or nil.

Returns:

  • (Proc, Method, nil)


93
94
95
# File 'lib/tuile/component/text_field.rb', line 93

def on_enter
  @on_enter
end

#on_key_downProc, ...

Optional callback fired when the DOWN arrow key is pressed. When set, DOWN is consumed by the field; when nil, DOWN falls through to the parent (default behavior). Only triggered by Keys::DOWN_ARROW, not by j, since j is a printable character inserted into AbstractStringField#text.

@return — no-arg callable, or nil.

Returns:

  • (Proc, Method, nil)


87
88
89
# File 'lib/tuile/component/text_field.rb', line 87

def on_key_down
  @on_key_down
end

#on_key_upProc, ...

Optional callback fired when the UP arrow key is pressed. When set, UP is consumed by the field; when nil, UP falls through to the parent (default behavior). Only triggered by Keys::UP_ARROW, not by k, since k is a printable character inserted into AbstractStringField#text.

@return — no-arg callable, or nil.

Returns:

  • (Proc, Method, nil)


80
81
82
# File 'lib/tuile/component/text_field.rb', line 80

def on_key_up
  @on_key_up
end

Instance Method Details

#adjust_left_columnvoid

This method returns an undefined value.

Scrolls the minimum needed to keep the caret's column visible.



240
241
242
243
244
245
246
247
248
249
# File 'lib/tuile/component/text_field.rb', line 240

def adjust_left_column
  return unless rect.width.positive?

  col = column_at(@caret)
  @left_column = col if col < @left_column
  @left_column = col - rect.width + 1 if col > @left_column + rect.width - 1
  # The caret may park one column past the last glyph, so the scrollable
  # range runs one column past the text.
  @left_column = snap_to_glyph_start(@left_column.clamp(0, [text_columns - rect.width + 1, 0].max))
end

#column_at(index) ⇒ Integer

@param index — a AbstractStringField#text index in 0..text.length.

@return — the column it sits at. An index landing inside a grapheme cluster measures the whole cluster, putting the caret just past it.

Parameters:

  • index (Integer)

Returns:

  • (Integer)


197
# File 'lib/tuile/component/text_field.rb', line 197

def column_at(index) = columns_of(display_text[0, index] || "")

#cursor_positionPoint?

Returns:



96
97
98
99
100
101
102
103
104
# File 'lib/tuile/component/text_field.rb', line 96

def cursor_position
  return nil unless rect.width.positive?

  # Scrolling already keeps the caret inside the rect, so the cap is a
  # guard rather than a policy: a cursor parked at rect.left + rect.width
  # reads as column 0 of the next row on an auto-wrapping terminal.
  offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1)
  Point.new(rect.left + offset, rect.top)
end

#display_textString

What the field paints in place of AbstractStringField#text: one display character per AbstractStringField#text character, in order. column_at measures display_text[0, i] as the rendering of text[0, i], so an override that changes the character count — or reorders — desynchronizes the caret from the display. Nothing enforces it at runtime; a subclass pins it with a spec.

@returnAbstractStringField#text itself, unless a subclass substitutes.

Returns:

  • (String)


176
# File 'lib/tuile/component/text_field.rb', line 176

def display_text = @text

#handle_mouse(event) ⇒ void

This method returns an undefined value.

Places the caret at the clicked column. A click on the right half of a wide glyph lands after it, as in any editor.

@param event

Parameters:



110
111
112
113
114
115
# File 'lib/tuile/component/text_field.rb', line 110

def handle_mouse(event)
  super
  return unless event.button == :left && rect.contains?(event.point)

  self.caret = index_at(event.x - rect.left + @left_column)
end

#handle_text_input_key(key) ⇒ Boolean

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
# File 'lib/tuile/component/text_field.rb', line 128

def handle_text_input_key(key)
  case key
  when *Keys::HOMES then self.caret = 0
  when *Keys::ENDS_ then self.caret = @text.length
  when *Keys::BACKSPACES then delete_before_caret
  when Keys::DELETE then delete_at_caret
  when Keys::UP_ARROW
    return false if @on_key_up.nil?

    @on_key_up.call
  when Keys::DOWN_ARROW
    return false if @on_key_down.nil?

    @on_key_down.call
  when Keys::ENTER
    return false if @on_enter.nil?

    @on_enter.call
  else
    return insert(key) if Keys.printable?(key)

    return super
  end
  true
end

#index_at(column) ⇒ Integer

@param column — a text column (0 is the first glyph).

@return — the nearest AbstractStringField#text index — a column falling in a wide glyph's right half resolves past it.

Parameters:

  • column (Integer)

Returns:

  • (Integer)


202
203
204
205
206
207
208
209
210
211
212
213
# File 'lib/tuile/component/text_field.rb', line 202

def index_at(column)
  col = 0
  i = 0
  display_text.each_grapheme_cluster do |g|
    w = Buffer.display_width(g)
    return i if column < col + ((w + 1) / 2)

    col += w
    i += g.length
  end
  i
end

#insert(char) ⇒ Boolean

@param char

@return — always true — a field at #max_text_length swallows the key rather than declining it, so typing can never fall through to a scope-wide binding.

Parameters:

  • char (String)

Returns:

  • (Boolean)


184
185
186
187
188
189
190
191
# File 'lib/tuile/component/text_field.rb', line 184

def insert(char)
  return true if @max_text_length && @text.length >= @max_text_length

  new_text = @text.dup.insert(@caret, char)
  @caret += 1
  self.text = new_text
  true
end

#on_caret_mutatedvoid

This method returns an undefined value.



160
161
162
# File 'lib/tuile/component/text_field.rb', line 160

def on_caret_mutated
  adjust_left_column
end

#on_text_mutatedvoid

This method returns an undefined value.



155
156
157
# File 'lib/tuile/component/text_field.rb', line 155

def on_text_mutated
  adjust_left_column
end

#on_width_changedvoid

This method returns an undefined value.



165
166
167
168
# File 'lib/tuile/component/text_field.rb', line 165

def on_width_changed
  super
  adjust_left_column
end

#repaintvoid

This method returns an undefined value.



118
119
120
121
122
# File 'lib/tuile/component/text_field.rb', line 118

def repaint
  return if rect.empty?

  screen.buffer.set_line(rect.left, rect.top, background(visible_text))
end

#snap_to_glyph_start(column) ⇒ Integer

Snapping right is the only safe direction, and not because it shows more: the caret's own column is always a glyph boundary, so the next boundary at or after left_column can never overshoot it. Snapping left instead pulls the window's right edge inward, which strands the caret outside it whenever wide glyphs exactly fill a narrow field.

@param column

@return — the smallest glyph-boundary column >= column, so the window never opens on a wide glyph's right half.

Parameters:

  • column (Integer)

Returns:

  • (Integer)


260
261
262
263
264
265
266
267
268
# File 'lib/tuile/component/text_field.rb', line 260

def snap_to_glyph_start(column)
  col = 0
  display_text.each_grapheme_cluster do |g|
    return col if col >= column

    col += Buffer.display_width(g)
  end
  col
end

#text_columnsInteger

@return — total display width of AbstractStringField#text.

Returns:

  • (Integer)


216
# File 'lib/tuile/component/text_field.rb', line 216

def text_columns = column_at(@text.length)

#visible_textString

@return — the windowed text, padded with spaces to rect.width. A wide glyph straddling the right edge is dropped rather than painted as a half glyph.

Returns:

  • (String)


221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
# File 'lib/tuile/component/text_field.rb', line 221

def visible_text
  right = @left_column + rect.width
  visible = +""
  width = 0
  col = 0
  display_text.each_grapheme_cluster do |g|
    start = col
    col += Buffer.display_width(g)
    next if start < @left_column
    break if col > right

    visible << g
    width = col - @left_column
  end
  visible << (" " * (rect.width - width))
end