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
@@ -26,26 +26,25 @@ module Tuile
26
26
  def initialize
27
27
  super
28
28
  # Three parallel structures, kept in lockstep by every mutator:
29
- # `@hard_lines` is the logical model (one entry per `\n`-delimited
30
- # line, width-independent); `@physical_lines` is the rendered view
31
- # (each hard line word-wrapped to `wrap_width` and padded with
29
+ # `@lines` is the logical model (one entry per `\n`-delimited
30
+ # line, width-independent); `@rows` is the rendered view
31
+ # (each line word-wrapped to `wrap_width` and padded with
32
32
  # trailing blanks, so painting a row is a lookup); and
33
- # `@hard_line_wrap_counts` is an Integer-per-hard-line cache of
34
- # how many physical rows each hard line occupies, so a mid-buffer
35
- # splice can find its starting physical-row offset without
36
- # re-wrapping every preceding hard line.
33
+ # `@line_wrap_counts` is an Integer-per-line cache of how many
34
+ # rows each line occupies, so a mid-buffer splice can find its
35
+ # starting row offset without re-wrapping every preceding line.
37
36
  #
38
37
  # Invariants:
39
- # - `@hard_line_wrap_counts.size == @hard_lines.size`
40
- # - `@hard_line_wrap_counts.sum == @physical_lines.size`
38
+ # - `@line_wrap_counts.size == @lines.size`
39
+ # - `@line_wrap_counts.sum == @rows.size`
41
40
  # A full rebuild ({#rewrap}) happens on {#text=} and width changes;
42
41
  # other mutators splice incrementally.
43
- @hard_lines = []
44
- @physical_lines = []
45
- @hard_line_wrap_counts = []
42
+ @lines = []
43
+ @rows = []
44
+ @line_wrap_counts = []
46
45
  @text = StyledString::EMPTY
47
- @blank_line = StyledString::EMPTY
48
- @top_line = 0
46
+ @blank_row = StyledString::EMPTY
47
+ @scroll_top_row = 0
49
48
  @auto_scroll = false
50
49
  @follow = true
51
50
  @scrollbar_visibility = :gone
@@ -61,8 +60,8 @@ module Tuile
61
60
  @text ||= build_text
62
61
  end
63
62
 
64
- # @return [Integer] index of the first visible physical line.
65
- attr_reader :top_line
63
+ # @return [Integer] index of the first visible row.
64
+ attr_reader :scroll_top_row
66
65
 
67
66
  # @return [Symbol] `:gone` or `:visible`.
68
67
  attr_reader :scrollbar_visibility
@@ -101,13 +100,13 @@ module Tuile
101
100
  # the text," not "merge with what's there." The unchanged-content
102
101
  # path still skips the expensive rewrap / invalidate work.
103
102
  @text = new_text
104
- @hard_lines = new_text.empty? ? [] : new_text.lines
103
+ @lines = new_text.empty? ? [] : new_text.lines
105
104
  @regions.each { |r| r.send(:detach!) }
106
- @regions = [Region.send(:new, self, @hard_lines.size)]
105
+ @regions = [Region.send(:new, self, @lines.size)]
107
106
  return if content_unchanged
108
107
 
109
108
  rewrap
110
- update_top_line_if_auto_scroll
109
+ update_scroll_top_row_if_auto_scroll
111
110
  invalidate
112
111
  end
113
112
 
@@ -129,7 +128,7 @@ module Tuile
129
128
  end
130
129
 
131
130
  # @return [Boolean] true iff {#text} is empty (no hard lines).
132
- def empty? = @hard_lines.empty?
131
+ def empty? = @lines.empty?
133
132
 
134
133
  # Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
135
134
  # the text is concatenated onto the current last hard line. Designed for
@@ -154,21 +153,21 @@ module Tuile
154
153
  # region the app created at the tail) means new content starts on
155
154
  # a fresh hard line — we must not extend the previous region's
156
155
  # last line.
157
- new_segments.each { |hl| push_hard_line(hl, width) }
156
+ new_segments.each { |line| push_line(line, width) }
158
157
  added = new_segments.size
159
158
  else
160
159
  extension = new_segments.first
161
160
  unless extension.empty?
