tuile 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +31 -24
  5. data/book/04-event-loop.md +86 -0
  6. data/book/05-focus.md +82 -51
  7. data/book/06-theming.md +53 -1
  8. data/book/07-components.md +363 -9
  9. data/book/08-testing.md +21 -8
  10. data/book/09-styled-text.md +132 -0
  11. data/book/README.md +19 -13
  12. data/examples/sampler.rb +435 -20
  13. data/ideas/new-components.md +109 -0
  14. data/ideas/per-component-buffers.md +55 -0
  15. data/lib/tuile/buffer.rb +52 -51
  16. data/lib/tuile/color.rb +4 -10
  17. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  18. data/lib/tuile/component/button.rb +25 -21
  19. data/lib/tuile/component/checkbox.rb +133 -0
  20. data/lib/tuile/component/checkbox_group.rb +188 -0
  21. data/lib/tuile/component/combo_box.rb +281 -0
  22. data/lib/tuile/component/has_caption.rb +44 -0
  23. data/lib/tuile/component/has_content.rb +5 -5
  24. data/lib/tuile/component/has_value.rb +64 -0
  25. data/lib/tuile/component/integer_field.rb +135 -0
  26. data/lib/tuile/component/label.rb +13 -10
  27. data/lib/tuile/component/layout.rb +3 -17
  28. data/lib/tuile/component/list.rb +8 -7
  29. data/lib/tuile/component/list_dropdown.rb +106 -0
  30. data/lib/tuile/component/password_field.rb +105 -0
  31. data/lib/tuile/component/popup.rb +10 -14
  32. data/lib/tuile/component/progress_bar.rb +278 -0
  33. data/lib/tuile/component/radio_group.rb +188 -0
  34. data/lib/tuile/component/text_area.rb +189 -65
  35. data/lib/tuile/component/text_field.rb +170 -32
  36. data/lib/tuile/component/text_view.rb +57 -114
  37. data/lib/tuile/component/window.rb +32 -47
  38. data/lib/tuile/component.rb +250 -87
  39. data/lib/tuile/event_queue.rb +14 -17
  40. data/lib/tuile/fake_event_queue.rb +11 -2
  41. data/lib/tuile/fake_screen.rb +4 -5
  42. data/lib/tuile/fraction.rb +6 -10
  43. data/lib/tuile/screen.rb +202 -104
  44. data/lib/tuile/screen_pane.rb +51 -41
  45. data/lib/tuile/styled_string.rb +112 -83
  46. data/lib/tuile/theme.rb +78 -41
  47. data/lib/tuile/version.rb +1 -1
  48. data/sig/tuile.rbs +2043 -614
  49. metadata +18 -7
@@ -10,24 +10,42 @@ module Tuile
10
10
  # follows the caret so the line being edited stays visible. There is no
11
11
  # horizontal scrolling.
12
12
  #
13
- # The caret is a logical index in `0..text.length`. When the caret falls
13
+ # The caret is a logical index in `0..text.length`, always on a
14
+ # grapheme-cluster boundary ({AbstractStringField}). When the caret falls
14
15
  # inside a whitespace run that was absorbed by a soft wrap, it displays
15
16
  # at the end of the previous row (which is visually identical to the
16
17
  # start of the next row in nearly all cases).
17
18
  #
18
- # Currently only {#on_change} is wired; Enter inserts a newline as in any
19
- # plain `<textarea>` or text editor. A future `on_enter`/`on_submit`
20
- # callback may opt out of that by consuming Enter instead.
21
- class TextArea < TextInput
19
+ # Enter inserts a newline, as in a plain `<textarea>` or text editor; only
20
+ # {#on_change} is wired. A pasted line break arrives as `\n`
21
+ # ({Keys::CTRL_J}) rather than the `\r` a typed Enter sends, so both are
22
+ # accepted — otherwise a multi-line paste would silently lose its
23
+ # newlines.
24
+ #
25
+ # == Implementation details
26
+ #
27
+ # The same two axes {TextField} names apply, and the wrap straddles both: an
28
+ # **index** counts characters into {#text} ({#caret}, a row's `start` and
29
+ # `length`), a **column** counts terminal cells ({#rect}, a row's `columns`,
30
+ # {#cursor_position}, a {MouseEvent}). A row therefore carries *both* counts,
31
+ # and the wrap fills each row to a column budget while recording a character
32
+ # span. Everything crossing between them goes through the inherited
33
+ # `columns_of` and the private `chars_for_column`.
34
+ #
35
+ # The wrap walks **grapheme clusters**, not characters — a combining mark must
36
+ # add no columns and must not be split from its base across a row break. Note
37
+ # `"\r\n"` is a *single* cluster, so a hard break tests `end_with?("\n")`
38
+ # rather than equality.
39
+ class TextArea < AbstractStringField
22
40
  def initialize
