tuile 0.9.0 → 0.11.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 (58) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -36
  3. data/DECISIONS.md +2566 -0
  4. data/README.md +37 -24
  5. data/book/03-layout.md +153 -8
  6. data/book/04-event-loop.md +86 -0
  7. data/book/05-focus.md +84 -51
  8. data/book/06-theming.md +53 -1
  9. data/book/07-components.md +458 -9
  10. data/book/08-testing.md +21 -8
  11. data/book/09-styled-text.md +132 -0
  12. data/book/README.md +22 -14
  13. data/examples/sampler.rb +632 -67
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +118 -0
  16. data/ideas/per-component-buffers.md +55 -0
  17. data/lib/tuile/buffer.rb +52 -51
  18. data/lib/tuile/color.rb +4 -10
  19. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  20. data/lib/tuile/component/big_decimal_field.rb +199 -0
  21. data/lib/tuile/component/button.rb +25 -21
  22. data/lib/tuile/component/checkbox.rb +134 -0
  23. data/lib/tuile/component/checkbox_group.rb +188 -0
  24. data/lib/tuile/component/combo_box.rb +263 -0
  25. data/lib/tuile/component/float_field.rb +161 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/integer_field.rb +135 -0
  30. data/lib/tuile/component/label.rb +13 -10
  31. data/lib/tuile/component/layout/box.rb +316 -0
  32. data/lib/tuile/component/layout/horizontal.rb +40 -0
  33. data/lib/tuile/component/layout/vertical.rb +41 -0
  34. data/lib/tuile/component/layout.rb +149 -15
  35. data/lib/tuile/component/list.rb +8 -7
  36. data/lib/tuile/component/list_dropdown.rb +157 -0
  37. data/lib/tuile/component/password_field.rb +105 -0
  38. data/lib/tuile/component/popup.rb +10 -14
  39. data/lib/tuile/component/progress_bar.rb +278 -0
  40. data/lib/tuile/component/radio_group.rb +188 -0
  41. data/lib/tuile/component/select.rb +251 -0
  42. data/lib/tuile/component/text_area.rb +189 -65
  43. data/lib/tuile/component/text_field.rb +170 -32
  44. data/lib/tuile/component/text_view.rb +57 -114
  45. data/lib/tuile/component/window.rb +32 -47
  46. data/lib/tuile/component.rb +250 -87
  47. data/lib/tuile/event_queue.rb +14 -17
  48. data/lib/tuile/fake_event_queue.rb +11 -2
  49. data/lib/tuile/fake_screen.rb +4 -5
  50. data/lib/tuile/fraction.rb +6 -10
  51. data/lib/tuile/screen.rb +202 -104
  52. data/lib/tuile/screen_pane.rb +51 -41
  53. data/lib/tuile/styled_string.rb +125 -86
  54. data/lib/tuile/theme.rb +78 -41
  55. data/lib/tuile/version.rb +1 -1
  56. data/lib/tuile.rb +4 -0
  57. data/sig/tuile.rbs +2962 -680
  58. metadata +25 -7
