tuile 0.8.0 → 0.10.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +47 -0
  3. data/DECISIONS.md +1961 -0
  4. data/README.md +82 -48
  5. data/book/01-first-app.md +186 -0
  6. data/book/02-repaint.md +177 -0
  7. data/book/03-layout.md +379 -0
  8. data/book/04-event-loop.md +295 -0
  9. data/book/05-focus.md +219 -0
  10. data/book/06-theming.md +302 -0
  11. data/book/07-components.md +585 -0
  12. data/book/08-testing.md +199 -0
  13. data/book/09-styled-text.md +132 -0
  14. data/book/README.md +85 -0
  15. data/examples/hello_world.rb +1 -2
  16. data/examples/sampler.rb +435 -20
  17. data/ideas/new-components.md +109 -0
  18. data/ideas/per-component-buffers.md +55 -0
  19. data/lib/tuile/buffer.rb +113 -43
  20. data/lib/tuile/color.rb +4 -10
  21. data/lib/tuile/component/{text_input.rb → abstract_string_field.rb} +113 -20
  22. data/lib/tuile/component/button.rb +25 -29
  23. data/lib/tuile/component/checkbox.rb +133 -0
  24. data/lib/tuile/component/checkbox_group.rb +188 -0
  25. data/lib/tuile/component/combo_box.rb +281 -0
  26. data/lib/tuile/component/has_caption.rb +44 -0
  27. data/lib/tuile/component/has_content.rb +5 -5
  28. data/lib/tuile/component/has_value.rb +64 -0
  29. data/lib/tuile/component/info_window.rb +4 -2
  30. data/lib/tuile/component/integer_field.rb +135 -0
  31. data/lib/tuile/component/label.rb +20 -25
  32. data/lib/tuile/component/layout.rb +3 -26
  33. data/lib/tuile/component/list.rb +8 -33
  34. data/lib/tuile/component/list_dropdown.rb +106 -0
  35. data/lib/tuile/component/log_window.rb +0 -14
  36. data/lib/tuile/component/password_field.rb +105 -0
  37. data/lib/tuile/component/popup.rb +70 -79
  38. data/lib/tuile/component/progress_bar.rb +278 -0
  39. data/lib/tuile/component/radio_group.rb +188 -0
  40. data/lib/tuile/component/text_area.rb +189 -65
  41. data/lib/tuile/component/text_field.rb +170 -32
  42. data/lib/tuile/component/text_view.rb +57 -137
  43. data/lib/tuile/component/window.rb +88 -121
  44. data/lib/tuile/component.rb +246 -142
  45. data/lib/tuile/event_queue.rb +39 -21
  46. data/lib/tuile/fake_event_queue.rb +32 -7
  47. data/lib/tuile/fake_screen.rb +4 -5
  48. data/lib/tuile/fraction.rb +42 -0
  49. data/lib/tuile/screen.rb +210 -109
  50. data/lib/tuile/screen_pane.rb +56 -44
  51. data/lib/tuile/styled_string.rb +112 -83
  52. data/lib/tuile/theme.rb +78 -41
  53. data/lib/tuile/version.rb +1 -1
  54. data/sig/tuile.rbs +2291 -890
  55. metadata +28 -9
  56. data/ideas/back-buffer.md +0 -217
  57. data/lib/tuile/sizing.rb +0 -59
@@ -3,31 +3,25 @@
3
3
  module Tuile
4
4
  class Component
5
5
  # A read-only viewer for prose: chunks of formatted text that scroll
6
- # vertically. Shape-wise a hybrid between {Label} (string-shaped content
7
- # via {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
6
+ # vertically. Shape-wise a hybrid between {Label} (string content via
7
+ # {#text=}) and {List} (scroll keys, optional scrollbar, auto-scroll).
8
8
  #
