tuile 0.16.0 → 0.17.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 (92) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +108 -0
  3. data/README.md +21 -12
  4. data/book/02-repaint.md +47 -19
  5. data/book/03-layout.md +98 -49
  6. data/book/04-event-loop.md +5 -4
  7. data/book/05-focus.md +12 -9
  8. data/book/06-theming.md +55 -17
  9. data/book/07-components.md +188 -40
  10. data/book/08-testing.md +115 -15
  11. data/book/10-locale.md +1 -1
  12. data/book/README.md +5 -5
  13. data/examples/file_commander.rb +38 -27
  14. data/examples/hello_world.rb +1 -1
  15. data/examples/sampler.rb +225 -169
  16. data/lib/tuile/buffer.rb +12 -1
  17. data/lib/tuile/canvas/backend.rb +46 -0
  18. data/lib/tuile/canvas.rb +212 -0
  19. data/lib/tuile/color.rb +38 -9
  20. data/lib/tuile/component/abstract_string_field.rb +81 -80
  21. data/lib/tuile/component/abstract_wrapping_field.rb +59 -54
  22. data/lib/tuile/component/big_decimal_field.rb +7 -6
  23. data/lib/tuile/component/button.rb +19 -11
  24. data/lib/tuile/component/checkbox.rb +12 -10
  25. data/lib/tuile/component/checkbox_group.rb +11 -13
  26. data/lib/tuile/component/combo_box.rb +30 -40
  27. data/lib/tuile/component/confirm_window.rb +27 -22
  28. data/lib/tuile/component/date_field.rb +27 -20
  29. data/lib/tuile/component/date_time_field.rb +75 -31
  30. data/lib/tuile/component/fill.rb +93 -0
  31. data/lib/tuile/component/float_field.rb +7 -6
  32. data/lib/tuile/component/form_item.rb +250 -0
  33. data/lib/tuile/component/form_layout.rb +206 -0
  34. data/lib/tuile/component/has_bad_input.rb +98 -27
  35. data/lib/tuile/component/has_caption.rb +14 -5
  36. data/lib/tuile/component/has_content.rb +5 -12
  37. data/lib/tuile/component/has_validation.rb +39 -13
  38. data/lib/tuile/component/has_value.rb +70 -16
  39. data/lib/tuile/component/integer_field.rb +7 -6
  40. data/lib/tuile/component/label.rb +8 -15
  41. data/lib/tuile/component/layout/absolute.rb +86 -0
  42. data/lib/tuile/component/layout/box.rb +38 -63
  43. data/lib/tuile/component/layout.rb +124 -10
  44. data/lib/tuile/component/list.rb +197 -94
  45. data/lib/tuile/component/list_dropdown.rb +148 -88
  46. data/lib/tuile/component/menu_bar/cascade.rb +97 -27
  47. data/lib/tuile/component/menu_bar.rb +84 -64
  48. data/lib/tuile/component/notification.rb +44 -31
  49. data/lib/tuile/component/overlay.rb +210 -52
  50. data/lib/tuile/component/password_field.rb +1 -8
  51. data/lib/tuile/component/picker_window.rb +15 -10
  52. data/lib/tuile/component/popup.rb +13 -24
  53. data/lib/tuile/component/progress_bar.rb +7 -7
  54. data/lib/tuile/component/radio_group.rb +10 -12
  55. data/lib/tuile/component/scroller.rb +266 -0
  56. data/lib/tuile/component/select.rb +15 -31
  57. data/lib/tuile/component/slot.rb +1 -2
  58. data/lib/tuile/component/tab_sheet.rb +21 -28
  59. data/lib/tuile/component/tabs.rb +39 -24
  60. data/lib/tuile/component/text_area/wrapped_text.rb +1 -1
  61. data/lib/tuile/component/text_area.rb +21 -19
  62. data/lib/tuile/component/text_field.rb +55 -39
  63. data/lib/tuile/component/text_view.rb +143 -79
  64. data/lib/tuile/component/time_field.rb +26 -21
  65. data/lib/tuile/component/vertical_scroll_bar.rb +257 -0
  66. data/lib/tuile/component/window.rb +27 -26
  67. data/lib/tuile/component.rb +481 -259
  68. data/lib/tuile/component_background.rb +177 -0
  69. data/lib/tuile/component_util.rb +43 -0
  70. data/lib/tuile/event.rb +29 -0
  71. data/lib/tuile/event_queue.rb +14 -0
  72. data/lib/tuile/fake_screen.rb +41 -10
  73. data/lib/tuile/keys.rb +15 -6
  74. data/lib/tuile/layout_pass.rb +180 -0
  75. data/lib/tuile/listeners.rb +219 -0
  76. data/lib/tuile/mouse/router.rb +51 -35
  77. data/lib/tuile/mouse.rb +96 -29
  78. data/lib/tuile/point.rb +6 -0
  79. data/lib/tuile/rect.rb +33 -0
  80. data/lib/tuile/screen.rb +419 -84
  81. data/lib/tuile/screen_pane.rb +144 -31
  82. data/lib/tuile/strict_layout.rb +127 -0
  83. data/lib/tuile/styled_string.rb +139 -9
  84. data/lib/tuile/testing/gestures.rb +35 -0
  85. data/lib/tuile/testing.rb +310 -36
  86. data/lib/tuile/theme.rb +170 -19
  87. data/lib/tuile/theme_def.rb +4 -0
  88. data/lib/tuile/version.rb +1 -1
  89. data/lib/tuile.rb +53 -0
  90. data/sig/tuile.rbs +4951 -1158
  91. metadata +16 -2
  92. data/lib/tuile/vertical_scroll_bar.rb +0 -122