23
41
  super
24
42
  @top_display_row = 0
25
43
  # Lazy cache of the word-wrapped layout: an
26
44
  # `Array<Hash{Symbol=>Integer}>` whose entries are
27
- # `{start: <text-index>, length: <chars>}`, one per display row, built
28
- # by {#compute_display_rows}. `nil` means "stale, recompute on next
29
- # read". Reset to nil whenever {#text} mutates or the width changes;
30
- # see {#on_text_mutated} and {#on_width_changed}.
45
+ # `{start: <text-index>, length: <chars>, columns: <cols>}`, one per
46
+ # display row, built by {#compute_display_rows}. `nil` means "stale,
47
+ # recompute on next read". Reset to nil whenever {#text} mutates or the
48
+ # width changes; see {#on_text_mutated} and {#on_width_changed}.
31
49
  @display_rows = nil
32
50
  end
33
51
 
@@ -62,7 +80,7 @@ module Tuile
62
80
  self.caret = @text.length
63
81
  else
64
82
  r = rows[target_row]
65
- self.caret = r[:start] + target_col.clamp(0, r[:length])
83
+ self.caret = r[:start] + chars_for_column(r, target_col)
66
84
  end
67
85
  end
68
86
 
@@ -73,13 +91,7 @@ module Tuile
73
91
  rows = display_rows
74
92
  (0...rect.height).each do |screen_row|
75
93
  row_idx = screen_row + @top_display_row
76
- line = if row_idx >= rows.size
77
- " " * rect.width
78
- else
79
- r = rows[row_idx]
80
- chunk = @text[r[:start], r[:length]] || ""
81
- chunk + (" " * (rect.width - r[:length]))
82
- end
94
+ line = row_idx >= rows.size ? " " * rect.width : padded_row(rows[row_idx])
83
95
  screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
84
96
  end
85
97
  end
@@ -107,7 +119,7 @@ module Tuile
107
119
  when *Keys::ENDS_ then move_caret_to_row_end
108
120
  when *Keys::BACKSPACES then delete_before_caret
109
121
  when Keys::DELETE then delete_at_caret
110
- when Keys::ENTER then insert_char("\n")
122
+ when Keys::ENTER, Keys::CTRL_J then insert_char("\n")
111
123
  else
112
124
  return insert_char(key) if Keys.printable?(key)
113
125
 
@@ -131,78 +143,149 @@ module Tuile
131
143
  @display_rows ||= compute_display_rows
132
144
  end
133
145
 
134
- # Greedy word-wrap. Whitespace at a soft-wrap break point is absorbed
135
- # (not rendered on either row). A token longer than {Rect#width} hard-
136
- # wraps inside the token. Newlines force a hard break and the wrap
137
- # restarts on the next character.
146
+ # @return [Array<Hash{Symbol=>Object}>] one entry per grapheme cluster of
147
+ # {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
148
+ # Rebuilt per wrap and discarded — the wrap is what's cached.
149
+ def cluster_table
150
+ offset = 0
151
+ @text.each_grapheme_cluster.map do |g|
152
+ entry = { offset: offset, text: g, width: Buffer.display_width(g) }
153
+ offset += g.length
154
+ entry
155
+ end
156
+ end
157
+
158
+ # @param cluster [Hash{Symbol=>Object}]
159
+ # @return [Boolean] true for a space or tab (each exactly one column).
160
+ def blank?(cluster) = cluster[:text].match?(/[ \t]/)
161
+
162
+ # @param cluster [Hash{Symbol=>Object}]
163
+ # @return [Boolean] true for a hard line break. Tests the suffix rather
164
+ # than equality because `"\r\n"` is one grapheme cluster.
165
+ def newline?(cluster) = cluster[:text].end_with?("\n")
166
+
167
+ # Greedy word-wrap, filling each row to a **column** budget while recording
168
+ # the **character** span that produced it. Whitespace at a soft-wrap break
169
+ # point is absorbed (not rendered on either row). A token wider than
170
+ # {Rect#width} hard-wraps inside the token. Newlines force a hard break and
171
+ # the wrap restarts on the next cluster.
138
172
  # @return [Array<Hash{Symbol=>Integer}>]
