tuile 0.12.0 → 0.13.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 +45 -0
- data/DECISIONS.md +1297 -13
- data/README.md +136 -490
- data/TERMINOLOGY.md +11 -2
- data/book/01-first-app.md +22 -17
- data/book/02-repaint.md +18 -5
- data/book/03-layout.md +11 -10
- data/book/05-focus.md +133 -18
- data/book/06-theming.md +5 -2
- data/book/07-components.md +402 -12
- data/book/08-testing.md +18 -4
- data/book/README.md +7 -5
- data/examples/file_commander.rb +22 -16
- data/examples/hello_world.rb +17 -5
- data/examples/sampler.rb +385 -66
- data/ideas/arrow-key-navigation.md +16 -0
- data/ideas/new-components.md +7 -6
- data/lib/tuile/ansi.rb +10 -0
- data/lib/tuile/component/abstract_string_field.rb +36 -0
- data/lib/tuile/component/combo_box.rb +3 -1
- data/lib/tuile/component/list.rb +22 -0
- data/lib/tuile/component/list_dropdown.rb +86 -3
- data/lib/tuile/component/menu_bar/cascade.rb +255 -0
- data/lib/tuile/component/menu_bar.rb +582 -0
- data/lib/tuile/component/notification.rb +14 -11
- data/lib/tuile/component/picker_window.rb +0 -5
- data/lib/tuile/component/popup.rb +75 -9
- data/lib/tuile/component/select.rb +3 -1
- data/lib/tuile/component/tab_sheet.rb +242 -0
- data/lib/tuile/component/tabs.rb +528 -0
- data/lib/tuile/component/text_area.rb +5 -4
- data/lib/tuile/component/text_field.rb +23 -6
- data/lib/tuile/component/text_view.rb +8 -5
- data/lib/tuile/component.rb +38 -13
- data/lib/tuile/event_queue.rb +25 -1
- data/lib/tuile/fake_screen.rb +14 -0
- data/lib/tuile/keys.rb +65 -0
- data/lib/tuile/screen.rb +94 -77
- data/lib/tuile/screen_pane.rb +109 -27
- data/lib/tuile/styled_string.rb +40 -0
- data/lib/tuile/version.rb +1 -1
- data/sig/tuile.rbs +1473 -93
- metadata +6 -3
- data/mise.toml +0 -2
|
@@ -40,6 +40,10 @@ module Tuile
|
|
|
40
40
|
# declined it (see {ScreenPane#handle_key}). That's why typing `q` into a
|
|
41
41
|
# nested {Component::TextField} doesn't dismiss the popup: the field
|
|
42
42
|
# consumes it first.
|
|
43
|
+
#
|
|
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.
|
|
43
47
|
class Popup < Component
|
|
44
48
|
include Component::HasContent
|
|
45
49
|
|
|
@@ -52,10 +56,16 @@ module Tuile
|
|
|
52
56
|
# @param size [Size, Fraction] the popup's size, applied top-down. A
|
|
53
57
|
# {Fraction} is resolved against the screen each layout pass; a {Size}
|
|
54
58
|
# is clamped to the screen. Defaults to {Fraction::HALF}.
|
|
55
|
-
|
|
59
|
+
# @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)
|
|
56
63
|
super()
|
|
57
64
|
@modal = modal
|
|
58
65
|
@size = size
|
|
66
|
+
@close_on_outside_click = close_on_outside_click
|
|
67
|
+
@owner = nil
|
|
68
|
+
@on_close = nil
|
|
59
69
|
@content = nil
|
|
60
70
|
self.content = content unless content.nil?
|
|
61
71
|
reposition
|
|
@@ -67,6 +77,63 @@ module Tuile
|
|
|
67
77
|
# @return [Boolean] whether this popup is modal. See {#initialize}.
|
|
68
78
|
def modal? = @modal
|
|
69
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
|
|
106
|
+
|
|
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
|
|
136
|
+
|
|
70
137
|
def focusable? = true
|
|
71
138
|
|
|
72
139
|
# Sets the popup's size and repositions it. Accepts a {Fraction}
|
|
@@ -147,14 +214,6 @@ module Tuile
|
|
|
147
214
|
self.rect = rect.centered(screen.size)
|
|
148
215
|
end
|
|
149
216
|
|
|
150
|
-
# Hint for the status bar: own "q Close" plus the wrapped content's hint.
|
|
151
|
-
# @return [String]
|
|
152
|
-
def keyboard_hint
|
|
153
|
-
prefix = "q #{screen.theme.hint("Close")}"
|
|
154
|
-
child_hint = @content&.keyboard_hint.to_s
|
|
155
|
-
child_hint.empty? ? prefix : "#{prefix} #{child_hint}"
|
|
156
|
-
end
|
|
157
|
-
|
|
158
217
|
# `q` and ESC close the popup. The popup sits on the focus chain of
|
|
159
218
|
# whatever it wraps, so the key reaches here by bubbling up from the
|
|
160
219
|
# focused content after that content declined to handle it.
|
|
@@ -169,6 +228,13 @@ module Tuile
|
|
|
169
228
|
end
|
|
170
229
|
end
|
|
171
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
|
+
|
|
172
238
|
protected
|
|
173
239
|
|
|
174
240
|
# Content fills the popup's full rect — Popup has no border to subtract.
|
|
@@ -72,6 +72,9 @@ module Tuile
|
|
|
72
72
|
@value = value
|
|
73
73
|
@on_value_change = nil
|
|
74
74
|
@overlay = ListDropdown.new
|
|
75
|
+
# Outside-click dismissal spans the owner chain, so a click on this
|
|
76
|
+
# select's dropdown must not dismiss a dialog the select sits in.
|
|
77
|
+
@overlay.owner = self
|
|
75
78
|
@overlay.renderer = method(:label_for)
|
|
76
79
|
@overlay.on_item_chosen = ->(_index, item) { commit(item) }
|
|
77
80
|
end
|
|
@@ -108,7 +111,6 @@ module Tuile
|
|
|
108
111
|
end
|
|
109
112
|
|
|
110
113
|
# @return [String]
|
|
111
|
-
def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}"
|
|
112
114
|
|
|
113
115
|
# Re-anchors the (open) dropdown after a move or resize.
|
|
114
116
|
# @param new_rect [Rect]
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Tuile
|
|
4
|
+
class Component
|
|
5
|
+
# A {Component::Tabs} strip on its top row plus the pane belonging to the
|
|
6
|
+
# selected tab underneath it:
|
|
7
|
+
#
|
|
8
|
+
# ␣Details␣│␣Payment␣│␣Shipping␣
|
|
9
|
+
# the selected tab's pane fills the rest of the rect
|
|
10
|
+
#
|
|
11
|
+
# sheet = Component::TabSheet.new
|
|
12
|
+
# sheet.add_tab("Details", details_form) # selected, and shown
|
|
13
|
+
# sheet.add_tab("Payment", payment_form)
|
|
14
|
+
# sheet.select_next # shows payment_form
|
|
15
|
+
# sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
|
|
16
|
+
#
|
|
17
|
+
# Tab lands on the strip first and enters the pane on the next press, which
|
|
18
|
+
# is the browser's order; switching tabs does *not* move focus into the new
|
|
19
|
+
# pane, but if focus was inside the pane that just went away it lands back
|
|
20
|
+
# on the strip.
|
|
21
|
+
#
|
|
22
|
+
# == Hidden panes are detached
|
|
23
|
+
# Only the selected tab's pane is in the component tree — the others are
|
|
24
|
+
# detached, which is how Tuile hides a component (there is no visibility
|
|
25
|
+
# flag, and an empty rect gates painting only). Consequences worth
|
|
26
|
+
# designing around:
|
|
27
|
+
#
|
|
28
|
+
# - A hidden pane is invisible to *everything*: the Tab cycle, focus
|
|
29
|
+
# cascades, repaint, the cursor, `on_tree` walks. No gates anywhere.
|
|
30
|
+
# - Its state survives, because state is ivars — scroll position, caret,
|
|
31
|
+
# list cursor, text are all exactly as the user left them, and mutating a
|
|
32
|
+
# hidden pane is safe (`invalidate` while detached is a silent no-op).
|
|
33
|
+
# - {Component#on_detached} / {Component#on_attached} fire on every switch,
|
|
34
|
+
# so a pane holding a mounted-lifetime resource — a {Component::ProgressBar}'s
|
|
35
|
+
# ticker — releases it while hidden and re-acquires it on return. A pane
|
|
36
|
+
# that must keep something alive while hidden can't; that something
|
|
37
|
+
# belongs in the model the pane renders, not in the pane.
|
|
38
|
+
#
|
|
39
|
+
# == Implementation details
|
|
40
|
+
# `children` is `[strip, pane]`, the strip pinned at index 0, so pre-order
|
|
41
|
+
# traversal gives the strip-then-pane Tab order for free. The swap follows
|
|
42
|
+
# the slot-swap recipe {Component#detach_child} documents — detach, rewire,
|
|
43
|
+
# `on_child_removed` last, so the focus repair sees the new occupant.
|
|
44
|
+
#
|
|
45
|
+
# Panes live in an identity-keyed `Tab => Component` map here rather than in
|
|
46
|
+
# a slot on {Tabs::Tab}: the strip's tab array stays the sole ordering
|
|
47
|
+
# authority, and the strip itself stays ignorant of panes. One idempotent
|
|
48
|
+
# `sync_pane` is the sole writer of the visible pane, deriving it from
|
|
49
|
+
# `strip.selected` on every call, so registering a pane and selecting a tab
|
|
50
|
+
# can happen in either order.
|
|
51
|
+
#
|
|
52
|
+
# The sheet owns the strip's `on_tab_selected` (that is what drives the
|
|
53
|
+
# swap); an app's listener goes on {#on_tab_selected} here, which fires
|
|
54
|
+
# after the pane has been swapped in.
|
|
55
|
+
class TabSheet < Component
|
|
56
|
+
# An app's own selection listener, called after the pane has been swapped
|
|
57
|
+
# in — `(index, tab)`, or `(nil, nil)` once the last tab is gone. Same
|
|
58
|
+
# contract as {Tabs#on_tab_selected}: it reports that the selection
|
|
59
|
+
# changed, whatever changed it.
|
|
60
|
+
# @return [Proc, nil]
|
|
61
|
+
attr_accessor :on_tab_selected
|
|
62
|
+
|
|
63
|
+
# @param separator [String, StyledString] the strip's separator; see
|
|
64
|
+
# {Tabs#separator=}.
|
|
65
|
+
def initialize(separator: Tabs::DEFAULT_SEPARATOR)
|
|
66
|
+
super()
|
|
67
|
+
@panes = {}.compare_by_identity
|
|
68
|
+
@strip = Tabs.new(separator:)
|
|
69
|
+
add_child(@strip)
|
|
70
|
+
@strip.on_tab_selected = lambda do |index, tab|
|
|
71
|
+
sync_pane
|
|
72
|
+
@on_tab_selected&.call(index, tab)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# @return [Tabs] the strip. Reach through it for the rest of its API —
|
|
77
|
+
# `sheet.strip.separator = "|"` — but leave its `on_tab_selected` alone:
|
|
78
|
+
# the sheet drives the pane swap through it, and {#on_tab_selected} is
|
|
79
|
+
# where an app's listener goes.
|
|
80
|
+
attr_reader :strip
|
|
81
|
+
|
|
82
|
+
# @return [Component, nil] the pane currently in the tree — the selected
|
|
83
|
+
# tab's, `nil` while the sheet has no tabs.
|
|
84
|
+
attr_reader :pane
|
|
85
|
+
|
|
86
|
+
# Adds a tab and the pane to show while it is selected. The first tab
|
|
87
|
+
# added becomes the selection, so its pane is shown immediately.
|
|
88
|
+
# @param caption [String, StyledString, nil] parsed as {Tabs::Tab#caption=}
|
|
89
|
+
# parses it.
|
|
90
|
+
# @param pane [Component] shown while this tab is selected, detached while
|
|
91
|
+
# it isn't.
|
|
92
|
+
# @raise [TypeError] when `pane` isn't a {Component}.
|
|
93
|
+
# @raise [ArgumentError] when `pane` is already this sheet's pane for
|
|
94
|
+
# another tab — a component has one parent, so two tabs cannot share it.
|
|
95
|
+
# @return [Tabs::Tab] the new tab's handle.
|
|
96
|
+
def add_tab(caption, pane)
|
|
97
|
+
raise TypeError, "expected Component, got #{pane.inspect}" unless pane.is_a?(Component)
|
|
98
|
+
|
|
99
|
+
forget_removed_tabs
|
|
100
|
+
if @panes.each_value.any? { |existing| existing.equal?(pane) }
|
|
101
|
+
raise ArgumentError, "#{pane} is already a pane of this TabSheet"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
tab = @strip.add_tab(caption)
|
|
105
|
+
@panes[tab] = pane
|
|
106
|
+
sync_pane
|
|
107
|
+
tab
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Removes a tab and forgets its pane, detaching it if it was the visible
|
|
111
|
+
# one. The strip re-selects as {Tabs#remove_tab} describes, and this
|
|
112
|
+
# sheet shows whatever it lands on.
|
|
113
|
+
# @param tab [Tabs::Tab] one of this sheet's tabs.
|
|
114
|
+
# @raise [ArgumentError] when the tab isn't on this sheet's strip.
|
|
115
|
+
# @return [Component, nil] the pane that tab owned.
|
|
116
|
+
def remove_tab(tab)
|
|
117
|
+
pane = @panes[tab] # read first: the strip's own removal may prune the entry
|
|
118
|
+
@strip.remove_tab(tab)
|
|
119
|
+
@panes.delete(tab)
|
|
120
|
+
pane
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param tab [Tabs::Tab, nil]
|
|
124
|
+
# @return [Component, nil] the pane registered for `tab`; `nil` for a
|
|
125
|
+
# removed tab, a tab of another sheet, or `nil`.
|
|
126
|
+
def pane_for(tab)
|
|
127
|
+
return nil unless tab&.attached?
|
|
128
|
+
|
|
129
|
+
@panes[tab]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
# @return [Array<Tabs::Tab>] the strip's tabs, in order.
|
|
133
|
+
def tabs = @strip.tabs
|
|
134
|
+
|
|
135
|
+
# @return [Tabs::Tab, nil] the selected tab.
|
|
136
|
+
def selected = @strip.selected
|
|
137
|
+
|
|
138
|
+
# @param tab [Tabs::Tab] one of this sheet's tabs.
|
|
139
|
+
# @return [void]
|
|
140
|
+
def selected=(tab)
|
|
141
|
+
@strip.selected = tab
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
# @return [Integer, nil] the selected tab's position.
|
|
145
|
+
def selected_index = @strip.selected_index
|
|
146
|
+
|
|
147
|
+
# @param index [Integer] a position in `0...tabs.size`.
|
|
148
|
+
# @return [void]
|
|
149
|
+
def selected_index=(index)
|
|
150
|
+
@strip.selected_index = index
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Selects the next tab, clamping at the last one.
|
|
154
|
+
# @return [Boolean] `false` only when there are no tabs.
|
|
155
|
+
def select_next = @strip.select_next
|
|
156
|
+
|
|
157
|
+
# Selects the previous tab, clamping at the first one.
|
|
158
|
+
# @return [Boolean] `false` only when there are no tabs.
|
|
159
|
+
def select_previous = @strip.select_previous
|
|
160
|
+
|
|
161
|
+
# @param new_rect [Rect]
|
|
162
|
+
# @return [void]
|
|
163
|
+
def rect=(new_rect)
|
|
164
|
+
super
|
|
165
|
+
@strip.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height, 1].min)
|
|
166
|
+
layout_pane
|
|
167
|
+
end
|
|
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
|
+
# Sends focus to the strip: a sheet is a container, and the strip is where
|
|
181
|
+
# a tab switch is driven from. The pane is a Tab press away.
|
|
182
|
+
# @return [void]
|
|
183
|
+
def on_focus
|
|
184
|
+
super
|
|
185
|
+
screen.focused = @strip
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Lands focus on the strip rather than on `self` when the focused pane is
|
|
189
|
+
# swapped out — a bare container can't use keys, and the user's last
|
|
190
|
+
# action was a tab switch.
|
|
191
|
+
# @param child [Component]
|
|
192
|
+
# @return [void]
|
|
193
|
+
def on_child_removed(child)
|
|
194
|
+
super
|
|
195
|
+
screen.focused = @strip if attached? && screen.focused.equal?(self)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
# Makes the visible pane match `strip.selected`, swapping if it doesn't.
|
|
201
|
+
# Idempotent and the sole writer of `@pane`: it derives everything from
|
|
202
|
+
# current state, so {#add_tab} can register a pane after the strip has
|
|
203
|
+
# already selected its tab.
|
|
204
|
+
# @return [void]
|
|
205
|
+
def sync_pane
|
|
206
|
+
forget_removed_tabs
|
|
207
|
+
wanted = @panes[@strip.selected]
|
|
208
|
+
return if wanted.equal?(@pane)
|
|
209
|
+
|
|
210
|
+
old = @pane
|
|
211
|
+
detach_child(old) unless old.nil?
|
|
212
|
+
@pane = wanted
|
|
213
|
+
unless wanted.nil?
|
|
214
|
+
add_child(wanted) # appended: the strip stays at index 0
|
|
215
|
+
wanted.invalidate
|
|
216
|
+
layout_pane
|
|
217
|
+
end
|
|
218
|
+
invalidate
|
|
219
|
+
on_child_removed(old) unless old.nil?
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
# Drops entries whose tab is gone. {Tabs::Tab#remove} takes a tab off the
|
|
223
|
+
# strip without passing through {#remove_tab}, and a detached tab can never
|
|
224
|
+
# be selected again, so its entry is dead weight — it pins the pane against
|
|
225
|
+
# garbage collection and makes {#add_tab} reject that pane as still in use.
|
|
226
|
+
# Idempotent and the only cleaner, because the rule it enforces is an
|
|
227
|
+
# invariant ("every key is a live tab of my strip") rather than a step in
|
|
228
|
+
# one code path.
|
|
229
|
+
# @return [void]
|
|
230
|
+
def forget_removed_tabs
|
|
231
|
+
@panes.delete_if { |tab, _pane| !tab.attached? }
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# @return [void]
|
|
235
|
+
def layout_pane
|
|
236
|
+
return if @pane.nil?
|
|
237
|
+
|
|
238
|
+
@pane.rect = Rect.new(rect.left, rect.top + 1, rect.width, [rect.height - 1, 0].max)
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|