@@ -17,7 +17,7 @@ module Tuile
17
17
  # start of the next row in nearly all cases).
18
18
  #
19
19
  # Enter inserts a newline, as in a plain `<textarea>` or text editor; only
20
- # {#on_change} is wired. {Keys::CTRL_J} does the same, since that is the
20
+ # {#on_value_change} is wired. {Keys::CTRL_J} does the same, since that is the
21
21
  # byte a terminal sends for a typed Ctrl+J. A *pasted* line break arrives
22
22
  # through {AbstractStringField#handle_paste} instead and never as a key at
23
23
  # all — so a subclass rebinding Enter to submit keeps working under a
@@ -44,18 +44,18 @@ module Tuile
44
44
  # == Implementation details
45
45
  #
46
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}).
47
+ # **index** and a **row/column** — lives in {WrappedText}, a snapshot of
48
+ # `(text, rect.width)` this class caches: a text mutation drops it, and a
49
+ # read at another width rebuilds it. What stays here is the widget: keys,
50
+ # mouse, painting, and the {#scroll_top_row} viewport, which {WrappedText}
51
+ # deliberately knows nothing about (it is a pure function of text and
52
+ # width; the viewport is stateful and needs {Rect#height}).
53
53
  class TextArea < AbstractStringField
54
54
  def initialize
55
55
  super
56
56
  @scroll_top_row = 0
57
57
  # Lazy cache; nil means "stale, rebuild on next read". Reset whenever
58
- # {#text} mutates or the width changes.
58
+ # {#text} mutates; {#wrap} notices a width change itself.
59
59
  @wrap = nil
60
60
  end
61
61
 
@@ -80,11 +80,10 @@ module Tuile
80
80
  row_in_viewport = row - @scroll_top_row
81
81
  return nil if row_in_viewport.negative? || row_in_viewport >= rect.height
82
82
 
83
- # Cap so the hardware cursor never lands at rect.left+rect.width
84
- # (one past the rect). Terminals with auto-wrap interpret that as
85
- # column 0 of the row below; capping pins the cursor on the last
86
- # visible cell instead.
87
- Point.new(rect.left + col.clamp(0, rect.width - 1), rect.top + row_in_viewport)
83
+ # Cap so the hardware cursor never lands one past the last column.
84
+ # Terminals with auto-wrap interpret that as column 0 of the row below;
85
+ # capping pins the cursor on the last visible cell instead.
86
+ Point.new(col.clamp(0, rect.width - 1), row_in_viewport)
88
87
  end
89
88
 
90
89
  # @param event [Mouse::DownEvent]
@@ -92,22 +91,23 @@ module Tuile
92
91
  def handle_mouse_down?(event)
93
92
  return false unless event.button == :left
94
93
 
95
- target_row = (event.y - rect.top) + @scroll_top_row
94
+ target_row = event.y + @scroll_top_row
96
95
  self.caret = if target_row >= wrap.row_count
97
96
  @text.length
98
97
  else
99
- wrap.index_at(target_row, event.x - rect.left)
98
+ wrap.index_at(target_row, event.x)
100
99
  end
101
100
  true
102
101
  end
103
102
 
103
+ # @param canvas [Canvas] see {Component#repaint}.
104
104
  # @return [void]
105
- def repaint
105
+ def repaint(canvas)
106
106
  return if rect.empty?
107
107
 
108
108
  (0...rect.height).each do |row_in_viewport|
109
109
  line = wrap.row_text(row_in_viewport + @scroll_top_row)