139
173
  def compute_display_rows
140
174
  width = rect.width
141
- return [{ start: 0, length: 0 }] if width <= 0 || @text.empty?
175
+ return [{ start: 0, length: 0, columns: 0 }] if width <= 0 || @text.empty?
142
176
 
177
+ cl = cluster_table
143
178
  rows = []
144
- pos = 0
145
- n = @text.length
146
-
147
- while pos < n
148
- row_start = pos
149
- row_chars = 0
150
-
151
- while pos < n
152
- c = @text[pos]
153
- break if c == "\n"
154
-
155
- if c.match?(/[ \t]/)
156
- if row_chars < width
157
- row_chars += 1
158
- pos += 1
179
+ i = 0
180
+ n = cl.size
181
+
182
+ while i < n
183
+ start = cl[i][:offset]
184
+ chars = 0
185
+ cols = 0
186
+
187
+ while i < n
188
+ g = cl[i]
189
+ break if newline?(g)
190
+
191
+ if blank?(g)
192
+ if cols < width
193
+ chars += g[:text].length
194
+ cols += g[:width]
195
+ i += 1
159
196
  else
160
- row_chars = trim_trailing_whitespace(row_start, row_chars)
161
- pos += 1 while pos < n && @text[pos].match?(/[ \t]/)
197
+ chars, cols = trim_trailing_whitespace(start, chars, cols)
198
+ i += 1 while i < n && blank?(cl[i])
162
199
  break
163
200
  end
164
201
  else
165
- word_end = pos
166
- word_end += 1 while word_end < n && !@text[word_end].match?(/\s/)
167
- word_len = word_end - pos
168
-
169
- if row_chars + word_len <= width
170
- row_chars += word_len
171
- pos = word_end
172
- elsif row_chars.zero?
173
- row_chars = width
174
- pos += width
202
+ word_chars, word_cols, word_end = measure_word(cl, i)
203
+
204
+ if cols + word_cols <= width
205
+ chars += word_chars
206
+ cols += word_cols
207
+ i = word_end
208
+ elsif cols.zero?
209
+ chars, cols, i = hard_wrap(cl, i, width)
175
210
  break
176
211
  else
177
- row_chars = trim_trailing_whitespace(row_start, row_chars)
212
+ chars, cols = trim_trailing_whitespace(start, chars, cols)
178
213
  break
179
214
  end
180
215
  end
181
216
  end
182
217
 
183
- rows << { start: row_start, length: row_chars }
218
+ rows << { start: start, length: chars, columns: cols }
184
219
 
185
- if pos < n && @text[pos] == "\n"
186
- pos += 1
187
- rows << { start: pos, length: 0 } if pos == n
188
- end
220
+ next unless i < n && newline?(cl[i])
221
+
222
+ i += 1
223
+ rows << { start: @text.length, length: 0, columns: 0 } if i >= n
189
224
  end
190
225
 
191
- rows << { start: 0, length: 0 } if rows.empty?
226
+ rows << { start: 0, length: 0, columns: 0 } if rows.empty?
192
227
  rows
193
228
  end
194
229
 
