Class: Tuile::Component::RadioGroup

Inherits:
Component
  • Object
show all
Includes:
HasContent, HasValue
Defined in:
lib/tuile/component/radio_group.rb,
sig/tuile.rbs

Overview

Single-select from a set of typed items, one row each. Arrows move a cursor; Space, Enter or a left click selects the row under it:

(*) Ascending
( ) Descending    <- cursor row, highlighted across the full width
( ) Unsorted
^ the composed {List}'s one-column gutter

rg = Component::RadioGroup.new(items: %w[Ascending Descending Unsorted])
rg.value = "Descending"                      # or seed it via the ctor
rg.on_value_change = ->(order) { resort(order) }
rg.value                                     # => "Descending"
rg.item_label = ->(o) { o.title }            # default :to_s

#value is the selected item itself — of whatever type #items holds, never its label. nil means nothing is selected: that is the initial state, and assigning it is the only way back, since Space on the already-selected row is a no-op rather than a deselect.

Composes rather than subclasses, like ComboBox: a List of the items is its single HasContent child, which is where the cursor, scrolling, the scrollbar and per-row mouse hit-testing come from — the group only supplies the List#renderer that puts the marker in front of the label. content is that list, so an app can tune it (scrollbar_visibility, show_cursor_when_inactive, …). Rows beyond #rect's height scroll; the inner list is the tab stop, not the group.

The cursor is chrome

The cursor and the selection are two independent things, as in CheckboxGroup — arrows roam without changing #value, so a listener that resorts a pane fires once on intent instead of once per row crossed. #value= therefore does not move the cursor. An app that wants it parked on the selection parks it:

rg.content.cursor = List::Cursor.new(position: rg.items.index(rg.value))

#items= is the one thing that moves it, clamping it back into range.

items is chrome; value is authoritative

#items= changes only what is presented. It never touches #value and never fires HasValue#on_value_change, and a selected item absent from #items renders no marked row while surviving intact — so a form saved without the user editing anything changes nothing silently. Keeping the two in sync is the app's job. Same contract as ComboBox#value and CheckboxGroup#value.

Implementation details

Two ==-equal items share one selection, so selecting either marks both rows; two distinct items that merely render the same label stay independent, because a row resolves to its own item, never to its label.

Rows are (*) /( ) literals, mirroring Checkbox's convention rather than importing constants from it. ASCII deliberately: (•) would measure two columns in a terminal configured for East-Asian-Ambiguous glyphs and shift every row's text, which no test would catch.

UI-thread-confined, like every component (see Screen).

Instance Attribute Summary collapse

Attributes included from HasValue

#on_value_change

Attributes included from HasContent

#content

Instance Method Summary collapse

Constructor Details

#initialize(items: [], value: nil) ⇒ RadioGroup

@param items — the items to present, one row each; also settable via #items=.

@param value — the initially selected item. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.

Parameters:

  • items: (::Array[untyped]) (defaults to: [])
  • value: (Object, nil) (defaults to: nil)


71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/tuile/component/radio_group.rb', line 71

def initialize(items: [], value: nil)
  super()
  @item_label = :to_s.to_proc
  @value = value
  @on_value_change = nil

  list = List.new
  # A List has no cursor at all by default (Cursor::None, position -1).
  list.cursor = List::Cursor.new
  list.renderer = method(:render_row)
  list.on_item_chosen = ->(_index, item) { self.value = item }
  list.items = items.to_a
  self.content = list
end

Instance Attribute Details

#item_labelProc, Method