9
- # Text is modeled as a {StyledString}: embedded `\n` are hard line breaks,
10
- # lines wider than the viewport are word-wrapped via {StyledString#wrap}
11
- # (style spans are preserved across wrap boundaries — unlike the older
12
- # ANSI-as-bytes wrapping, color does *not* get dropped on continuation
13
- # rows). {#text=} accepts a {String} (parsed via {StyledString.parse},
14
- # so embedded ANSI is honored) or a {StyledString} directly; {#text}
15
- # always returns the {StyledString}.
9
+ # Text is a {StyledString}: embedded `\n` are hard line breaks, longer lines
10
+ # are word-wrapped via {StyledString#wrap} with style spans preserved across
11
+ # wrap boundaries. {#text=} takes a {String} (parsed via {StyledString.parse},
12
+ # honoring embedded ANSI) or a {StyledString}; {#text} always returns the
13
+ # {StyledString}.
16
14
  #
17
- # For incremental updates pick the right primitive: {#append} (aliased
18
- # as `<<`) is verbatim and stream-friendly — chunks are concatenated
19
- # straight onto the buffer, with embedded `\n` becoming hard breaks.
20
- # {#add_line} is the "log entry" convenience — it starts the content on
21
- # a fresh line by inserting a leading `\n` when the buffer is non-empty.
22
- # {#remove_last_n_lines} pops hard lines back off the tail — the
23
- # inverse of building up a region with {#append} / {#add_line}, so a
24
- # caller streaming reformattable content (e.g. partially-rendered
25
- # Markdown that may need to retract its last paragraph) can replace
26
- # the tail without rewriting the whole text. Turn on {#auto_scroll}
27
- # to keep the latest content in view.
15
+ # Pick the right incremental primitive: {#append} (aliased `<<`) concatenates
16
+ # a chunk verbatim onto the buffer (stream-friendly, `\n` → hard breaks);
17
+ # {#add_line} starts the chunk on a fresh line (the "log entry" convenience);
18
+ # {#remove_last_n_lines} pops hard lines off the tail, so a caller streaming
19
+ # reformattable content can retract and rewrite it; {#replace} / {#insert}
20
+ # splice a range in place. Turn on {#auto_scroll} to keep the latest content
21
+ # in view.
28
22
  #
29
- # TextView is meant to be the content of a {Window} — focus indication and
30
- # keyboard-hint surfacing rely on the surrounding window chrome.
23
+ # Meant to be the content of a {Window} — focus indication and keyboard-hint
24
+ # surfacing rely on the surrounding window chrome.
31
25
  class TextView < Component
32
26
  def initialize
33
27
  super
@@ -55,21 +49,14 @@ module Tuile
55
49
  @auto_scroll = false
56
50
  @follow = true
57
51
  @scrollbar_visibility = :gone
58
- # The view always has at least one region — an implicit default. It
59
- # owns whatever hard lines exist that no later region claims. App
60
- # code that never calls {#create_region} sees the same behavior as
61
- # before (a single region containing everything); apps that do call
62
- # {#create_region} stack additional regions at the spatial tail.
52
+ # Always ≥1 region; the implicit default owns any hard lines no
53
+ # app-created region claims. See {Region}.
63
54
  @regions = [Region.send(:new, self)]
64
55
  end
65
56
 
66
- # @return [StyledString] the current text. Defaults to an empty
67
- # {StyledString}. Internally the text is stored as an array of hard
68
- # lines so {#append} can stay O(appended) instead of re-scanning the
69
- # whole buffer; the joined {StyledString} returned here is
70
- # reconstructed on first read after a mutation and cached, so
71
- # repeated reads are O(1) but the first read after {#append} pays
72
- # O(total spans).
57
+ # @return [StyledString] the current text (empty by default). Rebuilt
58
+ # lazily on the first read after a mutation (O(total spans)), then
59
+ # cached — repeated reads are O(1).
73
60
  def text
74
61
  @text ||= build_text
75
62
  end
@@ -122,7 +109,6 @@ module Tuile
122
109
  rewrap
123
110
  update_top_line_if_auto_scroll
124
111
  invalidate
125
- self.content_size = compute_content_size
126
112
  end
127
113
 
128
114
  # Creates a new empty {Region} at the spatial tail of the document
@@ -145,19 +131,12 @@ module Tuile
145
131
  # @return [Boolean] true iff {#text} is empty (no hard lines).
146
132
  def empty? = @hard_lines.empty?
147
133
 