110
- draw_text(rect.left, rect.top + row_in_viewport, StyledString.plain(line))
110
+ canvas.set_text(0, row_in_viewport, StyledString.plain(line))
111
111
  end
112
112
  end
113
113
 
@@ -148,10 +148,11 @@ module Tuile
148
148
  true
149
149
  end
150
150
 
151
+ # Re-clamps the viewport to the caret, since either axis of a new rect can
152
+ # scroll it away.
151
153
  # @return [void]
152
- def handle_width_changed
154
+ def relayout
153
155
  super
154
- @wrap = nil
155
156
  adjust_scroll_top_row
156
157
  end
157
158
 
@@ -159,6 +160,7 @@ module Tuile
159
160
 
160
161
  # @return [WrappedText] the current wrap of {#text} at {Rect#width}.
161
162
  def wrap
163
+ @wrap = nil unless @wrap&.width == rect.width
162
164
  @wrap ||= WrappedText.new(@text, rect.width)
163
165
  end
164
166
 
@@ -48,15 +48,12 @@ module Tuile
48
48
  @placeholder = nil
49
49
  @left_column = 0
50
50
  @max_text_length = nil
51
- @on_key_up = nil
52
- @on_key_down = nil
53
- @on_enter = nil
54
51
  end
55
52
 
56
53
  # Optional cap on {#text}'s length **in characters** — a wide glyph counts
57
54
  # once. Typing into a field already at the cap does nothing.
58
55
  #
59
- # Deliberately does not police {#text=}: lowering the cap under an existing
56
+ # Deliberately does not police {#value=}: lowering the cap under an existing
60
57
  # value leaves that value intact rather than silently trimming it.
61
58
  # @return [Integer, nil] maximum characters, or nil for unbounded (default).
62
59
  attr_reader :max_text_length
@@ -72,35 +69,54 @@ module Tuile
72
69
  @max_text_length = max
73
70
  end
74
71
 
75
- # Optional callback fired when the UP arrow key is pressed. When set, UP
76
- # is consumed by the field; when nil, UP falls through to the parent
77
- # (default behavior). Only triggered by {Keys::UP_ARROW}, not by `k`,
78
- # since `k` is a printable character inserted into {#text}.
79
- # @return [Proc, Method, nil] no-arg callable, or nil.
80
- attr_accessor :on_key_up
81
-
82
- # Optional callback fired when the DOWN arrow key is pressed. When set,
83
- # DOWN is consumed by the field; when nil, DOWN falls through to the
84
- # parent (default behavior). Only triggered by {Keys::DOWN_ARROW}, not by
85
- # `j`, since `j` is a printable character inserted into {#text}.
86
- # @return [Proc, Method, nil] no-arg callable, or nil.
87
- attr_accessor :on_key_down
88
-
89
- # Optional callback fired when ENTER is pressed. When set, ENTER is
90
- # consumed by the field; when nil, ENTER falls through to the parent
91
- # (default behavior).
92
- # @return [Proc, Method, nil] no-arg callable, or nil.
93
- attr_accessor :on_enter
72
+ # What {#on_key_up} fires.
73
+ #
74
+ # @!attribute [r] source
75
+ # @return [TextField] the field the key reached.
76
+ KeyUpEvent = Data.define(:source) { include Tuile::Event }
77
+
78
+ # What {#on_key_down} fires.
79
+ #
80
+ # @!attribute [r] source
81
+ # @return [TextField] the field the key reached.
82
+ KeyDownEvent = Data.define(:source) { include Tuile::Event }
83
+
84
+ # What {#on_enter} fires.
85
+ #
86
+ # @!attribute [r] source
87
+ # @return [TextField] the field the key reached.
88
+ EnterEvent = Data.define(:source) { include Tuile::Event }
89
+
90
+ # @!method on_key_up
91
+ # Fired with a {KeyUpEvent} when the UP arrow key is pressed. **Empty means
92
+ # the field declines UP**, which then falls through to the parent; a
93
+ # registered listener consumes it. Only triggered by {Keys::UP_ARROW},
94
+ # not by `k`, which is a printable character inserted into {#text}.
95
+ # @return [Listeners]
96
+ listener :on_key_up
97
+
98
+ # @!method on_key_down
99
+ # Fired with a {KeyDownEvent} when the DOWN arrow key is pressed. **Empty
100
+ # means the field declines DOWN**, which then falls through to the
101
+ # parent. Only triggered by {Keys::DOWN_ARROW}, not by `j`.
102
+ # @return [Listeners]
103
+ listener :on_key_down
104
+
105
+ # @!method on_enter
106
+ # Fired with an {EnterEvent} when ENTER is pressed. **Empty means the field
107
+ # declines ENTER**, which then falls through to the parent — which is how
108
+ # a scope's default button keeps working.
109
+ # @return [Listeners]
110
+ listener :on_enter
94
111
 
