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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -36
- data/DECISIONS.md +2566 -0
- data/README.md +37 -24
- data/book/03-layout.md +153 -8
- data/book/04-event-loop.md +86 -0
- data/book/05-focus.md +84 -51
- data/book/06-theming.md +53 -1
- data/book/07-components.md +458 -9
- data/book/08-testing.md +21 -8
- data/book/09-styled-text.md +132 -0
- data/book/README.md +22 -14
- data/examples/sampler.rb +632 -67
- data/ideas/arrow-key-navigation.md +205 -0
- data/ideas/new-components.md +118 -0
- data/ideas/per-component-buffers.md +55 -0
- data/lib/tuile/buffer.rb +52 -51
- data/lib/tuile/color.rb +4 -10
- data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
- data/lib/tuile/component/big_decimal_field.rb +199 -0
- data/lib/tuile/component/button.rb +25 -21
- data/lib/tuile/component/checkbox.rb +134 -0
- data/lib/tuile/component/checkbox_group.rb +188 -0
- data/lib/tuile/component/combo_box.rb +263 -0
- data/lib/tuile/component/float_field.rb +161 -0
- data/lib/tuile/component/has_caption.rb +44 -0
- data/lib/tuile/component/has_content.rb +5 -5
- data/lib/tuile/component/has_value.rb +64 -0
- data/lib/tuile/component/integer_field.rb +135 -0
- data/lib/tuile/component/label.rb +13 -10
- 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 -15
- data/lib/tuile/component/list.rb +8 -7
- data/lib/tuile/component/list_dropdown.rb +157 -0
- data/lib/tuile/component/password_field.rb +105 -0
- data/lib/tuile/component/popup.rb +10 -14
- data/lib/tuile/component/progress_bar.rb +278 -0
- data/lib/tuile/component/radio_group.rb +188 -0
- data/lib/tuile/component/select.rb +251 -0
- data/lib/tuile/component/text_area.rb +189 -65
- data/lib/tuile/component/text_field.rb +170 -32
- data/lib/tuile/component/text_view.rb +57 -114
- data/lib/tuile/component/window.rb +32 -47
- data/lib/tuile/component.rb +250 -87
- data/lib/tuile/event_queue.rb +14 -17
- data/lib/tuile/fake_event_queue.rb +11 -2
- data/lib/tuile/fake_screen.rb +4 -5
- data/lib/tuile/fraction.rb +6 -10
- data/lib/tuile/screen.rb +202 -104
- data/lib/tuile/screen_pane.rb +51 -41
- data/lib/tuile/styled_string.rb +125 -86
- data/lib/tuile/theme.rb +78 -41
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile.rb +4 -0
- data/sig/tuile.rbs +2962 -680
- 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
|
|
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
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
|
|
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
|
|
28
|
-
# by {#compute_display_rows}. `nil` means "stale,
|
|
29
|
-
# read". Reset to nil whenever {#text} mutates or the
|
|
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] +
|
|
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 =
|
|
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
|
-
#
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
|
|
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
|
-
|
|
145
|
-
n =
|
|
146
|
-
|
|
147
|
-
while
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
161
|
-
|
|
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 =
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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:
|
|
218
|
+
rows << { start: start, length: chars, columns: cols }
|
|
184
219
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
# @
|
|
203
|
-
|
|
204
|
-
|
|
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, (
|
|
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
|
-
|
|
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] +
|
|
360
|
+
self.caret = r[:start] + chars_for_column(r, cur_col)
|
|
237
361
|
end
|
|
238
362
|
|
|
239
363
|
# @return [void]
|