162
- old_last = pop_hard_line
163
- push_hard_line(old_last + extension, width)
161
+ old_last = pop_line
162
+ push_line(old_last + extension, width)
164
163
  end
165
- new_segments[1..].each { |hl| push_hard_line(hl, width) }
164
+ new_segments[1..].each { |line| push_line(line, width) }
166
165
  added = new_segments.size - 1
167
166
  end
168
167
 
169
168
  tail_region.send(:line_count=, tail_region.line_count + added)
170
169
  @text = nil
171
- update_top_line_if_auto_scroll
170
+ update_scroll_top_row_if_auto_scroll
172
171
  invalidate
173
172
  end
174
173
 
@@ -204,7 +203,7 @@ module Tuile
204
203
  # Drops the last `n` hard lines from the buffer — the inverse of building
205
204
  # up a tail with {#append} / {#add_line}, so a caller can `remove` then
206
205
  # `append` to rewrite a damaged tail in place. Operates on **hard lines**
207
- # (the `\n`-delimited entries), not wrapped physical rows. `n == 0` and the
206
+ # (the `\n`-delimited entries), not wrapped rows. `n == 0` and the
208
207
  # empty buffer are no-ops; `n >= hard-line count` empties the buffer.
209
208
  # @param n [Integer] number of hard lines to drop; must be >= 0.
210
209
  # @raise [TypeError] if `n` isn't an `Integer`.
@@ -217,8 +216,8 @@ module Tuile
217
216
  screen.check_locked
218
217
  return if n.zero? || empty?
219
218
 
220
- to_drop = [n, @hard_lines.size].min
221
- to_drop.times { pop_hard_line }
219
+ to_drop = [n, @lines.size].min
220
+ to_drop.times { pop_line }
222
221
 
223
222
  # Cascade-shrink regions from the spatial tail. The tail region
224
223
  # gives up lines first; if more are still owed (because the tail
@@ -233,8 +232,8 @@ module Tuile
233
232
  end
234
233
 
235
234
  @text = nil
236
- @top_line = top_line_max if @top_line > top_line_max
237
- update_top_line_if_auto_scroll
235
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
236
+ update_scroll_top_row_if_auto_scroll
238
237
  invalidate
239
238
  end
240
239
 
@@ -249,7 +248,7 @@ module Tuile
249
248
  # - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
250
249
  # *insertion* at that position — nothing removed. {#insert} aliases this.
251
250
  #
252
- # Splices in place — only the affected slice of the physical-row buffer is
251
+ # Splices in place — only the affected slice of the row buffer is
253
252
  # touched, no preceding lines re-wrapped (cost O(from + length + new
254
253
  # content)). A no-op when the replacement equals the covered range, so
255
254
  # `replace(n...n, "")` is cheap.
@@ -267,15 +266,15 @@ module Tuile
267
266
  from, to = normalize_replace_range(range)
268
267
 
269
268
  parsed = StyledString.parse(str)
270
- new_hard_lines = parsed.empty? ? [] : parsed.lines
269
+ new_lines = parsed.empty? ? [] : parsed.lines
271
270
  length = to - from + 1
272
- return if new_hard_lines == @hard_lines[from, length]
271
+ return if new_lines == @lines[from, length]
273
272
 
274
- splice_hard_lines(from, length, new_hard_lines)
275
- update_region_counts(from, length, new_hard_lines.size)
273
+ splice_lines(from, length, new_lines)
274
+ update_region_counts(from, length, new_lines.size)
276
275
  @text = nil
277
- @top_line = top_line_max if @top_line > top_line_max
278
- update_top_line_if_auto_scroll
276
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
277
+ update_scroll_top_row_if_auto_scroll
279
278
  invalidate
280
279
  end
281
280
 
@@ -297,15 +296,15 @@ module Tuile
297
296
  self.text = StyledString::EMPTY
298
297
  end
299
298
 
300
- # @param new_top_line [Integer] 0 or greater. Not clamped against the
301
- # number of lines (matches {List#top_line=}).
299
+ # @param new_row [Integer] 0 or greater. Not clamped against the
300
+ # number of rows (matches {List#scroll_top_row=}).
302
301
  # @return [void]
