tuile 0.11.0 → 0.13.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -0
  3. data/DECISIONS.md +1970 -14
  4. data/README.md +136 -491
  5. data/TERMINOLOGY.md +70 -0
  6. data/book/01-first-app.md +22 -17
  7. data/book/02-repaint.md +19 -6
  8. data/book/03-layout.md +12 -11
  9. data/book/05-focus.md +133 -18
  10. data/book/06-theming.md +6 -3
  11. data/book/07-components.md +498 -38
  12. data/book/08-testing.md +18 -4
  13. data/book/README.md +7 -5
  14. data/examples/file_commander.rb +27 -20
  15. data/examples/hello_world.rb +17 -5
  16. data/examples/sampler.rb +422 -66
  17. data/ideas/arrow-key-navigation.md +16 -0
  18. data/ideas/new-components.md +16 -10
  19. data/lib/tuile/ansi.rb +10 -0
  20. data/lib/tuile/buffer.rb +7 -7
  21. data/lib/tuile/component/abstract_string_field.rb +36 -0
  22. data/lib/tuile/component/button.rb +1 -1
  23. data/lib/tuile/component/checkbox.rb +1 -1
  24. data/lib/tuile/component/checkbox_group.rb +31 -26
  25. data/lib/tuile/component/combo_box.rb +13 -8
  26. data/lib/tuile/component/info_window.rb +1 -1
  27. data/lib/tuile/component/label.rb +14 -14
  28. data/lib/tuile/component/list.rb +313 -216
  29. data/lib/tuile/component/list_dropdown.rb +100 -10
  30. data/lib/tuile/component/menu_bar/cascade.rb +255 -0
  31. data/lib/tuile/component/menu_bar.rb +582 -0
  32. data/lib/tuile/component/notification.rb +320 -0
  33. data/lib/tuile/component/picker_window.rb +3 -8
  34. data/lib/tuile/component/popup.rb +83 -19
  35. data/lib/tuile/component/progress_bar.rb +1 -1
  36. data/lib/tuile/component/radio_group.rb +32 -30
  37. data/lib/tuile/component/select.rb +10 -8
  38. data/lib/tuile/component/tab_sheet.rb +242 -0
  39. data/lib/tuile/component/tabs.rb +528 -0
  40. data/lib/tuile/component/text_area/wrapped_text.rb +320 -0
  41. data/lib/tuile/component/text_area.rb +84 -277
  42. data/lib/tuile/component/text_field.rb +24 -7
  43. data/lib/tuile/component/text_view.rb +197 -180
  44. data/lib/tuile/component/window.rb +8 -8
  45. data/lib/tuile/component.rb +43 -18
  46. data/lib/tuile/event_queue.rb +25 -1
  47. data/lib/tuile/fake_screen.rb +14 -0
  48. data/lib/tuile/keys.rb +65 -0
  49. data/lib/tuile/screen.rb +95 -78
  50. data/lib/tuile/screen_pane.rb +109 -27
  51. data/lib/tuile/styled_string.rb +52 -12
  52. data/lib/tuile/version.rb +1 -1
  53. data/lib/tuile/vertical_scroll_bar.rb +6 -6
  54. data/sig/tuile.rbs +2307 -516
  55. metadata +9 -3
  56. data/mise.toml +0 -2
@@ -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,28 +327,43 @@ 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: dispatch delivers keys only along
336
+ # the focus chain, so this is what a host with focus elsewhere — a chat
337
+ # transcript under an input field — calls instead of forwarding a
338
+ # synthetic keystroke that would lie about where focus is.
339
+ # @return [void]
340
+ def scroll_half_page_up = move_scroll_top_row_by(-half_page_rows)
341
+
342
+ # The `Ctrl+D` twin of {#scroll_half_page_up}, clamped at the last row —
343
+ # arriving there re-arms {#following?}.
344
+ # @return [void]
345
+ def scroll_half_page_down = move_scroll_top_row_by(half_page_rows)
346
+
334
347
  def focusable? = true
335
348
 
336
349
  def tab_stop? = true
337
350
 
