Class: Tuile::Component::Tabs
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::Tabs
- 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.
"│"
Instance Attribute Summary collapse
-
#left_column ⇒ Integer
readonly
@return — the strip column painted in #rect's leftmost cell — the horizontal scroll offset.
-
#on_tab_selected ⇒ Proc?
Called on every change of #selected with the new selection —
(index, tab), or(nil, nil)once the last tab has been removed. -
#selected_index ⇒ Integer?
@return — the selected tab's position;
nilonly while there are no tabs. -
#separator ⇒ StyledString, String
@return — the column painted between two segments.
-
#tabs ⇒ ::Array[Tab]
readonly
@return — the tabs, in strip order.
Instance Method Summary collapse
-
#add_tab(caption = nil) ⇒ Tab
Appends a tab and returns its handle.
-
#adjust_left_column ⇒ void
Scrolls the minimum needed to show the selected segment whole, and is the sole writer of #left_column.
-
#apply_selection(index, previous) ⇒ void
Stores the selection, repaints, and fires #on_tab_selected when the selected tab changed.
-
#draw_cue(row, column, glyph) ⇒ void
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.
-
#draw_cues(row) ⇒ void
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. -
#extent ⇒ Size
The cells the strip actually paints: one row, as wide as its segments and separators need, clipped to #rect.
-
#focusable? ⇒ Boolean
@return —
true— the strip takes focus, so its arrows work. -
#handle_key(key) ⇒ Boolean
Switches tabs on LEFT / RIGHT, consuming the key even at the ends of the strip (the selection clamps).
-
#handle_mouse(event) ⇒ void
Selects the tab under a left click;
superruns first, so a click anywhere in #rect still focuses. -
#index_of!(tab) ⇒ Integer
@param
tab. -
#initialize(separator: DEFAULT_SEPARATOR) ⇒ Tabs
constructor
@param
separator— see #separator=. -
#on_width_changed ⇒ void
The rect's width is the only part of it the offset depends on, so this hook is the whole geometry story; #rect= invalidates for us.
-
#painted_width ⇒ Integer
@return — columns the strip would paint given an unlimited rect.
-
#refresh ⇒ void
Re-syncs the scroll offset and repaints — what every change to the captions, the separator or the selection ends in.
-
#remove_tab(tab) ⇒ void
Removes
taband detaches its handle permanently. - #repaint ⇒ void
-
#segment_text(tab, index) ⇒ StyledString
@param
tab. -
#segments ⇒ ::Array[[Tab, Integer, Integer]]
One
[tab, start_column, width]triple per tab, in strip order, in columns relative to #rect.left. -
#select_at(index) ⇒ void
@param
index. -
#select_next ⇒ Boolean
Selects the next tab, clamping at the last — the strip never wraps.
-
#select_previous ⇒ Boolean
Selects the previous tab, clamping at the first.
-
#selected ⇒ Tab?
@return — the selected tab;
nilonly while there are no tabs. -
#selected=(tab) ⇒ void
@param
tab— one of this strip's tabs. -
#selection_after_removing(removed_index) ⇒ Integer?
Called with
@tabsalready shortened and@selected_indexstill holding the pre-removal position. -
#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.
-
#step_selection(delta) ⇒ Boolean
@param
delta—+1/-1. -
#strip_row ⇒ StyledString
@return — the whole strip as one row, unclipped: segments left to right, joined by the separator.
-
#tab_at(point) ⇒ Tab?
@param
point. -
#tab_stop? ⇒ Boolean
@return —
true— one stop for the whole strip.
Constructor Details
#initialize(separator: DEFAULT_SEPARATOR) ⇒ Tabs
@param separator — see #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_column ⇒ Integer (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.
329 330 331 |
# File 'lib/tuile/component/tabs.rb', line 329 def left_column @left_column end |
#on_tab_selected ⇒ Proc?
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.
159 160 161 |
# File 'lib/tuile/component/tabs.rb', line 159 def on_tab_selected @on_tab_selected end |
#selected_index ⇒ Integer?
@return — the selected tab's position; nil only while
there are no tabs.
205 206 207 |
# File 'lib/tuile/component/tabs.rb', line 205 def selected_index @selected_index end |
#separator ⇒ StyledString, String
@return — the column painted between two segments.
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" }.
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.
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_column ⇒ void
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.
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
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.
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 |
#extent ⇒ Size
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.
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
@return — true — the strip takes focus, so its arrows work.
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
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
306 307 308 309 310 311 312 |
# File 'lib/tuile/component/tabs.rb', line 306 def handle_mouse(event) super return unless event. == :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.
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_changed ⇒ void
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_width ⇒ Integer
@return — columns the strip would paint given an unlimited rect.
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 |
#refresh ⇒ void
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.
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 |
#repaint ⇒ void
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.
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.
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
492 493 494 |
# File 'lib/tuile/component/tabs.rb', line 492 def select_at(index) apply_selection(index, selected) end |
#select_next ⇒ Boolean
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.
@return — false only when there are no tabs.
265 |
# File 'lib/tuile/component/tabs.rb', line 265 def select_next = step_selection(1) |
#select_previous ⇒ Boolean
Selects the previous tab, clamping at the first.
@return — false only when there are no tabs.
269 |
# File 'lib/tuile/component/tabs.rb', line 269 def select_previous = step_selection(-1) |
#selected ⇒ Tab?
@return — the selected tab; nil only while there are no tabs.
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.
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.
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.
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.
@return — false only when there are no tabs.
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_row ⇒ StyledString
@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.
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.
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
@return — true — one stop for the whole strip.
174 |
# File 'lib/tuile/component/tabs.rb', line 174 def tab_stop? = true |