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 = ->(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_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).
  • #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

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)


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_selectedProc?

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.

Returns:

  • (Proc, nil)


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

def on_tab_selected
  @on_tab_selected
end

#paneComponent? (readonly)

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

Returns:



84
85
86
# File 'lib/tuile/component/tab_sheet.rb', line 84

def pane
  @pane
end

#stripTabs (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.

Returns:



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.

Parameters:

Returns:



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_tabsvoid

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

Parameters:



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_panevoid

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

Parameters:



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_focusvoid

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.

Parameters:

Returns:



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

Parameters:



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.

Parameters:

Returns:



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_nextBoolean

Selects the next tab, clamping at the last one.

@returnfalse only when there are no tabs.

Returns:

  • (Boolean)


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

def select_next = @strip.select_next

#select_previousBoolean

Selects the previous tab, clamping at the first one.

@returnfalse only when there are no tabs.

Returns:

  • (Boolean)


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

def select_previous = @strip.select_previous

#selectedTabs::Tab?

@return — the selected tab.

Returns:



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.

Parameters:



140
141
142
# File 'lib/tuile/component/tab_sheet.rb', line 140

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

#selected_indexInteger?

@return — the selected tab's position.

Returns:

  • (Integer, nil)


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.

Parameters:

  • index (Integer)


149
150
151
# File 'lib/tuile/component/tab_sheet.rb', line 149

def selected_index=(index)
  @strip.selected_index = index
end

#sync_panevoid

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.

Returns:



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

def tabs = @strip.tabs