351
+ # Claims the scroll ladder: the arrows and `j`/`k`, PageUp/PageDown,
352
+ # `Ctrl+U`/`Ctrl+D`, Home/`g`/End/`G`. Acts on the key alone — a clamped
353
+ # scroll at either edge is still a handled key, and hand-feeding a key to
354
+ # an unfocused view scrolls it (dispatch gates on focus, this doesn't).
338
355
  # @param key [String]
339
356
  # @return [Boolean]
340
357
  def handle_key(key)
341
- return false unless active?
342
- return true if super
343
-
344
358
  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)
359
+ when *Keys::DOWN_ARROWS then move_scroll_top_row_by(1)
360
+ when *Keys::UP_ARROWS then move_scroll_top_row_by(-1)
361
+ when Keys::PAGE_DOWN then move_scroll_top_row_by(viewport_rows)
362
+ when Keys::PAGE_UP then move_scroll_top_row_by(-viewport_rows)
363
+ when Keys::CTRL_D then scroll_half_page_down
364
+ when Keys::CTRL_U then scroll_half_page_up
365
+ when *Keys::HOMES, "g" then move_scroll_top_row_to(0)
366
+ when *Keys::ENDS_, "G" then move_scroll_top_row_to(scroll_top_row_max)
353
367
  else return false
354
368
  end
355
369
  true
@@ -360,8 +374,8 @@ module Tuile
360
374
  def handle_mouse(event)
361
375
  super
362
376
  case event.button
363
- when :scroll_down then move_top_line_by(4)
364
- when :scroll_up then move_top_line_by(-4)
377
+ when :scroll_down then move_scroll_top_row_by(4)
378
+ when :scroll_up then move_scroll_top_row_by(-4)
365
379
  end
366
380
  end
367
381
 
@@ -370,18 +384,18 @@ module Tuile
370
384
  # Skips the {Component#repaint} default's auto-clear: every row is
371
385
  # painted explicitly (with padded blanks past the last line), so the
372
386
  # "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
387
+ # Rows go through {Component#draw_text}, so content and blank rows inherit
374
388
  # {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
375
389
  # @return [void]
376
390
  def repaint
377
391
  return if rect.empty?
378
392
 
379
393
  scrollbar = if scrollbar_visible?
380
- VerticalScrollBar.new(rect.height, line_count: @physical_lines.size, top_line: @top_line)
394
+ VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
381
395
  end
382
396
  (0...rect.height).each do |row|
383
- line = paintable_line(row + @top_line, row, scrollbar)
384
- draw_line(rect.left, rect.top + row, line)
397
+ line = paintable_row(row + @scroll_top_row, row, scrollbar)
398
+ draw_text(rect.left, rect.top + row, line)
385
399
  end
386
400
  end
387
401
 
@@ -411,7 +425,7 @@ module Tuile
411
425
  # @param size [Integer]
412
426
  # @param what [String]
413
427
  # @return [Array(Integer, Integer)]
414
- def normalize_replace_range(range, size = @hard_lines.size, what = "the buffer")
428
+ def normalize_replace_range(range, size = @lines.size, what = "the buffer")
415
429
  case range
416
430
  when Integer
417
431
  from = to = range
@@ -435,7 +449,7 @@ module Tuile
435
449
  [from, to]
436
450
  end
437
451
 
438
- # Hard-line index where `region` begins in {@hard_lines} — derived
452
+ # Hard-line index where `region` begins in {@lines} — derived
439
453
  # by summing the line counts of all regions that precede it.
440
454
  # @param region [Region]
441
455
  # @return [Integer]
@@ -456,13 +470,13 @@ module Tuile
456
470
  start = region_start_index(region)
457
471
  count = region.line_count
458
472
  return StyledString::EMPTY if count.zero?
459
- return @hard_lines[start] if count == 1
473
+ return @lines[start] if count == 1
460
474
 
461
475
  newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
462
476
  spans = []
463
477
  count.times do |i|
464
478
  spans << newline if i.positive?
465
- spans.concat(@hard_lines[start + i].spans)
479
+ spans.concat(@lines[start + i].spans)
466
480
  end
467
481
  StyledString.new(spans)
468
482
  end
@@ -480,13 +494,13 @@ module Tuile
480
494
  new_lines = parsed.empty? ? [] : parsed.lines
