Class: Tuile::Component::TabSheet
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::TabSheet
- Defined in:
- lib/tuile/component/tab_sheet.rb,
sig/tuile.rbs
Overview
A Tabs strip on its top row plus the pane belonging to the selected tab underneath it:
␣Details␣│␣Payment␣│␣Shipping␣
the selected tab's pane fills the rest of the rect
sheet = Component::TabSheet.new
sheet.add_tab("Details", details_form) # selected, and shown
sheet.add_tab("Payment", payment_form)
sheet.select_next # shows payment_form
sheet.on_tab_selected = ->(index, tab) { log("now on #{tab&.caption}") }
Tab lands on the strip first and enters the pane on the next press, which is the browser's order; switching tabs does not move focus into the new pane, but if focus was inside the pane that just went away it lands back on the strip.
Hidden panes are detached
Only the selected tab's pane is in the component tree — the others are detached, which is how Tuile hides a component (there is no visibility flag, and an empty rect gates painting only). Consequences worth designing around:
- A hidden pane is invisible to everything: the Tab cycle, focus
cascades, repaint, the cursor,
on_treewalks. No gates anywhere. - Its state survives, because state is ivars — scroll position, caret,
list cursor, text are all exactly as the user left them, and mutating a
hidden pane is safe (
invalidatewhile detached is a silent no-op). - #on_detached / #on_attached fire on every switch, so a pane holding a mounted-lifetime resource — a ProgressBar's ticker — releases it while hidden and re-acquires it on return. A pane that must keep something alive while hidden can't; that something belongs in the model the pane renders, not in the pane.
Implementation details
children is [strip, pane], the strip pinned at index 0, so pre-order
traversal gives the strip-then-pane Tab order for free. The swap follows
the slot-swap recipe #detach_child documents — detach, rewire,
on_child_removed last, so the focus repair sees the new occupant.
Panes live in an identity-keyed Tab => Component map here rather than in
a slot on Tuile::Component::Tabs::Tab: the strip's tab array stays the sole ordering
authority, and the strip itself stays ignorant of panes. One idempotent
sync_pane is the sole writer of the visible pane, deriving it from
strip.selected on every call, so registering a pane and selecting a tab
can happen in either order.
The sheet owns the strip's on_tab_selected (that is what drives the
swap); an app's listener goes on #on_tab_selected here, which fires
after the pane has been swapped in.
Instance Attribute Summary collapse
-
#on_tab_selected ⇒ Proc?
An app's own selection listener, called after the pane has been swapped in —
(index, tab), or(nil, nil)once the last tab is gone. -
#pane ⇒ Component?
readonly
@return — the pane currently in the tree — the selected tab's,
nilwhile the sheet has no tabs. -
#strip ⇒ Tabs
readonly
@return — the strip.
Instance Method Summary collapse
-
#add_tab(caption, pane) ⇒ Tabs::Tab
Adds a tab and the pane to show while it is selected.
-
#forget_removed_tabs ⇒ void
Drops entries whose tab is gone.
-
#handle_mouse(event) ⇒ void
Forwards to whichever child the click landed on — the strip's row, or the pane below it.
-
#initialize(separator: Tabs::DEFAULT_SEPARATOR) ⇒ TabSheet
constructor
@param
separator— the strip's separator; see Tuile::Component::Tabs#separator=. - #layout_pane ⇒ void
-
#on_child_removed(child) ⇒ void
Lands focus on the strip rather than on
selfwhen the focused pane is swapped out — a bare container can't use keys, and the user's last action was a tab switch. -
#on_focus ⇒ void
Sends focus to the strip: a sheet is a container, and the strip is where a tab switch is driven from.
-
#pane_for(tab) ⇒ Component?
@param
tab. -
#rect=(new_rect) ⇒ void
@param
new_rect. -
#remove_tab(tab) ⇒ Component?
Removes a tab and forgets its pane, detaching it if it was the visible one.
-
#select_next ⇒ Boolean
Selects the next tab, clamping at the last one.
-
#select_previous ⇒ Boolean
Selects the previous tab, clamping at the first one.
-
#selected ⇒ Tabs::Tab?
@return — the selected tab.
-
#selected=(tab) ⇒ void
@param
tab— one of this sheet's tabs. -
#selected_index ⇒ Integer?
@return — the selected tab's position.
-
#selected_index=(index) ⇒ void
@param
index— a position in0...tabs.size. -
#sync_pane ⇒ void
Makes the visible pane match
strip.selected, swapping if it doesn't. -
#tabs ⇒ ::Array[Tabs::Tab]
@return — the strip's tabs, in order.
Constructor Details
#initialize(separator: Tabs::DEFAULT_SEPARATOR) ⇒ TabSheet
@param separator — the strip's separator; see Tuile::Component::Tabs#separator=.
65 66 67 68 69 70 71 72 73 74 |
# File 'lib/tuile/component/tab_sheet.rb', line 65 def initialize(separator: Tabs::DEFAULT_SEPARATOR) super() @panes = {}.compare_by_identity @strip = Tabs.new(separator:) add_child(@strip) @strip.on_tab_selected = lambda do |index, tab| sync_pane @on_tab_selected&.call(index, tab) end end |
Instance Attribute Details
#on_tab_selected ⇒ Proc?
An app's own selection listener, called after the pane has been swapped
in — (index, tab), or (nil, nil) once the last tab is gone. Same
contract as Tuile::Component::Tabs#on_tab_selected: it reports that the selection
changed, whatever changed it.
61 62 63 |
# File 'lib/tuile/component/tab_sheet.rb', line 61 def on_tab_selected @on_tab_selected end |
#pane ⇒ Component? (readonly)
@return — the pane currently in the tree — the selected
tab's, nil while the sheet has no tabs.
84 85 86 |
# File 'lib/tuile/component/tab_sheet.rb', line 84 def pane @pane end |
#strip ⇒ Tabs (readonly)
@return — the strip. Reach through it for the rest of its API —
sheet.strip.separator = "|" — but leave its on_tab_selected alone:
the sheet drives the pane swap through it, and #on_tab_selected is
where an app's listener goes.
80 81 82 |
# File 'lib/tuile/component/tab_sheet.rb', line 80 def strip @strip end |
Instance Method Details
#add_tab(caption, pane) ⇒ Tabs::Tab
Adds a tab and the pane to show while it is selected. The first tab added becomes the selection, so its pane is shown immediately.
@param caption — parsed as Tuile::Component::Tabs::Tab#caption= parses it.
@param pane — shown while this tab is selected, detached while it isn't.
@return — the new tab's handle.
96 97 98 99 100 101 102 103 104 105 106 107 108 |
# File 'lib/tuile/component/tab_sheet.rb', line 96 def add_tab(caption, pane) raise TypeError, "expected Component, got #{pane.inspect}" unless pane.is_a?(Component) forget_removed_tabs if @panes.each_value.any? { |existing| existing.equal?(pane) } raise ArgumentError, "#{pane} is already a pane of this TabSheet" end tab = @strip.add_tab(caption) @panes[tab] = pane sync_pane tab end |
#forget_removed_tabs ⇒ void
This method returns an undefined value.
Drops entries whose tab is gone. Tuile::Component::Tabs::Tab#remove takes a tab off the strip without passing through #remove_tab, and a detached tab can never be selected again, so its entry is dead weight — it pins the pane against garbage collection and makes #add_tab reject that pane as still in use. Idempotent and the only cleaner, because the rule it enforces is an invariant ("every key is a live tab of my strip") rather than a step in one code path.
230 231 232 |
# File 'lib/tuile/component/tab_sheet.rb', line 230 def forget_removed_tabs @panes.delete_if { |tab, _pane| !tab.attached? } end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
Forwards to whichever child the click landed on — the strip's row, or the pane below it.
@param event
173 174 175 176 177 178 |
# File 'lib/tuile/component/tab_sheet.rb', line 173 def handle_mouse(event) super children.each do |child| child.handle_mouse(event) if child.rect.contains?(event.point) end end |
#layout_pane ⇒ void
This method returns an undefined value.
235 236 237 238 239 |
# File 'lib/tuile/component/tab_sheet.rb', line 235 def layout_pane return if @pane.nil? @pane.rect = Rect.new(rect.left, rect.top + 1, rect.width, [rect.height - 1, 0].max) end |
#on_child_removed(child) ⇒ void
This method returns an undefined value.
Lands focus on the strip rather than on self when the focused pane is
swapped out — a bare container can't use keys, and the user's last
action was a tab switch.
@param child
193 194 195 196 |
# File 'lib/tuile/component/tab_sheet.rb', line 193 def on_child_removed(child) super screen.focused = @strip if attached? && screen.focused.equal?(self) end |
#on_focus ⇒ void
This method returns an undefined value.
Sends focus to the strip: a sheet is a container, and the strip is where a tab switch is driven from. The pane is a Tab press away.
183 184 185 186 |
# File 'lib/tuile/component/tab_sheet.rb', line 183 def on_focus super screen.focused = @strip end |
#pane_for(tab) ⇒ Component?
@param tab
@return — the pane registered for tab; nil for a
removed tab, a tab of another sheet, or nil.
126 127 128 129 130 |
# File 'lib/tuile/component/tab_sheet.rb', line 126 def pane_for(tab) return nil unless tab&.attached? @panes[tab] end |
#rect=(new_rect) ⇒ void
This method returns an undefined value.
@param new_rect
163 164 165 166 167 |
# File 'lib/tuile/component/tab_sheet.rb', line 163 def rect=(new_rect) super @strip.rect = Rect.new(rect.left, rect.top, rect.width, [rect.height, 1].min) layout_pane end |
#remove_tab(tab) ⇒ Component?
Removes a tab and forgets its pane, detaching it if it was the visible one. The strip re-selects as Tuile::Component::Tabs#remove_tab describes, and this sheet shows whatever it lands on.
@param tab — one of this sheet's tabs.
@return — the pane that tab owned.
116 117 118 119 120 121 |
# File 'lib/tuile/component/tab_sheet.rb', line 116 def remove_tab(tab) pane = @panes[tab] # read first: the strip's own removal may prune the entry @strip.remove_tab(tab) @panes.delete(tab) pane end |
#select_next ⇒ Boolean
Selects the next tab, clamping at the last one.
@return — false only when there are no tabs.
155 |
# File 'lib/tuile/component/tab_sheet.rb', line 155 def select_next = @strip.select_next |
#select_previous ⇒ Boolean
Selects the previous tab, clamping at the first one.
@return — false only when there are no tabs.
159 |
# File 'lib/tuile/component/tab_sheet.rb', line 159 def select_previous = @strip.select_previous |
#selected ⇒ Tabs::Tab?
@return — the selected tab.
136 |
# File 'lib/tuile/component/tab_sheet.rb', line 136 def selected = @strip.selected |
#selected=(tab) ⇒ void
This method returns an undefined value.
@param tab — one of this sheet's tabs.
140 141 142 |
# File 'lib/tuile/component/tab_sheet.rb', line 140 def selected=(tab) @strip.selected = tab end |
#selected_index ⇒ Integer?
@return — the selected tab's position.
145 |
# File 'lib/tuile/component/tab_sheet.rb', line 145 def selected_index = @strip.selected_index |
#selected_index=(index) ⇒ void
This method returns an undefined value.
@param index — a position in 0...tabs.size.
149 150 151 |
# File 'lib/tuile/component/tab_sheet.rb', line 149 def selected_index=(index) @strip.selected_index = index end |
#sync_pane ⇒ void
This method returns an undefined value.
Makes the visible pane match strip.selected, swapping if it doesn't.
Idempotent and the sole writer of @pane: it derives everything from
current state, so #add_tab can register a pane after the strip has
already selected its tab.
205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 |
# File 'lib/tuile/component/tab_sheet.rb', line 205 def sync_pane forget_removed_tabs wanted = @panes[@strip.selected] return if wanted.equal?(@pane) old = @pane detach_child(old) unless old.nil? @pane = wanted unless wanted.nil? add_child(wanted) # appended: the strip stays at index 0 wanted.invalidate layout_pane end invalidate on_child_removed(old) unless old.nil? end |
#tabs ⇒ ::Array[Tabs::Tab]
@return — the strip's tabs, in order.
133 |
# File 'lib/tuile/component/tab_sheet.rb', line 133 def tabs = @strip.tabs |