95
112
  # @return [Point, nil]
96
113
  def cursor_position
97
114
  return nil unless rect.width.positive?
98
115
 
99
116
  # Scrolling already keeps the caret inside the rect, so the cap is a
100
- # guard rather than a policy: a cursor parked at rect.left + rect.width
117
+ # guard rather than a policy: a cursor parked one past the last column
101
118
  # reads as column 0 of the next row on an auto-wrapping terminal.
102
- offset = (column_at(@caret) - @left_column).clamp(0, rect.width - 1)
103
- Point.new(rect.left + offset, rect.top)
119
+ Point.new((column_at(@caret) - @left_column).clamp(0, rect.width - 1), 0)
104
120
  end
105
121
 
106
122
  # Places the caret at the pressed column. A press on the right half of a
@@ -110,17 +126,18 @@ module Tuile
110
126
  def handle_mouse_down?(event)
111
127
  return false unless event.button == :left
112
128
 
113
- self.caret = index_at(event.x - rect.left + @left_column)
129
+ self.caret = index_at(event.x + @left_column)
114
130
  true
115
131
  end
116
132
 
133
+ # @param canvas [Canvas] see {Component#repaint}.
117
134
  # @return [void]
118
- def repaint
135
+ def repaint(canvas)
119
136
  return if rect.empty?
120
137
 
121
- return draw_text(rect.left, rect.top, placeholder_row) if show_placeholder?
138
+ return canvas.set_text(0, 0, placeholder_row) if show_placeholder?
122
139
 
123
- draw_text(rect.left, rect.top, StyledString.plain(visible_text))
140
+ canvas.set_text(0, 0, StyledString.plain(visible_text))
124
141
  end
125
142
 
126
143
  protected
@@ -137,17 +154,17 @@ module Tuile
137
154
  when *Keys::BACKSPACES then delete_before_caret
138
155
  when Keys::DELETE then delete_at_caret
139
156
  when Keys::UP_ARROW
140
- return false if @on_key_up.nil?
157
+ return false if on_key_up.empty?
141
158
 
142
- @on_key_up.call
159
+ on_key_up.fire(KeyUpEvent.new(source: self))
143
160
  when Keys::DOWN_ARROW
144
- return false if @on_key_down.nil?
161
+ return false if on_key_down.empty?
145
162
 
146
- @on_key_down.call
163
+ on_key_down.fire(KeyDownEvent.new(source: self))
147
164
  when Keys::ENTER
148
- return false if @on_enter.nil?
165
+ return false if on_enter.empty?
149
166
 
150
- @on_enter.call
167
+ on_enter.fire(EnterEvent.new(source: self))
151
168
  else
152
169
  return insert(key) if Keys.printable?(key)
153
170
 
@@ -187,7 +204,7 @@ module Tuile
187
204
  end
188
205
 
189
206
  # @return [void]
190
- def handle_width_changed
207
+ def relayout
191
208
  super
192
209
  adjust_left_column
193
210
  end
@@ -246,8 +263,7 @@ module Tuile
246
263
  # @return [StyledString] the hint, ellipsized to `rect.width` and padded
247
264
  # back out to it.
248
265
  def placeholder_row
249
- hint = StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width)
250
- hint + StyledString.plain(" " * [rect.width - hint.display_width, 0].max)
266
+ StyledString.styled(placeholder, fg: screen.theme.placeholder_color).ellipsize(rect.width).ljust(rect.width)
251
267
  end
252
268
 
253
269
  # @return [Integer] total display width of {#text}.
@@ -20,6 +20,17 @@ module Tuile
20
20
  # splice a range in place. Turn on {#auto_scroll} to keep the latest content
21
21
  # in view.
22
22
  #
23
+ # {#scrollbar_visibility} turns on a {VerticalScrollBar} in the rightmost
24
+ # column — a real child component, so the user can drag its handle
25
+ # (wanting `run_event_loop(capture_mouse: :drag)`) and press its track to
26
+ # page. Its column and the blank one beside it are reserved whenever it is
27
+ # `:visible`, whether anything scrolls or not, so the text never rewraps
28
+ # behind a growing buffer (`D_scrollbar_reserve`, `D_scrollbar_ink`).
29
+ # Under `:auto` the bar shows only while the text, wrapped at the full
30
+ # width, has more rows than the viewport — and then the text rewraps once,
31
+ # two columns narrower, which is the price of the columns it hands back
32
+ # while everything fits.
33
+ #
23
34
  # Meant to be the content of a {Window} — focus indication relies on the