481
495
  start = region_start_index(region)
482
496
  old_count = region.line_count
483
- return if new_lines == @hard_lines[start, old_count]
497
+ return if new_lines == @lines[start, old_count]
484
498
 
485
- splice_hard_lines(start, old_count, new_lines)
499
+ splice_lines(start, old_count, new_lines)
486
500
  region.send(:line_count=, new_lines.size)
487
501
  @text = nil
488
- @top_line = top_line_max if @top_line > top_line_max
489
- update_top_line_if_auto_scroll
502
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
503
+ update_scroll_top_row_if_auto_scroll
490
504
  invalidate
491
505
  end
492
506
 
@@ -501,17 +515,17 @@ module Tuile
501
515
  screen.check_locked
502
516
  from, to = normalize_replace_range(range, region.line_count, "the region")
503
517
  parsed = StyledString.parse(str)
504
- new_hard_lines = parsed.empty? ? [] : parsed.lines
518
+ new_lines = parsed.empty? ? [] : parsed.lines
505
519
  start = region_start_index(region)
506
520
  abs_from = start + from
507
521
  length = to - from + 1
508
- return if new_hard_lines == @hard_lines[abs_from, length]
522
+ return if new_lines == @lines[abs_from, length]
509
523
 
510
- splice_hard_lines(abs_from, length, new_hard_lines)
511
- region.send(:line_count=, region.line_count - length + new_hard_lines.size)
524
+ splice_lines(abs_from, length, new_lines)
525
+ region.send(:line_count=, region.line_count - length + new_lines.size)
512
526
  @text = nil
513
- @top_line = top_line_max if @top_line > top_line_max
514
- update_top_line_if_auto_scroll
527
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
528
+ update_scroll_top_row_if_auto_scroll
515
529
  invalidate
516
530
  end
517
531
 
@@ -532,7 +546,7 @@ module Tuile
532
546
  new_segments = parsed.lines
533
547
  start = region_start_index(region)
534
548
  if region.empty?
535
- splice_hard_lines(start, 0, new_segments)
549
+ splice_lines(start, 0, new_segments)
536
550
  region.send(:line_count=, new_segments.size)
537
551
  else
538
552
  last_idx = start + region.line_count - 1
@@ -541,21 +555,21 @@ module Tuile
541
555
  if extension.empty?
542
556
  return if rest.empty?
543
557
 
544
- splice_hard_lines(last_idx + 1, 0, rest)
558
+ splice_lines(last_idx + 1, 0, rest)
545
559
  else
546
- extended = @hard_lines[last_idx] + extension
547
- splice_hard_lines(last_idx, 1, [extended, *rest])
560
+ extended = @lines[last_idx] + extension
561
+ splice_lines(last_idx, 1, [extended, *rest])
548
562
  end
549
563
  region.send(:line_count=, region.line_count + rest.size)
550
564
  end
551
565
  @text = nil
552
- @top_line = top_line_max if @top_line > top_line_max
553
- update_top_line_if_auto_scroll
566
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
567
+ update_scroll_top_row_if_auto_scroll
554
568
  invalidate
555
569
  end
556
570
 
557
571
  # Drops the last `n` hard lines from `region`'s tail via
558
- # {#splice_hard_lines}. `n` is clamped to the region's current
572
+ # {#splice_lines}. `n` is clamped to the region's current
559
573
  # line count; callers guarantee `n > 0` and the region is
560
574
  # non-empty (the {Region#remove_last_n_lines} guard handles the
561
575
  # no-op cases).
@@ -567,16 +581,16 @@ module Tuile
567
581
  to_drop = [n, region.line_count].min
568
582
  start = region_start_index(region)
569
583
  drop_from = start + region.line_count - to_drop
570
- splice_hard_lines(drop_from, to_drop, [])
584
+ splice_lines(drop_from, to_drop, [])
571
585
  region.send(:line_count=, region.line_count - to_drop)
572
586
  @text = nil
573
- @top_line = top_line_max if @top_line > top_line_max
574
- update_top_line_if_auto_scroll
587
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
588
+ update_scroll_top_row_if_auto_scroll
575
589
  invalidate
