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
@@ -7,7 +7,7 @@ module Tuile
7
7
  # {MIDNIGHT}. Give it a single-row {#rect}:
8
8
  #
9
9
  # field = Component::TimeField.new
10
- # field.on_value_change = ->(t) { puts t&.strftime("%H:%M") }
10
+ # field.on_value_change { |e| puts e.value&.strftime("%H:%M") }
11
11
  # field.set_to(13, 45) # field shows "13:45"
12
12
  # field.placeholder # => "hh:mm"
13
13
  # field.value # => 2000-01-01 13:45:00 UTC
@@ -157,8 +157,8 @@ module Tuile
157
157
  editor.max_text_length = MAX_TEXT_LENGTH
158
158
  # Claiming the editor's two arrow slots, not the general interceptor:
159
159
  # that one stays free for the app.
160
- editor.on_key_up = -> { step_by(@step) }
161
- editor.on_key_down = -> { step_by(-@step) }
160
+ editor.on_key_up { step_by(@step) }
161
+ editor.on_key_down { step_by(-@step) }
162
162
  @settled = false
163
163
  @placeholder_override = nil
164
164
  @step = DEFAULT_STEP
@@ -192,12 +192,13 @@ module Tuile
192
192
  # @return [void]
193
193
  # @raise [TypeError] on a `Date` (it has no hour, and midnight would be
194
194
  # invented) or a `String` (that is what the buffer is for).
195
- def value=(new_value)
196
- editor.text = new_value.nil? ? "" : coerce(new_value).strftime(formats.first)
195
+ # @param from_user [Boolean] see {HasValue#set_value}.
196
+ def set_value(new_value, from_user:)
197
+ editor.set_value(new_value.nil? ? "" : coerce(new_value).strftime(formats.first), from_user:)
197
198
  editor.caret = editor.text.length
198
199
  # The edit above announced nothing ({#notify_on_edit?}); a time written
199
200
  # rather than typed has no prefix to be mistaken for a value.
200
- fire_if_changed
201
+ fire_if_changed(from_user:)
201
202
  end
202
203
 
203
204
  # Sets the value from its parts, so nothing assembles a `Time` on the
@@ -320,6 +321,14 @@ module Tuile
320
321
  # @return [String, nil]
321
322
  def bad_input_message = value.nil? && !editor.text.empty? ? BAD_INPUT_MESSAGE : nil
322
323
 
324
+ # Every prefix of a time is bad input, so the report is latched to the
325
+ # commit gestures instead of shown per keystroke: `1`, `13`, `13:` on the
326
+ # way to `13:45` never redden and announce nothing, and a time the field
327
+ # cannot parse reddens the moment the user leaves the field or presses
328
+ # ENTER ({HasBadInput}).
329
+ # @return [Boolean]
330
+ def bad_input_settled? = @settled
331
+
323
332
  # Claims PageUp/PageDown for the hour step; they reach this field only
324
333
  # because the editor declines them.
325
334
  # @param key [String]
@@ -342,7 +351,8 @@ module Tuile
342
351
  # @return [void]
343
352
  def commit
344
353
  time = value
345
- self.value = time unless time.nil? # …which unsettles, hence the order
354
+ # The user's: a commit gesture, and a held notice announces here.
355
+ set_value(time, from_user: true) unless time.nil? # …which unsettles, hence the order
346
356
  settle(true)
347
357
  end
348
358
 
@@ -352,20 +362,14 @@ module Tuile
352
362
  # @return [Boolean]
353
363
  def notify_on_edit? = false
354
364
 
355
- # Every prefix of a time is bad input, so the well is latched to the
356
- # commit gestures instead of painted per keystroke: `1`, `13`, `13:` on
357
- # the way to `13:45` never redden, and a time the field cannot parse
358
- # reddens the moment the user leaves the field or presses ENTER
359
- # ({HasBadInput}).
360
- # @return [Boolean]
361
- def bad_input_settled? = @settled
362
-
363
365
  # An edit is the user having another go, so the well goes quiet again
364
366
  # until the next commit gesture.
365
367
  # @return [void]
366
368
  def handle_editor_change
367
- super
369
+ # Unsettled before the sync riding `super`, which would otherwise read
370
+ # the new buffer against the old latch.
368
371
  settle(false)
372
+ super
369
373
  end
370
374
 
371
375
  # @return [void]
@@ -387,7 +391,7 @@ module Tuile
387
391
  sync_placeholder
388
392
  time = value || losslessly(carried)
389
393
  self.value = time unless time.nil?