@@ -0,0 +1,251 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A closed-choice field on one row: the selected item's label plus a `▾`
6
+ # affordance, dropping open a {ListDropdown} of the options. Enter, Space or
7
+ # Down opens it; the arrows (and PgUp/PgDn) move the highlight; Enter or
8
+ # Space commits; ESC dismisses without committing.
9
+ #
10
+ # warn ▾ <- the face: one row, on a field well
11
+ # debug <- the dropdown, measured to the widest label
12
+ # info (the one-column gutters are {List}'s)
13
+ # warn <- highlighted: the value's row, on open
14
+ # error
15
+ #
16
+ # sel = Component::Select.new(items: LogLevel.all)
17
+ # sel.item_label = ->(l) { l.name } # item -> shown label; default :to_s
18
+ # sel.on_value_change = ->(l) { relog(l) } # fires on commit, with the item
19
+ # sel.value = LogLevel::WARN # selects it; the face shows its label
20
+ #
21
+ # Use it for an **enum** — labels the developer authored, a closed set known
22
+ # when the code is written: log level, sort order, line endings, Yes/No/Ask.
23
+ # For items the app supplies at runtime with labels you don't control
24
+ # (countries, users, branches) reach for {ComboBox} instead, where filtering
25
+ # is the navigation. Item count is a symptom, not the criterion; book ch7 has
26
+ # the widget-choice table.
27
+ #
28
+ # {#value} is the selected *item*, of whatever type {#items} holds, never its
29
+ # label; `nil` — a blank face — is the initial state and stays legal, so an
30
+ # optional enum field needs no placeholder. As on {ComboBox}, {#items=} is
31
+ # chrome: it never touches {#value}, never fires {HasValue#on_value_change},
32
+ # and a value absent from {#items} survives intact while rendering nothing
33
+ # selected. Keeping the two in sync is the app's job.
34
+ #
35
+ # == It claims no printable key but Space
36
+ # Enter, Space, ESC, {ListDropdown::MOVE_KEYS} and the mouse. *Every other*
37
+ # printable key bubbles past it (key-dispatch rung 3), so a form's `s`-to-save
38
+ # and a layout's `1`/`2`/`3` pane jumps keep working while a Select has focus
39
+ # — the one capability no {ComboBox} configuration can offer, since a text
40
+ # field eats printables unconditionally. Space is the single exception, and it
41
+ # forecloses nothing: every activatable widget in the gem already claims it.
42
+ # Home/End are declined too, so they stay available app-wide.
43
+ #
44
+ # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
45
+ # the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
46
+ # need no prefix-disambiguation.
47
+ #
48
+ # == Implementation details
49
+ # A leaf widget: it paints its own row (the face is *derived* from {#value}
50
+ # each paint, never a synced copy) and owns the dropdown as an overlay, which
51
+ # is not a child — like {ComboBox}'s. The well is read from
52
+ # {Screen#theme} at paint time, so it tracks a theme flip with no hook.
53
+ #
54
+ # The dropdown is at least as wide as the face and grows to fit the widest
55
+ # label, so the labels are never the thing that ellipsizes. It is not opened
56
+ # at all when {#items} is empty: an item-less Select is a programming bug, and
57
+ # an empty tinted panel reads as a broken list rather than as "nothing to
58
+ # pick". Enter/Space/Down are claimed either way.
59
+ #
60
+ # UI-thread-confined, like every component (see {Screen}).
61
+ class Select < Component
62
+ include HasValue
63
+
64
+ # @param items [Array] the options (any type); also settable via {#items=}.
65
+ # @param value [Object, nil] the initially selected item. Seeds the backing
66
+ # ivar directly, so no listener fires and assignment order doesn't matter
67
+ # to a form helper.
68
+ def initialize(items: [], value: nil)
69
+ super()
70
+ @items = items.to_a
71
+ @item_label = :to_s.to_proc
72
+ @value = value
73
+ @on_value_change = nil
74
+ @overlay = ListDropdown.new
75
+ @overlay.on_item_chosen = ->(index, _line) { commit(index) }
76
+ end
77
+
78
+ # @return [Array] the options.
79
+ attr_reader :items
80
+
81
+ # @return [Proc, Method] item -> shown label (a `String` or
82
+ # {StyledString}); `:to_s` by default. Never called with `nil` — an
83
+ # unselected Select renders a blank face.
84
+ attr_reader :item_label
85
+
86
+ def tab_stop? = true
87
+
88
+ # Replaces the options, leaving {#value} untouched. An open dropdown is
89
+ # rebuilt (and re-measured) around them, or closed when none are left.
90
+ # @param new_items [Array]
91
+ # @raise [TypeError] unless `new_items` is an `Array`.
92
+ # @return [void]
93
+ def items=(new_items)
94
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
95
+
96
+ @items = new_items
97
+ refill if @overlay.open?
98
+ invalidate
99
+ end
100
+
101
+ # @param proc [Proc, Method] item -> shown label.
102
+ # @return [void]
103
+ def item_label=(proc)
104
+ @item_label = proc
105
+ refill if @overlay.open?
106
+ invalidate
107
+ end
108
+
109
+ # @return [String]
110
+ def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
111
+
112
+ # Re-anchors the (open) dropdown after a move or resize.
113
+ # @param new_rect [Rect]
114
+ # @return [void]
115
+ def rect=(new_rect)
116
+ super
117
+ anchor if @overlay.open?
118
+ end
119
+
120
+ # Closes the dropdown when the Select leaves the focus chain, so tabbing
121
+ # away doesn't strand an open menu. Safe against re-entrancy: focus never
122
+ # sits inside the (non-focusable) {ListDropdown}, so closing it repairs no
123
+ # focus.
124
+ # @param flag [Boolean]
125
+ # @return [void]
126
+ def active=(flag)
127
+ was = active?
128
+ super
129
+ close_menu if was && !active?
130
+ end
131
+
132
+ # Opens the dropdown on Enter, Space or Down; while it is open, forwards
133
+ # {ListDropdown::MOVE_KEYS} to it, commits the highlight on Enter or Space,
134
+ # and dismisses on ESC. Everything else — every other printable included —
135
+ # is left unhandled so it bubbles to an ancestor.
136
+ # @param key [String]
137
+ # @return [Boolean]
138
+ def handle_key(key)
139
+ if @overlay.open?
140
+ return true if @overlay.move(key)
141
+
142
+ case key
143
+ when Keys::ENTER, " " then @overlay.choose
144
+ when Keys::ESC then close_menu
145
+ else return false
146
+ end
147
+ true
148
+ elsif [Keys::ENTER, " ", Keys::DOWN_ARROW].include?(key)
149
+ open_menu
150
+ true
151
+ else
152
+ false
153
+ end
154
+ end
155
+
156
+ # Toggles the dropdown on a left click anywhere in {#rect} — a field's
157
+ # affordance is its whole row, as the well advertises; `super` runs first,
158
+ # so the click also focuses.
159
+ # @param event [MouseEvent]
160
+ # @return [void]
161
+ def handle_mouse(event)
162
+ super
163
+ return unless event.button == :left && rect.contains?(event.point)
164
+
165
+ @overlay.open? ? close_menu : open_menu
166
+ end
167
+
168
+ # @return [void]
169
+ def repaint
170
+ return if rect.empty?
171
+
172
+ tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
173
+ clear_background(tail) unless tail.empty?
174
+ draw_line(rect.left, rect.top, face_row)
175
+ end
176
+
177
+ private
178
+
179
+ # The painted row: the value's label padded across all but the last column,
180
+ # then the `▾`, all on the field well — {Theme#active_bg_color} while on the
181
+ # focus chain, {Theme#input_bg_color} otherwise.
182
+ # @return [StyledString]
183
+ def face_row
184
+ width = [rect.width - 1, 0].max
185
+ label = label_for(value).ellipsize(width)
186
+ row = label + StyledString.plain("#{" " * (width - label.display_width)}▾")
187
+ row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
188
+ end
189
+
190
+ # Rebuilds the dropdown's rows, highlight and geometry, opening it if
191
+ # needed; closes it instead when there is nothing to show.
192
+ # @return [void]
193
+ def refill
194
+ if @items.empty?
195
+ close_menu
196
+ return
197
+ end
198
+
199
+ @overlay.lines = @items.map { |item| label_for(item) }
200
+ @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
201
+ @overlay.open unless @overlay.open?
202
+ anchor
203
+ end
204
+
205
+ # @return [void]
206
+ def open_menu = refill
207
+
208
+ # @return [void]
209
+ def close_menu = (@overlay.close if @overlay.open?)
210
+
211
+ # Adopts the item on row `index` as {#value} and closes the dropdown.
212
+ # @param index [Integer]
213
+ # @return [void]
214
+ def commit(index)
215
+ item = @items[index]
216
+ close_menu
217
+ self.value = item
218
+ end
219
+
220
+ # @return [void]
221
+ def anchor = @overlay.anchor_to(rect, rows: @items.size, width: menu_width)
222
+
223
+ # The dropdown's width: the widest label plus {List}'s two row gutters, plus
224
+ # the scrollbar column when the rows can't all be shown at once — but never
225
+ # narrower than the Select itself, so both edges line up with the face and
226
+ # the panel reads as belonging to it. Only a label that needs more pushes it
227
+ # wider.
228
+ #
229
+ # A dropdown the screen clamps shorter than
230
+ # {ListDropdown::MAX_VISIBLE_ROWS} scrolls without having bought that
231
+ # column, ellipsizing its labels one early — the {ComboBox} trade, in the
232
+ # one case measuring can't predict the height.
233
+ # @return [Integer]
234
+ def menu_width
235
+ widest = @items.map { |item| label_for(item).display_width }.max || 0
236
+ measured = widest + 2 + (@items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0)
237
+ [measured, rect.width].max
238
+ end
239
+
240
+ # @param item [Object]
241
+ # @return [StyledString] `item`'s label, or empty for `nil` — so {#value}
242
+ # being unset never reaches an {#item_label} that assumes an item.
243
+ def label_for(item)
244
+ return StyledString::EMPTY if item.nil?
245
+
246
+ label = @item_label.call(item)
247
+ label.is_a?(StyledString) ? label : StyledString.parse(label.to_s)
248
+ end
249
+ end
250
+ end
251
+ end
@@ -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]