24
35
  # surrounding window chrome.
25
36
  class TextView < Component
@@ -37,20 +48,29 @@ module Tuile
37
48
  # Invariants:
38
49
  # - `@line_wrap_counts.size == @lines.size`
39
50
  # - `@line_wrap_counts.sum == @rows.size`
40
- # A full rebuild ({#rewrap}) happens on {#text=} and width changes;
41
- # other mutators splice incrementally.
51
+ # A full rebuild ({#rewrap}) happens on {#text=} and when
52
+ # {#wrap_width} moves off `@wrapped_at`; other mutators splice
53
+ # incrementally.
42
54
  @lines = []
43
55
  @rows = []
44
56
  @line_wrap_counts = []
57
+ @wrapped_at = 0
45
58
  @text = StyledString::EMPTY
46
59
  @blank_row = StyledString::EMPTY
47
60
  @scroll_top_row = 0
48
61
  @auto_scroll = false
49
62
  @follow = true
50
63
  @scrollbar_visibility = :gone
64
+ # Whether an `:auto` bar is showing — state, not derived on read,
65
+ # because `@rows` is wrapped at the width it leaves; only
66
+ # {#sync_auto_scrollbar} flips it.
67
+ @auto_scrollbar_shown = false
51
68
  # Always ≥1 region; the implicit default owns any hard lines no
52
69
  # app-created region claims. See {Region}.
53
70
  @regions = [Region.send(:new, self)]
71
+ @scrollbar = VerticalScrollBar.new
72
+ @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
73
+ add_child(@scrollbar) # chrome, appended: a TextView has no content children
54
74
  end
55
75
 
56
76
  # @return [StyledString] the current text (empty by default). Rebuilt
@@ -63,7 +83,8 @@ module Tuile
63
83
  # @return [Integer] index of the first visible row.
64
84
  attr_reader :scroll_top_row
65
85
 
66
- # @return [Symbol] `:gone` or `:visible`.
86
+ # @return [Symbol] `:gone`, `:visible`, or `:auto` — shown only while the
87
+ # text overflows the viewport at the full width. `:gone` by default.
67
88
  attr_reader :scrollbar_visibility
68
89
 
69
90
  # @return [Boolean] if true, mutating the text scrolls the viewport so
@@ -108,6 +129,7 @@ module Tuile
108
129
  rewrap
109
130
  update_scroll_top_row_if_auto_scroll
110
131
  invalidate
132
+ invalidate_layout
111
133
  end
112
134
 
113
135
  # Creates a new empty {Region} at the spatial tail of the document
@@ -169,6 +191,7 @@ module Tuile
169
191
  @text = nil
170
192
  update_scroll_top_row_if_auto_scroll
171
193
  invalidate
194
+ invalidate_layout # `@rows` grew, and the bar's row_count follows it
172
195
  end
173
196
 
174
197
  # Verbatim append, returning `self` for chainability (`view << a << b`).
@@ -231,10 +254,7 @@ module Tuile
231
254
  remaining -= take
232
255
  end
233
256
 
234
- @text = nil
235
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
236
- update_scroll_top_row_if_auto_scroll
237
- invalidate
257
+ content_changed
238
258
  end
239
259
 
240
260
  # Replaces a contiguous range of hard lines with the parsed content of
@@ -272,10 +292,7 @@ module Tuile
272
292
 
273
293
  splice_lines(from, length, new_lines)
274
294
  update_region_counts(from, length, new_lines.size)
275
- @text = nil
276
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
277
- update_scroll_top_row_if_auto_scroll
278
- invalidate
295
+ content_changed
279
296
  end
280
297
 
281
298
  # Inserts `str` at hard-line index `at`. Equivalent to
@@ -307,17 +324,23 @@ module Tuile
307
324
  @scroll_top_row = new_row
308
325
  @follow = at_bottom?
309
326
  invalidate
327
+ invalidate_layout # {#relayout} pushes the row to the bar
310
328
  end
311
329
 
312
- # @param value [Symbol] `:gone` or `:visible`.
330
+ # @param value [Symbol] `:gone`, `:visible` or `:auto`.
331
+ # @raise [ArgumentError] on any other value.
313
332
  # @return [void]
314
333
  def scrollbar_visibility=(value)
315
- raise ArgumentError, "expected :gone or :visible, got #{value.inspect}" unless %i[gone visible].include?(value)
334
+ unless %i[gone visible auto].include?(value)
335
+ raise ArgumentError, "expected :gone, :visible or :auto, got #{value.inspect}"
336
+ end
316
337
  return if @scrollbar_visibility == value
317
338
 
