tuile 0.11.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 +32 -0
- data/DECISIONS.md +680 -8
- data/README.md +12 -13
- data/TERMINOLOGY.md +61 -0
- data/book/02-repaint.md +1 -1
- data/book/03-layout.md +1 -1
- data/book/06-theming.md +1 -1
- data/book/07-components.md +97 -27
- data/examples/file_commander.rb +5 -4
- data/examples/sampler.rb +38 -1
- data/ideas/new-components.md +9 -4
- data/lib/tuile/buffer.rb +7 -7
- data/lib/tuile/component/button.rb +1 -1
- data/lib/tuile/component/checkbox.rb +1 -1
- data/lib/tuile/component/checkbox_group.rb +31 -26
- data/lib/tuile/component/combo_box.rb +10 -7
- data/lib/tuile/component/info_window.rb +1 -1
- data/lib/tuile/component/label.rb +14 -14
- data/lib/tuile/component/list.rb +291 -216
- data/lib/tuile/component/list_dropdown.rb +14 -7
- 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 +7 -7
- 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 +12 -12
- data/lib/tuile/version.rb +1 -1
- data/lib/tuile/vertical_scroll_bar.rb +6 -6
- data/sig/tuile.rbs +788 -377
- metadata +4 -1
|
@@ -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
|