303
- def top_line=(new_top_line)
304
- raise TypeError, "expected Integer, got #{new_top_line.inspect}" unless new_top_line.is_a? Integer
305
- raise ArgumentError, "top_line must not be negative, got #{new_top_line}" if new_top_line.negative?
306
- return if @top_line == new_top_line
302
+ def scroll_top_row=(new_row)
303
+ raise TypeError, "expected Integer, got #{new_row.inspect}" unless new_row.is_a? Integer
304
+ raise ArgumentError, "scroll_top_row must not be negative, got #{new_row}" if new_row.negative?
305
+ return if @scroll_top_row == new_row
307
306
 
308
- @top_line = new_top_line
307
+ @scroll_top_row = new_row
309
308
  @follow = at_bottom?
310
309
  invalidate
311
310
  end
@@ -328,9 +327,21 @@ module Tuile
328
327
  def auto_scroll=(value)
329
328
  @auto_scroll = value ? true : false
330
329
  @follow = true if @auto_scroll
331
- update_top_line_if_auto_scroll
330
+ update_scroll_top_row_if_auto_scroll
332
331
  end
333
332
 
333
+ # Scrolls up half a viewport (`rect.height / 2`, at least one row),
334
+ # clamped at the top — unlike {#scroll_top_row=}, which raises below `0`.
335
+ # What `Ctrl+U` does, minus the focus: {#handle_key} ignores every key
336
+ # while the view is inactive, this works whoever holds focus.
337
+ # @return [void]
338
+ def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)
339
+
340
+ # The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
341
+ # arriving there re-arms {#following?}.
342
+ # @return [void]
343
+ def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)
344
+
334
345
  def focusable? = true
335
346
 
336
347
  def tab_stop? = true
@@ -342,14 +353,14 @@ module Tuile
342
353
  return true if super
343
354
 
344
355
  case key
345
- when *Keys::DOWN_ARROWS then move_top_line_by(1)
346
- when *Keys::UP_ARROWS then move_top_line_by(-1)
347
- when Keys::PAGE_DOWN then move_top_line_by(viewport_lines)
348
- when Keys::PAGE_UP then move_top_line_by(-viewport_lines)
349
- when Keys::CTRL_D then move_top_line_by(viewport_lines / 2)
350
- when Keys::CTRL_U then move_top_line_by(-viewport_lines / 2)
351
- when *Keys::HOMES, "g" then move_top_line_to(0)
352
- when *Keys::ENDS_, "G" then move_top_line_to(top_line_max)
356
+ when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
357
+ when *Keys::UP_ARROWS then move_scroll_top_row_by(-1)
358
+ when Keys::PAGE_DOWN then move_scroll_top_row_by(viewport_rows)
359
+ when Keys::PAGE_UP then move_scroll_top_row_by(-viewport_rows)
360
+ when Keys::CTRL_D then scroll_half_page_down
361
+ when Keys::CTRL_U then scroll_half_page_up
362
+ when *Keys::HOMES, "g" then move_scroll_top_row_to(0)
363
+ when *Keys::ENDS_, "G" then move_scroll_top_row_to(scroll_top_row_max)
353
364
  else return false
354
365
  end
355
366
  true
@@ -360,8 +371,8 @@ module Tuile
360
371
  def handle_mouse(event)
361
372
  super
362
373
  case event.button
363
- when :scroll_down then move_top_line_by(4)
364
- when :scroll_up then move_top_line_by(-4)
374
+ when :scroll_down then move_scroll_top_row_by(4)
375
+ when :scroll_up then move_scroll_top_row_by(-4)
365
376
  end
366
377
  end
367
378
 
@@ -370,18 +381,18 @@ module Tuile
370
381
  # Skips the {Component#repaint} default's auto-clear: every row is
371
382
  # painted explicitly (with padded blanks past the last line), so the
372
383
  # "fully draw over your rect" contract is met without an upfront wipe.
373
- # Rows go through {Component#draw_line}, so content and blank rows inherit
384
+ # Rows go through {Component#draw_text}, so content and blank rows inherit
374
385
  # {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
375
386
  # @return [void]
376
387
  def repaint
377
388
  return if rect.empty?