318
339
  @scrollbar_visibility = value
340
+ @auto_scrollbar_shown = false # starts at the full width; the settle decides
319
341
  rewrap
320
342
  invalidate
343
+ invalidate_layout # the bar's rect appears or collapses
321
344
  end
322
345
 
323
346
  # Sets `auto_scroll`. If true, re-engages tailing and immediately
@@ -383,35 +406,50 @@ module Tuile
383
406
  scroll_top_row != before
384
407
  end
385
408
 
386
- # Paints the text into {#rect}.
409
+ # Paints the text into {#rect}, out to the blank column that
410
+ # {#scrollbar_columns} reserves. The bar's own column is not painted
411
+ # here — it is the child {VerticalScrollBar}'s, placed by {#relayout}.
387
412
  #
388
413
  # Skips the {Component#repaint} default's auto-clear: every row is
389
414
  # painted explicitly (with padded blanks past the last line), so the
390
415
  # "fully draw over your rect" contract is met without an upfront wipe.
391
- # Rows go through {Component#draw_text}, so content and blank rows inherit
392
- # {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
416
+ # {Component#invalidate_children} is the half that cannot be skipped
417
+ # with it, or the bar goes stale under an ancestor's clear
418
+ # (`D_repaint_cascade`). Rows go through {Canvas#set_text}, so content
419
+ # and blank rows inherit {ComponentBackground#effective} (a {#bg_color}
420
+ # set here or on an ancestor).
421
+ # @param canvas [Canvas] see {Component#repaint}.
393
422
  # @return [void]
394
- def repaint
423
+ def repaint(canvas)
395
424
  return if rect.empty?
396
425
 
397
- scrollbar = if scrollbar_visible?
398
- VerticalScrollBar.new(rect.height, row_count: @rows.size, scroll_top_row: @scroll_top_row)
399
- end
426
+ invalidate_children
400
427
  (0...rect.height).each do |row|
401
- line = paintable_row(row + @scroll_top_row, row, scrollbar)
402
- draw_text(rect.left, rect.top + row, line)
428
+ canvas.set_text(0, row, paintable_row(row + @scroll_top_row))
403
429
  end
404
430
  end
405
431
 
406
432
  protected
407
433
 
408
- # Rewraps the text on width changes — {#wrap_width} is {#rect}`.width`
409
- # minus {#scrollbar_columns}, and the latter varies with the width too.
410
- # A {#scrollbar_visibility=} flip rewraps from its own setter instead.
434
+ # Places the scrollbar, the view's one child, and pushes the two numbers
435
+ # it paints a handle from. No `rect.empty?` guard: a container assigns
436
+ # every child a rect on every pass (`D_empty_ancestor`), and
437
+ # {#scrollbar_visible?} already answers false for an empty rect.
438
+ #
439
+ # The rows are settled first, before the bar is placed, so the layout mark
440
+ # a scroll clamp makes is dropped rather than costing a second pass: a
441
+ # rewrap if {#wrap_width} moved — on a height change too, since an empty
442
+ # rect hides a `:visible` bar — then the `:auto` bar, then the
443
+ # {#auto_scroll} pin, which a taller viewport moves. Every mutator already
444
+ # marks, for the bar's `row_count`, so this runs after each one.
411
445
  # @return [void]
412
- def handle_width_changed
413
- super
414
- rewrap
446
+ def relayout
447
+ rewrap unless @wrapped_at == wrap_width
448
+ sync_auto_scrollbar
449
+ update_scroll_top_row_if_auto_scroll
450
+ @scrollbar.rect = scrollbar_visible? ? Rect.new(rect.width - 1, 0, 1, rect.height) : Rect.new(0, 0, 0, 0)
451
+ @scrollbar.row_count = @rows.size
452
+ @scrollbar.scroll_top_row = @scroll_top_row
415
453
  end
416
454
 
417
455
  private
@@ -502,10 +540,7 @@ module Tuile
502
540
 
503
541
  splice_lines(start, old_count, new_lines)
504
542
  region.send(:line_count=, new_lines.size)
505
- @text = nil
506
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
507
- update_scroll_top_row_if_auto_scroll
508
- invalidate
543
+ content_changed
509
544
  end
510
545
 
511
546
  # Region-scoped {#replace}. Validates `range` against
@@ -527,10 +562,7 @@ module Tuile
527
562
 
528
563
  splice_lines(abs_from, length, new_lines)
529
564
  region.send(:line_count=, region.line_count - length + new_lines.size)
530
- @text = nil
531
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
532
- update_scroll_top_row_if_auto_scroll
533
- invalidate
565
+ content_changed
534
566
  end
