tuile 0.13.0 → 0.14.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 (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +81 -37
  3. data/COMPARISON.md +101 -0
  4. data/DECISIONS.md +1095 -195
  5. data/README.md +19 -19
  6. data/TERMINOLOGY.md +6 -5
  7. data/book/03-layout.md +17 -10
  8. data/book/05-focus.md +4 -1
  9. data/book/06-theming.md +98 -0
  10. data/book/07-components.md +169 -19
  11. data/book/08-testing.md +16 -0
  12. data/book/09-styled-text.md +3 -3
  13. data/examples/file_commander.rb +1 -1
  14. data/examples/sampler.rb +143 -43
  15. data/ideas/arrow-key-navigation.md +2 -2
  16. data/ideas/modal-backdrop.md +24 -0
  17. data/ideas/new-components.md +24 -24
  18. data/lib/tuile/buffer.rb +51 -3
  19. data/lib/tuile/color.rb +143 -0
  20. data/lib/tuile/color_depth.rb +80 -0
  21. data/lib/tuile/component/button.rb +3 -3
  22. data/lib/tuile/component/checkbox.rb +3 -3
  23. data/lib/tuile/component/combo_box.rb +9 -2
  24. data/lib/tuile/component/confirm_window.rb +442 -0
  25. data/lib/tuile/component/has_content.rb +22 -9
  26. data/lib/tuile/component/has_value.rb +1 -1
  27. data/lib/tuile/component/info_window.rb +64 -16
  28. data/lib/tuile/component/layout.rb +0 -10
  29. data/lib/tuile/component/list_dropdown.rb +18 -10
  30. data/lib/tuile/component/log_text_view.rb +71 -0
  31. data/lib/tuile/component/log_window.rb +13 -48
  32. data/lib/tuile/component/menu_bar/cascade.rb +3 -3
  33. data/lib/tuile/component/menu_bar.rb +5 -5
  34. data/lib/tuile/component/notification.rb +16 -34
  35. data/lib/tuile/component/overlay.rb +192 -0
  36. data/lib/tuile/component/popup.rb +59 -187
  37. data/lib/tuile/component/progress_bar.rb +1 -1
  38. data/lib/tuile/component/select.rb +14 -6
  39. data/lib/tuile/component/slot.rb +54 -0
  40. data/lib/tuile/component/tab_sheet.rb +0 -11
  41. data/lib/tuile/component/tabs.rb +5 -5
  42. data/lib/tuile/component/window.rb +22 -46
  43. data/lib/tuile/component.rb +149 -19
  44. data/lib/tuile/event_queue.rb +21 -1
  45. data/lib/tuile/fake_screen.rb +26 -2
  46. data/lib/tuile/keys.rb +7 -0
  47. data/lib/tuile/screen.rb +120 -38
  48. data/lib/tuile/screen_pane.rb +37 -35
  49. data/lib/tuile/styled_string.rb +40 -7
  50. data/lib/tuile/terminal_background.rb +74 -16
  51. data/lib/tuile/version.rb +1 -1
  52. data/sig/tuile.rbs +1157 -368
  53. metadata +8 -1
@@ -2,210 +2,98 @@
2
2
 
3
3
  module Tuile
4
4
  class Component
5
- # An overlay that wraps any {Component} as its content. Popup itself
6
- # paints nothing — it's a transparent host that handles its lifecycle
7
- # ({#open} / {#close} / {#open?}, ESC/q to close) and holds a top-down
8
- # {#size} the {Screen} applies.
9
- #
10
- # The popup does *not* size itself to its content. Its box is declared by
11
- # {#size} — a {Fraction} (resolved against the screen every layout pass, so
12
- # it tracks resize) or an absolute {Size} (clamped to the screen). The
13
- # default is {Fraction::HALF}: half the screen, centered. The wrapped
14
- # content then fills that box and handles its own overflow by wrapping and
15
- # scrolling, so use content that can — a {Component::TextView} or
16
- # {Component::TextArea} — for anything longer than fits. A
17
- # {Component::Label} only truncates.
18
- #
19
- # Modal by default: it centers on the screen, grabs focus, eats keys, and
20
- # blocks clicks beneath it. Pass `modal: false` for a non-modal overlay
21
- # that floats above the content without taking focus or capturing input —
22
- # the caller positions it (via {#rect=}), sizes it, and drives it from app
23
- # code. That's the building block for an autocomplete/slash-command list
24
- # anchored to a text field's caret: typing keeps focus in the input while
25
- # the caller refills and drives the overlay.
26
- #
27
- # The wrapped content fills the popup's full {#rect}; if you want a frame
28
- # and caption, wrap a {Component::Window} (or any subclass — including
29
- # {Component::LogWindow}) and let it draw its own border:
5
+ # A modal dialog: an {Component::Overlay} that centers itself on the screen,
6
+ # grabs focus, scopes keys to its own subtree, blocks clicks beneath it, and
7
+ # closes on ESC or `q`.
30
8
  #
31
9
  # window = Component::Window.new("Help")
32
10
  # window.content = Component::List.new.tap { _1.lines = lines }
33
11
  # Component::Popup.new(content: window).open
34
12
  #
35
13
  # Bare content also works (a {Component::Label}, a {Component::List}…), in
36
- # which case the popup is borderless.
14
+ # which case the popup is borderless. For a floating layer that does *not*
15
+ # take focus or capture input — an autocomplete list anchored to a field, a
16
+ # toast — use {Component::Overlay} directly.
17
+ #
18
+ # The popup does *not* size itself to its content. Its box is set by
19
+ # {#declared_size} — a {Fraction} (resolved against the screen every layout
20
+ # pass, so it tracks resize) or an absolute {Size} (clamped to the screen).
21
+ # The default is {Fraction::HALF}: half the screen, centered. The wrapped content
22
+ # then fills that box and handles its own overflow by wrapping and scrolling,
23
+ # so use content that can — a {Component::TextView} or
24
+ # {Component::TextArea} — for anything longer than fits. A
25
+ # {Component::Label} only truncates.
26
+ #
27
+ # == Implementation details
37
28
  #
38
29
  # `q` and ESC close the popup — handled here, at the top of the popup's own
39
30
  # subtree, so the key only arrives after every component on the focus chain
40
31
  # declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
41
- # nested {Component::TextField} doesn't dismiss the popup: the field
42
- # consumes it first.
32
+ # nested {Component::TextField} doesn't dismiss the popup: the field consumes
33
+ # it first.
43
34
  #
44
- # A left click *outside* the popup closes it too, modal or not — see
45
- # {#close_on_outside_click?} for the exact contract and {#on_close=} for
46
- # the notice a driver hears when it happens.
47
- class Popup < Component
48
- include Component::HasContent
49
-
35
+ # A left click *outside* the popup closes it too — see
36
+ # {Overlay#close_on_outside_click?} for the exact contract and
37
+ # {Overlay#on_close=} for the notice a driver hears when it happens.
38
+ #
39
+ # UI-thread-confined, like every component (see {Screen}).
40
+ class Popup < Overlay
50
41
  # @param content [Component, nil] initial content; can be set later via
51
42
  # {#content=}. The content fills the popup's {#rect}; it does not
52
43
  # determine the popup's size.
53
- # @param modal [Boolean] true (default) for a centered, focus-grabbing,
54
- # input-capturing modal; false for a non-modal overlay the caller
55
- # positions and drives (see the class docs).
56
- # @param size [Size, Fraction] the popup's size, applied top-down. A
57
- # {Fraction} is resolved against the screen each layout pass; a {Size}
44
+ # @param declared_size [Size, Fraction] the popup's box, applied top-down.
45
+ # A {Fraction} is resolved against the screen each layout pass; a {Size}
58
46
  # is clamped to the screen. Defaults to {Fraction::HALF}.
59
47
  # @param close_on_outside_click [Boolean] true (default) to dismiss on a
60
- # left click that misses this popup. See {#close_on_outside_click?}.
61
- def initialize(content: nil, modal: true, size: Fraction::HALF,
62
- close_on_outside_click: true)
63
- super()
64
- @modal = modal
65
- @size = size
66
- @close_on_outside_click = close_on_outside_click
67
- @owner = nil
68
- @on_close = nil
69
- @content = nil
70
- self.content = content unless content.nil?
48
+ # left click that misses this popup. See
49
+ # {Overlay#close_on_outside_click?}.
50
+ def initialize(content: nil, declared_size: Fraction::HALF, close_on_outside_click: true)
51
+ super(content: content, close_on_outside_click: close_on_outside_click)
52
+ @declared_size = declared_size
71
53
  reposition
72
54
  end
73
55
 
74
- # @return [Size, Fraction] the popup's declared size. See {#size=}.
75
- attr_reader :size
76
-
77
- # @return [Boolean] whether this popup is modal. See {#initialize}.
78
- def modal? = @modal
79
-
80
- # Whether a left click outside this popup closes it (default true, modal or
81
- # not). The pane does the closing — {ScreenPane#handle_mouse} snapshots
82
- # the open popups *before* routing the click and closes the dismissable
83
- # ones *after*, so a widget that toggles its own overlay from a click on
84
- # its face (a {Component::Select}, a {Component::MenuBar} title) still
85
- # toggles correctly: the delivered click closes the overlay and the
86
- # dismissal then no-ops on it, rather than closing and reopening it. Only
87
- # `:left` dismisses; scroll and right clicks never do.
88
- #
89
- # **"Outside" spans the {#owner} chain, not just this rect.** A click
90
- # counts as inside this popup when it lands in its rect *or* in any popup
91
- # that belongs to it — so a dialog is not dismissed by a click on a
92
- # dropdown its own field opened, and a menu cascade is not dismissed by a
93
- # click on one of its deeper panels. Popups with no owner relationship are
94
- # independent: clicking one dismisses the other, which is what a
95
- # window-like overlay should do. A popup that must survive unrelated
96
- # clicks entirely ({Component::Notification}) sets this false.
97
- #
98
- # Every dismissable popup closes, not just the topmost, and stacking order
99
- # plays no part: a {Component::MenuBar} cascade must vanish whole on one
100
- # click on the background, not peel one panel per click.
101
- # @return [Boolean]
102
- def close_on_outside_click? = @close_on_outside_click
103
-
104
- # @return [Boolean] see {#close_on_outside_click?}.
105
- attr_writer :close_on_outside_click
56
+ # The box this popup asks for, *as set* — a {Fraction} comes back
57
+ # unresolved. {#rect} is the resolved answer. Named apart from `size`
58
+ # because a component's `size` is its `rect.size`, which this is not: it
59
+ # is an input re-read on every layout pass, not a report of the current
60
+ # geometry.
61
+ # @return [Size, Fraction] see {#declared_size=}.
62
+ attr_reader :declared_size
106
63
 
107
- # The component this overlay is *part of*, or `nil` (the default) when it
108
- # is an overlay in its own right. It exists for outside-click dismissal:
109
- # a click inside this popup also counts as inside whatever popup encloses
110
- # its owner, so the host is not dismissed by a click on a panel it put
111
- # there. See {#close_on_outside_click?}.
112
- #
113
- # Set it to the *driver* — {Component::ComboBox} hands its dropdown
114
- # `self` — rather than to the enclosing popup: the driver knows what it
115
- # is, while the popup above it is a tree relationship the pane resolves
116
- # at click time (so it cannot go stale). Any {Component} is accepted, and
117
- # a `Popup` resolves to itself, which is how a
118
- # {Component::MenuBar::Cascade} chains each panel to the one it dropped
119
- # out of.
120
- # @return [Component, nil]
121
- attr_accessor :owner
122
-
123
- # A callback taking no arguments, fired once this popup has left the
124
- # screen — **however it left**: {#close}, a direct {Screen#remove_popup},
125
- # an outside click, or teardown via {Screen#close}. That unconditionality
126
- # is the point, so it hangs off {#on_detached} rather than {#close}; a
127
- # driver keeping its own record of open popups reconciles it here and
128
- # cannot drift ({Component::MenuBar::Cascade} is the worked example).
129
- #
130
- # It fires *after* the popup is detached, so {#open?} is already false and
131
- # the usual {Component#on_detached} caveats apply: release state, don't
132
- # inspect the tree, keep it trivial (it may run while the pane is mid-way
133
- # through closing a batch of popups, and a raise propagates).
134
- # @return [Proc, nil]
135
- attr_accessor :on_close
64
+ # @return [Boolean] true — a Popup scopes key dispatch, grabs focus on
65
+ # open, and blocks clicks on the content beneath it.
66
+ def modal? = true
136
67
 
68
+ # @return [Boolean] true — {ScreenPane#add_popup} focuses a popup on open,
69
+ # and focus repair falls back to it when its subtree has no tab stop.
137
70
  def focusable? = true
138
71
 
139
- # Sets the popup's size and repositions it. Accepts a {Fraction}
140
- # (resolved against the screen every layout pass, so it tracks resize) or
141
- # an absolute {Size} (clamped to the screen). This is **authoritative**,
142
- # not a preference: the screen applies exactly what you ask for (clamped),
72
+ # Sets the popup's box and repositions it. Accepts a {Fraction} (resolved
73
+ # against the screen every layout pass, so it tracks resize) or an
74
+ # absolute {Size} (clamped to the screen). This is **authoritative**, not
75
+ # a preference: the screen applies exactly what you ask for (clamped),
143
76
  # with no negotiation — a popup has no siblings to compete with.
144
77
  # @param new_size [Size, Fraction]
145
78
  # @return [void]
146
- def size=(new_size)
147
- @size = new_size
79
+ def declared_size=(new_size)
80
+ @declared_size = new_size
148
81
  reposition
149
82
  end
150
83
 
151
- # Reassigns the popup's rect, escalating to a full scene repaint when an
152
- # open popup shrinks or moves so its new rect no longer covers the cells
153
- # it previously painted. A popup overdraws the scene without clipping and
154
- # nothing clears underneath it, so {Screen#repaint}'s popup-only fast path
155
- # would repaint into the new rect and leave the vacated cells showing
156
- # stale content. When the new rect fully covers the old one (the popup
157
- # only grew), the fast path is correct and the full repaint is skipped.
158
- # @param new_rect [Rect]
159
- # @return [void]
160
- def rect=(new_rect)
161
- old_rect = rect
162
- super
163
- screen.needs_full_repaint if open? && !new_rect.contains_rect?(old_rect)
164
- end
165
-
166
- # Mounts this popup on the {Screen}, re-resolving its {#size} against the
167
- # current screen first.
168
- #
169
- # popup = Component::Popup.new(content: window).open # construct and mount
84
+ # Re-resolves {#declared_size} against the current screen and recenters the popup
85
+ # *itself* (this is not laying out content — the popup's own rect). Called
86
+ # on {Overlay#open}, on {#declared_size=}, and by the screen's layout pass,
87
+ # so a {Fraction} tracks SIGWINCH.
170
88
  #
171
- # There is deliberately no class-level `Popup.open` factory — see
172
- # `DECISIONS.md` `D-popup-open`; returning `self` is what keeps the
173
- # one-liner above available without one.
174
- # @return [self]
175
- def open
176
- reposition
177
- screen.add_popup(self)
178
- self
179
- end
180
-
181
- # Removes this popup from the {Screen}. No-op if not currently open.
182
- # @return [void]
183
- def close
184
- screen.remove_popup(self)
185
- end
186
-
187
- # @return [Boolean] true if this popup is currently mounted on the screen.
188
- def open?
189
- screen.has_popup?(self)
190
- end
191
-
192
- # Re-resolves {#size} against the current screen and repositions the popup
193
- # *itself* (this is not laying out content — the popup's own rect): a
194
- # modal popup recenters; a non-modal overlay keeps its caller-assigned
195
- # top-left (only its size follows the screen). Called on {#open}, on
196
- # {#size=}, and by the screen's layout pass (so a {Fraction} size tracks
197
- # SIGWINCH).
198
- #
199
- # The final rect is computed and assigned in one step rather than sizing
200
- # at the origin and then centering: the intermediate origin rect rarely
201
- # covers the previous one, which would make {#rect=}'s shrink/move
89
+ # The final rect is computed and assigned in one step rather than sizing at
90
+ # the origin and then centering: the intermediate origin rect rarely covers
91
+ # the previous one, which would make {Overlay#rect=}'s shrink/move
202
92
  # detection fire a full repaint on every resize.
203
93
  # @return [void]
204
94
  def reposition
205
- size = @size.is_a?(Fraction) ? @size.resolve(screen.size) : @size.clamp(screen.size)
206
- r = Rect.new(rect.left, rect.top, size.width, size.height)
207
- r = r.centered(screen.size) if modal?
208
- self.rect = r
95
+ size = @declared_size.is_a?(Fraction) ? @declared_size.resolve(screen.size) : @declared_size.clamp(screen.size)
96
+ self.rect = Rect.new(rect.left, rect.top, size.width, size.height).centered(screen.size)
209
97
  end
210
98
 
211
99
  # Recenters the popup on the screen, preserving its current width/height.
@@ -227,22 +115,6 @@ module Tuile
227
115
  false
228
116
  end
229
117
  end
230
-
231
- # Fires {#on_close}. A subclass overriding this **must** call `super`, or
232
- # the popup's driver never hears that it closed.
233
- # @return [void]
234
- def on_detached
235
- @on_close&.call
236
- end
237
-
238
- protected
239
-
240
- # Content fills the popup's full rect — Popup has no border to subtract.
241
- # @param content [Component]
242
- # @return [void]
243
- def layout(content)
244
- content.rect = rect
245
- end
246
118
  end
247
119
  end
248
120
  end
@@ -38,7 +38,7 @@ module Tuile
38
38
  # The `█`/`░` pair is the same one {VerticalScrollBar} uses — East-Asian
39
39
  # Ambiguous and Neutral respectively, so under an ambiguous-as-wide terminal
40
40
  # the rendered length would vary with the fill level. Shipped anyway, per
41
- # `DECISIONS.md` `D-ambiguous-width`: a bar that rhymes with the scrollbar
41
+ # `DECISIONS.md` `D_ambiguous_width`: a bar that rhymes with the scrollbar
42
42
  # beats a third convention, and if that bet is ever reversed both swap
43
43
  # together.
44
44
  class ProgressBar < Component
@@ -42,7 +42,7 @@ module Tuile
42
42
  # Home/End are declined too, so they stay available app-wide.
43
43
  #
44
44
  # There is no type-ahead: a hidden prefix buffer *is* the ComboBox query with
45
- # the feedback removed (`DECISIONS.md` `D-select`). Which is also why labels
45
+ # the feedback removed (`DECISIONS.md` `D_select`). Which is also why labels
46
46
  # need no prefix-disambiguation.
47
47
  #
48
48
  # == Implementation details
@@ -156,24 +156,32 @@ module Tuile
156
156
  end
157
157
  end
158
158
 
159
- # Toggles the dropdown on a left click anywhere in {#rect} — a field's
159
+ # The one row this Select paints — the full width, at the top of {#rect}.
160
+ # A single-slot container ({Component::Window}, {Component::Popup}) hands
161
+ # its content the whole inner rect, so a Select is routinely assigned more
162
+ # height than it uses; {#repaint} clears that tail, {#handle_mouse} refuses
163
+ # clicks in it, and the dropdown hangs under this rather than under the
164
+ # unused space.
165
+ # @return [Size]
166
+ def extent = Size.new(rect.width, 1)
167
+
168
+ # Toggles the dropdown on a left click anywhere in {#extent} — a field's
160
169
  # affordance is its whole row, as the well advertises; `super` runs first,
161
170
  # so the click also focuses.
162
171
  # @param event [MouseEvent]
163
172
  # @return [void]
164
173
  def handle_mouse(event)
165
174
  super
166
- return unless event.button == :left && rect.contains?(event.point)
175
+ return unless event.button == :left && extent_rect.contains?(event.point)
167
176
 
168
177
  @overlay.open? ? close_menu : open_menu
169
178
  end
170
179
 
171
180
  # @return [void]
172
181
  def repaint
182
+ super
173
183
  return if rect.empty?
174
184
 
175
- tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
176
- clear_background(tail) unless tail.empty?
177
185
  draw_text(rect.left, rect.top, face_row)
178
186
  end
179
187
 
@@ -220,7 +228,7 @@ module Tuile
220
228
  end
221
229
 
222
230
  # @return [void]
223
- def anchor = @overlay.anchor_to(rect, rows: @items.size, width: menu_width)
231
+ def anchor = @overlay.anchor_to(extent_rect, rows: @items.size, width: menu_width)
224
232
 
225
233
  # The dropdown's width: the widest label plus {List}'s two row gutters, plus
226
234
  # the scrollbar column when the rows can't all be shown at once — but never
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tuile
4
+ class Component
5
+ # A one-child region: a place in the tree reserved for content that may be
6
+ # absent, arrive late, or be swapped. The occupant fills the slot's {#rect}.
7
+ #
8
+ # Wire one per region at construction, then swap occupants through
9
+ # {HasContent#content=} — which is how {Window} holds its footer:
10
+ #
11
+ # @footer_slot = Slot.new
12
+ # add_child(@footer_slot) # the region, wired once
13
+ # …
14
+ # @footer_slot.content = new_footer # the occupant, swapped at will
15
+ #
16
+ # Because the slot never leaves {Component#children}, a swap's insert index
17
+ # is always 0 — never a function of which *other* regions happen to be
18
+ # occupied, which is the whole reason to reach for one (`D_slots`).
19
+ #
20
+ # An empty slot does not collapse: it keeps its rect and clears it, so a
21
+ # dialog with no message shows the hole. Close the gap with a zero extent
22
+ # from the parent, or assign an empty {Rect} to suppress the clear entirely
23
+ # (what {Window} does with an absent footer); never detach it.
24
+ #
25
+ # Transparent to input: not {Component#focusable?},
26
+ # {Component#handle_mouse} descends through it, and a departing occupant's
27
+ # focus repair is handed to the container.
28
+ class Slot < Component
29
+ include Component::HasContent
30
+
31
+ # @param content [Component, nil] the initial occupant.
32
+ def initialize(content = nil)
33
+ super()
34
+ @content = nil
35
+ self.content = content unless content.nil?
36
+ end
37
+
38
+ # Hands the repair to the container rather than doing it here: the default
39
+ # would move focus to `self`, and a slot is inert — no cursor, no keys,
40
+ # nothing to bubble from.
41
+ # @param child [Component] the just-detached occupant.
42
+ # @return [void]
43
+ def on_child_removed(child)
44
+ parent&.on_child_removed(child)
45
+ end
46
+
47
+ protected
48
+
49
+ # @param content [Component]
50
+ # @return [void]
51
+ def layout(content) = content.rect = rect
52
+ end
53
+ end
54
+ end
@@ -166,17 +166,6 @@ module Tuile
166
166
  layout_pane
167
167
  end
168
168
 
169
- # Forwards to whichever child the click landed on — the strip's row, or
170
- # the pane below it.
171
- # @param event [MouseEvent]
172
- # @return [void]
173
- def handle_mouse(event)
174
- super
175
- children.each do |child|
176
- child.handle_mouse(event) if child.rect.contains?(event.point)
177
- end
178
- end
179
-
180
169
  # Sends focus to the strip: a sheet is a container, and the strip is where
181
170
  # a tab switch is driven from. The pane is a Tab press away.
182
171
  # @return [void]
@@ -29,7 +29,7 @@ module Tuile
29
29
  #
30
30
  # {Tab} handles are minted by {#add_tab} and owned by the strip. There is no
31
31
  # `items=`: a tab is identity plus its own state, so the set grows and
32
- # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D-tabs`.
32
+ # shrinks one tab at a time. See book ch7 and `DECISIONS.md` `D_tabs`.
33
33
  #
34
34
  # == Sizing
35
35
  # Assign a {#rect} (typically from the surrounding {Layout}). One wider than
@@ -277,11 +277,11 @@ module Tuile
277
277
  # blank tail — or on a lower row, when the rect is taller than one —
278
278
  # selects nothing. It still *focuses*: {Component#handle_mouse}'s
279
279
  # click-to-focus is ungated by geometry.
280
- # @return [Rect]
280
+ # @return [Size]
281
281
  def extent
282
- return Rect.new(rect.left, rect.top, 0, 1) if rect.empty?
282
+ return Size.new(0, 1) if rect.empty?
283
283
 
284
- Rect.new(rect.left, rect.top, [painted_width - @left_column, rect.width].min, 1)
284
+ Size.new([painted_width - @left_column, rect.width].min, 1)
285
285
  end
286
286
 
287
287
  # @return [String]
@@ -435,7 +435,7 @@ module Tuile
435
435
  # @return [Tab, nil] the tab painted at `point`; `nil` for a separator
436
436
  # column, the blank tail, or a row the strip doesn't paint.
437
437
  def tab_at(point)
438
- return nil unless extent.contains?(point)
438
+ return nil unless extent_rect.contains?(point)
439
439
 
440
440
  column = point.x - rect.left + @left_column
441
441
  found = segments.find { |_tab, start, width| column >= start && column < start + width }
@@ -22,11 +22,10 @@ module Tuile
22
22
  @border_right = 1
23
23
  self.caption = caption
24
24
  @content = nil
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 row (mutually exclusive — the component, when present,
28
- # occupies the row and hides the text).
29
- @footer = nil
25
+ # The bottom row holds either a widget or chrome text, never both; see
26
+ # the precedence note on #footer=.
27
+ @footer_slot = Slot.new
28
+ add_child(@footer_slot) # appended: the footer paints over the border row
30
29
  @footer_text = StyledString::EMPTY
31
30
  end
32
31
 
@@ -34,7 +33,7 @@ module Tuile
34
33
 
35
34
  # @return [Component, nil] optional focusable component occupying the
36
35
  # bottom border row, always spanning the full inner width.
37
- attr_reader :footer
36
+ def footer = @footer_slot.content
38
37
 
39
38
  # @return [StyledString] optional chrome embedded into the bottom border
40
39
  # line, mirroring {#caption} on the top line. Empty by default; hidden
@@ -56,48 +55,19 @@ module Tuile
56
55
  invalidate # repaint the bottom border row
57
56
  end
58
57
 
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.
58
+ # Mounts a component in the bottom border row, spanning the full inner
59
+ # width and positioned automatically; `nil` removes it.
62
60
  #
63
61
  # Precedence: a footer component present hides {#footer_text}; absent, the
64
62
  # text embeds into the bottom border. No window needs both at once.
65
- #
66
- # Symmetric to {#content=}: validates the new component, swaps parent
67
- # pointers, invalidates the old/new components and the window border, and
68
- # repairs focus via {#on_child_removed} if the removed footer held it.
69
63
  # @param new_footer [Component, nil]
64
+ # @raise [TypeError] if `new_footer` is neither a {Component} nor nil.
65
+ # @raise [ArgumentError] if `new_footer` already has a parent.
66
+ # @return [void]
70
67
  def footer=(new_footer)
71
- unless new_footer.nil? || new_footer.is_a?(Component)
72
- raise TypeError, "expected Component or nil, got #{new_footer.inspect}"
73
- end
74
- return if @footer == new_footer
75
- if !new_footer.nil? && !new_footer.parent.nil?
76
- raise ArgumentError, "#{new_footer} already has a parent #{new_footer.parent}"
77
- end
78
-
79
- old = @footer
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?
83
- @footer = new_footer
84
- unless new_footer.nil?
85
- add_child(new_footer) # appended: the footer paints over the border row
86
- new_footer.invalidate
87
- layout_footer
88
- end
68
+ @footer_slot.content = new_footer
69
+ layout_footer
89
70
  invalidate # repaint border row that the footer covers/uncovers
90
- on_child_removed(old) unless old.nil?
91
- end
92
-
93
- # @param event [MouseEvent]
94
- # @return [void]
95
- def handle_mouse(event)
96
- if @footer&.rect&.contains?(event.point)
97
- @footer.handle_mouse(event)
98
- else
99
- super
100
- end
101
71
  end
102
72
 
103
73
  # @param new_rect [Rect]
@@ -196,7 +166,7 @@ module Tuile
196
166
  # @return [StyledString]
197
167
  def bottom_border(inner_w, fg)
198
168
  interior =
199
- if @footer || @footer_text.empty?
169
+ if footer || @footer_text.empty?
200
170
  StyledString.styled("─" * inner_w, fg: fg)
201
171
  else
202
172
  embedded = @footer_text.slice(0, inner_w)
@@ -207,15 +177,21 @@ module Tuile
207
177
 
208
178
  private
209
179
 
210
- # Positions the footer over the bottom border row, spanning the full
180
+ # Positions the footer slot over the bottom border row, spanning the full
211
181
  # inner width (the only dimension a bottom-row widget needs — the window
212
182
  # already knows it).
183
+ #
184
+ # An unoccupied slot gets an *empty* rect, not the row — a {Slot} clears
185
+ # whatever it is given, which would blank the border underneath.
213
186
  # @return [void]
214
187
  def layout_footer
215
- return if @footer.nil? || rect.empty?
188
+ if footer.nil? || rect.empty?
189
+ @footer_slot.rect = Rect.new(0, 0, 0, 0)
190
+ return
191
+ end
216
192
 
217
193
  width = [rect.width - 2, 0].max
218
- @footer.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
194
+ @footer_slot.rect = Rect.new(rect.left + 1, rect.top + rect.height - 1, width, 1)
219
195
  end
220
196
  end
221
197
  end