148
- # Appends `str` verbatim. Embedded `\n` characters become hard line
149
- # breaks; otherwise the text is concatenated onto the current last
150
- # hard line. Designed for streaming use (e.g. an LLM chat window
151
- # receiving partial messages — feed each chunk straight in). Accepts
152
- # the same input forms as {#text=}; empty/`nil` input is a no-op.
153
- #
154
- # For the "add an entry on a new line" pattern use {#add_line}.
155
- #
156
- # Cost is O(appended + width-of-current-last-hard-line) — the
157
- # previously last hard line is re-wrapped (because the extension may
158
- # cause it to wrap differently), any additional hard lines created by
159
- # embedded `\n` are wrapped fresh. The cached {#text} is invalidated
160
- # and rebuilt on demand.
134
+ # Appends `str` verbatim. Embedded `\n` become hard line breaks; otherwise
135
+ # the text is concatenated onto the current last hard line. Designed for
136
+ # streaming use (feed each partial chunk straight in). Accepts the same
137
+ # input forms as {#text=}; empty/`nil` is a no-op. For the "entry on a new
138
+ # line" pattern use {#add_line}. Cost is O(appended + width of the last
139
+ # hard line), which is re-wrapped since the extension may wrap differently.
161
140
  # @param str [String, StyledString, nil]
162
141
  # @return [void]
163
142
  def append(str)
@@ -191,7 +170,6 @@ module Tuile
191
170
  @text = nil
192
171
  update_top_line_if_auto_scroll
193
172
  invalidate
194
- self.content_size = compute_content_size
195
173
  end
196
174
 
197
175
  # Verbatim append, returning `self` for chainability (`view << a << b`).
@@ -223,19 +201,11 @@ module Tuile
223
201
  end
224
202
  end
225
203
 
226
- # Drops the last `n` hard lines from the buffer. The inverse of
227
- # building up a tail region with {#append} / {#add_line}: a caller
228
- # streaming partially-rendered content whose tail must occasionally
229
- # be retracted (e.g. Markdown-to-ANSI where a new token reformats
230
- # the table being built) can call `remove_last_n_lines(k)` followed
231
- # by `append(new_tail)` to replace the damaged region in place.
232
- #
233
- # `n == 0` and the empty-buffer case are no-ops (no invalidation).
234
- # `n >= hard-line count` empties the buffer.
235
- #
236
- # Operates on **hard lines** (the `\n`-delimited entries the
237
- # buffer stores), not on wrapped physical rows — same granularity
238
- # as {#add_line}. Cost is O(rendered-rows of the popped lines).
204
+ # Drops the last `n` hard lines from the buffer — the inverse of building
205
+ # up a tail with {#append} / {#add_line}, so a caller can `remove` then
206
+ # `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
208
+ # empty buffer are no-ops; `n >= hard-line count` empties the buffer.
239
209
  # @param n [Integer] number of hard lines to drop; must be >= 0.
240
210
  # @raise [TypeError] if `n` isn't an `Integer`.
241
211
  # @raise [ArgumentError] if `n` is negative.
@@ -266,43 +236,23 @@ module Tuile
266
236
  @top_line = top_line_max if @top_line > top_line_max
267
237
  update_top_line_if_auto_scroll
268
238
  invalidate
269
- self.content_size = compute_content_size
270
239
  end
271
240
 
272
- # Replaces a contiguous range of hard lines with the parsed content
273
- # of `str`. The replacement is parsed exactly like {#text=} and
274
- # {#append}: a {String} is run through {StyledString.parse} (so
275
- # embedded ANSI is honored), a {StyledString} is used as-is, `nil`
276
- # behaves like an empty replacement (the range is deleted). Embedded
277
- # `"\n"` in the replacement produces multiple hard lines, so a single
278
- # `replace` can grow or shrink the buffer.
279
- #
280
- # `range` selects which hard lines to swap out:
281
- #
282
- # - an `Integer` `n` is shorthand for `n..n` (replace one existing
283
- # line — `n` must be in `[0, hard-line count)`);
284
- # - a non-empty `Range` of hard-line indices replaces those lines;
285
- # - an empty `Range` (e.g. `2...2`, or the canonical end-insertion
286
- # `hard_lines.size...hard_lines.size`) is *insertion* at that
287
- # position — no lines are removed. {#insert} is a thin alias for
288
- # this case.
241
+ # Replaces a contiguous range of hard lines with the parsed content of
242
+ # `str` (parsed like {#text=}: `String` → {StyledString.parse}, `nil` →
243
+ # empty, so `nil` deletes the range). Embedded `"\n"` yields multiple hard
244
+ # lines, so one `replace` can grow or shrink the buffer. `range` selects
245
+ # which hard lines to swap out:
289
246
  #