535
567
 
536
568
  # Verbatim append into `region`.
@@ -566,10 +598,7 @@ module Tuile
566
598
  end
567
599
  region.send(:line_count=, region.line_count + rest.size)
568
600
  end
569
- @text = nil
570
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
571
- update_scroll_top_row_if_auto_scroll
572
- invalidate
601
+ content_changed
573
602
  end
574
603
 
575
604
  # Drops the last `n` hard lines from `region`'s tail via
@@ -587,10 +616,7 @@ module Tuile
587
616
  drop_from = start + region.line_count - to_drop
588
617
  splice_lines(drop_from, to_drop, [])
589
618
  region.send(:line_count=, region.line_count - to_drop)
590
- @text = nil
591
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
592
- update_scroll_top_row_if_auto_scroll
593
- invalidate
619
+ content_changed
594
620
  end
595
621
 
596
622
  # Drops `region` from {@regions}: its hard lines are removed via
@@ -612,10 +638,7 @@ module Tuile
612
638
  @regions << Region.send(:new, self) if @regions.empty?
613
639
  return unless had_lines
614
640
 
615
- @text = nil
616
- @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
617
- update_scroll_top_row_if_auto_scroll
618
- invalidate
641
+ content_changed
619
642
  end
620
643
 
621
644
  # Adjusts region line counts after a {@lines} splice that removed
@@ -681,7 +704,8 @@ module Tuile
681
704
  # @return [void]
682
705
  def rewrap
683
706
  width = wrap_width
684
- @blank_row = pad_to(StyledString::EMPTY, width)
707
+ @wrapped_at = width
708
+ @blank_row = StyledString::EMPTY.ljust(width)
685
709
  @rows = []
686
710
  @line_wrap_counts = []
687
711
  @lines.each do |line|
@@ -695,7 +719,7 @@ module Tuile
695
719
  # Wraps `line` at `width` and returns the padded rows alongside the
696
720
  # row count. Empty lines (e.g. from a `"\n\n"`
697
721
  # run) and degenerate `width <= 0` both emit a single {@blank_row}
698
- # row, matching what `@text.wrap(width).map { |l| pad_to(l, width) }`
722
+ # row, matching what `@text.wrap(width).map { |l| l.ljust(width) }`
699
723
  # would have produced.
700
724
  # @param line [StyledString]
701
725
  # @param width [Integer]
@@ -704,7 +728,7 @@ module Tuile
704
728
  return [[@blank_row], 1] if line.empty? || width <= 0
705
729
 
706
730
  wrapped = line.wrap(width)
707
- [wrapped.map { |row| pad_to(row, width) }, wrapped.size]
731
+ [wrapped.map { |row| row.ljust(width) }, wrapped.size]
708
732
  end
709
733
 
710
734
  # Appends `line` to the tail of {@lines}, updating the
@@ -796,11 +820,13 @@ module Tuile
796
820
 
797
821
  # Columns the scrollbar claims off the right edge: the bar itself plus one
798
822
  # blank column, so a row wrapping at the full width doesn't run into `█`
799
- # (`…to show the█`). `0` when the bar is hidden.
823
+ # (`…to show the█`). `0` when the bar is hidden. Only the blank is this
824
+ # view's to paint — {#paintable_row} emits it, the child
825
+ # {VerticalScrollBar} owns the column beyond it.
800
826
  #
801
827
  # The blank is dropped below width 3, where reserving it would leave no
802
- # column for text at all — that keeps {#paintable_row}'s "exactly
803
- # {#rect}`.width` columns" contract true at every width.
828
+ # column for text at all — that keeps row and bar together covering
829
+ # exactly {#rect}`.width` columns at every width.
804
830
  # @return [Integer] `0`, `1` or `2`.
805
831
  def scrollbar_columns
806
832
  return 0 unless scrollbar_visible?
@@ -821,6 +847,24 @@ module Tuile
821
847
  self.scroll_top_row = clamped unless @scroll_top_row == clamped
822
848
  end
823
849
 
850
+ # Every splice's tail. {#invalidate_layout} rides along because
851
+ # {#relayout} pushes the bar's `row_count`: a splice that changed
852
+ # `@rows.size` without the mark leaves the handle sized for the old
853
+ # buffer.
854
+ #
855
+ # Not for {#append} or {#text=}, which mark by hand — appending must not
856
+ # clamp, or a viewport deliberately parked past the end (which
857
+ # {#scroll_top_row=} allows) is yanked back by the next line, and
858
+ # {#text=} clamps inside {#rewrap} already.
859
+ # @return [void]
860
+ def content_changed
861
+ @text = nil
862
+ @scroll_top_row = scroll_top_row_max if @scroll_top_row > scroll_top_row_max
863
+ update_scroll_top_row_if_auto_scroll
864
+ invalidate
865
+ invalidate_layout
866
+ end
867
+
824
868
  # Gated on {#following?}: once the user scrolls up off the bottom the