378
389
 
379
390
  scrollbar = if scrollbar_visible?
380
- VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
391
+ VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
381
392
  end
382
393
  (0...rect.height).each do |row|
383
- line = paintable_line(row + @top_line, row, scrollbar)
384
- draw_line(rect.left, rect.top + row, line)
394
+ line = paintable_row(row + @scroll_top_row, row, scrollbar)
395
+ draw_text(rect.left, rect.top + row, line)
385
396
  end
386
397
  end
387
398
 
@@ -411,7 +422,7 @@ module Tuile
411
422
  # @param size [Integer]
412
423
  # @param what [String]
413
424
  # @return [Array(Integer, Integer)]
414
- def normalize_replace_range(range, size = @hard_lines.size, what = "the buffer")
425
+ def normalize_replace_range(range, size = @lines.size, what = "the buffer")
415
426
  case range
416
427
  when Integer
417
428
  from = to = range
@@ -435,7 +446,7 @@ module Tuile
435
446
  [from, to]
436
447
  end
437
448
 
438
- # Hard-line index where `region` begins in {@hard_lines} — derived
449
+ # Hard-line index where `region` begins in {@lines} — derived
439
450
  # by summing the line counts of all regions that precede it.
440
451
  # @param region [Region]
441
452
  # @return [Integer]
@@ -456,13 +467,13 @@ module Tuile
456
467
  start = region_start_index(region)
457
468
  count = region.line_count
458
469
  return StyledString::EMPTY if count.zero?
459
- return @hard_lines[start] if count == 1
470
+ return @lines[start] if count == 1
460
471
 
461
472
  newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
462
473
  spans = []
463
474
  count.times do |i|
464
475
  spans << newline if i.positive?
465
- spans.concat(@hard_lines[start + i].spans)
476
+ spans.concat(@lines[start + i].spans)
466
477
  end
467
478
  StyledString.new(spans)
468
479
  end
@@ -480,13 +491,13 @@ module Tuile
480
491
  new_lines = parsed.empty? ? [] : parsed.lines
481
492
  start = region_start_index(region)
482
493
  old_count = region.line_count
483
- return if new_lines == @hard_lines[start, old_count]
494
+ return if new_lines == @lines[start, old_count]
484
495
 
485
- splice_hard_lines(start, old_count, new_lines)
496
+ splice_lines(start, old_count, new_lines)
486
497
  region.send(:line_count=, new_lines.size)
487
498
  @text = nil
488
- @top_line = top_line_max if @top_line > top_line_max
489
- update_top_line_if_auto_scroll
499
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
500
+ update_scroll_top_row_if_auto_scroll
490
501
  invalidate
491
502
  end
492
503
 
@@ -501,17 +512,17 @@ module Tuile
501
512
  screen.check_locked
502
513
  from, to = normalize_replace_range(range, region.line_count, "the region")
503
514
  parsed = StyledString.parse(str)
504
- new_hard_lines = parsed.empty? ? [] : parsed.lines
515
+ new_lines = parsed.empty? ? [] : parsed.lines
505
516
  start = region_start_index(region)
506
517
  abs_from = start + from
507
518
  length = to - from + 1
508
- return if new_hard_lines == @hard_lines[abs_from, length]
519
+ return if new_lines == @lines[abs_from, length]
509
520
 
510
- splice_hard_lines(abs_from, length, new_hard_lines)
511
- region.send(:line_count=, region.line_count - length + new_hard_lines.size)
521
+ splice_lines(abs_from, length, new_lines)
522
+ region.send(:line_count=, region.line_count - length + new_lines.size)
512
523
  @text = nil
513
- @top_line = top_line_max if @top_line > top_line_max
514
- update_top_line_if_auto_scroll
524
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
525
+ update_scroll_top_row_if_auto_scroll
515
526
  invalidate
516
527
  end
517
528
 
@@ -532,7 +543,7 @@ module Tuile
532
543
  new_segments = parsed.lines
533
544
  start = region_start_index(region)
534
545
  if region.empty?
535
- splice_hard_lines(start, 0, new_segments)
546
+ splice_lines(start, 0, new_segments)
536
547
  region.send(:line_count=, new_segments.size)