230
+ # @param clusters [Array<Hash{Symbol=>Object}>]
231
+ # @param index [Integer] cluster index of the word's first glyph.
232
+ # @return [Array(Integer, Integer, Integer)] `[chars, columns, next_index]`
233
+ # for the run of non-whitespace starting at `index`.
234
+ def measure_word(clusters, index)
235
+ chars = 0
236
+ cols = 0
237
+ while index < clusters.size && !blank?(clusters[index]) && !newline?(clusters[index])
238
+ chars += clusters[index][:text].length
239
+ cols += clusters[index][:width]
240
+ index += 1
241
+ end
242
+ [chars, cols, index]
243
+ end
244
+
245
+ # Splits a token too wide for a whole row, taking entire glyphs while they
246
+ # fit. Consumes at least one glyph even when that single glyph is wider than
247
+ # the row — otherwise the wrap would not terminate (the row would stay empty
248
+ # and the same token be reconsidered forever). Such a row reports more
249
+ # columns than the rect holds and {#padded_row} drops the glyph; a
250
+ # 2-column glyph in a 1-column area is unpaintable either way.
251
+ # @param clusters [Array<Hash{Symbol=>Object}>]
252
+ # @param index [Integer]
253
+ # @param width [Integer] column budget.
254
+ # @return [Array(Integer, Integer, Integer)] `[chars, columns, next_index]`
255
+ def hard_wrap(clusters, index, width)
256
+ chars = 0
257
+ cols = 0
258
+ while index < clusters.size && cols + clusters[index][:width] <= width
259
+ chars += clusters[index][:text].length
260
+ cols += clusters[index][:width]
261
+ index += 1
262
+ end
263
+ if chars.zero? && index < clusters.size
264
+ chars = clusters[index][:text].length
265
+ cols = clusters[index][:width]
266
+ index += 1
267
+ end
268
+ [chars, cols, index]
269
+ end
270
+
195
271
  # Trims trailing space/tab characters off a row's visible length so the
196
272
  # whitespace at a soft-wrap point is absorbed (not rendered) rather than
197
273
  # left at the end of the row. Without this, soft-wrapping `"foo bar"`
198
274
  # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
199
275
  # end-of-row caret position would coincide with row 1's start.
276
+ #
277
+ # Both counts drop by one per trimmed character: a space and a tab each
278
+ # measure exactly one column.
200
279
  # @param row_start [Integer]
201
280
  # @param row_chars [Integer]
202
- # @return [Integer] new row_chars.
203
- def trim_trailing_whitespace(row_start, row_chars)
204
- row_chars -= 1 while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
205
- row_chars
281
+ # @param row_cols [Integer]
282
+ # @return [Array(Integer, Integer)] `[row_chars, row_cols]`
283
+ def trim_trailing_whitespace(row_start, row_chars, row_cols)
284
+ while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
285
+ row_chars -= 1
286
+ row_cols -= 1
287
+ end
288
+ [row_chars, row_cols]
206
289
  end
207
290
 
208
291
  # @param caret [Integer]
@@ -213,10 +296,51 @@ module Tuile
213
296
  next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
214
297
  next unless caret >= r[:start] && caret < next_start
215
298
 
216
- return [i, (caret - r[:start]).clamp(0, r[:length])]
299
+ return [i, caret_column_in(r, caret)]
300
+ end
301
+ [rows.size - 1, caret_column_in(rows.last, caret)]
302
+ end
303
+
304
+ # @param row [Hash{Symbol=>Integer}]
305
+ # @param caret [Integer]
306
+ # @return [Integer] `caret`'s column offset within `row`.
307
+ def caret_column_in(row, caret)
308
+ chars = (caret - row[:start]).clamp(0, row[:length])
309
+ columns_of(@text[row[:start], chars] || "").clamp(0, row[:columns])
310
+ end
311
+
312
+ # @param row [Hash{Symbol=>Integer}]
313
+ # @param column [Integer] a column offset within `row`.
314
+ # @return [Integer] characters from the row's start. A column landing in a
315
+ # wide glyph's right half resolves past it, as a click does in
316
+ # {TextField}.
317
+ def chars_for_column(row, column)
318
+ chars = 0
319
+ col = 0
320
+ (@text[row[:start], row[:length]] || "").each_grapheme_cluster do |g|
321
+ w = Buffer.display_width(g)
322
+ return chars if column < col + ((w + 1) / 2)
323
+
324
+ col += w
325
+ chars += g.length
326
+ end
327
+ chars
328
+ end
329
+
330
+ # @param row [Hash{Symbol=>Integer}]
331
+ # @return [String] the row's text padded to `rect.width` columns. A glyph
332
+ # with no room left is dropped rather than half-painted.
333
+ def padded_row(row)
334
+ out = +""
335
+ cols = 0
336
+ (@text[row[:start], row[:length]] || "").each_grapheme_cluster do |g|
337
+ w = Buffer.display_width(g)
338
+ break if cols + w > rect.width
339
+
340
+ out << g
341
+ cols += w
217
342
  end