390
- fire_if_changed # for the buffer that just *stopped* parsing: nothing above touched it
394
+ fire_if_changed(from_user: false) # for the buffer that just *stopped* parsing: nothing above touched it
391
395
  end
392
396
 
393
397
  # A narrowing {#step=} must not discard seconds the user typed — but
@@ -432,14 +436,12 @@ module Tuile
432
436
  end
433
437
 
434
438
  # Steps {#value} by `delta` seconds; an empty or unparseable field steps
435
- # to *now* ({#set_to_now}) instead, and `delta` is ignored.
439
+ # to *now* (as {#set_to_now} would) instead, and `delta` is ignored.
436
440
  # @param delta [Integer] seconds, either sign.
437
441
  # @return [void]
438
442
  def step_by(delta)
439
443
  time = value
440
- return set_to_now if time.nil?
441
-
442
- self.value = advance(time, delta)
444
+ set_value(time.nil? ? now : advance(time, delta), from_user: true)
443
445
  end
444
446
 
445
447
  # @param time [Time]
@@ -463,6 +465,9 @@ module Tuile
463
465
  # Nothing else painted: an ENTER on an untouched buffer writes no cells,
464
466
  # and neither does leaving the field with bad input in it.
465
467
  invalidate
468
+ # The latch moves without the buffer, so this is the one input to the
469
+ # showable report that {HasBadInput}'s edit funnel cannot see.
470
+ sync_bad_input
466
471
  end
467
472
 