825
869
  # viewport pin is skipped, so reading older content is not interrupted
826
870
  # by incoming lines. {#scroll_top_row=} re-arms `@follow` when the viewport
@@ -841,40 +885,60 @@ module Tuile
841
885
  def scrollbar_visible?
842
886
  return false if rect.empty?
843
887
 
844
- @scrollbar_visibility == :visible
888
+ case @scrollbar_visibility
889
+ when :visible then true
890
+ when :auto then @auto_scrollbar_shown
891
+ else false
892
+ end
845
893
  end
846
894
 
847
- # Pads `line` with trailing default-styled spaces out to `width` display
848
- # columns. Callers rely on {StyledString#wrap} having already
849
- # constrained the line to `<= width`, so no truncation is performed.
850
- # `width <= 0` returns {StyledString::EMPTY} to handle the degenerate
851
- # `wrap_width == 0` case (rect.width == 1 with scrollbar).
852
- # @param row [StyledString]
853
- # @param width [Integer]
854
- # @return [StyledString]
855
- def pad_to(row, width)
856
- return StyledString::EMPTY if width <= 0
895
+ # The sole writer of `@auto_scrollbar_shown`: shows the bar iff the text
896
+ # overflows the viewport at the *full* width, and rewraps when that
897
+ # flips. Deciding at the full width alone is what settles it in one
898
+ # step — the answer does not depend on the width the bar leaves, so
899
+ # showing the bar can't flip it back. Idempotent.
900
+ # @return [void]
901
+ def sync_auto_scrollbar
902
+ return unless @scrollbar_visibility == :auto
857
903
 
858
- diff = width - row.display_width
859
- return row if diff <= 0
904
+ shown = !rect.empty? && overflows_at_full_width?
905
+ return if shown == @auto_scrollbar_shown
860
906
 
861
- row + StyledString.plain(" " * diff)
907
+ @auto_scrollbar_shown = shown
908
+ rewrap
909
+ invalidate
910
+ end
911
+
912
+ # Whether the text, wrapped at {#rect}`.width`, has more rows than the
913
+ # viewport. With the bar hidden `@rows` already *is* that wrap; with it
914
+ # shown the lines are re-wrapped at the full width, stopping at the first
915
+ # row past the viewport — so the cost is bounded by what fits on screen,
916
+ # and a buffer with more hard lines than rows costs nothing at all.
917
+ # @return [Boolean]
918
+ def overflows_at_full_width?
919
+ return @rows.size > viewport_rows unless @auto_scrollbar_shown
920
+ return true if @lines.size > viewport_rows
921
+
922
+ width = rect.width
923
+ rows = 0
924
+ @lines.each do |line|
925
+ rows += line.empty? ? 1 : line.wrap(width).size
926
+ return true if rows > viewport_rows
927
+ end
928
+ false
862
929
  end
863
930
 
864
931
  # @param index [Integer] 0-based index into `@rows`.
865
- # @param row_in_viewport [Integer] 0-based row within the viewport.
866
- # @param scrollbar [VerticalScrollBar, nil]
867
- # @return [StyledString] paintable row exactly `rect.width` columns wide.
868
- # Body rows come pre-padded from {#rewrap}, so this reduces to a lookup
869
- # plus a concat of the blank column and the scrollbar glyph when a bar
870
- # is present (see {#scrollbar_columns}).
871
- def paintable_row(index, row_in_viewport, scrollbar)
932
+ # @return [StyledString] every column of {#rect} the child bar does not
933
+ # own: all of them with the bar hidden, one fewer with it visible.
934
+ # Rows arrive padded to {#wrap_width} from {#rewrap}, so only the blank
935
+ # {#scrollbar_columns} reserves is added here.
936
+ def paintable_row(index)
872
937
  row = @rows[index] || @blank_row
873
- return row unless scrollbar
938
+ blanks = scrollbar_columns - 1
939
+ return row unless blanks.positive?
874
940
 
875
- blanks = " " * (scrollbar_columns - 1)
876
- bar = StyledString.styled(scrollbar.scrollbar_char(row_in_viewport), fg: screen.theme.scrollbar_color)
877
- row + StyledString.plain(blanks) + bar
941
+ row + StyledString.plain(" " * blanks)
878
942
  end
879
943
 
880
944
  # A logical section of a {TextView}'s text — a contiguous run of