290
- # Endpoints must be non-negative integers; `begin` may equal
291
- # `hard-line count` (insertion at the end), `end` may not exceed
292
- # `hard-line count - 1`. `nil` endpoints (beginless / endless ranges)
293
- # are not accepted.
294
- #
295
- # Cost is roughly `O(from + length + new content)`: the splice
296
- # updates only the affected slice of the physical-row buffer, using
297
- # the per-hard-line wrap-count cache to locate the starting offset
298
- # without re-wrapping preceding lines. Lines outside the splice are
299
- # never re-wrapped. {#top_line} is clamped if the new line count
300
- # puts it past the end; {#auto_scroll} pins it to the bottom as
301
- # usual. The call is a no-op (no invalidation) when the parsed
302
- # replacement equals the covered range (vacuously true for an empty
303
- # range plus empty replacement, so `replace(n...n, "")` is a cheap
304
- # no-op).
247
+ # - an `Integer` `n` is shorthand for `n..n` (replace one existing line);
248
+ # - a non-empty `Range` replaces those lines;
249
+ # - an empty `Range` (e.g. `2...2`, or `size...size` at the end) is
250
+ # *insertion* at that position — nothing removed. {#insert} aliases this.
305
251
  #
252
+ # Splices in place — only the affected slice of the physical-row buffer is
253
+ # touched, no preceding lines re-wrapped (cost O(from + length + new
254
+ # content)). A no-op when the replacement equals the covered range, so
255
+ # `replace(n...n, "")` is cheap.
306
256
  # @param range [Range, Integer] hard-line indices to replace.
307
257
  # @param str [String, StyledString, nil] replacement content.
308
258
  # @raise [TypeError] if `range` is neither an `Integer` nor a `Range`,
@@ -327,7 +277,6 @@ module Tuile
327
277
  @top_line = top_line_max if @top_line > top_line_max
328
278
  update_top_line_if_auto_scroll
329
279
  invalidate
330
- self.content_size = compute_content_size
331
280
  end
332
281
 
333
282
  # Inserts `str` at hard-line index `at`. Equivalent to
@@ -386,13 +335,6 @@ module Tuile
386
335
 
387
336
  def tab_stop? = true
388
337
 
389
- # @return [Size] longest hard-line's display width × number of hard
390
- # lines. Reported on the *unwrapped* text — wrap-aware sizing would
391
- # be circular (width depends on width). Empty text returns
392
- # `Size.new(0, 0)`. Maintained incrementally by {#text=} and
393
- # {#append}, so reads are O(1).
394
- attr_reader :content_size
395
-
396
338
  # @param key [String]
397
339
  # @return [Boolean]
398
340
  def handle_key(key)
@@ -428,6 +370,8 @@ module Tuile
428
370
  # Skips the {Component#repaint} default's auto-clear: every row is
429
371
  # painted explicitly (with padded blanks past the last line), so the
430
372
  # "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
374
+ # {Component#effective_bg_color} (a {#bg_color} set here or on an ancestor).
431
375
  # @return [void]
432
376
  def repaint
433
377
  return if rect.empty?
@@ -437,7 +381,7 @@ module Tuile
437
381
  end
438
382
  (0...rect.height).each do |row|
439
383
  line = paintable_line(row + @top_line, row, scrollbar)
440
- screen.buffer.set_line(rect.left, rect.top + row, line)
384
+ draw_line(rect.left, rect.top + row, line)
441
385
  end
442
386
  end
443
387
 
@@ -544,7 +488,6 @@ module Tuile
544
488
  @top_line = top_line_max if @top_line > top_line_max
545
489
  update_top_line_if_auto_scroll
546
490
  invalidate
547
- self.content_size = compute_content_size
548
491
  end
549
492
 
550
493
  # Region-scoped {#replace}. Validates `range` against
@@ -570,7 +513,6 @@ module Tuile
570
513
  @top_line = top_line_max if @top_line > top_line_max
