Class: Tuile::Component::RadioGroup
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::RadioGroup
- 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
-
#item_label ⇒ Proc, Method
@return — item -> row label (a
String, StyledString, or anything with#to_s);:to_sby default.
Attributes included from HasValue
Attributes included from HasContent
Instance Method Summary collapse
-
#clamp_cursor(item_count) ⇒ void
Pulls an over-range cursor back onto the last row (row 0 when there are none).
-
#clear ⇒ void
Resets #value to #empty_value.
-
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
- #empty_value ⇒ Object
-
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. -
#handle_key(key) ⇒ Boolean
Selects the cursor row on Space.
-
#handle_mouse ⇒ void
@param
event. -
#initialize(items: [], value: nil) ⇒ RadioGroup
constructor
@param
items— the items to present, one row each; also settable via #items=. -
#items ⇒ ::Array[untyped]
@return — the presented items.
-
#items=(new_items) ⇒ void
Replaces the presented rows, leaving #value untouched and clamping the cursor back into range.
-
#label_for(item) ⇒ StyledString, String
@param
item. -
#layout(list) ⇒ void
Places the composed list across the whole rect (HasContent hook).
- #on_focus ⇒ void
-
#rect= ⇒ void
@param
rect. -
#render_row(item) ⇒ StyledString
@param
item. -
#select_at(index) ⇒ void
Selects the item on row
index; an index outside #items is ignored — List::Cursor::None's-1would otherwise select the last item. -
#value ⇒ Object
@return — the current value;
niluntil first set. -
#value=(new_value) ⇒ void
Selects
new_value, firing HasValue#on_value_change when it really changed.
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.
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_label ⇒ Proc, Method
@return — item -> row label (a String, StyledString, or
anything with #to_s); :to_s by default.
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.
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 |
#clear ⇒ void
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.
4639 |
# File 'sig/tuile.rbs', line 4639
def empty?: () -> bool
|
#empty_value ⇒ 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).
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
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_mouse ⇒ void
This method returns an undefined value.
@param event
4656 |
# File 'sig/tuile.rbs', line 4656
def handle_mouse: (MouseEvent event) -> void
|
#items ⇒ ::Array[untyped]
@return — the presented items.
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
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.
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
146 |
# File 'lib/tuile/component/radio_group.rb', line 146 def layout(list) = (list.rect = rect) |
#on_focus ⇒ void
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
4659 |
# File 'sig/tuile.rbs', line 4659
def rect=: (Rect rect) -> void
|
#render_row(item) ⇒ StyledString
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
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 |
#value ⇒ Object
@return — the current value; nil until first set.
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_value — nil selects nothing; an item outside #items is kept but renders no marked row.
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 |