576
590
  end
577
591
 
578
592
  # Drops `region` from {@regions}: its hard lines are removed via
579
- # {#splice_hard_lines}, the handle is detached, and the always-one
593
+ # {#splice_lines}, the handle is detached, and the always-one
580
594
  # default is restored if the removal would have left zero regions.
581
595
  # Skips the rewrap / invalidate work when the region was empty
582
596
  # (the buffer didn't change), but always detaches.
@@ -587,7 +601,7 @@ module Tuile
587
601
  had_lines = region.line_count.positive?
588
602
  if had_lines
589
603
  start = region_start_index(region)
590
- splice_hard_lines(start, region.line_count, [])
604
+ splice_lines(start, region.line_count, [])
591
605
  end
592
606
  @regions.delete(region)
593
607
  region.send(:detach!)
@@ -595,12 +609,12 @@ module Tuile
595
609
  return unless had_lines
596
610
 
597
611
  @text = nil
598
- @top_line = top_line_max if @top_line > top_line_max
599
- update_top_line_if_auto_scroll
612
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
613
+ update_scroll_top_row_if_auto_scroll
600
614
  invalidate
601
615
  end
602
616
 
603
- # Adjusts region line counts after a {@hard_lines} splice that removed
617
+ # Adjusts region line counts after a {@lines} splice that removed
604
618
  # `removed_count` lines at `from` and inserted `added_count`. Subtracts
605
619
  # each region's overlap with the removed range, then credits the added
606
620
  # lines to the first region that lost lines. Pure insertions have no such
@@ -644,122 +658,125 @@ module Tuile
644
658
  end
645
659
 
646
660
  # @return [Integer] number of visible lines.
647
- def viewport_lines = rect.height
661
+ def viewport_rows = rect.height
662
+
663
+ # @return [Integer] the max value of {#scroll_top_row} for scroll-key clamping.
664
+ def scroll_top_row_max = (@rows.size - viewport_rows).clamp(0, nil)
648
665
 
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)
666
+ # @return [Integer] half a viewport, at least one row, for the half-page
667
+ # scroll verbs and their keys.
668
+ def half_page_rows = [viewport_rows / 2, 1].max
651
669
 
652
- # Full rebuild of {@physical_lines} and {@hard_line_wrap_counts}
653
- # from {@hard_lines}. Called when wrap width changes (which
670
+ # Full rebuild of {@rows} and {@line_wrap_counts}
671
+ # from {@lines}. Called when wrap width changes (which
654
672
  # invalidates every cached row count) and from {#text=} (which
655
673
  # 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
674
+ # incrementally via {#splice_lines} and do *not* go through
675
+ # here. Clamps {@scroll_top_row} if the new line count puts it out of
658
676
  # range.
659
677
  # @return [void]
660
678
  def rewrap
661
679
  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
680
+ @blank_row = pad_to(StyledString::EMPTY, width)
681
+ @rows = []
682
+ @line_wrap_counts = []
683
+ @lines.each do |line|
684
+ wrapped, n = wrap_line(line, width)
685
+ @rows.concat(wrapped)
686
+ @line_wrap_counts << n
669
687
  end
670
- @top_line = top_line_max if @top_line > top_line_max
688
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
671
689
  end
672
690
 
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}
691
+ # Wraps `line` at `width` and returns the padded rows alongside the
692
+ # row count. Empty lines (e.g. from a `"\n\n"`
693
+ # run) and degenerate `width <= 0` both emit a single {@blank_row}
676
694
  # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
677
695
  # would have produced.
678
- # @param hard_line [StyledString]
696
+ # @param line [StyledString]
679
697
  # @param width [Integer]
680
698
  # @return [Array(Array<StyledString>, Integer)]
681
- def wrap_hard_line(hard_line, width)
682
- return [[@blank_line], 1] if hard_line.empty? || width <= 0
699
+ def wrap_line(line, width)
700
+ return [[@blank_row], 1] if line.empty? || width <= 0
683
701
 
684
- wrapped = hard_line.wrap(width)
685
- [wrapped.map { |line| pad_to(line, width) }, wrapped.size]
702
+ wrapped = line.wrap(width)
703
+ [wrapped.map { |row| pad_to(row, width) }, wrapped.size]
686
704
  end