571
514
  update_top_line_if_auto_scroll
572
515
  invalidate
573
- self.content_size = compute_content_size
574
516
  end
575
517
 
576
518
  # Verbatim append into `region`.
@@ -610,7 +552,6 @@ module Tuile
610
552
  @top_line = top_line_max if @top_line > top_line_max
611
553
  update_top_line_if_auto_scroll
612
554
  invalidate
613
- self.content_size = compute_content_size
614
555
  end
615
556
 
616
557
  # Drops the last `n` hard lines from `region`'s tail via
@@ -632,7 +573,6 @@ module Tuile
632
573
  @top_line = top_line_max if @top_line > top_line_max
633
574
  update_top_line_if_auto_scroll
634
575
  invalidate
635
- self.content_size = compute_content_size
636
576
  end
637
577
 
638
578
  # Drops `region` from {@regions}: its hard lines are removed via
@@ -658,22 +598,14 @@ module Tuile
658
598
  @top_line = top_line_max if @top_line > top_line_max
659
599
  update_top_line_if_auto_scroll
660
600
  invalidate
661
- self.content_size = compute_content_size
662
601
  end
663
602
 
664
- # Adjusts region line counts after a {@hard_lines} splice that
665
- # removed `removed_count` lines at index `from` and inserted
666
- # `added_count` in their place. Two passes:
667
- #
668
- # 1. Subtract each region's overlap with the removed range (uses
669
- # the original counts to compute positions). Remember the first
670
- # region that lost lines — that's the natural home for the
671
- # replacement content.
672
- # 2. Credit `added_count` to that region. For pure insertions (no
673
- # removal), there's no "first overlapping region" to pick from;
674
- # walk regions and credit the latest one starting at `from` (the
675
- # boundary tiebreaker matches the spatial-tail-routing of
676
- # {#append}). Past-the-end inserts fall back to the tail region.
603
+ # Adjusts region line counts after a {@hard_lines} splice that removed
604
+ # `removed_count` lines at `from` and inserted `added_count`. Subtracts
605
+ # each region's overlap with the removed range, then credits the added
606
+ # lines to the first region that lost lines. Pure insertions have no such
607
+ # region — they credit the latest region starting at `from`, matching
608
+ # {#append}'s spatial-tail routing (past-the-end falls back to the tail).
677
609
  # @param from [Integer]
678
610
  # @param removed_count [Integer]
679
611
  # @param added_count [Integer]
@@ -832,13 +764,6 @@ module Tuile
832
764
  StyledString.new(spans)
833
765
  end
834
766
 
835
- # @return [Size] {#content_size} computed from {@hard_lines}.
836
- def compute_content_size
837
- return Size::ZERO if @hard_lines.empty?
838
-
839
- Size.new(@hard_lines.map(&:display_width).max || 0, @hard_lines.size)
840
- end
841
-
842
767
  # @return [Integer] column width available for wrapped text — viewport
843
768
  # width minus the scrollbar gutter (when visible). `0` when {#rect}'s
844
769
  # width is non-positive, which yields a degenerate "no wrap" result.
@@ -979,14 +904,9 @@ module Tuile
979
904
  end
980
905
 
981
906
  # Verbatim append into this region's tail. Same semantics as
982
- # {TextView#append} but scoped to the region: embedded `"\n"`
983
- # creates new hard lines within the region, no-leading-newline
984
- # input extends the region's last hard line. Empty / `nil` input
985
- # is a no-op (but still raises when detached). When the region is
986
- # the spatial tail of the view, this uses the incremental
987
- # {TextView#append} path; mid-document regions splice the affected
988
- # slice of the physical-row buffer (lines outside the region are
989
- # not re-wrapped).
907
+ # {TextView#append} but scoped: embedded `"\n"` creates new hard lines
908
+ # within the region, other input extends the region's last hard line.
909
+ # Empty / `nil` is a no-op (but still raises when detached).
990
910
  # @param str [String, StyledString, nil]
991
911
  # @raise [RuntimeError] when the region is detached.
992
912
  # @return [void]
@@ -13,45 +13,55 @@ module Tuile
13
13
  # by {Component#invalidate}; subclasses don't need to re-check.)