218
- r = rows.last
219
- [rows.size - 1, (caret - r[:start]).clamp(0, r[:length])]
343
+ out << (" " * (rect.width - cols))
220
344
  end
221
345
 
222
346
  # @param delta [Integer] `+1` for down, `-1` for up.
@@ -233,7 +357,7 @@ module Tuile
233
357
  end
234
358
 
235
359
  r = rows[new_row]
236
- self.caret = r[:start] + cur_col.clamp(0, r[:length])
360
+ self.caret = r[:start] + chars_for_column(r, cur_col)
237
361
  end
238
362
 
239
363
  # @return [void]
@@ -2,23 +2,76 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # A single-line text input field with hardware-cursor caret.
5
+ # A single-line text input with a real hardware caret, scrolling
6
+ # horizontally to keep that caret in view:
6
7
  #
7
- # The field does not scroll. Any keystroke that would make {#text} longer
8
- # than `rect.width - 1` (the last column is reserved for the caret past the
9
- # last char) is rejected.
8
+ # f = TextField.new
9
+ # f.rect = Rect.new(0, 0, 6, 1) # six columns wide …
10
+ # f.text = "hello world" # … eleven columns of text, so it scrolls
11
+ # f.caret = 11 # paints "world " — left_column 6, cursor on the last column
12
+ # f.caret = 0 # paints "hello " — left_column 0
10
13
  #
11
- # The caret is a logical index in `0..text.length`. The hardware cursor is
12
- # positioned by {Screen} after each repaint cycle when this component is
13
- # focused; see {Component#cursor_position}.
14
- class TextField < TextInput
14
+ # The field's width never bounds its contents — {#max_text_length} does, and
15
+ # only for typing.
16
+ #
17
+ # == Implementation details
18
+ #
19
+ # Two axes run through this class and are *not* interchangeable:
20
+ #
21
+ # - an **index** counts characters into {#text} — {#caret},
22
+ # {#max_text_length}, `text[i]`, every edit;
23
+ # - a **column** counts terminal cells — {#rect}, {#left_column},
24
+ # {#cursor_position}, a {MouseEvent}.
25
+ #
26
+ # They coincide only while every glyph is one column wide. A fullwidth CJK
27
+ # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
28
+ # column 6. Every crossing goes through the private `column_at` / `index_at`
29
+ # pair; adding an index to a column anywhere else is the bug those two exist
30
+ # to prevent.
31
+ #
32
+ # Indices count characters while widths measure grapheme clusters, but the
33
+ # caret never falls between the two: {AbstractStringField} keeps it on a
34
+ # cluster boundary, so a column derived from it always names a real glyph
35
+ # edge.
36
+ #
37
+ # What gets *painted* is {#display_text}, a third seam that is `text` itself
38
+ # here and the mask in {PasswordField}. Every column measurement reads it, so
39
+ # a subclass showing something else overrides that and never {#repaint} —
40
+ # overriding the paint alone leaves the measurements on the buffer while the
41
+ # cells show the substitute, and the two drift apart by a growing offset.
42
+ class TextField < AbstractStringField
15
43
  def initialize
16
44
  super
45
+ @left_column = 0
46
+ @max_text_length = nil
17
47
  @on_key_up = nil
18
48
  @on_key_down = nil
19
49
  @on_enter = nil
20
50
  end
21
51
 
