tuile 0.10.0 → 0.12.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 +109 -64
  3. data/DECISIONS.md +1299 -22
  4. data/README.md +18 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +154 -9
  8. data/book/05-focus.md +2 -0
  9. data/book/06-theming.md +1 -1
  10. data/book/07-components.md +202 -37
  11. data/book/README.md +3 -1
  12. data/examples/file_commander.rb +5 -4
  13. data/examples/sampler.rb +320 -133
  14. data/ideas/arrow-key-navigation.md +205 -0
  15. data/ideas/new-components.md +26 -12
  16. data/lib/tuile/buffer.rb +7 -7
  17. data/lib/tuile/component/big_decimal_field.rb +199 -0
  18. data/lib/tuile/component/button.rb +1 -1
  19. data/lib/tuile/component/checkbox.rb +11 -10
  20. data/lib/tuile/component/checkbox_group.rb +31 -26
  21. data/lib/tuile/component/combo_box.rb +18 -33
  22. data/lib/tuile/component/float_field.rb +161 -0
  23. data/lib/tuile/component/info_window.rb +1 -1
  24. data/lib/tuile/component/label.rb +14 -14
  25. data/lib/tuile/component/layout/box.rb +316 -0
  26. data/lib/tuile/component/layout/horizontal.rb +40 -0
  27. data/lib/tuile/component/layout/vertical.rb +41 -0
  28. data/lib/tuile/component/layout.rb +149 -1
  29. data/lib/tuile/component/list.rb +291 -216
  30. data/lib/tuile/component/list_dropdown.rb +82 -24
  31. data/lib/tuile/component/notification.rb +317 -0
  32. data/lib/tuile/component/picker_window.rb +3 -3
  33. data/lib/tuile/component/popup.rb +8 -10
  34. data/lib/tuile/component/progress_bar.rb +1 -1
  35. data/lib/tuile/component/radio_group.rb +32 -30
  36. data/lib/tuile/component/select.rb +251 -0
  37. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  38. data/lib/tuile/component/text_area.rb +79 -273
  39. data/lib/tuile/component/text_field.rb +1 -1
  40. data/lib/tuile/component/text_view.rb +191 -177
  41. data/lib/tuile/component/window.rb +8 -8
  42. data/lib/tuile/component.rb +5 -5
  43. data/lib/tuile/screen.rb +1 -1
  44. data/lib/tuile/styled_string.rb +25 -15
  45. data/lib/tuile/version.rb +1 -1
  46. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  47. data/lib/tuile.rb +4 -0
  48. data/sig/tuile.rbs +1670 -406
  49. metadata +11 -1