14
14
  class Window < Component
15
15
  include Component::HasContent
16
+ include Component::HasCaption
16
17
 
17
- # @param caption [String]
18
- def initialize(caption = "")
18
+ # @param caption [String, StyledString, nil] the border title, coerced
19
+ # the same way {HasCaption#caption=} coerces it.
20
+ def initialize(caption = nil)
19
21
  super()
20
22
  @border_right = 1
21
- @caption = caption
23
+ self.caption = caption
22
24
  @content = nil
23
- # Optional bottom-row chrome that overlays the bottom border (e.g. a
24
- # search field).
25
+ # Optional bottom-row widget slot (e.g. a search field), spanning the
26
+ # full inner width; and optional bottom-border chrome text embedded in
27
+ # the border line (mutually exclusive — the component, when present,
28
+ # occupies the row and hides the text).
25
29
  @footer = nil
26
- @footer_sizing = Sizing::FILL
27
- update_content_size
30
+ @footer_text = StyledString::EMPTY
28
31
  end
29
32
 
30
33
  def focusable? = true
31
34
 
32
- # @return [Component, nil] optional component overlaying the bottom border
33
- # row.
35
+ # @return [Component, nil] optional focusable component occupying the
36
+ # bottom border row, always spanning the full inner width.
34
37
  attr_reader :footer
35
38
 
36
- # @return [Sizing] how the footer's width is computed from the window's
37
- # inner width; defaults to {Sizing::FILL} (the footer spans the full
38
- # inner width). The footer's height is always 1 (the border row).
39
- attr_reader :footer_sizing
40
-
41
- # Sets the footer width policy and re-lays-out the footer.
42
- # @param sizing [Sizing]
43
- def footer_sizing=(sizing)
44
- raise TypeError, "expected Sizing, got #{sizing.inspect}" unless sizing.is_a?(Sizing)
45
- return if @footer_sizing == sizing
46
-
47
- @footer_sizing = sizing
48
- layout_footer
49
- invalidate # repaint border cells the footer may have just vacated
50
- end
51
-
52
- # Sets the bottom-row chrome slot. The footer overlays the bottom border
53
- # row and is positioned automatically — its width is governed by
54
- # {#footer_sizing}; pass `nil` to remove.
39
+ # @return [StyledString] optional chrome embedded into the bottom border
40
+ # line, mirroring {#caption} on the top line. Empty by default; hidden
41
+ # whenever a {#footer} component is present.
42
+ attr_reader :footer_text
43
+
44
+ # Sets the bottom-border chrome. Accepts a `String` (parsed via
45
+ # {StyledString.parse}), a {StyledString}, or `nil` (clears it). The text
46
+ # embeds into the bottom border at its own width with the border's dashes
47
+ # filling the remainder, clipped to the inner width — border decoration,
48
+ # not a component (not focusable). Hidden whenever a {#footer} component
49
+ # occupies the bottom row (see the precedence note on {#footer=}).
50
+ # @param text [String, StyledString, nil]
51
+ def footer_text=(text)
52
+ new_text = StyledString.parse(text)
53
+ return if @footer_text == new_text
54
+
55
+ @footer_text = new_text
56
+ invalidate # repaint the bottom border line
57
+ end
58
+
59
+ # Sets the bottom-row widget slot. The footer occupies the bottom border
60
+ # row, spanning the full inner width, and is positioned automatically;
61
+ # pass `nil` to remove.
62
+ #
63
+ # Precedence: a footer component present hides {#footer_text}; absent, the
64
+ # text embeds into the bottom border. No window needs both at once.
55
65
  #
56
66
  # Symmetric to {#content=}: validates the new component, swaps parent
57
67
  # pointers, invalidates the old/new components and the window border, and
@@ -67,10 +77,12 @@ module Tuile
67
77
  end
68
78
 
69
79
  old = @footer
70
- old&.parent = nil
80
+ # Same slot-swap order as HasContent#content=: notified last, so the
81
+ # focus repair cascades into the new occupant rather than the old.
82
+ detach_child(old) unless old.nil?
71
83
  @footer = new_footer
72
84
  unless new_footer.nil?
73
- new_footer.parent = self
85
+ add_child(new_footer) # appended: the footer paints over the border row
74
86
  new_footer.invalidate