468
473
  # @return [void]
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-column scrollbar the user can drag, and press the track of to page.
6
+ # It is chrome a container owns: you hand it a column, keep it told how
7
+ # tall the content is and how far it has scrolled, and listen for the row
8
+ # it asks for:
9
+ #
10
+ # @scrollbar = VerticalScrollBar.new(row_count: @content_rows)
11
+ # @scrollbar.on_scroll_request { self.scroll_top_row = _1.scroll_top_row }
12
+ # add_child(@scrollbar)
13
+ # # …and from wherever the geometry settles:
14
+ # @scrollbar.rect = Rect.new(rect.width - 1, 0, 1, rect.height)
15
+ # @scrollbar.scroll_top_row = @scroll_top_row
16
+ #
17
+ # **It never moves itself.** Both gestures compute a row and fire
18
+ # {#on_scroll_request}; nothing changes until the owner assigns
19
+ # {#scroll_top_row=}. So an unwired bar is inert — the honest picture,
20
+ # nothing behind it having scrolled either.
21
+ #
22
+ # **Dragging needs `run_event_loop(capture_mouse: :drag)`**: the `:clicks`
23
+ # default asks the terminal for no motion reports, so
24
+ # {Component#handle_mouse_drag} never fires and the handle sits still.
25
+ # Pressing the track works at every level.
26
+ #
27
+ # Its track is the whole {#rect} — no arrow buttons — so `rect.height` is
28
+ # the viewport height. The glyphs are the app-global {handle_char} /
29
+ # {track_char} pair and the color is `Theme#scrollbar_color`, read at paint
30
+ # time. {Scroller} is the worked example; why a component rather than drag
31
+ # handlers on the owner, and why the handle sits where it does, are
32
+ # `D_draggable_scrollbar`.
33
+ class VerticalScrollBar < Component
34
+ class << self
35
+ # The glyph drawn where the handle covers a row, `█` by default. Set
36
+ # the pair at startup for a lazygit-style bar:
37
+ #
38
+ # Tuile::Component::VerticalScrollBar.handle_char = "▐"
39
+ # Tuile::Component::VerticalScrollBar.track_char = "│"
40
+ #
41
+ # Process-global, and assigning invalidates nothing — a change after
42
+ # the first paint shows up only where something repaints anyway. Why
43
+ # app-global rather than per instance is `D_scrollbar_ink`.
44
+ # @return [String]
45
+ attr_reader :handle_char
46
+
47
+ # The glyph drawn on the rows the handle doesn't cover, `░` by
48
+ # default — and on every row when nothing scrolls ({#repaint}).
49
+ # @return [String]
50
+ attr_reader :track_char
51
+
52
+ # @param char [String]
53
+ # @return [String]
54
+ # @raise [TypeError] when `char` is not a String.
55
+ # @raise [ArgumentError] when `char` is not exactly one grapheme
56
+ # cluster one column wide.
57
+ def handle_char=(char)
58
+ @handle_char = StyledString.validate_glyph(char, :handle_char)
59
+ end
60
+
61
+ # @param char [String] see {handle_char=}.
62
+ # @return [String]
63
+ def track_char=(char)
64
+ @track_char = StyledString.validate_glyph(char, :track_char)
65
+ end
66
+ end
67
+
68
+ self.handle_char = "█"
69
+ self.track_char = "░"
70
+
71
+ # What {#on_scroll_request} fires.
72
+ #
73
+ # @!attribute [r] source
74
+ # @return [VerticalScrollBar] the bar that was dragged or pressed.
75
+ # @!attribute [r] scroll_top_row
76
+ # @return [Integer] the content row the user is asking to see at the
77
+ # top, clamped so it never asks past the last scrollable row.
78
+ ScrollRequestEvent = Data.define(:source, :scroll_top_row) { include Tuile::Event }
79
+
80
+ # @!method on_scroll_request
81
+ # Fired with a {ScrollRequestEvent} when a drag or a track press asks
82
+ # for a row other than the current one — never from {#scroll_top_row=},
83
+ # so an owner assigning the row it was just handed loops nothing.
84
+ #
85
+ # **Empty means a bar that does not move**: it reports the request and
86
+ # waits to be told, so nothing at all happens until somebody listens.
87
+ # @return [Listeners]
88
+ listener :on_scroll_request
89
+
90
+ # @param row_count [Integer] see {#row_count=}.
91
+ # @param scroll_top_row [Integer] see {#scroll_top_row=}.
92
+ def initialize(row_count: 0, scroll_top_row: 0)
93
+ super()
94
+ @row_count = validate_rows(row_count, :row_count)
95
+ @scroll_top_row = validate_rows(scroll_top_row, :scroll_top_row)
96
+ @drag_origin = nil
97
+ end
98
+
99
+ # @return [Integer] how many rows of content the bar stands for.
100
+ attr_reader :row_count
101
+
102
+ # @return [Integer] the content row currently at the top of the viewport.
103
+ attr_reader :scroll_top_row
104
+
105
+ # @param count [Integer] `>= 0`.
106
+ # @raise [ArgumentError] unless a non-negative Integer.
107
+ # @return [void]
108
+ def row_count=(count)
109
+ count = validate_rows(count, :row_count)
110
+ return if @row_count == count
111
+
112
+ @row_count = count
113
+ invalidate
114
+ end
115
+
116
+ # Not clamped against {#row_count} — the owner's scroll state is
117
+ # authoritative, and a bar that silently disagreed with it would paint a
118
+ # handle where the content is not.
119
+ # @param row [Integer] `>= 0`.
120
+ # @raise [ArgumentError] unless a non-negative Integer.
121
+ # @return [void]
122
+ def scroll_top_row=(row)
123
+ row = validate_rows(row, :scroll_top_row)
124
+ return if @scroll_top_row == row
125
+
126
+ @scroll_top_row = row
127
+ invalidate
128
+ end
129
+
130
+ # @return [Boolean] whether there is content out of view — `false` also
131
+ # for an empty rect, and what {#repaint} asks before drawing a handle.
132
+ def scrollable? = rect.height >= 1 && @row_count > rect.height
133
+
134
+ # Rows the handle covers, capped at one below the track so there is
135
+ # always somewhere to drag to: a 10-row viewport onto 11 rows of content
136
+ # gets a 9-row handle, not an immovable 10-row one.
137
+ # @return [Integer] `0` for an empty rect, the whole track when the
138
+ # content fits (nothing is *painted* then — see {#repaint}).
139
+ def handle_height
140
+ return rect.height unless scrollable?
141
+
142
+ (rect.height**2.0 / @row_count).ceil.clamp(1, [rect.height - 1, 1].max)
143
+ end
144
+
145
+ # The handle's first row, mapped over the *free* track so that the last
146
+ # scrollable row puts the handle's last row at the bottom of the track.
147
+ # @return [Integer] 0-based row within the track; `0` when the content
148
+ # fits.
149
+ def handle_start
150
+ free = free_rows
151
+ return 0 if free.zero?
152
+
153
+ (free * @scroll_top_row.clamp(0, max_top) / max_top.to_f).round
154
+ end
155
+
156
+ # One column wide, however wide a rect it was handed — which is also what
157
+ # the router hit-tests, so a press on the dead columns beside the bar
158
+ # reaches whatever is behind it instead.
159
+ # @return [Size]
160
+ def extent = Size.new([1, rect.width].min, rect.height)
161
+
162
+ # Claims every left press: on the handle it starts a drag, on the track
163
+ # it pages towards the pointer.
164
+ # @param event [Mouse::DownEvent]
165
+ # @return [Boolean]
166
+ def handle_mouse_down?(event)
167
+ return false unless event.button == :left
168
+
169
+ @drag_origin = nil
170
+ if scrollable? && event.y >= handle_start && event.y < handle_start + handle_height
171
+ @drag_origin = [event.y, @scroll_top_row]
172
+ else
173
+ request(@scroll_top_row + (event.y < handle_start ? -page_rows : page_rows))
174
+ end
175
+ true
176
+ end
177
+
178
+ # Scrolls by how far the pointer has moved *since the press*, not by
179
+ # where it is. Absolute positioning would jump on the first report: the
180
+ # track has a row per several rows of content, so inverting the handle's
181
+ # rounded position does not give back the row it came from.
182
+ # @param event [Mouse::DragEvent]
183
+ # @return [void]
184
+ def handle_mouse_drag(event)
185
+ super
186
+ return if @drag_origin.nil? || free_rows.zero?
187
+
188
+ y_at_press, top_at_press = @drag_origin
189
+ request(top_at_press + ((event.y - y_at_press) * max_top / free_rows.to_f).round)
190
+ end
191
+
192
+ # @param event [Mouse::UpEvent]
193
+ # @return [void]
194
+ def handle_mouse_up(event)
195
+ super
196
+ @drag_origin = nil
197
+ end
198
+
199
+ # Paints {track_char} down the column with {handle_char} over it — and
200
+ # bare track when {#scrollable?} is false, a solid handle filling the
201
+ # track carrying no information (`D_scrollbar_ink`).
202
+ # @param canvas [Canvas] see {Component#repaint}.
203
+ # @return [void]
204
+ def repaint(canvas)
205
+ super
206
+ return if rect.empty?
207
+
208
+ style = StyledString::Style.new(fg: screen.theme.scrollbar_color)
209
+ handle = (handle_start...handle_start + handle_height) if scrollable?
210
+ rect.height.times do |row|
211
+ # Named, not `self.class` — a class-level ivar is not inherited, so a
212
+ # subclassed bar would read nil off its own class.
213
+ glyph = handle&.cover?(row) ? VerticalScrollBar.handle_char : VerticalScrollBar.track_char
214
+ canvas.set_char(0, row, glyph, style)
215
+ end
216
+ end
217
+
218
+ # @return [Array<String>]
219
+ def inspect_details = super + ["#{scroll_top_row}/#{row_count} rows"]
220
+
221
+ private
222
+
223
+ # @return [Integer] the largest {#scroll_top_row} that still fills the
224
+ # viewport; `0` when the content fits.
225
+ def max_top = [@row_count - rect.height, 0].max
226
+
227
+ # @return [Integer] track rows the handle can travel over; `0` means no
228
+ # drag is possible.
229
+ def free_rows = scrollable? ? rect.height - handle_height : 0
230
+
231
+ # @return [Integer] a viewport, at least one row.
232
+ def page_rows = [rect.height, 1].max
233
+
234
+ # Fires {#on_scroll_request} for `row`, clamped; silent on a no-op.
235
+ # @param row [Integer]
236
+ # @return [void]
237
+ def request(row)
238
+ row = row.clamp(0, max_top)
239
+ return if row == @scroll_top_row
240
+
241
+ on_scroll_request.fire(ScrollRequestEvent.new(source: self, scroll_top_row: row))
242
+ end
243
+
244
+ # @param rows [Integer]
245
+ # @param name [Symbol] the accessor, for the message.
246
+ # @return [Integer] `rows`.
247
+ # @raise [ArgumentError] unless `rows` is a non-negative Integer.
248
+ def validate_rows(rows, name)
249
+ unless rows.is_a?(Integer) && !rows.negative?
250
+ raise ArgumentError, "#{name} expects a non-negative Integer, got #{rows.inspect}"
251
+ end
252
+
253
+ rows
254
+ end
255
+ end
256
+ end
257
+ end
@@ -66,17 +66,10 @@ module Tuile
66
66
  # @return [void]