687
705
 
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]
706
+ # Appends `line` to the tail of {@lines}, updating the
707
+ # wrap-count cache and {@rows} in lockstep.
708
+ # @param line [StyledString]
691
709
  # @param width [Integer]
692
710
  # @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)
711
+ def push_line(line, width)
712
+ wrapped, n = wrap_line(line, width)
713
+ @lines << line
714
+ @line_wrap_counts << n
715
+ @rows.concat(wrapped)
698
716
  end
699
717
 
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.
718
+ # Pops the last line, the corresponding cache entry, and the rows
719
+ # that line contributed. Returns the popped line.
703
720
  # @return [StyledString]
704
- def pop_hard_line
705
- n = @hard_line_wrap_counts.pop
706
- n.times { @physical_lines.pop }
707
- @hard_lines.pop
721
+ def pop_line
722
+ n = @line_wrap_counts.pop
723
+ n.times { @rows.pop }
724
+ @lines.pop
708
725
  end
709
726
 
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
727
+ # Splices `new_lines` into the buffer in place of the `count` lines
728
+ # starting at index `from`. Updates {@lines},
729
+ # {@line_wrap_counts}, and {@rows} consistently.
730
+ # The starting row offset is computed in O(`from`) integer
731
+ # adds via the cache — no wraps of preceding lines. Wraps are
715
732
  # done only for the new content, so total cost is
716
- # `O(from + count + new_hard_lines.sum(&:display_width))`.
733
+ # `O(from + count + new_lines.sum(&:display_width))`.
717
734
  # @param from [Integer]
718
- # @param count [Integer] number of existing hard lines to remove.
719
- # @param new_hard_lines [Array<StyledString>]
735
+ # @param count [Integer] number of existing lines to remove.
736
+ # @param new_lines [Array<StyledString>]
720
737
  # @return [void]
721
- def splice_hard_lines(from, count, new_hard_lines)
738
+ def splice_lines(from, count, new_lines)
722
739
  width = wrap_width
723
- phys_start = phys_offset_at(from)
724
- old_phys_count = @hard_line_wrap_counts[from, count].sum
740
+ row_start = row_offset_at(from)
741
+ old_row_count = @line_wrap_counts[from, count].sum
725
742
 
726
- @hard_lines[from, count] = new_hard_lines
743
+ @lines[from, count] = new_lines
727
744
 
728
745
  new_rows = []
729
746
  new_counts = []
730
- new_hard_lines.each do |hl|
731
- rows, n = wrap_hard_line(hl, width)
732
- new_rows.concat(rows)
747
+ new_lines.each do |line|
748
+ wrapped, n = wrap_line(line, width)
749
+ new_rows.concat(wrapped)
733
750
  new_counts << n
734
751
  end
735
752
 
736
- @hard_line_wrap_counts[from, count] = new_counts
737
- @physical_lines[phys_start, old_phys_count] = new_rows
753
+ @line_wrap_counts[from, count] = new_counts
754
+ @rows[row_start, old_row_count] = new_rows
738
755
  end
739
756
 
740
757
  # @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
758
+ # @return [Integer] the {@rows} index where the line
759
+ # at {@lines}`[idx]` starts. O(`idx`) integer adds via the
743
760
  # wrap-count cache.
744
- def phys_offset_at(idx)
761
+ def row_offset_at(idx)
745
762
  return 0 if idx.zero?
746
763
 
747
- @hard_line_wrap_counts[0, idx].sum
764
+ @line_wrap_counts[0, idx].sum
748
765
  end
749
766
 
750
- # Rebuilds the joined {StyledString} from {@hard_lines}, inserting a
751
- # default-styled `"\n"` between hard lines. Called from the {#text}
767
+ # Rebuilds the joined {StyledString} from {@lines}, inserting a
768
+ # default-styled `"\n"` between lines. Called from the {#text}
752
769
  # reader when the cache is cold. Cost is O(total spans).
753
770
  # @return [StyledString]
754
771
  def build_text