537
548
  else
538
549
  last_idx = start + region.line_count - 1
@@ -541,21 +552,21 @@ module Tuile
541
552
  if extension.empty?
542
553
  return if rest.empty?
543
554
 
544
- splice_hard_lines(last_idx + 1, 0, rest)
555
+ splice_lines(last_idx + 1, 0, rest)
545
556
  else
546
- extended = @hard_lines[last_idx] + extension
547
- splice_hard_lines(last_idx, 1, [extended, *rest])
557
+ extended = @lines[last_idx] + extension
558
+ splice_lines(last_idx, 1, [extended, *rest])
548
559
  end
549
560
  region.send(:line_count=, region.line_count + rest.size)
550
561
  end
551
562
  @text = nil
552
- @top_line = top_line_max if @top_line > top_line_max
553
- update_top_line_if_auto_scroll
563
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
564
+ update_scroll_top_row_if_auto_scroll
554
565
  invalidate
555
566
  end
556
567
 
557
568
  # Drops the last `n` hard lines from `region`'s tail via
558
- # {#splice_hard_lines}. `n` is clamped to the region's current
569
+ # {#splice_lines}. `n` is clamped to the region's current
559
570
  # line count; callers guarantee `n > 0` and the region is
560
571
  # non-empty (the {Region#remove_last_n_lines} guard handles the
561
572
  # no-op cases).
@@ -567,16 +578,16 @@ module Tuile
567
578
  to_drop = [n, region.line_count].min
568
579
  start = region_start_index(region)
569
580
  drop_from = start + region.line_count - to_drop
570
- splice_hard_lines(drop_from, to_drop, [])
581
+ splice_lines(drop_from, to_drop, [])
571
582
  region.send(:line_count=, region.line_count - to_drop)
572
583
  @text = nil
573
- @top_line = top_line_max if @top_line > top_line_max
574
- update_top_line_if_auto_scroll
584
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
585
+ update_scroll_top_row_if_auto_scroll
575
586
  invalidate
576
587
  end
577
588
 
578
589
  # Drops `region` from {@regions}: its hard lines are removed via
579
- # {#splice_hard_lines}, the handle is detached, and the always-one
590
+ # {#splice_lines}, the handle is detached, and the always-one
580
591
  # default is restored if the removal would have left zero regions.
581
592
  # Skips the rewrap / invalidate work when the region was empty
582
593
  # (the buffer didn't change), but always detaches.
@@ -587,7 +598,7 @@ module Tuile
587
598
  had_lines = region.line_count.positive?
588
599
  if had_lines
589
600
  start = region_start_index(region)
590
- splice_hard_lines(start, region.line_count, [])
601
+ splice_lines(start, region.line_count, [])
591
602
  end
592
603
  @regions.delete(region)
593
604
  region.send(:detach!)
@@ -595,12 +606,12 @@ module Tuile
595
606
  return unless had_lines
596
607
 
597
608
  @text = nil
598
- @top_line = top_line_max if @top_line > top_line_max
599
- update_top_line_if_auto_scroll
609
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
610
+ update_scroll_top_row_if_auto_scroll
600
611
  invalidate
601
612
  end
602
613
 
603
- # Adjusts region line counts after a {@hard_lines} splice that removed
614
+ # Adjusts region line counts after a {@lines} splice that removed
604
615
  # `removed_count` lines at `from` and inserted `added_count`. Subtracts
605
616
  # each region's overlap with the removed range, then credits the added
606
617
  # lines to the first region that lost lines. Pure insertions have no such
@@ -644,122 +655,125 @@ module Tuile
644
655
  end
645
656
 
646
657
  # @return [Integer] number of visible lines.
647
- def viewport_lines = rect.height
658
+ def viewport_rows = rect.height
659
+
660
+ # @return [Integer] the max value of {#scroll_top_row} for scroll-key clamping.
661
+ def scroll_top_row_max = (@rows.size - viewport_rows).clamp(0, nil)
648
662
 
649
- # @return [Integer] the max value of {#top_line} for scroll-key clamping.
650
- def top_line_max = (@physical_lines.size - viewport_lines).clamp(0, nil)
663
+ # @return [Integer] half a viewport, at least one row, for the half-page
664
+ # scroll verbs and their keys.
665
+ def half_page_rows = [viewport_rows / 2, 1].max
651
666
 
