Class: Tuile::Component::Tabs

Inherits:
Component
  • Object
show all
Defined in:
lib/tuile/component/tabs.rb,
sig/tuile.rbs

Overview

A one-row strip of captions with exactly one of them selected — the map of where the user is. Knows nothing about content: pair it with TabSheet to swap panes, or swap views yourself from #on_tab_selected.

␣Details␣│␣Payment␣│␣Shipping␣
^^^^^^^   selected: bold, and highlighted while the strip has focus

tabs = Component::Tabs.new
tabs.add_tab("Details")                # the first tab is selected
payment = tabs.add_tab("Payment")
tabs.on_tab_selected = ->(index, tab) { show(index) }
tabs.selected = payment                # fires the listener
payment.caption = "Payment ⚠"          # repaints the strip

LEFT / RIGHT switch tabs immediately — no cursor to move first, no Enter to confirm — clamping at both ends rather than wrapping; a left click selects the tab under the pointer. Everything else bubbles to an ancestor, Enter, Space, Up, Down, Home and End included, so a form's default button and the app's own keys keep working while the strip has focus.

One tab stop for the whole strip: Tab moves past it, never between its tabs. For a key of your own that switches tabs from elsewhere in the app, bind it yourself and call #select_next / #select_previous.

Tab handles are minted by #add_tab and owned by the strip. There is no items=: a tab is identity plus its own state, so the set grows and shrinks one tab at a time. See book ch7 and DECISIONS.md D_tabs.

Sizing

Assign a #rect (typically from the surrounding Layout). One wider than #extent.width leaves a dead tail; a narrower one scrolls. The strip keeps the selected segment whole in view, moving its window by the minimum needed, so arrowing into an off-screen tab brings that tab on screen — and a click on a half-visible segment at an edge selects it and pulls it into view. A < or > painted over an edge column says there is more strip that way, as does the cut caption underneath it. The one thing that cannot be shown whole is a caption wider than the entire rect: it shows its head and clips its tail. The scroll offset itself is not API — the invariant is.

Implementation details

A segment is one space of padding, the caption, one space of padding, and segments are joined by a single DEFAULT_SEPARATOR column. The padding belongs to the segment: the highlight covers it and a click on it selects the tab, while the separator column is chrome and selects nothing, like the blank tail past #extent. One private segments method is the sole source of that arithmetic — both the paint and the hit test read it, and both offset it by the same scroll column, so a click cannot land on a tab other than the one drawn under it — and it is derived from the captions on each call rather than recorded during the last paint, so a hit test is correct before the first paint.

The selected caption is bold always, so the strip still says where you are once focus has moved on, and additionally sits on Theme#active_bg_color while the strip is on the focus chain. Bold is the selection channel and not strip chrome: bolding every caption would leave selection to the focus-gated background alone, and an unfocused strip would then show no selection at all.

Defined Under Namespace

Classes: Tab

Constant Summary collapse

DEFAULT_SEPARATOR =

The column between two segments — the glyph Window paints its side borders with, so a strip inside a window lines up with it.

Returns:

  • (String)
""

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(separator: DEFAULT_SEPARATOR) ⇒ Tabs

@param separator — see #separator=.

Parameters:

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


162
163
164
165
166
167
168
# File 'lib/tuile/component/tabs.rb', line 162

def initialize(separator: DEFAULT_SEPARATOR)
  super()
  @tabs = []
  @selected_index = nil
  @left_column = 0
  self.separator = separator
end

Instance Attribute Details

#left_columnInteger (readonly)

@return — the strip column painted in Tuile::Component#rect's leftmost cell — the horizontal scroll offset. 0 unless the strip overflows its rect; #adjust_left_column is its sole writer.

Returns:

  • (Integer)


329
330
331
# File 'lib/tuile/component/tabs.rb', line 329

def left_column
  @left_column
end

#on_tab_selectedProc?

Called on every change of #selected with the new selection — (index, tab), or (nil, nil) once the last tab has been removed.

It reports that the selection changed, not that the user pressed something: arrows, a click, #selected= / #selected_index=, the autoselect of the first #add_tab and the re-selection that follows removing the selected tab all fire it. Re-selecting the tab already selected fires nothing.

Returns:

  • (Proc, nil)


159
160
161
# File 'lib/tuile/component/tabs.rb', line 159

def on_tab_selected
  @on_tab_selected
end

#selected_indexInteger?

@return — the selected tab's position; nil only while there are no tabs.

Returns:

  • (Integer, nil)


205
206
207
# File 'lib/tuile/component/tabs.rb', line 205

def selected_index
  @selected_index
end

