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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/DECISIONS.md +680 -8
  4. data/README.md +12 -13
  5. data/TERMINOLOGY.md +61 -0
  6. data/book/02-repaint.md +1 -1
  7. data/book/03-layout.md +1 -1
  8. data/book/06-theming.md +1 -1
  9. data/book/07-components.md +97 -27
  10. data/examples/file_commander.rb +5 -4
  11. data/examples/sampler.rb +38 -1
  12. data/ideas/new-components.md +9 -4
  13. data/lib/tuile/buffer.rb +7 -7
  14. data/lib/tuile/component/button.rb +1 -1
  15. data/lib/tuile/component/checkbox.rb +1 -1
  16. data/lib/tuile/component/checkbox_group.rb +31 -26
  17. data/lib/tuile/component/combo_box.rb +10 -7
  18. data/lib/tuile/component/info_window.rb +1 -1
  19. data/lib/tuile/component/label.rb +14 -14
  20. data/lib/tuile/component/list.rb +291 -216
  21. data/lib/tuile/component/list_dropdown.rb +14 -7
  22. data/lib/tuile/component/notification.rb +317 -0
  23. data/lib/tuile/component/picker_window.rb +3 -3
  24. data/lib/tuile/component/popup.rb +8 -10
  25. data/lib/tuile/component/progress_bar.rb +1 -1
  26. data/lib/tuile/component/radio_group.rb +32 -30
  27. data/lib/tuile/component/select.rb +7 -7
  28. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  29. data/lib/tuile/component/text_area.rb +79 -273
  30. data/lib/tuile/component/text_field.rb +1 -1
  31. data/lib/tuile/component/text_view.rb +191 -177
  32. data/lib/tuile/component/window.rb +8 -8
  33. data/lib/tuile/component.rb +5 -5
  34. data/lib/tuile/screen.rb +1 -1
  35. data/lib/tuile/styled_string.rb +12 -12
  36. data/lib/tuile/version.rb +1 -1
  37. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  38. data/sig/tuile.rbs +788 -377
  39. 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