652
- # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
653
- # from {@hard_lines}. Called when wrap width changes (which
667
+ # Full rebuild of {@rows} and {@line_wrap_counts}
668
+ # from {@lines}. Called when wrap width changes (which
654
669
  # invalidates every cached row count) and from {#text=} (which
655
670
  # replaces the whole logical model). Mid-buffer mutators splice
656
- # incrementally via {#splice_hard_lines} and do *not* go through
657
- # here. Clamps {@top_line} if the new line count puts it out of
671
+ # incrementally via {#splice_lines} and do *not* go through
672
+ # here. Clamps {@scroll_top_row} if the new line count puts it out of
658
673
  # range.
659
674
  # @return [void]
660
675
  def rewrap
661
676
  width = wrap_width
662
- @blank_line = pad_to(StyledString::EMPTY, width)
663
- @physical_lines = []
664
- @hard_line_wrap_counts = []
665
- @hard_lines.each do |hl|
666
- rows, n = wrap_hard_line(hl, width)
667
- @physical_lines.concat(rows)
668
- @hard_line_wrap_counts << n
677
+ @blank_row = pad_to(StyledString::EMPTY, width)
678
+ @rows = []
679
+ @line_wrap_counts = []
680
+ @lines.each do |line|
681
+ wrapped, n = wrap_line(line, width)
682
+ @rows.concat(wrapped)
683
+ @line_wrap_counts << n
669
684
  end
670
- @top_line = top_line_max if @top_line > top_line_max
685
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
671
686
  end
672
687
 
673
- # Wraps `hard_line` at `width` and returns the padded physical rows
674
- # alongside the row count. Empty hard lines (e.g. from a `"\n\n"`
675
- # run) and degenerate `width <= 0` both emit a single {@blank_line}
688
+ # Wraps `line` at `width` and returns the padded rows alongside the
689
+ # row count. Empty lines (e.g. from a `"\n\n"`
690
+ # run) and degenerate `width <= 0` both emit a single {@blank_row}
676
691
  # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
677
692
  # would have produced.
678
- # @param hard_line [StyledString]
693
+ # @param line [StyledString]
679
694
  # @param width [Integer]
680
695
  # @return [Array(Array<StyledString>, Integer)]
681
- def wrap_hard_line(hard_line, width)
682
- return [[@blank_line], 1] if hard_line.empty? || width <= 0
696
+ def wrap_line(line, width)
697
+ return [[@blank_row], 1] if line.empty? || width <= 0
683
698
 
684
- wrapped = hard_line.wrap(width)
685
- [wrapped.map { |line| pad_to(line, width) }, wrapped.size]
699
+ wrapped = line.wrap(width)
700
+ [wrapped.map { |row| pad_to(row, width) }, wrapped.size]
686
701
  end
687
702
 
688
- # Appends `hard_line` to the tail of {@hard_lines}, updating the
689
- # wrap-count cache and {@physical_lines} in lockstep.
690
- # @param hard_line [StyledString]
703
+ # Appends `line` to the tail of {@lines}, updating the
704
+ # wrap-count cache and {@rows} in lockstep.
705
+ # @param line [StyledString]
691
706
  # @param width [Integer]
692
707
  # @return [void]
693
- def push_hard_line(hard_line, width)
694
- rows, n = wrap_hard_line(hard_line, width)
695
- @hard_lines << hard_line
696
- @hard_line_wrap_counts << n
697
- @physical_lines.concat(rows)
708
+ def push_line(line, width)
709
+ wrapped, n = wrap_line(line, width)
710
+ @lines << line
711
+ @line_wrap_counts << n
712
+ @rows.concat(wrapped)
698
713
  end
699
714
 
700
- # Pops the last hard line, the corresponding cache entry, and the
701
- # physical rows that hard line contributed. Returns the popped
702
- # hard line.
715
+ # Pops the last line, the corresponding cache entry, and the rows
716
+ # that line contributed. Returns the popped line.
703
717
  # @return [StyledString]
