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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +81 -37
- data/COMPARISON.md +101 -0
- data/DECISIONS.md +1095 -195
- data/README.md +19 -19
- data/TERMINOLOGY.md +6 -5
- data/book/03-layout.md +17 -10
- data/book/05-focus.md +4 -1
- data/book/06-theming.md +98 -0
- data/book/07-components.md +169 -19
- data/book/08-testing.md +16 -0
- data/book/09-styled-text.md +3 -3
- data/examples/file_commander.rb +1 -1
- data/examples/sampler.rb +143 -43
- data/ideas/arrow-key-navigation.md +2 -2
- data/ideas/modal-backdrop.md +24 -0
- data/ideas/new-components.md +24 -24
- data/lib/tuile/buffer.rb +51 -3
- data/lib/tuile/color.rb +143 -0
- data/lib/tuile/color_depth.rb +80 -0
- data/lib/tuile/component/button.rb +3 -3
- data/lib/tuile/component/checkbox.rb +3 -3
- data/lib/tuile/component/combo_box.rb +9 -2
- data/lib/tuile/component/confirm_window.rb +442 -0
- data/lib/tuile/component/has_content.rb +22 -9
- data/lib/tuile/component/has_value.rb +1 -1
- data/lib/tuile/component/info_window.rb +64 -16
- data/lib/tuile/component/layout.rb +0 -10
- data/lib/tuile/component/list_dropdown.rb +18 -10
- data/lib/tuile/component/log_text_view.rb +71 -0
- data/lib/tuile/component/log_window.rb +13 -48
- data/lib/tuile/component/menu_bar/cascade.rb +3 -3
- data/lib/tuile/component/menu_bar.rb +5 -5
- data/lib/tuile/component/notification.rb +16 -34
- data/lib/tuile/component/overlay.rb +192 -0
- data/lib/tuile/component/popup.rb +59 -187
- data/lib/tuile/component/progress_bar.rb +1 -1
- data/lib/tuile/component/select.rb +14 -6
- data/lib/tuile/component/slot.rb +54 -0
- data/lib/tuile/component/tab_sheet.rb +0 -11
- data/lib/tuile/component/tabs.rb +5 -5
- data/lib/tuile/component/window.rb +22 -46
- data/lib/tuile/component.rb +149 -19
- data/lib/tuile/event_queue.rb +21 -1
- data/lib/tuile/fake_screen.rb +26 -2
- data/lib/tuile/keys.rb +7 -0
- data/lib/tuile/screen.rb +120 -38
- data/lib/tuile/screen_pane.rb +37 -35
- data/lib/tuile/styled_string.rb +40 -7
- data/lib/tuile/terminal_background.rb +74 -16
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1157 -368
- metadata +8 -1
|
@@ -2,210 +2,98 @@
|
|
|
2
2
|
|
|
3
3
|
module Tuile
|
|
4
4
|
class Component
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
#
|
|
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
|
|
45
|
-
# {#close_on_outside_click?} for the exact contract and
|
|
46
|
-
# the notice a driver hears when it happens.
|
|
47
|
-
|
|
48
|
-
|
|
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
|
|
54
|
-
#
|
|
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
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
super()
|
|
64
|
-
@
|
|
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
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
#
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
#
|
|
108
|
-
#
|
|
109
|
-
|
|
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
|
|
140
|
-
#
|
|
141
|
-
#
|
|
142
|
-
#
|
|
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
|
|
147
|
-
@
|
|
79
|
+
def declared_size=(new_size)
|
|
80
|
+
@declared_size = new_size
|
|
148
81
|
reposition
|
|
149
82
|
end
|
|
150
83
|
|
|
151
|
-
#
|
|
152
|
-
#
|
|
153
|
-
#
|
|
154
|
-
#
|
|
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
|
-
#
|
|
172
|
-
#
|
|
173
|
-
# 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 = @
|
|
206
|
-
|
|
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` `
|
|
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` `
|
|
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
|
-
#
|
|
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 &&
|
|
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(
|
|
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]
|
data/lib/tuile/component/tabs.rb
CHANGED
|
@@ -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` `
|
|
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 [
|
|
280
|
+
# @return [Size]
|
|
281
281
|
def extent
|
|
282
|
-
return
|
|
282
|
+
return Size.new(0, 1) if rect.empty?
|
|
283
283
|
|
|
284
|
-
|
|
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
|
|
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
|
-
#
|
|
26
|
-
#
|
|
27
|
-
|
|
28
|
-
#
|
|
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
|
-
|
|
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
|
-
#
|
|
60
|
-
#
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|