Class: Tuile::Component::TextField

Inherits:
AbstractStringField show all
Includes:
HasPlaceholder
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. An empty field paints its HasPlaceholder#placeholder instead, when it has one.

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.

Instance Attribute Summary collapse

Attributes inherited from AbstractStringField

#caret, #on_change, #on_escape, #text

Attributes included from HasValue

#on_value_change

Attributes included from HasValidation

#on_error_message_change

Instance Method Summary collapse

Methods inherited from AbstractStringField

#clear, #cluster_boundary_after, #cluster_boundary_before, #columns_of, #default_bg_color, #default_on_escape, #delete_at_caret, #delete_back_to, #delete_before_caret, #empty?, #empty_value, #error_bg_color, #error_ink?, #error_message, #error_message=, #focusable?, #handle_key?, #handle_paste, #insert_text, #preprocess_text, #snap_to_cluster, #tab_stop?, #value, #value=, #word_left, #word_right

Methods included from HasValue

#clear, #empty?, #empty_value, #error_bg_color, #error_ink?, #error_message, #error_message=, #focusable?, #value, #value=

Methods included from HasValidation

#error_bg_color, #error_ink?, #error_message, #error_message=

Constructor Details

#initializeTextField

Returns a new instance of TextField.



46
47
48
49
50
51
52
53
54
# File 'lib/tuile/component/text_field.rb', line 46

def initialize
  super
  @placeholder = nil
  @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)

Internal — the field's own scroll state, with no caller outside this class: the paint, the cursor and the hit test all read the ivar, and nothing above the field has a column to spend it on. Specs assert the scrolling through send.

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

Returns:

  • (Integer)


282
283
284
# File 'lib/tuile/component/text_field.rb', line 282

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)


62
63
64
# File 'lib/tuile/component/text_field.rb', line 62

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.



286
287
288
289
290
291
292
293
294
295
# File 'lib/tuile/component/text_field.rb', line 286

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)


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

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)


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

def display_text = @text

#handle_caret_mutatedvoid

This method returns an undefined value.



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

def handle_caret_mutated
  super
  adjust_left_column
end

#handle_mouse_down?(event) ⇒ Boolean

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

@param event

Parameters:

Returns:

  • (Boolean)


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

def handle_mouse_down?(event)
  return false unless event.button == :left

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

#handle_text_input_key?(key) ⇒ Boolean

CTRL+U kills back to the start of the field — with the caret at the end, "clear what I typed".

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/tuile/component/text_field.rb', line 132

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::CTRL_U then delete_back_to(0)
  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

#handle_text_mutatedvoid

This method returns an undefined value.



178
179
180
181
# File 'lib/tuile/component/text_field.rb', line 178

def handle_text_mutated
  super
  adjust_left_column
end

#handle_width_changedvoid

This method returns an undefined value.



190
191
192
193
# File 'lib/tuile/component/text_field.rb', line 190

def handle_width_changed
  super
  adjust_left_column
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)


227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/tuile/component/text_field.rb', line 227

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

Routes a typed character through AbstractStringField#insert_text, the same mutation a paste lands on.

@param char

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

Parameters:

  • char (String)

Returns:

  • (Boolean)


211
212
213
214
215
216
# File 'lib/tuile/component/text_field.rb', line 211

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

  insert_text(char)
  true
end

#inspect_details::Array[String]

Adds placeholder="…" to Tuile::Component#inspect, omitted while unset.

Returns:

  • (::Array[String])


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

def inspect_details: () -> ::Array[String]

#placeholderString?

@return — the hint, or nil when there is none (default).

Returns:

  • (String, nil)


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

def placeholder: () -> String?

#placeholder=void

This method returns an undefined value.

Sets the hint and invalidates the component. No-op when unchanged.

@param textnil removes it.

Parameters:

  • text (String, nil)


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

def placeholder=: (String? text) -> void

#placeholder_rowStyledString

#repaint does not call super, so this padded row is the only thing that clears the rect: it is the well.

@return — the hint, ellipsized to rect.width and padded back out to it.

Returns:



248
249
250
251
# File 'lib/tuile/component/text_field.rb', line 248

def placeholder_row
  hint = StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width)
  hint + StyledString.plain(" " * [rect.width - hint.display_width, 0].max)
end

#preprocess_paste(text) ⇒ String

Keeps the paste's first line and drops the rest, then trims what's left to what #max_text_length still allows:

f.handle_paste("name\nstreet\ncity")
f.text                                 # => "name"

Overshooting the cap trims rather than rejects, which is what typing the same characters would have done. Why the first line, and not a newline-to-space flattening: D_paste_newlines.

@param text

Parameters:

  • text (String)

Returns:

  • (String)


170
171
172
173
174
175
# File 'lib/tuile/component/text_field.rb', line 170

def preprocess_paste(text)
  first_line = super[/\A[^\n]*/]
  return first_line if @max_text_length.nil?

  first_line[0, [@max_text_length - @text.length, 0].max] || ""
end

#repaintvoid

This method returns an undefined value.



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

def repaint
  return if rect.empty?

  return draw_text(rect.left, rect.top, placeholder_row) if show_placeholder?

  draw_text(rect.left, rect.top, StyledString.plain(visible_text))
end

#show_placeholder?Boolean

@return — true when the empty field should paint its HasPlaceholder#placeholder instead of its (blank) contents.

Returns:

  • (Boolean)


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

def show_placeholder? = @text.empty? && !placeholder.to_s.empty?

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


306
307
308
309
310
311
312
313
314
# File 'lib/tuile/component/text_field.rb', line 306

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)


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

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)


259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
# File 'lib/tuile/component/text_field.rb', line 259

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