75
87
  layout_footer
76
88
  end
@@ -78,11 +90,6 @@ module Tuile
78
90
  on_child_removed(old) unless old.nil?
79
91
  end
80
92
 
81
- # @return [Array<Component>]
82
- def children
83
- @footer.nil? ? super : super + [@footer]
84
- end
85
-
86
93
  # @param event [MouseEvent]
87
94
  # @return [void]
88
95
  def handle_mouse(event)
@@ -114,46 +121,6 @@ module Tuile
114
121
  layout(content)
115
122
  end
116
123
 
117
- # @return [String] the current caption, empty by default.
118
- attr_reader :caption
119
-
120
- # Sets new caption and repaints the window.
121
- # @param new_caption [String]
122
- def caption=(new_caption)
123
- @caption = new_caption
124
- invalidate
125
- update_content_size
126
- end
127
-
128
- # Sets the new content. Also recomputes the window's natural size.
129
- # @param new_content [Component, nil]
130
- def content=(new_content)
131
- super
132
- update_content_size
133
- end
134
-
135
- # Re-lays-out a {Sizing::WRAP_CONTENT} footer when the footer's natural
136
- # size changes, and folds a content resize into the window's own
137
- # natural size (whose change then bubbles to the window's parent — e.g.
138
- # a {Popup} re-self-sizes). The footer deliberately does *not*
139
- # participate in the window's {#content_size}: it is decoration
140
- # overlaying the border, and must not drive the window's size — if it
141
- # doesn't fit, it is clipped to the inner width.
142
- # @param child [Component]
143
- # @return [void]
144
- def on_child_content_size_changed(child)
145
- if child.equal?(@footer)
146
- old_rect = @footer.rect
147
- layout_footer
148
- # Repaint on any footer geometry change: a shrinking footer vacates
149
- # border cells that must be re-dashed (a growing one merely
150
- # overdraws, but distinguishing isn't worth the code).
151
- invalidate if @footer.rect != old_rect
152
- else
153
- update_content_size
154
- end
155
- end
156
-
157
124
  # Fully repaints the window: both frame and contents.
158
125
  #
159
126
  # Window deliberately paints over its entire rect (border around the
@@ -172,15 +139,6 @@ module Tuile
172
139
  repaint_border
173
140
  end
174
141
 
175
- # @param key [String, nil]
176
- # @return [void]
177
- def key_shortcut=(key)
178
- super
179
- # The shortcut key is shown in the caption — repaint.
180
- invalidate
181
- update_content_size
182
- end
183
-
184
142
  protected
185
143
 
186
144
  # @param content [Component]
@@ -189,9 +147,12 @@ module Tuile
189
147
  content.rect = Rect.new(rect.left + 1, rect.top + 1, rect.width - 1 - @border_right, rect.height - 2)
190
148
  end
191
149
 
192
- # Paints the window border into the {Screen#buffer}. Title is clipped to
193
- # the inner width so the box never overflows {#rect}; when the window is
194
- # active the whole border is drawn in {Theme#active_border_color}.
150
+ # Paints the window border via {Component#draw_line}/{Component#draw_char},
151
+ # so the border cells inherit {Component#effective_bg_color} — a
152
+ # {Component#bg_color} on the window tints border and content alike. Both
153
+ # border lines are clipped by *display* width, so no caption overflows the
154
+ # box; when the window is active the whole border — the caption's own
155
+ # colors included — is drawn in {Theme#active_border_color}.
195
156
  # @return [void]
196
157
  def repaint_border
197
158
  return if rect.empty?
@@ -201,53 +162,59 @@ module Tuile
201
162
  top = rect.top
202
163
  left = rect.left
203
164
  inner_w = [w - 2, 0].max
204
- title = frame_caption.to_s
205
- title = title[0, inner_w] if title.length > inner_w
206
- dashes = "─" * (inner_w - title.length)
207
165
 
208
166
  fg = active? ? screen.theme.active_border_color : nil
209
167
  bar = StyledString::Style.new(fg: fg)
210
- buf = screen.buffer
211
- buf.set_line(left, top, StyledString.styled("┌#{title}#{dashes}┐", fg: fg))
168
+ draw_line(left, top, top_border(inner_w, fg).slice(0, w))
212
169
  (1..(h - 2)).each do |dy|