52
+ # Optional cap on {#text}'s length **in characters** — a wide glyph counts
53
+ # once. Typing into a field already at the cap does nothing.
54
+ #
55
+ # Deliberately does not police {#text=}: lowering the cap under an existing
56
+ # value leaves that value intact rather than silently trimming it.
57
+ # @return [Integer, nil] maximum characters, or nil for unbounded (default).
58
+ attr_reader :max_text_length
59
+
60
+ # @param max [Integer, nil]
61
+ # @return [void]
62
+ # @raise [TypeError] unless `max` is an Integer or nil.
63
+ # @raise [ArgumentError] if `max` is negative.
64
+ def max_text_length=(max)
65
+ raise TypeError, "expected Integer or nil, got #{max.inspect}" unless max.nil? || max.is_a?(Integer)
66
+ raise ArgumentError, "expected a non-negative max, got #{max}" if max&.negative?
67
+
68
+ @max_text_length = max
69
+ end
70
+
71
+ # @return [Integer] text column drawn in the field's leftmost cell — the
72
+ # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
73
+ attr_reader :left_column
74
+
22
75
  # Optional callback fired when the UP arrow key is pressed. When set, UP
23
76
  # is consumed by the field; when nil, UP falls through to the parent
24
77
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -43,37 +96,33 @@ module Tuile
43
96
  def cursor_position
44
97
  return nil unless rect.width.positive?
45
98
 
46
- Point.new(rect.left + @caret, rect.top)
99
+ # Scrolling already keeps the caret inside the rect, so the cap is a
100
+ # guard rather than a policy: a cursor parked at rect.left + rect.width
101
+ # reads as column 0 of the next row on an auto-wrapping terminal.
102
+ offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1)
103
+ Point.new(rect.left + offset, rect.top)
47
104
  end
48
105
 
106
+ # Places the caret at the clicked column. A click on the right half of a
107
+ # wide glyph lands *after* it, as in any editor.
49
108
  # @param event [MouseEvent]
50
109
  # @return [void]
51
110
  def handle_mouse(event)
52
111
  super
53
112
  return unless event.button == :left && rect.contains?(event.point)
54
113
 
55
- self.caret = (event.x - rect.left).clamp(0, @text.length)
114
+ self.caret = index_at(event.x - rect.left + @left_column)
56
115
  end
57
116
 
58
117
  # @return [void]
59
118
  def repaint
60
119
  return if rect.empty?
61
120
 
62
- padded = @text + (" " * (rect.width - @text.length))
63
- screen.buffer.set_line(rect.left, rect.top, background(padded))
121
+ screen.buffer.set_line(rect.left, rect.top, background(visible_text))
64
122
  end
65
123
 
66
124
  protected
67
125
 
68
- # Truncate to fit `rect.width - 1` — single-line fields can't grow past
69
- # their width.
70
- # @param new_text [String]
71
- # @return [String]
72
- def preprocess_text(new_text)
73
- new_text = new_text.to_s
74
- new_text.length > max_text_length ? new_text[0, max_text_length] : new_text
75
- end
76
-
77
126
  # @param key [String]
78
127
  # @return [Boolean]
79
128
  def handle_text_input_key(key)
@@ -102,32 +151,121 @@ module Tuile
102
151
  true
103
152
  end
104
153
 
154
+ # @return [void]
155
+ def on_text_mutated
156
+ adjust_left_column
157
+ end
158
+
159
+ # @return [void]
160
+ def on_caret_mutated
161
+ adjust_left_column
162
+ end
163
+
105
164
  # @return [void]
106
165
  def on_width_changed
107
166
  super
108
- return if @text.length <= max_text_length
109
-
110
- @text = @text[0, [max_text_length, 0].max]
111
- @caret = @caret.clamp(0, @text.length)
112
- @on_change&.call(@text)
167
+ adjust_left_column
113
168
  end
114
169
 