704
- def pop_hard_line
705
- n = @hard_line_wrap_counts.pop
706
- n.times { @physical_lines.pop }
707
- @hard_lines.pop
718
+ def pop_line
719
+ n = @line_wrap_counts.pop
720
+ n.times { @rows.pop }
721
+ @lines.pop
708
722
  end
709
723
 
710
- # Splices `new_hard_lines` into the buffer in place of the `count`
711
- # hard lines starting at index `from`. Updates {@hard_lines},
712
- # {@hard_line_wrap_counts}, and {@physical_lines} consistently.
713
- # The starting physical-row offset is computed in O(`from`) integer
714
- # adds via the cache — no wraps of preceding hard lines. Wraps are
724
+ # Splices `new_lines` into the buffer in place of the `count` lines
725
+ # starting at index `from`. Updates {@lines},
726
+ # {@line_wrap_counts}, and {@rows} consistently.
727
+ # The starting row offset is computed in O(`from`) integer
728
+ # adds via the cache — no wraps of preceding lines. Wraps are
715
729
  # done only for the new content, so total cost is
716
- # `O(from + count + new_hard_lines.sum(&:display_width))`.
730
+ # `O(from + count + new_lines.sum(&:display_width))`.
717
731
  # @param from [Integer]
718
- # @param count [Integer] number of existing hard lines to remove.
719
- # @param new_hard_lines [Array<StyledString>]
732
+ # @param count [Integer] number of existing lines to remove.
733
+ # @param new_lines [Array<StyledString>]
720
734
  # @return [void]
721
- def splice_hard_lines(from, count, new_hard_lines)
735
+ def splice_lines(from, count, new_lines)
722
736
  width = wrap_width
723
- phys_start = phys_offset_at(from)
724
- old_phys_count = @hard_line_wrap_counts[from, count].sum
737
+ row_start = row_offset_at(from)
738
+ old_row_count = @line_wrap_counts[from, count].sum
725
739
 
726
- @hard_lines[from, count] = new_hard_lines
740
+ @lines[from, count] = new_lines
727
741
 
728
742
  new_rows = []
729
743
  new_counts = []
730
- new_hard_lines.each do |hl|
731
- rows, n = wrap_hard_line(hl, width)
732
- new_rows.concat(rows)
744
+ new_lines.each do |line|
745
+ wrapped, n = wrap_line(line, width)
746
+ new_rows.concat(wrapped)
733
747
  new_counts << n
734
748
  end
735
749
 
736
- @hard_line_wrap_counts[from, count] = new_counts
737
- @physical_lines[phys_start, old_phys_count] = new_rows
750
+ @line_wrap_counts[from, count] = new_counts
751
+ @rows[row_start, old_row_count] = new_rows
738
752
  end
739
753
 
740
754
  # @param idx [Integer]
741
- # @return [Integer] the {@physical_lines} index where the hard line
742
- # at {@hard_lines}`[idx]` starts. O(`idx`) integer adds via the
755
+ # @return [Integer] the {@rows} index where the line
756
+ # at {@lines}`[idx]` starts. O(`idx`) integer adds via the
743
757
  # wrap-count cache.
744
- def phys_offset_at(idx)
758
+ def row_offset_at(idx)
745
759
  return 0 if idx.zero?
746
760
 
747
- @hard_line_wrap_counts[0, idx].sum
761
+ @line_wrap_counts[0, idx].sum
748
762
  end
749
763
 
750
- # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
751
- # default-styled `"\n"` between hard lines. Called from the {#text}
764
+ # Rebuilds the joined {StyledString} from {@lines}, inserting a
765
+ # default-styled `"\n"` between lines. Called from the {#text}
752
766
  # reader when the cache is cold. Cost is O(total spans).
753
767
  # @return [StyledString]
754
768
  def build_text
755
- return StyledString::EMPTY if @hard_lines.empty?
756
- return @hard_lines.first if @hard_lines.size == 1
769
+ return StyledString::EMPTY if @lines.empty?
770
+ return @lines.first if @lines.size == 1
757
771
 
758
772
  newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
759
773
  spans = []
760
- @hard_lines.each_with_index do |hl, i|
774
+ @lines.each_with_index do |line, i|
761
775
  spans << newline if i.positive?
