Class: Tuile::Component::TabSheet

Inherits:
Component
  • Object
show all
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 { |e| log("now on #{e.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, walk_tree walks. 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 (invalidate while detached is a silent no-op).
  • #handle_detached / #handle_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, handle_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 registers on the strip's on_tab_selected first (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

Instance Method Summary collapse

Constructor Details

#initialize(separator: Tabs::DEFAULT_SEPARATOR) ⇒ TabSheet

@param separator — the strip's separator; see Tuile::Component::Tabs#separator=.

Parameters:

  • separator: (String, StyledString) (defaults to: Tabs::DEFAULT_SEPARATOR)


66
67
68
69
70
71
72
73
74
75
# File 'lib/tuile/component/tab_sheet.rb', line 66

def initialize(separator: Tabs::DEFAULT_SEPARATOR)
  super()
  @panes = {}.compare_by_identity
  @strip = Tabs.new(separator:)
  add_child(@strip)
  @strip.on_tab_selected do |e|
    sync_pane
    on_tab_selected.fire(Tabs::TabSelectedEvent.new(source: self, index: e.index, tab: e.tab))
  end
end

Instance Attribute Details

#pane ⇒ Component? (readonly)

@return — the pane currently in the tree — the selected tab's, nil while the sheet has no tabs.

Returns:



86
87
88
# File 'lib/tuile/component/tab_sheet.rb', line 86

def pane
  @pane
end

#strip ⇒ Tabs (readonly)

@return — the strip. Reach through it for the rest of its API — sheet.strip.separator = "|". Its on_tab_selected is safe to register on (the sheet's own listener is wired first, so the pane is already swapped), but #on_tab_selected is the one that names this sheet as the event's source.

Returns:



82
83
84
# File 'lib/tuile/component/tab_sheet.rb', line 82

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.

Parameters:

Returns:



98
99
100
101
102
103
104
105
106
107
108
109
110
# File 'lib/tuile/component/tab_sheet.rb', line 98

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.



212
213
214
# File 'lib/tuile/component/tab_sheet.rb', line 212

def forget_removed_tabs
  @panes.delete_if { |tab, _pane| !tab.attached? }
end

#handle_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

Parameters:



176
177
178
179
# File 'lib/tuile/component/tab_sheet.rb', line 176

def handle_child_removed(child)
  super
  screen.focused = @strip if attached? && screen.focused.equal?(self)
end

#handle_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.



166
167
168
169
# File 'lib/tuile/component/tab_sheet.rb', line 166

def handle_focus
  super
  screen.focused = @strip
end

#on_tab_selected ⇒ Listeners

An app's own selection listener, fired with a Tuile::Component::Tabs::TabSelectedEvent carrying this sheet as its source, after the pane has been swapped in. Same contract as Tuile::Component::Tabs#on_tab_selected: it reports that the selection changed, whatever changed it.

Returns:



62
# File 'lib/tuile/component/tab_sheet.rb', line 62

listener :on_tab_selected

#pane_for(tab) ⇒ Component?

@param tab

@return — the pane registered for tab; nil for a removed tab, a tab of another sheet, or nil.

Parameters:

Returns:



128
129
130
131
132
# File 'lib/tuile/component/tab_sheet.rb', line 128

def pane_for(tab)
  return nil unless tab&.attached?

  @panes[tab]
end

#relayout ⇒ void

This method returns an undefined value.

The strip takes the top row, the selected pane everything under it.



218
219
220
221
# File 'lib/tuile/component/tab_sheet.rb', line 218

def relayout
  @strip.rect = Rect.new(0, 0, rect.width, [rect.height, 1].min)
  @pane&.rect = Rect.new(0, 1, rect.width, [rect.height - 1, 0].max)
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.

Parameters:

Returns:



118
119
120
121
122
123
# File 'lib/tuile/component/tab_sheet.rb', line 118

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.

Returns:

  • (Boolean)


157
# File 'lib/tuile/component/tab_sheet.rb', line 157

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.

Returns:

  • (Boolean)


161
# File 'lib/tuile/component/tab_sheet.rb', line 161

def select_previous = @strip.select_previous

#selected ⇒ Tabs::Tab?

@return — the selected tab.

Returns:



138
# File 'lib/tuile/component/tab_sheet.rb', line 138

def selected = @strip.selected

#selected=(tab) ⇒ void

This method returns an undefined value.

@param tab — one of this sheet's tabs.

Parameters:



142
143
144
# File 'lib/tuile/component/tab_sheet.rb', line 142

def selected=(tab)
  @strip.selected = tab
end

#selected_index ⇒ Integer?

@return — the selected tab's position.

Returns:

  • (Integer, nil)


147
# File 'lib/tuile/component/tab_sheet.rb', line 147

def selected_index = @strip.selected_index

#selected_index=(index) ⇒ void

This method returns an undefined value.

@param index — a position in 0...tabs.size.

Parameters:

  • index (Integer)


151
152
153
# File 'lib/tuile/component/tab_sheet.rb', line 151

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.



188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
# File 'lib/tuile/component/tab_sheet.rb', line 188

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
  end
  invalidate
  handle_child_removed(old) unless old.nil?
end

#tabs ⇒ ::Array[Tabs::Tab]

@return — the strip's tabs, in order.

Returns:



135
# File 'lib/tuile/component/tab_sheet.rb', line 135

def tabs = @strip.tabs