67
67
  def footer=(new_footer)
68
68
  @footer_slot.content = new_footer
69
- layout_footer
69
+ invalidate_layout
70
70
  invalidate # repaint border row that the footer covers/uncovers
71
71
  end
72
72
 
73
- # @param new_rect [Rect]
74
- # @return [void]
75
- def rect=(new_rect)
76
- super
77
- layout_footer
78
- end
79
-
80
73
  # @param value [Boolean]
81
74
  # @return [void]
82
75
  def scrollbar=(value)
@@ -88,7 +81,7 @@ module Tuile
88
81
  content.scrollbar_visibility = value ? :visible : :gone
89
82
  @border_right = value ? 0 : 1
90
83
  invalidate
91
- layout(content)
84
+ invalidate_layout
92
85
  end
93
86
 
94
87
  # Fully repaints the window: the border ring here, the interior through
@@ -103,57 +96,65 @@ module Tuile
103
96
  # (`D_component_contract`). The ring is this window's own paint and the
104
97
  # interior is the content's, so the only cell nobody covers is an
105
98
  # interior with no content in it — cleared here, exactly.
99
+ # @param canvas [Canvas] see {Component#repaint}.
106
100
  # @return [void]
107
- def repaint
101
+ def repaint(canvas)
108
102
  return if rect.empty?