@@ -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.renderer = method(:label_for)
76
+ @overlay.on_item_chosen = ->(_index, item) { commit(item) }
77
+ end
78
+
79
+ # @return [Array] the options.
80
+ attr_reader :items
81
+
82
+ # @return [Proc, Method] item -> shown label (a `String` or
83
+ # {StyledString}); `:to_s` by default. Never called with `nil` — an
84
+ # unselected Select renders a blank face.
85
+ attr_reader :item_label
86
+
87
+ def tab_stop? = true
88
+
89
+ # Replaces the options, leaving {#value} untouched. An open dropdown is
90
+ # rebuilt (and re-measured) around them, or closed when none are left.
91
+ # @param new_items [Array]
92
+ # @raise [TypeError] unless `new_items` is an `Array`.
93
+ # @return [void]
94
+ def items=(new_items)
95
+ raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)
96
+
97
+ @items = new_items
98
+ refill if @overlay.open?
99
+ invalidate
100
+ end
101
+
102
+ # @param proc [Proc, Method] item -> shown label.
103
+ # @return [void]
104
+ def item_label=(proc)
105
+ @item_label = proc
106
+ refill if @overlay.open?
107
+ invalidate
108
+ end
109
+
110
+ # @return [String]
111
+ def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
112
+
113
+ # Re-anchors the (open) dropdown after a move or resize.
114
+ # @param new_rect [Rect]
115
+ # @return [void]
116
+ def rect=(new_rect)
117
+ super
118
+ anchor if @overlay.open?
119
+ end
120
+
121
+ # Closes the dropdown when the Select leaves the focus chain, so tabbing
122
+ # away doesn't strand an open menu. Safe against re-entrancy: focus never
123
+ # sits inside the (non-focusable) {ListDropdown}, so closing it repairs no
124
+ # focus.
125
+ # @param flag [Boolean]
126
+ # @return [void]
127
+ def active=(flag)
128
+ was = active?
129
+ super
130
+ close_menu if was && !active?
131
+ end
132
+
133
+ # Opens the dropdown on Enter, Space or Down; while it is open, forwards
134
+ # {ListDropdown::MOVE_KEYS} to it, commits the highlight on Enter or Space,
135
+ # and dismisses on ESC. Everything else — every other printable included —
136
+ # is left unhandled so it bubbles to an ancestor.
137
+ # @param key [String]
138
+ # @return [Boolean]
139
+ def handle_key(key)
140
+ if @overlay.open?
141
+ return true if @overlay.move(key)
142
+
143
+ case key
144
+ when Keys::ENTER, " " then @overlay.choose
145
+ when Keys::ESC then close_menu
146
+ else return false
147
+ end
148
+ true
149
+ elsif [Keys::ENTER, " ", Keys::DOWN_ARROW].include?(key)
150
+ open_menu
151
+ true
152
+ else
153
+ false
154
+ end
155
+ end
156
+
157
+ # Toggles the dropdown on a left click anywhere in {#rect} — a field's
158
+ # affordance is its whole row, as the well advertises; `super` runs first,
159
+ # so the click also focuses.
160
+ # @param event [MouseEvent]
161
+ # @return [void]
162
+ def handle_mouse(event)
163
+ super
164
+ return unless event.button == :left && rect.contains?(event.point)
165
+
166
+ @overlay.open? ? close_menu : open_menu
167
+ end
168
+
169
+ # @return [void]
170
+ def repaint
171
+ return if rect.empty?
172
+
173
+ tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
174
+ clear_background(tail) unless tail.empty?
175
+ draw_text(rect.left, rect.top, face_row)
176
+ end
177
+
178
+ private
179
+
180
+ # The painted row: the value's label padded across all but the last column,
181
+ # then the `▾`, all on the field well — {Theme#active_bg_color} while on the
182
+ # focus chain, {Theme#input_bg_color} otherwise.
183
+ # @return [StyledString]
184
+ def face_row
185
+ width = [rect.width - 1, 0].max
186
+ label = label_for(value).ellipsize(width)
187
+ row = label + StyledString.plain("#{" " * (width - label.display_width)}▾")
188
+ row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
189
+ end
190
+
191
+ # Rebuilds the dropdown's rows, highlight and geometry, opening it if
192
+ # needed; closes it instead when there is nothing to show.
193
+ # @return [void]
194
+ def refill
195
+ if @items.empty?
196
+ close_menu
197
+ return
198
+ end
199
+
200
+ @overlay.items = @items
201
+ @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
202
+ @overlay.open unless @overlay.open?
203
+ anchor
204
+ end
205
+
206
+ # @return [void]
207
+ def open_menu = refill
208
+
209
+ # @return [void]
210
+ def close_menu = (@overlay.close if @overlay.open?)
211
+
212
+ # Adopts the chosen item as {#value} and closes the dropdown.
213
+ # @param item [Object]
214
+ # @return [void]
215
+ def commit(item)
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
@@ -0,0 +1,320 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ class TextArea < AbstractStringField
6
+ # The word-wrapped layout of a {TextArea}'s text: which row each
7
+ # character lands on, and which glyphs each row paints.
8
+ #
9
+ # wrap = WrappedText.new("hello world", 6)
10
+ # wrap.row_count # => 2
11
+ # wrap.position_at(8) # => [1, 2] index 8 ("r") sits at row 1, column 2
12
+ # wrap.index_at(1, 2) # => 8 and back again
13
+ # wrap.row_text(0) # => "hello " padded out to the width
14
+ #
15
+ # A snapshot of `(text, width)` — rebuild it whenever either changes.
16
+ #
17
+ # == Implementation details
18
+ #
19
+ # Two axes meet here, and every method name says which one it speaks: an
20
+ # **index** counts characters into {#text}, a **column** counts terminal
21
+ # cells. They agree only for one-column glyphs. A {Row} therefore carries
22
+ # *both* counts — the wrap fills each row to a column budget while
23
+ # recording the character span that produced it.
24
+ #
25
+ # The wrap walks **grapheme clusters**, not characters: a combining mark
26
+ # must add no columns and must not be split from its base across a row
27
+ # break. Note `"\r\n"` is a *single* cluster, so a hard break tests
28
+ # `end_with?("\n")` rather than equality. Every branch of the wrap
29
+ # consumes at least one cluster — `"\v"` and `"\f"` match `/\s/` but are
30
+ # neither blank nor a newline here, and a loop that measured them as zero
31
+ # and did not advance would hang the UI thread on
32
+ # `area.text = File.read(...)`.
33
+ class WrappedText
34
+ # One row's span, measured on both axes.
35
+ #
36
+ # Both counts cover only the row's *visible* content: whitespace absorbed
37
+ # at a soft wrap, and the newline ending a hard one, belong to no row. So
38
+ # the next row's `start` may sit past this row's `start + length`, and an
39
+ # index in that gap resolves to the earlier row (see {WrappedText#row_at}).
40
+ #
41
+ # @!attribute [r] start
42
+ # @return [Integer] character index into {WrappedText#text} where the
43
+ # row begins.
44
+ # @!attribute [r] length
45
+ # @return [Integer] visible characters from `start`.
46
+ # @!attribute [r] columns
47
+ # @return [Integer] terminal cells those characters occupy. Exceeds
48
+ # {WrappedText#width} only for a single glyph too wide for a row.
49
+ class Row < Data.define(:start, :length, :columns)
50
+ # A zero-length row; `EMPTY.with(start: n)` rebases it onto an index.
51
+ # @return [Row]
52
+ EMPTY = new(start: 0, length: 0, columns: 0)
53
+ end
54
+
55
+ # @param text [String] the full buffer, unwrapped.
56
+ # @param width [Integer] column budget per row; `0` or less yields a
57
+ # single empty row.
58
+ def initialize(text, width)
59
+ @text = text
60
+ @width = width
61
+ @rows = compute_rows
62
+ end
63
+
64
+ # @return [String]
65
+ attr_reader :text
66
+
67
+ # @return [Integer]
68
+ attr_reader :width
69
+
70
+ # @return [Integer] rows the text occupies; always `>= 1`, since
71
+ # empty text still wraps to one (empty) row.
72
+ def row_count = @rows.size
73
+
74
+ # Display row holding `index`. An index inside a whitespace run absorbed
75
+ # by a soft wrap belongs to the row *before* the break.
76
+ # @param index [Integer] a character index into {#text}.
77
+ # @return [Integer] a row index in `0...row_count`.
78
+ def row_at(index)
79
+ @rows.each_with_index do |r, i|
80
+ next_start = i + 1 < @rows.size ? @rows[i + 1].start : @text.length + 1
81
+ return i if index >= r.start && index < next_start
82
+ end
83
+ @rows.size - 1
84
+ end
85
+
86
+ # @param index [Integer] a character index into {#text}.
87
+ # @return [Array(Integer, Integer)] `[row, column]` for `index`.
88
+ def position_at(index)
89
+ row = row_at(index)
90
+ [row, column_in(@rows[row], index)]
91
+ end
92
+
93
+ # Inverse of {#position_at}. A column landing in a wide glyph's right
94
+ # half resolves *past* it, as a click does in {TextField}.
95
+ # @param row [Integer] a row index in `0...row_count`.
96
+ # @param column [Integer] a column offset within that row.
97
+ # @return [Integer] a character index into {#text}.
98
+ def index_at(row, column)
99
+ r = @rows[row]
100
+ r.start + chars_for_column(r, column)
101
+ end
102
+
103
+ # @param row [Integer] a row index in `0...row_count`.
104
+ # @return [Integer] character index where the row begins.
105
+ def row_start(row) = @rows[row].start
106
+
107
+ # @param row [Integer] a row index in `0...row_count`.
108
+ # @return [Integer] character index one past the row's last *visible*
109
+ # character — whitespace absorbed by a soft wrap is excluded.
110
+ def row_end(row)
111
+ r = @rows[row]
112
+ r.start + r.length
113
+ end
114
+
115
+ # The row's glyphs, padded with spaces out to {#width}. A trailing glyph
116
+ # with no room left is dropped rather than half-painted. A row past the
117
+ # end of the text is all spaces, so a caller can paint a viewport taller
118
+ # than the text without a bounds check.
119
+ # @param row [Integer]
120
+ # @return [String]
121
+ def row_text(row)
122
+ r = @rows[row]
123
+ return " " * @width if r.nil?
124
+
125
+ out = +""
126
+ cols = 0
127
+ glyphs_of(r).each_grapheme_cluster do |g|
128
+ w = Buffer.display_width(g)
129
+ break if cols + w > @width
130
+
131
+ out << g
132
+ cols += w
133
+ end
134
+ out << (" " * (@width - cols))
135
+ end
136
+
137
+ private
138
+
139
+ # A plain Hash rather than a {Row}-style Data: this table is one entry
140
+ # per grapheme cluster, built and discarded inside a single {#compute_rows}
141
+ # call and never handed to another method as a documented type.
142
+ # @return [Array<Hash{Symbol=>Object}>] one entry per grapheme cluster of
143
+ # {#text}: `{offset: <text-index>, text: <cluster>, width: <columns>}`.
144
+ def cluster_table
145
+ offset = 0
146
+ @text.each_grapheme_cluster.map do |g|
147
+ entry = { offset: offset, text: g, width: Buffer.display_width(g) }
148
+ offset += g.length
149
+ entry
150
+ end
151
+ end
152
+
153
+ # @param cluster [Hash{Symbol=>Object}]
154
+ # @return [Boolean] true for a space or tab (each exactly one column).
155
+ def blank?(cluster) = cluster[:text].match?(/[ \t]/)
156
+
157
+ # @param cluster [Hash{Symbol=>Object}]
158
+ # @return [Boolean] true for a hard line break. Tests the suffix rather
159
+ # than equality because `"\r\n"` is one grapheme cluster.
160
+ def newline?(cluster) = cluster[:text].end_with?("\n")
161
+
162
+ # Greedy word-wrap, filling each row to a **column** budget while recording
163
+ # the **character** span that produced it. Whitespace at a soft-wrap break
164
+ # point is absorbed (not rendered on either row). A token wider than
165
+ # {#width} hard-wraps inside the token. Newlines force a hard break and
166
+ # the wrap restarts on the next cluster.
167
+ # @return [Array<Row>] one entry per row.
168
+ def compute_rows
169
+ return [Row::EMPTY] if @width <= 0 || @text.empty?
170
+
171
+ cl = cluster_table
172
+ rows = []
173
+ i = 0
174
+ n = cl.size
175
+
176
+ while i < n
177
+ start = cl[i][:offset]
178
+ chars = 0
179
+ cols = 0
180
+
181
+ while i < n
182
+ g = cl[i]
183
+ break if newline?(g)
184
+
185
+ if blank?(g)
186
+ if cols < @width
187
+ chars += g[:text].length
188
+ cols += g[:width]
189
+ i += 1
190
+ else
191
+ chars, cols = trim_trailing_whitespace(start, chars, cols)
192
+ i += 1 while i < n && blank?(cl[i])
193
+ break
194
+ end
195
+ else
196
+ word_chars, word_cols, word_end = measure_word(cl, i)
197
+
198
+ if cols + word_cols <= @width
199
+ chars += word_chars
200
+ cols += word_cols
201
+ i = word_end
202
+ elsif cols.zero?
203
+ chars, cols, i = hard_wrap(cl, i)
204
+ break
205
+ else
206
+ chars, cols = trim_trailing_whitespace(start, chars, cols)
207
+ break
208
+ end
209
+ end
210
+ end
211
+
212
+ rows << Row.new(start: start, length: chars, columns: cols)
213
+
214
+ next unless i < n && newline?(cl[i])
215
+
216
+ i += 1
217
+ rows << Row::EMPTY.with(start: @text.length) if i >= n
218
+ end
219
+
220
+ rows
221
+ end
222
+
223
+ # @param clusters [Array<Hash{Symbol=>Object}>]
224
+ # @param index [Integer] cluster index of the word's first glyph.
225
+ # @return [Array(Integer, Integer, Integer)] `[chars, columns, next_index]`
226
+ # for the run of non-whitespace starting at `index`.
227
+ def measure_word(clusters, index)
228
+ chars = 0
229
+ cols = 0
230
+ while index < clusters.size && !blank?(clusters[index]) && !newline?(clusters[index])
231
+ chars += clusters[index][:text].length
232
+ cols += clusters[index][:width]
233
+ index += 1
234
+ end
235
+ [chars, cols, index]
236
+ end
237
+
238
+ # Splits a token too wide for a whole row, taking entire glyphs while they
239
+ # fit. Consumes at least one glyph even when that single glyph is wider than
240
+ # the row — otherwise the wrap would not terminate (the row would stay empty
241
+ # and the same token be reconsidered forever). Such a row reports more
242
+ # columns than {#width} holds and {#row_text} drops the glyph; a 2-column
243
+ # glyph in a 1-column area is unpaintable either way.
244
+ # @param clusters [Array<Hash{Symbol=>Object}>]
245
+ # @param index [Integer]
246
+ # @return [Array(Integer, Integer, Integer)] `[chars, columns, next_index]`
247
+ def hard_wrap(clusters, index)
248
+ chars = 0
249
+ cols = 0
250
+ while index < clusters.size && cols + clusters[index][:width] <= @width
251
+ chars += clusters[index][:text].length
252
+ cols += clusters[index][:width]
253
+ index += 1
254
+ end
255
+ if chars.zero? && index < clusters.size
256
+ chars = clusters[index][:text].length
257
+ cols = clusters[index][:width]
258
+ index += 1
259
+ end
260
+ [chars, cols, index]
261
+ end
262
+
263
+ # Trims trailing space/tab characters off a row's visible length so the
264
+ # whitespace at a soft-wrap point is absorbed (not rendered) rather than
265
+ # left at the end of the row. Without this, soft-wrapping `"foo bar"`
266
+ # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
267
+ # end-of-row caret position would coincide with row 1's start.
268
+ #
269
+ # Both counts drop by one per trimmed character: a space and a tab each
270
+ # measure exactly one column.
271
+ # @param row_start [Integer]
272
+ # @param row_chars [Integer]
273
+ # @param row_cols [Integer]
274
+ # @return [Array(Integer, Integer)] `[row_chars, row_cols]`
275
+ def trim_trailing_whitespace(row_start, row_chars, row_cols)
276
+ while row_chars.positive? && @text[row_start + row_chars - 1].match?(/[ \t]/)
277
+ row_chars -= 1
278
+ row_cols -= 1
279
+ end
280
+ [row_chars, row_cols]
281
+ end
282
+
283
+ # @param row [Row]
284
+ # @param index [Integer]
285
+ # @return [Integer] `index`'s column offset within `row`.
286
+ def column_in(row, index)
287
+ chars = (index - row.start).clamp(0, row.length)
288
+ columns_of(@text[row.start, chars] || "").clamp(0, row.columns)
289
+ end
290
+
291
+ # @param row [Row]
292
+ # @param column [Integer]
293
+ # @return [Integer] characters from the row's start.
294
+ def chars_for_column(row, column)
295
+ chars = 0
296
+ col = 0
297
+ glyphs_of(row).each_grapheme_cluster do |g|
298
+ w = Buffer.display_width(g)
299
+ return chars if column < col + ((w + 1) / 2)
300
+
301
+ col += w
302
+ chars += g.length
303
+ end
304
+ chars
305
+ end
306
+
307
+ # @param row [Row]
308
+ # @return [String] the row's visible characters.
309
+ def glyphs_of(row) = @text[row.start, row.length] || ""
310
+
311
+ # Mirrors {AbstractStringField}'s measurement primitive, which this class
312
+ # can't inherit. Per-cluster rather than whole-string so a multi-codepoint
313
+ # emoji measures as the one glyph a terminal draws.
314
+ # @param str [String]
315
+ # @return [Integer] columns.
316
+ def columns_of(str) = str.each_grapheme_cluster.sum { |g| Buffer.display_width(g) }
317
+ end
318
+ end
319
+ end
320
+ end