@return — item -> row label (a String, StyledString, or anything with #to_s); :to_s by default.

Returns:

  • (Proc, Method)


91
92
93
# File 'lib/tuile/component/radio_group.rb', line 91

def item_label
  @item_label
end

Instance Method Details

#clamp_cursor(item_count) ⇒ void

This method returns an undefined value.

Pulls an over-range cursor back onto the last row (row 0 when there are none). List#items= leaves a stale cursor alone, which would strand it off-content: no highlight, a dead Enter, and a Space that resolves to nil and silently clears the selection.

@param item_count — size of the incoming item list.

Parameters:

  • item_count (Integer)


174
175
176
177
178
179
# File 'lib/tuile/component/radio_group.rb', line 174

def clamp_cursor(item_count)
  cursor = content.cursor
  # go_to_last funnels through Cursor#go's clamp(0, nil), so an empty
  # items list floors at 0 instead of going negative.
  cursor.go_to_last(item_count) if cursor.position >= item_count
end

#clearvoid

This method returns an undefined value.

Resets #value to #empty_value.



4642
# File 'sig/tuile.rbs', line 4642

def clear: () -> void

#empty?Boolean

@return — true iff #value equals #empty_value.

Returns:

  • (Boolean)


4639
# File 'sig/tuile.rbs', line 4639

def empty?: () -> bool

#empty_valueObject

@return — the value #empty?/#clear treat as empty; nil unless an includer overrides it.

Returns:

  • (Object)


4646
# File 'sig/tuile.rbs', line 4646

def empty_value: () -> Object

#focusable?Boolean

Input fields are focusable by default (overrides Tuile::Component#focusable?); a read-only display field could override back to false. Only focusable? lives here — tab_stop? diverges between leaf fields and composing wrappers, so it stays per-class (DECISIONS.md D-integer-field).

Returns:

  • (Boolean)


4653
# File 'sig/tuile.rbs', line 4653

def focusable?: () -> bool

#handle_key(key) ⇒ Boolean

Selects the cursor row on Space. Nothing else is claimed: the composed List — being the focused component — has already had its chance at the key (its arrows, Home/End, PgUp/PgDn, ^U/^D and Enter), and whatever neither of us wants bubbles on to an ancestor.

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


134
135
136
137
138
139
# File 'lib/tuile/component/radio_group.rb', line 134

def handle_key(key)
  return false unless key == " "

  select_at(content.cursor.position)
  true
end

#handle_mousevoid

This method returns an undefined value.

@param event

Parameters:



4656
# File 'sig/tuile.rbs', line 4656

def handle_mouse: (MouseEvent event) -> void

#items::Array[untyped]

@return — the presented items.

Returns:

  • (::Array[untyped])


87
# File 'lib/tuile/component/radio_group.rb', line 87

def items = content.items

#items=(new_items) ⇒ void

This method returns an undefined value.

Replaces the presented rows, leaving #value untouched and clamping the cursor back into range.

@param new_items

Parameters:

  • new_items (::Array[untyped])


98
99
100
101
102
103
104
105
# File 'lib/tuile/component/radio_group.rb', line 98

def items=(new_items)
  raise TypeError, "expected Array, got #{new_items.inspect}" unless new_items.is_a?(Array)

  # Before the items land, so the single {List#on_cursor_changed} that
  # {List#items=} fires reports the final row rather than a stale one.
  clamp_cursor(new_items.size)
  content.items = new_items
end

#label_for(item) ⇒ StyledString, String

@param item

@return — whichever StyledString#+ accepts on the right — so a styled label keeps its spans and a plain one is parsed.

Parameters:

  • item (Object)

Returns:



184
185
186
187
# File 'lib/tuile/component/radio_group.rb', line 184

def label_for(item)
  label = @item_label.call(item)
  label.is_a?(StyledString) ? label : label.to_s
end

#layout(list) ⇒ void

This method returns an undefined value.

Places the composed list across the whole rect (HasContent hook).

@param list

Parameters:



146
# File 'lib/tuile/component/radio_group.rb', line 146

def layout(list) = (list.rect = rect)

#on_focusvoid

This method returns an undefined value.



4661
# File 'sig/tuile.rbs', line 4661

def on_focus: () -> void

#rect=void

This method returns an undefined value.

@param rect

Parameters:



4659
# File 'sig/tuile.rbs', line 4659

def rect=: (Rect rect) -> void

#render_row(item) ⇒ StyledString

@param item

@return — the item's row: its label behind a selection marker. The List calls this at paint time, so the marker tracks #value without re-rendering anything but the visible rows.

Parameters:

  • item (Object)

Returns:



164
165
166
# File 'lib/tuile/component/radio_group.rb', line 164

def render_row(item)
  StyledString.plain(item == value ? "(*) " : "( ) ") + label_for(item)
end

#select_at(index) ⇒ void

This method returns an undefined value.

Selects the item on row index; an index outside #items is ignored — List::Cursor::None's -1 would otherwise select the last item.

@param index

Parameters:

  • index (Integer)


154
155
156
157
158
# File 'lib/tuile/component/radio_group.rb', line 154

def select_at(index)
  return unless index.between?(0, items.size - 1)

  self.value = items[index]
end

#valueObject

@return — the current value; nil until first set.

Returns:

  • (Object)


4636
# File 'sig/tuile.rbs', line 4636

def value: () -> Object

#value=(new_value) ⇒ void

This method returns an undefined value.

Selects new_value, firing HasValue#on_value_change when it really changed. The cursor stays where it is.

@param new_valuenil selects nothing; an item outside #items is kept but renders no marked row.

Parameters:

  • new_value (Object, nil)


119
120
121
122
123
124
125
126
# File 'lib/tuile/component/radio_group.rb', line 119

def value=(new_value)
  # HasValue#value= no-ops on an unchanged value; this guard is what also
  # skips the row rebuild.
  return if value == new_value

  super
  content.refresh_rows
end