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
@@ -6,7 +6,7 @@ module Tuile
6
6
  #
7
7
  # Sized by the caller — {#rect} is fixed; the area does not grow with
8
8
  # content. Text is wrapped to {Rect#width} columns and any text that
9
- # doesn't fit vertically is reached by scrolling: {#top_display_row}
9
+ # doesn't fit vertically is reached by scrolling: {#scroll_top_row}
10
10
  # follows the caret so the line being edited stays visible. There is no
11
11
  # horizontal scrolling.
12
12
  #
@@ -22,49 +22,69 @@ module Tuile
22
22
  # accepted — otherwise a multi-line paste would silently lose its
23
23
  # newlines.
24
24
  #
25
- # == Implementation details
25
+ # Up/Down move the caret between rows and, at the first/last row, snap to
26
+ # the start/end of the text. A subclass can claim the key at that edge
27
+ # instead — shell-style history recall is the motivating case — by asking
28
+ # {#caret_row} and {#row_count} before delegating:
29
+ #
30
+ # class PromptArea < Component::TextArea
31
+ # protected
32
+ #
33
+ # def handle_text_input_key(key)
34
+ # return recall_previous if key == Keys::UP_ARROW && caret_row.zero?
35
+ # return recall_next if key == Keys::DOWN_ARROW && caret_row == row_count - 1
36
+ #
37
+ # super # anywhere else: the caret moves, and the edge still snaps
38
+ # end
39
+ # end
26
40
  #
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`.
41
+ # Both recalls return `true` to consume the key; app code that would rather
42
+ # not subclass claims the same keys through {AbstractStringField#on_key}.
34
43
  #
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.
44
+ # == Implementation details
45
+ #
46
+ # The wrap itself — and with it every conversion between a character
47
+ # **index** and a **row/column** — lives in {WrappedText}, a
48
+ # snapshot of `(text, rect.width)` this class caches and drops whenever
49
+ # either changes. What stays here is the widget: keys, mouse, painting, and
50
+ # the {#scroll_top_row} viewport, which {WrappedText} deliberately knows
51
+ # nothing about (it is a pure function of text and width; the viewport is
52
+ # stateful and needs {Rect#height}).
39
53
  class TextArea < AbstractStringField
40
54
  def initialize
41
55
  super
42
- @top_display_row = 0
43
- # Lazy cache of the word-wrapped layout: an
44
- # `Array<Hash{Symbol=>Integer}>` whose entries are
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}.
49
- @display_rows = nil
56
+ @scroll_top_row = 0
57
+ # Lazy cache; nil means "stale, rebuild on next read". Reset whenever
58
+ # {#text} mutates or the width changes.
59
+ @wrap = nil
50
60
  end
51
61
 
52
- # @return [Integer] index of the topmost display row currently visible.
53
- attr_reader :top_display_row
62
+ # @return [Integer] index of the topmost row currently visible.
63
+ attr_reader :scroll_top_row
64
+
65
+ # The caret's row, counted from the text's first row — *not* from the top
66
+ # of the viewport (subtract {#scroll_top_row} for that).
67
+ # @return [Integer] a row index in `0...row_count`.
68
+ def caret_row = wrap.row_at(@caret)
69
+
70
+ # @return [Integer] rows the wrapped text occupies at the current
71
+ # {Rect#width}; always `>= 1`, since empty text still wraps to one
72
+ # (empty) row.
73
+ def row_count = wrap.row_count
54
74
 
55
75
  # @return [Point, nil]
56
76
  def cursor_position
57
77
  return nil if rect.empty?
58
78
 
59
- row, col = caret_to_display(@caret)
60
- screen_row = row - @top_display_row
61
- return nil if screen_row.negative? || screen_row >= rect.height
79
+ row, col = wrap.position_at(@caret)
80
+ row_in_viewport = row - @scroll_top_row
81
+ return nil if row_in_viewport.negative? || row_in_viewport >= rect.height
62
82
 
63
83
  # Cap so the hardware cursor never lands at rect.left+rect.width
64
84
  # (one past the rect). Terminals with auto-wrap interpret that as
65
85
  # column 0 of the row below; capping pins the cursor on the last
66
86
  # visible cell instead.
67
- Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + screen_row)
87
+ Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
68
88
  end
69
89
 
70
90
  # @param event [MouseEvent]
@@ -73,26 +93,21 @@ module Tuile
73
93
  super
74
94
  return unless event.button == :left && rect.contains?(event.point)
75
95
 
76
- target_row = (event.y - rect.top) + @top_display_row
77
- target_col = event.x - rect.left
78
- rows = display_rows
79
- if target_row >= rows.size
80
- self.caret = @text.length
81
- else
82
- r = rows[target_row]
83
- self.caret = r[:start] + chars_for_column(r, target_col)
84
- end
96
+ target_row = (event.y - rect.top) + @scroll_top_row
97
+ self.caret = if target_row >= wrap.row_count
98
+ @text.length
99
+ else
100
+ wrap.index_at(target_row, event.x - rect.left)
101
+ end
85
102
  end
86
103
 
87
104
  # @return [void]
88
105
  def repaint
89
106
  return if rect.empty?
90
107
 
91
- rows = display_rows
92
- (0...rect.height).each do |screen_row|
93
- row_idx = screen_row + @top_display_row
94
- line = row_idx >= rows.size ? " " * rect.width : padded_row(rows[row_idx])
95
- screen.buffer.set_line(rect.left, rect.top + screen_row, background(line))
108
+ (0...rect.height).each do |row_in_viewport|
109
+ line = wrap.row_text(row_in_viewport + @scroll_top_row)
110
+ screen.buffer.set_text(rect.left, rect.top + row_in_viewport, background(line))
96
111
  end
97
112
  end
98
113
 
@@ -100,13 +115,13 @@ module Tuile
100
115
 
101
116
  # @return [void]
102
117
  def on_text_mutated
103
- @display_rows = nil
104
- adjust_top_display_row
118
+ @wrap = nil
119
+ adjust_scroll_top_row
105
120
  end
106
121
 
107
122
  # @return [void]
108
123
  def on_caret_mutated
109
- adjust_top_display_row
124
+ adjust_scroll_top_row
110
125
  end
111
126
 
112
127
  # @param key [String]
@@ -131,248 +146,40 @@ module Tuile
131
146
  # @return [void]
132
147
  def on_width_changed
133
148
  super
134
- @display_rows = nil
135
- adjust_top_display_row
149
+ @wrap = nil
150
+ adjust_scroll_top_row
136
151
  end
137
152
 
138
153
  private
139
154
 
140
- # @return [Array<Hash{Symbol=>Integer}>] cached wrap of {#text} for the
141
- # current {Rect#width}. Each entry is `{start:, length:}`.
142
- def display_rows
143
- @display_rows ||= compute_display_rows
144
- end
145
-
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.
172
- # @return [Array<Hash{Symbol=>Integer}>]
173
- def compute_display_rows
174
- width = rect.width
175
- return [{ start: 0, length: 0, columns: 0 }] if width <= 0 || @text.empty?
176
-
177
- cl = cluster_table
178
- rows = []
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
196
- else
197
- chars, cols = trim_trailing_whitespace(start, chars, cols)
198
- i += 1 while i < n && blank?(cl[i])
199
- break
200
- end
201
- else
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)
210
- break
211
- else
212
- chars, cols = trim_trailing_whitespace(start, chars, cols)
213
- break
214
- end
215
- end
216
- end
217
-
218
- rows << { start: start, length: chars, columns: cols }
219
-
220
- next unless i < n && newline?(cl[i])
221
-
222
- i += 1
223
- rows << { start: @text.length, length: 0, columns: 0 } if i >= n
224
- end
225
-
226
- rows << { start: 0, length: 0, columns: 0 } if rows.empty?
227
- rows
228
- end
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
-
271
- # Trims trailing space/tab characters off a row's visible length so the
272
- # whitespace at a soft-wrap point is absorbed (not rendered) rather than
273
- # left at the end of the row. Without this, soft-wrapping `"foo bar"`
274
- # to width 4 would yield row 0 length 4 (`"foo "`) and the natural
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.
279
- # @param row_start [Integer]
280
- # @param row_chars [Integer]
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]
289
- end
290
-
291
- # @param caret [Integer]
292
- # @return [Array(Integer, Integer)] `[row_index, column]` for `caret`.
293
- def caret_to_display(caret)
294
- rows = display_rows
295
- rows.each_with_index do |r, i|
296
- next_start = i + 1 < rows.size ? rows[i + 1][:start] : @text.length + 1
297
- next unless caret >= r[:start] && caret < next_start
298
-
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
342
- end
343
- out << (" " * (rect.width - cols))
155
+ # @return [WrappedText] the current wrap of {#text} at {Rect#width}.
156
+ def wrap
157
+ @wrap ||= WrappedText.new(@text, rect.width)
344
158
  end
345
159
 
346
160
  # @param delta [Integer] `+1` for down, `-1` for up.
347
161
  # @return [void]
348
162
  def move_caret_vertical(delta)
349
- rows = display_rows
350
- cur_row, cur_col = caret_to_display(@caret)
351
- new_row = (cur_row + delta).clamp(0, rows.size - 1)
163
+ cur_row, cur_col = wrap.position_at(@caret)
164
+ new_row = (cur_row + delta).clamp(0, wrap.row_count - 1)
352
165
  if new_row == cur_row
353
- # Already at the top/bottom display row. Snap to the absolute
354
- # start/end of the text so the user has a quick way to reach it.
166
+ # Already at the top/bottom row. Snap to the absolute start/end of the
167
+ # text so the user has a quick way to reach it.
355
168
  self.caret = delta.positive? ? @text.length : 0
356
169
  return
357
170
  end
358
171
 
359
- r = rows[new_row]
360
- self.caret = r[:start] + chars_for_column(r, cur_col)
172
+ self.caret = wrap.index_at(new_row, cur_col)
361
173
  end
362
174
 
363
175
  # @return [void]
364
176
  def move_caret_to_row_start
365
- rows = display_rows
366
- cur_row, = caret_to_display(@caret)
367
- self.caret = rows[cur_row][:start]
177
+ self.caret = wrap.row_start(wrap.row_at(@caret))
368
178
  end
369
179
 
370
180
  # @return [void]
371
181
  def move_caret_to_row_end
372
- rows = display_rows
373
- cur_row, = caret_to_display(@caret)
374
- r = rows[cur_row]
375
- self.caret = r[:start] + r[:length]
182
+ self.caret = wrap.row_end(wrap.row_at(@caret))
376
183
  end
377
184
 
378
185
  # @param char [String]
@@ -386,18 +193,17 @@ module Tuile
386
193
 
387
194
  # Keeps the caret visible by scrolling vertically.
388
195
  # @return [void]
389
- def adjust_top_display_row
196
+ def adjust_scroll_top_row
390
197
  return if rect.empty?
391
198
 
392
- rows = display_rows
393
- cur_row, = caret_to_display(@caret)
394
- if cur_row < @top_display_row
395
- @top_display_row = cur_row
396
- elsif cur_row >= @top_display_row + rect.height
397
- @top_display_row = cur_row - rect.height + 1
199
+ cur_row = wrap.row_at(@caret)
200
+ if cur_row < @scroll_top_row
201
+ @scroll_top_row = cur_row
202
+ elsif cur_row >= @scroll_top_row + rect.height
203
+ @scroll_top_row = cur_row - rect.height + 1
398
204
  end
399
- max_top = (rows.size - rect.height).clamp(0, nil)
400
- @top_display_row = @top_display_row.clamp(0, max_top)
205
+ max_top = (wrap.row_count - rect.height).clamp(0, nil)
206
+ @scroll_top_row = @scroll_top_row.clamp(0, max_top)
401
207
  end
402
208
  end
403
209
  end
@@ -118,7 +118,7 @@ module Tuile
118
118
  def repaint
119
119
  return if rect.empty?
120
120
 
121
- screen.buffer.set_line(rect.left, rect.top, background(visible_text))
121
+ screen.buffer.set_text(rect.left, rect.top, background(visible_text))
122
122
  end
123
123
 
124
124
  protected