762
- spans.concat(hl.spans)
776
+ spans.concat(line.spans)
763
777
  end
764
778
  StyledString.new(spans)
765
779
  end
@@ -775,32 +789,32 @@ module Tuile
775
789
 
776
790
  # @param delta [Integer] negative scrolls up, positive scrolls down.
777
791
  # @return [void]
778
- def move_top_line_by(delta)
779
- move_top_line_to(@top_line + delta)
792
+ def move_scroll_top_row_by(delta)
793
+ move_scroll_top_row_to(@scroll_top_row + delta)
780
794
  end
781
795
 
782
- # @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
796
+ # @param target [Integer] desired top line; clamped to `[0, scroll_top_row_max]`.
783
797
  # @return [void]
784
- def move_top_line_to(target)
785
- clamped = target.clamp(0, top_line_max)
786
- self.top_line = clamped unless @top_line == clamped
798
+ def move_scroll_top_row_to(target)
799
+ clamped = target.clamp(0, scroll_top_row_max)
800
+ self.scroll_top_row = clamped unless @scroll_top_row == clamped
787
801
  end
788
802
 
789
803
  # Gated on {#following?}: once the user scrolls up off the bottom the
790
804
  # viewport pin is skipped, so reading older content is not interrupted
791
- # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
805
+ # by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
792
806
  # returns to the bottom.
793
807
  # @return [void]
794
- def update_top_line_if_auto_scroll
808
+ def update_scroll_top_row_if_auto_scroll
795
809
  return unless @auto_scroll && @follow
796
810
 
797
- target = (@physical_lines.size - viewport_lines).clamp(0, nil)
798
- self.top_line = target if @top_line != target
811
+ target = (@rows.size - viewport_rows).clamp(0, nil)
812
+ self.scroll_top_row = target if @scroll_top_row != target
799
813
  end
800
814
 
801
815
  # @return [Boolean] whether the viewport is pinned to the last line.
802
- # Drives {#following?}: re-evaluated on every {#top_line=}.
803
- def at_bottom? = @top_line == top_line_max
816
+ # Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
817
+ def at_bottom? = @scroll_top_row == scroll_top_row_max
804
818
 
805
819
  # @return [Boolean]
806
820
  def scrollbar_visible?
@@ -814,29 +828,29 @@ module Tuile
814
828
  # constrained the line to `<= width`, so no truncation is performed.
815
829
  # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
816
830
  # `wrap_width == 0` case (rect.width == 1 with scrollbar).
817
- # @param line [StyledString]
831
+ # @param row [StyledString]
818
832
  # @param width [Integer]
819
833
  # @return [StyledString]
820
- def pad_to(line, width)
834
+ def pad_to(row, width)
821
835
  return StyledString::EMPTY if width <= 0
822
836
 
823
- diff = width - line.display_width
824
- return line if diff <= 0
837
+ diff = width - row.display_width
838
+ return row if diff <= 0
825
839
 
826
- line + StyledString.plain(" " * diff)
840
+ row + StyledString.plain(" " * diff)
827
841
  end
828
842
 
829
- # @param index [Integer] 0-based index into `@physical_lines`.
843
+ # @param index [Integer] 0-based index into `@rows`.
830
844
  # @param row_in_viewport [Integer] 0-based row within the viewport.
831
845
  # @param scrollbar [VerticalScrollBar, nil]
832
- # @return [StyledString] paintable line exactly `rect.width` columns wide.
833
- # Body lines come pre-padded from {#rewrap}, so this reduces to a lookup
846
+ # @return [StyledString] paintable row exactly `rect.width` columns wide.
847
+ # Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
834
848
  # plus a concat of the scrollbar glyph when one is present.
835
- def paintable_line(index, row_in_viewport, scrollbar)
836
- line = @physical_lines[index] || @blank_line
837
- return line unless scrollbar
849
+ def paintable_row(index, row_in_viewport, scrollbar)
850
+ row = @rows[index] || @blank_row
851
+ return row unless scrollbar
838
852
 
839
- line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
853
+ row + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
840
854
  end
841
855
 
842
856
  # A logical section of a {TextView}'s text — a contiguous run of