109
103
 
110
- clear_background(content_rect) if content.nil? && !content_rect.empty?
104
+ inner = content_rect
105
+ canvas.fill(inner) if content.nil? && !inner.empty?
111
106
  invalidate_children
112
- repaint_border
107
+ repaint_border(canvas)
113
108
  end
114
109
 
115
110
  protected
116
111
 
117
- # @param content [Component]
112
+ # The content takes the interior, the footer slot the bottom border row.
118
113
  # @return [void]
119
- def layout(content) = content.rect = content_rect
114
+ def relayout
115
+ content&.rect = content_rect
116
+ layout_footer
117
+ end
120
118
 
121
119
  # The interior the content fills: inside the border on three sides, and on
122
120
  # the fourth only while there is a right border — {#scrollbar=} drops it so
123
121
  # the content's own bar takes that column.
122
+ #
123
+ # One rect for both jobs, since a window paints and places its children in
124
+ # the same coordinates: it is what {#relayout} assigns the content, and what
125
+ # {#repaint} blanks when there is no content to cover it.
124
126
  # @return [Rect] may be {Rect#empty? empty}, for a window too small to have
125
127
  # an inside.
126
128
  def content_rect
127
- Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
129
+ Rect.new(1, 1, rect.width - 1 - @border_right, rect.height - 2)
128
130
  end
129
131
 
130
- # Paints the window border via {Component#draw_text}/{Component#draw_char},
131
- # so the border cells inherit {Component#effective_bg_color} — a
132
+ # Paints the window border via {Canvas#set_text} / {Canvas#set_char},
133
+ # so the border cells inherit {ComponentBackground#effective} — a
132
134
  # {Component#bg_color} on the window tints border and content alike. Both
133
135
  # border rows are clipped by *display* width, so no caption overflows the
134
136
  # box; when the window is active the whole border — the caption's own
135
137
  # colors included — is drawn in {Theme#active_border_color}.
138
+ # @param canvas [Canvas] the surface to paint onto.
136
139
  # @return [void]
137
- def repaint_border
140
+ def repaint_border(canvas)
138
141
  return if rect.empty?
139
142
 
140
143
  w = rect.width
141
144
  h = rect.height
142
- top = rect.top
143
- left = rect.left
144
145
  inner_w = [w - 2, 0].max
145
146
 
146
147
  fg = active? ? screen.theme.active_border_color : nil
147
148
  bar = StyledString::Style.new(fg: fg)
148
- draw_text(left, top, top_border(inner_w, fg).slice(0, w))
149
- (1..(h - 2)).each do |dy|
150
- draw_char(left, top + dy, "│", bar)
149
+ canvas.set_text(0, 0, top_border(inner_w, fg).slice(0, w))
150
+ (1..(h - 2)).each do |row|
151
+ canvas.set_char(0, row, "│", bar)
151
152
  # Skipped once {#scrollbar=} has given that column to the content: the
152
153
  # bar would paint over the border anyway, and painting it first only
153
154
  # dirties the column into every frame's diff (`D_component_contract`).
154
- draw_char(left + w - 1, top + dy, "│", bar) if @border_right.positive?
155
+ canvas.set_char(w - 1, row, "│", bar) if @border_right.positive?
155
156
  end
156
- draw_text(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
157
+ canvas.set_text(0, h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
157
158
  end
158
159
 
159
160
  # Builds the top border row: corners, {#caption} embedded at its own
@@ -204,7 +205,7 @@ module Tuile
204
205
  end
205
206
 
206
207
  width = [rect.width - 2, 0].max
207
- @footer_slot.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
208
+ @footer_slot.rect = Rect.new(1, rect.height - 1, width, 1)
208
209
  end
209
210
  end
210
211
  end