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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +109 -64
- data/DECISIONS.md +1299 -22
- data/README.md +18 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +154 -9
- data/book/05-focus.md +2 -0
- data/book/06-theming.md +1 -1
- data/book/07-components.md +202 -37
- data/book/README.md +3 -1
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +320 -133
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +26 -12
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +11 -10
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +18 -33
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/layout/box.rb +316 -0
- data/lib/tuile/component/layout/horizontal.rb +40 -0
- data/lib/tuile/component/layout/vertical.rb +41 -0
- data/lib/tuile/component/layout.rb +149 -1
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +82 -24
- data/lib/tuile/component/notification.rb +317 -0
- data/lib/tuile/component/picker_window.rb +3 -3
- data/lib/tuile/component/popup.rb +8 -10
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/radio_group.rb +32 -30
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
- data/lib/tuile/component/text_area.rb +79 -273
- data/lib/tuile/component/text_field.rb +1 -1
- data/lib/tuile/component/text_view.rb +191 -177
- data/lib/tuile/component/window.rb +8 -8
- data/lib/tuile/component.rb +5 -5
- data/lib/tuile/screen.rb +1 -1
- data/lib/tuile/styled_string.rb +25 -15
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +1670 -406
- 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
|