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.

Defined Under Namespace

Classes: EnterEvent, KeyDownEvent, KeyUpEvent

Instance Attribute Summary collapse

Attributes inherited from AbstractStringField

#caret, #escape_clears_focus, #text

Instance Method Summary collapse

Methods inherited from AbstractStringField

#clear, #cluster_boundary_after, #cluster_boundary_before, #columns_of, #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, #on_error_message_change, #on_escape, #on_value_change, #preprocess_text, #set_value, #shown_message, #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?, listener, #on_error_message_change, #on_value_change, #set_value, #shown_message, #value, #value=

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

#error_bg_color, #error_ink?, #error_message, #error_message=, listener, #on_error_message_change, #shown_message

Constructor Details

#initialize ⇒ TextField

Returns a new instance of TextField.



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

def initialize
  super
  @placeholder = nil
  @left_column = 0
  @max_text_length = nil
end

Instance Attribute Details

#left_column ⇒ Integer (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)


298
299
300
# File 'lib/tuile/component/text_field.rb', line 298

def left_column
  @left_column
end

#max_text_length ⇒ Integer?

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#value=: 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)


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

def max_text_length
  @max_text_length
end

Instance Method Details

#adjust_left_column ⇒ void

This method returns an undefined value.

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



302
303
304
305
306
307
308
309
310
311
# File 'lib/tuile/component/text_field.rb', line 302

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)


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

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

#cursor_position ⇒ Point?

Returns:



113
114
115
116
117
118
119
120
# File 'lib/tuile/component/text_field.rb', line 113

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 one past the last column
  # reads as column 0 of the next row on an auto-wrapping terminal.
  Point.new((column_at(@caret) - @left_column).clamp(0, rect.width - 1), 0)
end

#display_text ⇒ String

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.

@return — AbstractStringField#text itself, unless a subclass substitutes.

Returns:

  • (String)


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

def display_text = @text

#handle_caret_mutated ⇒ void

This method returns an undefined value.



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

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)


126
127
128
129
130
131
# File 'lib/tuile/component/text_field.rb', line 126

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

  self.caret = index_at(event.x + @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)


149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
# File 'lib/tuile/component/text_field.rb', line 149

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.empty?

    on_key_up.fire(KeyUpEvent.new(source: self))
  when Keys::DOWN_ARROW
    return false if on_key_down.empty?

    on_key_down.fire(KeyDownEvent.new(source: self))
  when Keys::ENTER
    return false if on_enter.empty?

    on_enter.fire(EnterEvent.new(source: self))
  else
    return insert(key) if Keys.printable?(key)

    return super
  end
  true
end

#handle_text_mutated ⇒ void

This method returns an undefined value.



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

def handle_text_mutated
  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)


244
245
246
247
248
249
250
251
252
253
254
255
# File 'lib/tuile/component/text_field.rb', line 244

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)


228
229
230
231
232
233
# File 'lib/tuile/component/text_field.rb', line 228

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


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

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

#on_enter ⇒ Listeners

Fired with an EnterEvent when ENTER is pressed. Empty means the field declines ENTER, which then falls through to the parent — which is how a scope's default button keeps working.

Returns:



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

listener :on_enter

#on_key_down ⇒ Listeners

Fired with a KeyDownEvent when the DOWN arrow key is pressed. Empty means the field declines DOWN, which then falls through to the parent. Only triggered by Keys::DOWN_ARROW, not by j.

Returns:



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

listener :on_key_down

#on_key_up ⇒ Listeners

Fired with a KeyUpEvent when the UP arrow key is pressed. Empty means the field declines UP, which then falls through to the parent; a registered listener consumes it. Only triggered by Keys::UP_ARROW, not by k, which is a printable character inserted into AbstractStringField#text.

Returns:



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

listener :on_key_up

#placeholder ⇒ String?

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

Returns:

  • (String, nil)


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

def placeholder: () -> String?

#placeholder= ⇒ void

This method returns an undefined value.

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

@param text — nil removes it.

Parameters:

  • text (String, nil)


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

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

#placeholder_row ⇒ StyledString

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



265
266
267
# File 'lib/tuile/component/text_field.rb', line 265

def placeholder_row
  StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width).ljust(rect.width)
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)


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

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

#relayout ⇒ void

This method returns an undefined value.



207
208
209
210
# File 'lib/tuile/component/text_field.rb', line 207

def relayout
  super
  adjust_left_column
end

#repaint(canvas) ⇒ void

This method returns an undefined value.

@param canvas — see Tuile::Component#repaint.

Parameters:



135
136
137
138
139
140
141
# File 'lib/tuile/component/text_field.rb', line 135

def repaint(canvas)
  return if rect.empty?

  return canvas.set_text(0, 0, placeholder_row) if show_placeholder?

  canvas.set_text(0, 0, 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)


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

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)


322
323
324
325
326
327
328
329
330
# File 'lib/tuile/component/text_field.rb', line 322

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_columns ⇒ Integer

@return — total display width of AbstractStringField#text.

Returns:

  • (Integer)


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

def text_columns = column_at(@text.length)

#visible_text ⇒ String

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


275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
# File 'lib/tuile/component/text_field.rb', line 275

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