115
- private
170
+ # What the field paints in place of {#text}: one display character per
171
+ # {#text} character, in order. `column_at` measures `display_text[0, i]` as
172
+ # the rendering of `text[0, i]`, so an override that changes the character
173
+ # count — or reorders — desynchronizes the caret from the display. Nothing
174
+ # enforces it at runtime; a subclass pins it with a spec.
175
+ # @return [String] {#text} itself, unless a subclass substitutes.
176
+ def display_text = @text
116
177
 
117
- # Maximum number of characters {#text} can hold given current width.
118
- # @return [Integer]
119
- def max_text_length = (rect.width - 1).clamp(0, nil)
178
+ private
120
179
 
121
180
  # @param char [String]
122
- # @return [Boolean]
181
+ # @return [Boolean] always true — a field at {#max_text_length} swallows the
182
+ # key rather than declining it, so typing can never fall through to a
183
+ # scope-wide binding.
123
184
  def insert(char)
124
- return false if @text.length >= max_text_length
185
+ return true if @max_text_length && @text.length >= @max_text_length
125
186
 
126
187
  new_text = @text.dup.insert(@caret, char)
127
188
  @caret += 1
128
189
  self.text = new_text
129
190
  true
130
191
  end
192
+
193
+ # @param index [Integer] a {#text} index in `0..text.length`.
194
+ # @return [Integer] the column it sits at. An index landing inside a
195
+ # grapheme cluster measures the whole cluster, putting the caret just
196
+ # past it.
197
+ def column_at(index) = columns_of(display_text[0, index] || "")
198
+
199
+ # @param column [Integer] a text column (0 is the first glyph).
200
+ # @return [Integer] the nearest {#text} index — a column falling in a wide
201
+ # glyph's right half resolves past it.
202
+ def index_at(column)
203
+ col = 0
204
+ i = 0
205
+ display_text.each_grapheme_cluster do |g|
206
+ w = Buffer.display_width(g)
207
+ return i if column < col + ((w + 1) / 2)
208
+
209
+ col += w
210
+ i += g.length
211
+ end
212
+ i
213
+ end
214
+
215
+ # @return [Integer] total display width of {#text}.
216
+ def text_columns = column_at(@text.length)
217
+
218
+ # @return [String] the windowed text, padded with spaces to `rect.width`.
219
+ # A wide glyph straddling the right edge is dropped rather than painted
220
+ # as a half glyph.
221
+ def visible_text
222
+ right = @left_column + rect.width
223
+ visible = +""
224
+ width = 0
225
+ col = 0
226
+ display_text.each_grapheme_cluster do |g|
227
+ start = col
228
+ col += Buffer.display_width(g)
229
+ next if start < @left_column
230
+ break if col > right
231
+
232
+ visible << g
233
+ width = col - @left_column
234
+ end
235
+ visible << (" " * (rect.width - width))
236
+ end
237
+
238
+ # Scrolls the minimum needed to keep the caret's column visible.
239
+ # @return [void]
240
+ def adjust_left_column
241
+ return unless rect.width.positive?
242
+
243
+ col = column_at(@caret)
244
+ @left_column = col if col < @left_column
245
+ @left_column = col - rect.width + 1 if col > @left_column + rect.width - 1
246
+ # The caret may park one column past the last glyph, so the scrollable
247
+ # range runs one column past the text.
248
+ @left_column = snap_to_glyph_start(@left_column.clamp(0, [text_columns - rect.width + 1, 0].max))
249
+ end
250
+
251
+ # @param column [Integer]
252
+ # @return [Integer] the smallest glyph-boundary column `>= column`, so the
253
+ # window never opens on a wide glyph's right half.
254
+ #
255
+ # Snapping *right* is the only safe direction, and not because it shows
256
+ # more: the caret's own column is always a glyph boundary, so the next
257
+ # boundary at or after `left_column` can never overshoot it. Snapping left
258
+ # instead pulls the window's right edge inward, which strands the caret
259
+ # outside it whenever wide glyphs exactly fill a narrow field.
260
+ def snap_to_glyph_start(column)
261
+ col = 0
262
+ display_text.each_grapheme_cluster do |g|
263
+ return col if col >= column
264
+
265
+ col += Buffer.display_width(g)
266
+ end
267
+ col
268
+ end
131
269
  end
132
270
  end
133
271
  end