tuile 0.11.0 → 0.13.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
@@ -6,7 +6,7 @@ module Tuile
6
6
  #
7
7
  # Sized by the caller — {#rect} is fixed; the area does not grow with
8
8
  # content. Text is wrapped to {Rect#width} columns and any text that
9
- # doesn't fit vertically is reached by scrolling: {#top_display_row}
9
+ # doesn't fit vertically is reached by scrolling: {#scroll_top_row}
10
10
  # follows the caret so the line being edited stays visible. There is no
11
11
  # horizontal scrolling.
12
12
  #
@@ -17,54 +17,75 @@ module Tuile
17
17
  # start of the next row in nearly all cases).
18
18
  #
19
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.
20
+ # {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
21
+ # byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
22
+ # through {AbstractStringField#handle_paste} instead and never as a key at
23
+ # all — so a subclass rebinding Enter to submit keeps working under a
24
+ # multi-line paste, which lands as one draft.
24
25
  #
25
- # == Implementation details
26
+ # Up/Down move the caret between rows and, at the first/last row, snap to
27
+ # the start/end of the text. A subclass can claim the key at that edge
28
+ # instead — shell-style history recall is the motivating case — by asking
29
+ # {#caret_row} and {#row_count} before delegating:
30
+ #
31
+ # class PromptArea < Component::TextArea
32
+ # protected
33
+ #
34
+ # def handle_text_input_key(key)
35
+ # return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
36
+ # return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
37
+ #
38
+ # super # anywhere else: the caret moves, and the edge still snaps
39
+ # end
40
+ # end
26
41
  #
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`.
42
+ # Both recalls return `true` to consume the key; app code that would rather
43
+ # not subclass claims the same keys through {AbstractStringField#on_key}.
34
44
  #
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.
45
+ # == Implementation details
46
+ #
47
+ # The wrap itself — and with it every conversion between a character
48
+ # **index** and a **row/column** — lives in {WrappedText}, a
49
+ # snapshot of `(text, rect.width)` this class caches and drops whenever
50
+ # either changes. What stays here is the widget: keys, mouse, painting, and
51
+ # the {#scroll_top_row} viewport, which {WrappedText} deliberately knows
52
+ # nothing about (it is a pure function of text and width; the viewport is
53
+ # stateful and needs {Rect#height}).
39
54
  class TextArea < AbstractStringField
40
55
  def initialize
41
56
  super
42
- @top_display_row = 0
43
- # Lazy cache of the word-wrapped layout: an
44
- # `Array<Hash{Symbol=>Integer}>` whose entries are
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}.
49
- @display_rows = nil
57
+ @scroll_top_row = 0
58
+ # Lazy cache; nil means "stale, rebuild on next read". Reset whenever
59
+ # {#text} mutates or the width changes.
60
+ @wrap = nil
50
61
  end
51
62
 
52
- # @return [Integer] index of the topmost display row currently visible.
53
- attr_reader :top_display_row
63
+ # @return [Integer] index of the topmost row currently visible.
64
+ attr_reader :scroll_top_row
65
+
66
+ # The caret's row, counted from the text's first row — *not* from the top
67
+ # of the viewport (subtract {#scroll_top_row} for that).
68
+ # @return [Integer] a row index in `0...row_count`.
69
+ def caret_row = wrap.row_at(@caret)
70
+
71
+ # @return [Integer] rows the wrapped text occupies at the current
72
+ # {Rect#width}; always `>= 1`, since empty text still wraps to one
73
+ # (empty) row.
74
+ def row_count = wrap.row_count
54
75
 
55
76
  # @return [Point, nil]
56
77
  def cursor_position
57
78
  return nil if rect.empty?
58
79
 
59
- row, col = caret_to_display(@caret)
60
- screen_row = row - @top_display_row
61
- return nil if screen_row.negative? || screen_row >= rect.height
80
+ row, col = wrap.position_at(@caret)
81
+ row_in_viewport = row - @scroll_top_row
82
+ return nil if row_in_viewport.negative? || row_in_viewport >= rect.height
62
83
 
63
84
  # Cap so the hardware cursor never lands at rect.left+rect.width
64
85
  # (one past the rect). Terminals with auto-wrap interpret that as
65
86
  # column 0 of the row below; capping pins the cursor on the last
66
87
  # visible cell instead.
67
- Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + screen_row)
88
+ Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
68
89
  end
69
90
 
70
91
  # @param event [MouseEvent]
@@ -73,26 +94,21 @@ module Tuile
73
94
  super
74
95
  return unless event.button == :left && rect.contains?(event.point)
75
96
 
76
- target_row = (event.y - rect.top) + @top_display_row
77
- target_col = event.x - rect.left
78
- rows = display_rows
79
- if target_row >= rows.size
80
- self.caret = @text.length
81
- else
82
- r = rows[target_row]
83
- self.caret = r[:start] + chars_for_column(r, target_col)
84
- end
97
+ target_row = (event.y - rect.top) + @scroll_top_row
98
+ self.caret = if target_row >= wrap.row_count
99
+ @text.length
100
+ else
101
+ wrap.index_at(target_row, event.x - rect.left)
102
+ end
85
103
  end
86
104
 
87
105
  # @return [void]
88
106
  def repaint
89
107
  return if rect.empty?
90
108
 
91
- rows = display_rows
92
- (0...rect.height).each do |screen_row|
93
- row_idx = screen_row + @top_display_row
94
- line = row_idx >= rows.size ? " " * rect.width : padded_row(rows[row_idx])
95
- screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
109
+ (0...rect.height).each do |row_in_viewport|
110
+ line = wrap.row_text(row_in_viewport + @scroll_top_row)
111
+ screen.buffer.set_text(rect.left, rect.top + row_in_viewport, background(line))
96
112
  end
97
113
  end
98
114
 
@@ -100,13 +116,13 @@ module Tuile
100
116
 
101
117
  # @return [void]
102
118
  def on_text_mutated
103
- @display_rows = nil
104
- adjust_top_display_row
119
+ @wrap = nil
120
+ adjust_scroll_top_row
105
121
  end
106
122
 
107
123
  # @return [void]
108
124
  def on_caret_mutated
109
- adjust_top_display_row
125
+ adjust_scroll_top_row
110
126
  end
111
127
 
112
128
  # @param key [String]
@@ -131,248 +147,40 @@ module Tuile
131
147
  # @return [void]
132
148
  def on_width_changed
133
149
  super
134
- @display_rows = nil
135
- adjust_top_display_row
150
+ @wrap = nil
151
+ adjust_scroll_top_row
136
152
  end
137
153
 
138
154
  private
139
155
 
140
- # @return [Array<Hash{Symbol=>Integer}>] cached wrap of {#text} for the
141
- # current {Rect#width}. Each entry is `{start:, length:}`.
142
- def display_rows
143
- @display_rows ||= compute_display_rows
144
- end
145
-
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.
172
- # @return [Array<Hash{Symbol=>Integer}>]
173
- def compute_display_rows
174
- width = rect.width
175
- return [{ start: 0, length: 0, columns: 0 }] if width <= 0 || @text.empty?
176
-
177
- cl = cluster_table
178
- rows = []
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
196
- else
197
- chars, cols = trim_trailing_whitespace(start, chars, cols)
198
- i += 1 while i < n && blank?(cl[i])
199
- break
200
- end
201
- else
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)
210
- break
211
- else
212
- chars, cols = trim_trailing_whitespace(start, chars, cols)
213
- break
214
- end
215
- end
216
- end
217
-
218
- rows << { start: start, length: chars, columns: cols }
219
-
220
- next unless i < n && newline?(cl[i])
221
-
222
- i += 1
223
- rows << { start: @text.length, length: 0, columns: 0 } if i >= n
224
- end
225
-
226
- rows << { start: 0, length: 0, columns: 0 } if rows.empty?
227
- rows
228
- end
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
-
271
- # Trims trailing space/tab characters off a row's visible length so the
272
- # whitespace at a soft-wrap point is absorbed (not rendered) rather than
273
- # left at the end of the row. Without this, soft-wrapping `"foo bar"`
274
- # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
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.
279
- # @param row_start [Integer]
280
- # @param row_chars [Integer]
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]
289
- end
290
-
291
- # @param caret [Integer]
292
- # @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
293
- def caret_to_display(caret)
294
- rows = display_rows
295
- rows.each_with_index do |r, i|
296
- next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
297
- next unless caret >= r[:start] && caret < next_start
298
-
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
342
- end
343
- out << (" " * (rect.width - cols))
156
+ # @return [WrappedText] the current wrap of {#text} at {Rect#width}.
157
+ def wrap
158
+ @wrap ||= WrappedText.new(@text, rect.width)
344
159
  end
345
160
 
346
161
  # @param delta [Integer] `+1` for down, `-1` for up.
347
162
  # @return [void]
348
163
  def move_caret_vertical(delta)
349
- rows = display_rows
350
- cur_row, cur_col = caret_to_display(@caret)
351
- new_row = (cur_row + delta).clamp(0, rows.size - 1)
164
+ cur_row, cur_col = wrap.position_at(@caret)
165
+ new_row = (cur_row + delta).clamp(0, wrap.row_count - 1)
352
166
  if new_row == cur_row
353
- # Already at the top/bottom display row. Snap to the absolute
354
- # start/end of the text so the user has a quick way to reach it.
167
+ # Already at the top/bottom row. Snap to the absolute start/end of the
168
+ # text so the user has a quick way to reach it.
355
169
  self.caret = delta.positive? ? @text.length : 0
356
170
  return
357
171
  end
358
172
 
359
- r = rows[new_row]
360
- self.caret = r[:start] + chars_for_column(r, cur_col)
173
+ self.caret = wrap.index_at(new_row, cur_col)
361
174
  end
362
175
 
363
176
  # @return [void]
364
177
  def move_caret_to_row_start
365
- rows = display_rows
366
- cur_row, = caret_to_display(@caret)
367
- self.caret = rows[cur_row][:start]
178
+ self.caret = wrap.row_start(wrap.row_at(@caret))
368
179
  end
369
180
 
370
181
  # @return [void]
371
182
  def move_caret_to_row_end
372
- rows = display_rows
373
- cur_row, = caret_to_display(@caret)
374
- r = rows[cur_row]
375
- self.caret = r[:start] + r[:length]
183
+ self.caret = wrap.row_end(wrap.row_at(@caret))
376
184
  end
377
185
 
378
186
  # @param char [String]
@@ -386,18 +194,17 @@ module Tuile
386
194
 
387
195
  # Keeps the caret visible by scrolling vertically.
388
196
  # @return [void]
389
- def adjust_top_display_row
197
+ def adjust_scroll_top_row
390
198
  return if rect.empty?
391
199
 
392
- rows = display_rows
393
- cur_row, = caret_to_display(@caret)
394
- if cur_row < @top_display_row
395
- @top_display_row = cur_row
396
- elsif cur_row >= @top_display_row + rect.height
397
- @top_display_row = cur_row - rect.height + 1
200
+ cur_row = wrap.row_at(@caret)
201
+ if cur_row < @scroll_top_row
202
+ @scroll_top_row = cur_row
203
+ elsif cur_row >= @scroll_top_row + rect.height
204
+ @scroll_top_row = cur_row - rect.height + 1
398
205
  end
399
- max_top = (rows.size - rect.height).clamp(0, nil)
400
- @top_display_row = @top_display_row.clamp(0, max_top)
206
+ max_top = (wrap.row_count - rect.height).clamp(0, nil)
207
+ @scroll_top_row = @scroll_top_row.clamp(0, max_top)
401
208
  end
402
209
  end
403
210
  end
@@ -20,8 +20,8 @@ module Tuile
20
20
  #
21
21
  # - an **index** counts characters into {#text} — {#caret},
22
22
  # {#max_text_length}, `text[i]`, every edit;
23
- # - a **column** counts terminal cells — {#rect}, {#left_column},
24
- # {#cursor_position}, a {MouseEvent}.
23
+ # - a **column** counts terminal cells — {#rect}, {#cursor_position}, a
24
+ # {MouseEvent}, and the private horizontal scroll offset `left_column`.
25
25
  #
26
26
  # They coincide only while every glyph is one column wide. A fullwidth CJK
27
27
  # char is two columns and a combining mark zero, so index 3 of `"日本語"` is
@@ -68,10 +68,6 @@ module Tuile
68
68
  @max_text_length = max
69
69
  end
70
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
-
75
71
  # Optional callback fired when the UP arrow key is pressed. When set, UP
76
72
  # is consumed by the field; when nil, UP falls through to the parent
77
73
  # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
@@ -118,7 +114,7 @@ module Tuile
118
114
  def repaint
119
115
  return if rect.empty?
120
116
 
121
- screen.buffer.set_line(rect.left, rect.top, background(visible_text))
117
+ screen.buffer.set_text(rect.left, rect.top, background(visible_text))
122
118
  end
123
119
 
124
120
  protected
@@ -151,6 +147,19 @@ module Tuile
151
147
  true
152
148
  end
153
149
 
150
+ # Flattens the paste onto the field's one row — newlines become spaces —
151
+ # and trims it to what {#max_text_length} still allows. Trimming rather
152
+ # than rejecting: a paste that overshoots the cap fills the field, which
153
+ # is what typing the same characters would have done.
154
+ # @param text [String]
155
+ # @return [String]
156
+ def preprocess_paste(text)
157
+ flat = super.tr("\n", " ")
158
+ return flat if @max_text_length.nil?
159
+
160
+ flat[0, [@max_text_length - @text.length, 0].max] || ""
161
+ end
162
+
154
163
  # @return [void]
155
164
  def on_text_mutated
156
165
  adjust_left_column
@@ -235,6 +244,14 @@ module Tuile
235
244
  visible << (" " * (rect.width - width))
236
245
  end
237
246
 
247
+ # Internal — the field's own scroll state, with no caller outside this
248
+ # class: the paint, the cursor and the hit test all read the ivar, and
249
+ # nothing above the field has a column to spend it on. Specs assert the
250
+ # scrolling through `send`.
251
+ # @return [Integer] text column drawn in the field's leftmost cell — the
252
+ # horizontal scroll offset. Follows {#caret}, always on a glyph boundary.
253
+ attr_reader :left_column
254
+
238
255
  # Scrolls the minimum needed to keep the caret's column visible.
239
256
  # @return [void]
240
257
  def adjust_left_column