#separatorStyledString, String

@return — the column painted between two segments.

Returns:



183
184
185
# File 'lib/tuile/component/tabs.rb', line 183

def separator
  @separator
end

#tabs::Array[Tab] (readonly)

@return — the tabs, in strip order. Read-only by convention (like Tuile::Component#children) — grow and shrink it through #add_tab / #remove_tab, which keep the selection consistent. Enumerate it to find a tab: tabs.find { |t| t.caption.to_s == "Payment" }.

Returns:

  • (::Array[Tab])


180
181
182
# File 'lib/tuile/component/tabs.rb', line 180

def tabs
  @tabs
end

Instance Method Details

#add_tab(caption = nil) ⇒ Tab

Appends a tab and returns its handle. The first tab added becomes the selection; later ones don't disturb it.

@param caption — parsed as Tuile::Component::Tabs::Tab#caption= parses it.

Parameters:

  • caption (?(String | StyledString), nil) (defaults to: nil)

Returns:



237
238
239
240
241
242
# File 'lib/tuile/component/tabs.rb', line 237

def add_tab(caption = nil)
  tab = Tab.send(:new, self, StyledString.parse(caption))
  @tabs << tab
  @selected_index.nil? ? select_at(0) : refresh
  tab
end

#adjust_left_columnvoid

This method returns an undefined value.

Scrolls the minimum needed to show the selected segment whole, and is the sole writer of #left_column. Idempotent, so every mutation site can call it blindly; it returns the offset to 0 on its own once the strip fits again, which is why no mutator owes a scroll-back branch.

A segment wider than the whole rect cannot be shown whole: its head wins, being the half of a caption that identifies it.



355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
# File 'lib/tuile/component/tabs.rb', line 355

def adjust_left_column
  if rect.empty? || painted_width <= rect.width || @selected_index.nil?
    @left_column = 0
    return
  end

  _tab, start, width = segments[@selected_index]
  if width >= rect.width
    @left_column = start
  else
    @left_column = start if start < @left_column
    @left_column = start + width - rect.width if start + width > @left_column + rect.width
  end
  @left_column = snap_to_glyph_start(@left_column.clamp(0, painted_width - rect.width))
end

#apply_selection(index, previous) ⇒ void

This method returns an undefined value.

Stores the selection, repaints, and fires #on_tab_selected when the selected tab changed.

previous is passed in rather than read here because a removal can leave the index numerically unchanged while a different tab sits under it — remove the selected middle tab of three and index 1 now holds what used to be index 2. Comparing indices would swallow that notification.

@param index — the new selection.

@param previous — the tab selected before the caller's change.

Parameters:

  • index (Integer, nil)
  • previous (Tab, nil)


506
507
508
509
510
511
512
513
# File 'lib/tuile/component/tabs.rb', line 506

def apply_selection(index, previous)
  @selected_index = index
  refresh
  tab = index.nil? ? nil : @tabs[index]
  return if tab.equal?(previous)

  @on_tab_selected&.call(index, tab)
end

#draw_cue(row, column, glyph) ⇒ void

This method returns an undefined value.

The cue keeps the style of the cell it covers, so one landing on the selected segment doesn't punch a default-background hole in its highlight.

@param row — the windowed row.

@param column — relative to Tuile::Component#rect.left.

@param glyph

Parameters:



409
410
411
412
# File 'lib/tuile/component/tabs.rb', line 409

def draw_cue(row, column, glyph)
  style = row.slice(column, 1).spans.first&.style || StyledString::Style::DEFAULT
  draw_char(rect.left + column, rect.top, glyph, style)
end

#draw_cues(row) ⇒ void

This method returns an undefined value.

Paints the overflow cues over the windowed row's edge columns: < when segments sit to the left of the window, > when more sit to the right. ASCII by convention rather than by constant, as Checkbox's brackets are, and overlaid rather than given reserved columns — reserving would make the window width a function of the offset computed from it.

@param row — the windowed row, as painted.

Parameters:



397
398
399
400
# File 'lib/tuile/component/tabs.rb', line 397

def draw_cues(row)
  draw_cue(row, 0, "<") if @left_column.positive?
  draw_cue(row, rect.width - 1, ">") if @left_column + rect.width < painted_width
end

#extentSize

The cells the strip actually paints: one row, as wide as its segments and separators need, clipped to Tuile::Component#rect. A layout routinely hands a strip a window's full width for a 32-column strip — the extent is those 32 columns.

Both the focus highlight and the click hit test use it, so a click on the blank tail — or on a lower row, when the rect is taller than one — selects nothing. It still focuses: Tuile::Component#handle_mouse's click-to-focus is ungated by geometry.

Returns:



281
282
283
284
285
# File 'lib/tuile/component/tabs.rb', line 281

def extent
  return Size.new(0, 1) if rect.empty?

  Size.new([painted_width - @left_column, rect.width].min, 1)
end

#focusable?Boolean

@returntrue — the strip takes focus, so its arrows work.

Returns:

  • (Boolean)


171
# File 'lib/tuile/component/tabs.rb', line 171

def focusable? = true

#handle_key(key) ⇒ Boolean

Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the strip (the selection clamps). Every other key is left unhandled so it bubbles to an ancestor; an empty strip handles nothing at all.

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


294
295
296
297
298
299
300
# File 'lib/tuile/component/tabs.rb', line 294

def handle_key(key)
  case key
  when Keys::LEFT_ARROW then select_previous
  when Keys::RIGHT_ARROW then select_next
  else false
  end
end

#handle_mouse(event) ⇒ void

This method returns an undefined value.

Selects the tab under a left click; super runs first, so a click anywhere in Tuile::Component#rect still focuses.

@param event

Parameters:



306
307
308
309
310
311
312
# File 'lib/tuile/component/tabs.rb', line 306

def handle_mouse(event)
  super
  return unless event.button == :left

  tab = tab_at(event.point)
  self.selected = tab if tab
end

#index_of!(tab) ⇒ Integer

@param tab

@return — the tab's position on this strip.

Parameters:

Returns:

  • (Integer)


474
475
476
477
478
479
# File 'lib/tuile/component/tabs.rb', line 474

def index_of!(tab)
  index = @tabs.index { |candidate| candidate.equal?(tab) }
  raise ArgumentError, "not a tab of this strip: #{tab.inspect}" if index.nil?

  index
end

#on_width_changedvoid

This method returns an undefined value.

The rect's width is the only part of it the offset depends on, so this hook is the whole geometry story; Tuile::Component#rect= invalidates for us.



342
343
344
345
# File 'lib/tuile/component/tabs.rb', line 342

def on_width_changed
  super
  adjust_left_column
end

#painted_widthInteger

@return — columns the strip would paint given an unlimited rect.

Returns:

  • (Integer)


429
430
431
432
# File 'lib/tuile/component/tabs.rb', line 429

def painted_width
  _tab, start, width = segments.last
  start.nil? ? 0 : start + width
end

#refreshvoid

This method returns an undefined value.

Re-syncs the scroll offset and repaints — what every change to the captions, the separator or the selection ends in.



334
335
336
337
# File 'lib/tuile/component/tabs.rb', line 334

def refresh
  adjust_left_column
  invalidate
end

#remove_tab(tab) ⇒ void

This method returns an undefined value.

Removes tab and detaches its handle permanently.

The selection is never left dangling: removing the selected tab selects whichever tab slid into its place (the new last tab, if it was the last), and removing the final tab leaves #selected nil. Either way #on_tab_selected fires — the empty case with (nil, nil), since a listener rendering from the selection has to be told to render nothing.

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

Parameters:



254
255
256
257
258
259
260
# File 'lib/tuile/component/tabs.rb', line 254

def remove_tab(tab)
  index = index_of!(tab)
  previous = selected
  @tabs.delete_at(index)
  tab.send(:detach)
  apply_selection(selection_after_removing(index), previous)
end

#repaintvoid

This method returns an undefined value.



315
316
317
318
319
320
321
322
# File 'lib/tuile/component/tabs.rb', line 315

def repaint
  super
  return if rect.empty?

  row = strip_row.slice(@left_column, rect.width)
  draw_text(rect.left, rect.top, row)
  draw_cues(row)
end

#segment_text(tab, index) ⇒ StyledString

@param tab

@param index

@return — the caption between its padding columns, styled for the selection.

Parameters:

  • tab (Tab)
  • index (Integer)

Returns:



462
463
464
465
466
467
468
469
# File 'lib/tuile/component/tabs.rb', line 462

def segment_text(tab, index)
  pad = StyledString.plain(" ")
  segment = pad + tab.caption + pad
  return segment unless index == @selected_index

  segment = segment.with_bold
  active? ? segment.with_bg(screen.theme.active_bg_color) : segment
end

#segments::Array[[Tab, Integer, Integer]]

One [tab, start_column, width] triple per tab, in strip order, in columns relative to Tuile::Component#rect.left. A segment's width is its caption plus the two padding columns; the separator columns between segments belong to no segment.

Returns:

  • (::Array[[Tab, Integer, Integer]])


419
420
421
422
423
424
425
426
# File 'lib/tuile/component/tabs.rb', line 419

def segments
  column = 0
  @tabs.each_with_index.map do |tab, index|
    column += separator.display_width if index.positive?
    width = tab.caption.display_width + 2
    [tab, column, width].tap { column += width }
  end
end

#select_at(index) ⇒ void

This method returns an undefined value.

@param index

Parameters:

  • index (Integer, nil)


492
493
494
# File 'lib/tuile/component/tabs.rb', line 492

def select_at(index)
  apply_selection(index, selected)
end

#select_nextBoolean

Selects the next tab, clamping at the last — the strip never wraps. Public because it is the verb an app's own key binding drives.

@returnfalse only when there are no tabs.

Returns:

  • (Boolean)


265
# File 'lib/tuile/component/tabs.rb', line 265

def select_next = step_selection(1)

#select_previousBoolean

Selects the previous tab, clamping at the first.

@returnfalse only when there are no tabs.

Returns:

  • (Boolean)


269
# File 'lib/tuile/component/tabs.rb', line 269

def select_previous = step_selection(-1)

#selectedTab?

@return — the selected tab; nil only while there are no tabs.

Returns:



201
# File 'lib/tuile/component/tabs.rb', line 201

def selected = @selected_index && @tabs[@selected_index]

#selected=(tab) ⇒ void

This method returns an undefined value.

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

Parameters:



211
212
213
# File 'lib/tuile/component/tabs.rb', line 211

def selected=(tab)
  select_at(index_of!(tab))
end

#selection_after_removing(removed_index) ⇒ Integer?

Called with @tabs already shortened and @selected_index still holding the pre-removal position.

@param removed_index — the position the removed tab held.

@return — where the selection lands.

Parameters:

  • removed_index (Integer)

Returns:

  • (Integer, nil)


519
520
521
522
523
524
525
# File 'lib/tuile/component/tabs.rb', line 519

def selection_after_removing(removed_index)
  return nil if @tabs.empty?
  return @selected_index if removed_index > @selected_index
  return @selected_index - 1 if removed_index < @selected_index

  [removed_index, @tabs.size - 1].min
end

#snap_to_glyph_start(column) ⇒ Integer

StyledString#slice drops a cluster straddling the window's edge rather than half-painting it, which would leave the painted row a column short and shift everything past the hole one column left — paint and hit test would then disagree, silently and only for wide glyphs. So the offset only ever lands on a cluster boundary. Snapping forward is the safe direction: it gives up at most one column of the segment to the left of the window, never of the one being revealed.

@param column

@return — the smallest cluster-boundary column >= column.

Parameters:

  • column (Integer)

Returns:

  • (Integer)


380
381
382
383
384
385
386
387
388
# File 'lib/tuile/component/tabs.rb', line 380

def snap_to_glyph_start(column)
  boundary = 0
  strip_row.to_s.each_grapheme_cluster do |glyph|
    return boundary if boundary >= column

    boundary += Buffer.display_width(glyph)
  end
  boundary
end

#step_selection(delta) ⇒ Boolean

@param delta+1 / -1.

@returnfalse only when there are no tabs.

Parameters:

  • delta (Integer)

Returns:

  • (Boolean)


483
484
485
486
487
488
# File 'lib/tuile/component/tabs.rb', line 483

def step_selection(delta)
  return false if @tabs.empty?

  select_at((@selected_index + delta).clamp(0, @tabs.size - 1))
  true
end

#strip_rowStyledString

@return — the whole strip as one row, unclipped: segments left to right, joined by the separator. #repaint windows it to the rect; nothing else may, since the window's own arithmetic is #adjust_left_column's.

Returns:



449
450
451
452
453
454
455
456
# File 'lib/tuile/component/tabs.rb', line 449

def strip_row
  row = StyledString::EMPTY
  @tabs.each_with_index do |tab, index|
    row += separator if index.positive?
    row += segment_text(tab, index)
  end
  row
end

#tab_at(point) ⇒ Tab?

@param point

@return — the tab painted at point; nil for a separator column, the blank tail, or a row the strip doesn't paint.

Parameters:

Returns:



437
438
439
440
441
442
443
# File 'lib/tuile/component/tabs.rb', line 437

def tab_at(point)
  return nil unless extent_rect.contains?(point)

  column = point.x - rect.left + @left_column
  found = segments.find { |_tab, start, width| column >= start && column < start + width }
  found&.first
end

#tab_stop?Boolean

@returntrue — one stop for the whole strip.

Returns:

  • (Boolean)


174
# File 'lib/tuile/component/tabs.rb', line 174

def tab_stop? = true