213
- buf.set_char(left, top + dy, "│", bar)
214
- buf.set_char(left + w - 1, top + dy, "│", bar)
170
+ draw_char(left, top + dy, "│", bar)
171
+ draw_char(left + w - 1, top + dy, "│", bar)
215
172
  end
216
- buf.set_line(left, top + h - 1, StyledString.styled("└#{"─" * inner_w}┘", fg: fg)) if h >= 2
217
- end
218
-
219
- # The caption text as it appears in the rendered border, including the
220
- # shortcut prefix when {#key_shortcut} is set.
221
- # @return [String]
222
- def frame_caption
223
- c = @caption || ""
224
- key_shortcut.nil? ? c : "[#{key_shortcut}]-#{c}"
173
+ draw_line(left, top + h - 1, bottom_border(inner_w, fg).slice(0, w)) if h >= 2
174
+ end
175
+
176
+ # Builds the top border line: corners, {#caption} embedded at its own
177
+ # width, dashes filling the remainder. The caption keeps its own styling
178
+ # unless `fg` is set — an active window's border claims it.
179
+ # @param inner_w [Integer] the border's interior width.
180
+ # @param fg [Color, nil] the active-border color, or nil when inactive.
181
+ # @return [StyledString]
182
+ def top_border(inner_w, fg)
183
+ title = caption.slice(0, inner_w)
184
+ title = title.with_fg(fg) if fg
185
+ dashes = StyledString.styled("─" * (inner_w - title.display_width), fg: fg)
186
+ StyledString.styled("┌", fg: fg) + title + dashes + StyledString.styled("┐", fg: fg)
187
+ end
188
+
189
+ # Builds the bottom border line. The corners take the border color; the
190
+ # interior is plain dashes when a {#footer} component occupies the row
191
+ # (it overpaints them) or when there's no chrome, otherwise it carries
192
+ # {#footer_text} embedded at its own width — keeping the text's own
193
+ # styling — with dashes filling the remainder up to the inner width.
194
+ # @param inner_w [Integer] the border's interior width.
195
+ # @param fg [Color, nil] the active-border color, or nil when inactive.
196
+ # @return [StyledString]
197
+ def bottom_border(inner_w, fg)
198
+ interior =
199
+ if @footer || @footer_text.empty?
200
+ StyledString.styled("─" * inner_w, fg: fg)
201
+ else
202
+ embedded = @footer_text.slice(0, inner_w)
203
+ embedded + StyledString.styled("─" * (inner_w - embedded.display_width), fg: fg)
204
+ end
205
+ StyledString.styled("└", fg: fg) + interior + StyledString.styled("┘", fg: fg)
225
206
  end
226
207
 
227
208
  private
228
209
 
229
- # Recomputes the window's natural size: content's natural size (or the
230
- # caption, whichever is wider) plus the 2-character border. The footer
231
- # is deliberately excluded — see {#on_child_content_size_changed}. A
232
- # window with no content or caption sizes to `Size.new(2, 2)` (bare
233
- # border).
234
- # @return [void]
235
- def update_content_size
236
- inner_w = [content&.content_size&.width || 0, frame_caption.length].max
237
- inner_h = content&.content_size&.height || 0
238
- self.content_size = Size.new(inner_w + 2, inner_h + 2)
239
- end
240
-
241
- # Positions the footer over the bottom border row, with its width
242
- # resolved by {#footer_sizing} against the inner width. A
243
- # {Sizing::WRAP_CONTENT} footer with zero natural width gets an empty
244
- # rect — i.e. it is invisible, as if never assigned.
210
+ # Positions the footer over the bottom border row, spanning the full
211
+ # inner width (the only dimension a bottom-row widget needs — the window
212
+ # already knows it).
245
213
  # @return [void]
246
214
  def layout_footer
247
215
  return if @footer.nil? || rect.empty?
248
216
 
249
- available = [rect.width - 2, 0].max
250
- width = @footer_sizing.resolve(available, @footer.content_size.width)
217
+ width = [rect.width - 2, 0].max
251
218
  @footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
252
219
  end
253
220
  end