755
- return StyledString::EMPTY if @hard_lines.empty?
756
- return @hard_lines.first if @hard_lines.size == 1
772
+ return StyledString::EMPTY if @lines.empty?
773
+ return @lines.first if @lines.size == 1
757
774
 
758
775
  newline = StyledString::Span.new(text: "\n", style: StyledString::Style::DEFAULT)
759
776
  spans = []
760
- @hard_lines.each_with_index do |hl, i|
777
+ @lines.each_with_index do |line, i|
761
778
  spans << newline if i.positive?
762
- spans.concat(hl.spans)
779
+ spans.concat(line.spans)
763
780
  end
764
781
  StyledString.new(spans)
765
782
  end
@@ -775,32 +792,32 @@ module Tuile
775
792
 
776
793
  # @param delta [Integer] negative scrolls up, positive scrolls down.
777
794
  # @return [void]
778
- def move_top_line_by(delta)
779
- move_top_line_to(@top_line + delta)
795
+ def move_scroll_top_row_by(delta)
796
+ move_scroll_top_row_to(@scroll_top_row + delta)
780
797
  end
781
798
 
782
- # @param target [Integer] desired top line; clamped to `[0, top_line_max]`.
799
+ # @param target [Integer] desired top line; clamped to `[0, scroll_top_row_max]`.
783
800
  # @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
801
+ def move_scroll_top_row_to(target)
802
+ clamped = target.clamp(0, scroll_top_row_max)
803
+ self.scroll_top_row = clamped unless @scroll_top_row == clamped
787
804
  end
788
805
 
789
806
  # Gated on {#following?}: once the user scrolls up off the bottom the
790
807
  # viewport pin is skipped, so reading older content is not interrupted
791
- # by incoming lines. {#top_line=} re-arms `@follow` when the viewport
808
+ # by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
792
809
  # returns to the bottom.
793
810
  # @return [void]
794
- def update_top_line_if_auto_scroll
811
+ def update_scroll_top_row_if_auto_scroll
795
812
  return unless @auto_scroll && @follow
796
813
 
797
- target = (@physical_lines.size - viewport_lines).clamp(0, nil)
798
- self.top_line = target if @top_line != target
814
+ target = (@rows.size - viewport_rows).clamp(0, nil)
815
+ self.scroll_top_row = target if @scroll_top_row != target
799
816
  end
800
817
 
801
818
  # @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
819
+ # Drives {#following?}: re-evaluated on every {#scroll_top_row=}.
820
+ def at_bottom? = @scroll_top_row == scroll_top_row_max
804
821
 
805
822
  # @return [Boolean]
806
823
  def scrollbar_visible?
@@ -814,29 +831,29 @@ module Tuile
814
831
  # constrained the line to `<= width`, so no truncation is performed.
815
832
  # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
816
833
  # `wrap_width == 0` case (rect.width == 1 with scrollbar).
817
- # @param line [StyledString]
834
+ # @param row [StyledString]
818
835
  # @param width [Integer]
819
836
  # @return [StyledString]
820
- def pad_to(line, width)
837
+ def pad_to(row, width)
821
838
  return StyledString::EMPTY if width <= 0
822
839
 
823
- diff = width - line.display_width
824
- return line if diff <= 0
840
+ diff = width - row.display_width
841
+ return row if diff <= 0
825
842
 
826
- line + StyledString.plain(" " * diff)
843
+ row + StyledString.plain(" " * diff)
827
844
  end
828
845
 
829
- # @param index [Integer] 0-based index into `@physical_lines`.
846
+ # @param index [Integer] 0-based index into `@rows`.
830
847
  # @param row_in_viewport [Integer] 0-based row within the viewport.
831
848
  # @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
849
+ # @return [StyledString] paintable row exactly `rect.width` columns wide.
850
+ # Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
834
851
  # 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
852
+ def paintable_row(index, row_in_viewport, scrollbar)
853
+ row = @rows[index] || @blank_row
854
+ return row unless scrollbar
838
855
 
839
- line + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
856
+ row + StyledString.plain(scrollbar.scrollbar_char(row_in_viewport))
840
857
  end
841
858
 
842
859
  # A logical section of a {TextView}'s text — a contiguous run of