Class: Tuile::Component::CheckboxGroup
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::CheckboxGroup
- Includes:
- HasValue
- Defined in:
- lib/tuile/component/checkbox_group.rb,
sig/tuile.rbs
Overview
Multi-select from a set of typed items, one checkable row each. Arrows move a cursor; Space, Enter or a left click toggles the row under it:
[x] Errors
[ ] Warnings <- cursor row, highlighted across the full width
[x] Info
^ the composed {List}'s one-column gutter
cg = Component::CheckboxGroup.new(items: %w[Errors Warnings Info])
cg.value = %w[Errors Info] # any Enumerable, stored as a Set
cg.on_value_change { |e| filter(e.value) } # once per toggle
cg.value # => #<Set: {"Errors", "Info"}>
cg.item_label = ->(level) { level.name } # default :to_s
#value is a frozen Set of the selected items themselves — of
whatever type #items holds, never their labels. Frozen so cg.value << item fails loudly rather than mutating the selection behind
HasValue#on_value_change's back; assign a new set or an Array instead.
Treat it as unordered: it iterates in toggle order, so use
cg.items & cg.value.to_a when you need #items order.
Composes rather than subclasses, like ComboBox: a List of the items is
its single 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 box in front of the label. #list is that
list, exposed read-only so an app can tune it (scrollbar_visibility,
show_cursor_when_inactive, …) but never swap it out. Rows beyond
#rect's height scroll; the inner list is the tab stop, not the group.
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 checked 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: cg.value &= cg.items.to_set reconciles them.
Same contract as Tuile::Component::ComboBox#value, one item at a time.
There is no select-all — neither a key nor a header row. An app that wants
one writes cg.value = cg.items behind its own affordance.
Implementation details
Items need stable #hash/#eql?, since the selection is a Set: an item
mutated after being selected becomes unfindable. Two ==-equal items also
share one selection — their rows check and uncheck together — whereas two
distinct items that merely render the same label toggle independently,
because a row resolves to its own item, never to its label.
Rows repeat Checkbox's [x] /[ ] glyph convention rather than
importing a constant from it.
UI-thread-confined, like every component (see Screen).
Constant Summary collapse
- EMPTY_SELECTION =
Set.new.freeze
Instance Attribute Summary collapse
-
#item_label ⇒ Proc, Method
@return — item -> row label (a
String, StyledString, or anything with#to_s);:to_sby default. -
#list ⇒ List
readonly
The composed List: an app may tune it — its scrollbar, its cursor,
show_cursor_when_inactive— but never replace it, since this group's renderer and selection are wired into this one (design/decisions.mdD_has_content).
Instance Method Summary collapse
-
#clear ⇒ void
Resets #value to #empty_value.
-
#coerce(new_value) ⇒ ::Set[untyped]
@param
new_value. -
#empty? ⇒ Boolean
Empty of value: a field whose parse is partial reports
truewhile the user is looking at glyphs it could not use, so ask HasBadInput#bad_input? first. -
#empty_value ⇒ ::Set[untyped]
@return — the frozen empty set — HasValue#empty? means nothing is selected.
-
#error_bg_color ⇒ Color?
The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides #error_bg_color).
-
#error_ink? ⇒ Boolean
Whether to paint the invalid well right now.
-
#error_message ⇒ StyledString?
@return — why the field is invalid, or
nilwhen it is not;niluntil something sets it. -
#error_message= ⇒ void
Sets the verdict and repaints the field in Theme#error_color;
nilclears it. -
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. - #handle_focus ⇒ void
-
#handle_key?(key) ⇒ Boolean
Toggles the cursor row on Space.
-
#initialize(items: [], value: nil) ⇒ CheckboxGroup
constructor
@param
items— the items to present, one row each; also settable via #items=. -
#inspect_details ⇒ ::Array[String]
Adds
value=…to #inspect, omitted while the value is nil. -
#items ⇒ ::Array[untyped]
@return — the presented items.
-
#items=(new_items) ⇒ void
Replaces the presented rows, leaving #value untouched.
-
#label_for(item) ⇒ StyledString, String
@param
item. -
#on_error_message_change ⇒ Listeners
Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set.
-
#on_value_change ⇒ Listeners
Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.
- #relayout ⇒ void
-
#render_row(item, _text_width) ⇒ StyledString
@param
item. -
#set_value(new_value, from_user:) ⇒ void
Replaces the selection, firing HasValue#on_value_change when it really changed.
-
#shown_message ⇒ StyledString, ...
The message a consumer with cells of its own paints beside the field: the verdict here, and HasBadInput widens it to prefer the field's own report, which outranks a verdict computed a pass ago.
-
#toggle(item) ⇒ void
Flips
item's membership of #value. -
#toggle_at(index) ⇒ void
Flips membership of the item on row
index; an index outside #items is ignored — List::Cursor::None's-1would otherwise toggle the last item. -
#value ⇒ Object
@return — the current value;
niluntil first set. -
#value= ⇒ void
A programmatic write: #set_value with
from_user: false.
Methods included from HasValue
Methods included from Listeners::Declare
Methods included from HasValidation
Constructor Details
#initialize(items: [], value: nil) ⇒ CheckboxGroup
@param items — the items to present, one row each; also settable via #items=.
@param value — the initial selection. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.
68 69 70 71 72 73 74 75 76 77 78 79 80 81 |
# File 'lib/tuile/component/checkbox_group.rb', line 68 def initialize(items: [], value: nil) super() @item_label = :to_s.to_proc @value = coerce(value) 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 { |e| toggle(e.item) } list.items = items.to_a @list = list add_child(list, at: 0) end |
Instance Attribute Details
#item_label ⇒ Proc, Method
@return — item -> row label (a String, StyledString, or
anything with #to_s); :to_s by default.
108 109 110 |
# File 'lib/tuile/component/checkbox_group.rb', line 108 def item_label @item_label end |
#list ⇒ List (readonly)
The composed List: an app may tune it — its scrollbar, its cursor,
show_cursor_when_inactive — but never replace it, since this group's
renderer and selection are wired into this one (design/decisions.md
D_has_content). Those knobs are List concepts rather than group
concepts, which is why they are reached here instead of forwarded
(D_wrapping_field).
90 91 92 |
# File 'lib/tuile/component/checkbox_group.rb', line 90 def list @list end |
Instance Method Details
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
An includer whose input can outrun its value (HasBadInput) must clear
the input: a field holding bad input already reads empty_value, so
inheriting this default — over a #set_value that returns early on a
no-op set — is a clear that leaves the garbage on screen. A clear is
programmatic: its event says from_user? == false.
12668 |
# File 'sig/tuile.rbs', line 12668
def clear: () -> void
|
#coerce(new_value) ⇒ ::Set[untyped]
@param new_value
@return — a frozen copy; nil becomes #empty_value.
192 193 194 195 196 197 |
# File 'lib/tuile/component/checkbox_group.rb', line 192 def coerce(new_value) return empty_value if new_value.nil? raise TypeError, "expected Enumerable, got #{new_value.inspect}" unless new_value.is_a?(Enumerable) Set.new(new_value).freeze end |
#empty? ⇒ Boolean
Empty of value: a field whose parse is partial reports true while the
user is looking at glyphs it could not use, so ask
HasBadInput#bad_input? first.
@return — true iff #value equals #empty_value.
12659 |
# File 'sig/tuile.rbs', line 12659
def empty?: () -> bool
|
#empty_value ⇒ ::Set[untyped]
@return — the frozen empty set — HasValue#empty? means nothing is selected.
127 |
# File 'lib/tuile/component/checkbox_group.rb', line 127 def empty_value = EMPTY_SELECTION |
#error_bg_color ⇒ Color?
The invalid well, picked up by everything this component paints — including the inner face of a composed field and the List of a group, neither of which forwards anything: both declare no background of their own, so the ordinary chain walks up to this (overrides Tuile::Component#error_bg_color).
12718 |
# File 'sig/tuile.rbs', line 12718
def error_bg_color: () -> Color?
|
#error_ink? ⇒ Boolean
Whether to paint the invalid well right now. Its own hook because HasBadInput widens it: a field holding input its value cannot represent is invalid on the face too, even with no verdict written.
12723 |
# File 'sig/tuile.rbs', line 12723
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
12688 |
# File 'sig/tuile.rbs', line 12688
def error_message: () -> StyledString?
|
#error_message= ⇒ void
This method returns an undefined value.
Sets the verdict and repaints the field in Theme#error_color; nil
clears it. No-op (no repaint, no listener) when unchanged. A String is
parsed via StyledString.parse, as HasCaption#caption= does.
Safe on a detached field — an app validates a form it assembled but has not mounted, and Tuile::Component#invalidate is already a no-op there.
@param new_message
12698 |
# File 'sig/tuile.rbs', line 12698
def error_message=: ((String | StyledString)? new_message) -> void
|
#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 (design/decisions.md
D_integer_field).
12675 |
# File 'sig/tuile.rbs', line 12675
def focusable?: () -> bool
|
#handle_focus ⇒ void
This method returns an undefined value.
96 97 98 99 100 101 |
# File 'lib/tuile/component/checkbox_group.rb', line 96 def handle_focus super # The list is what the arrows drive, so it takes the focus this group # was given; the group itself claims only Space. screen.focused = list if list.focusable? end |
#handle_key?(key) ⇒ Boolean
Toggles 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
152 153 154 155 156 157 |
# File 'lib/tuile/component/checkbox_group.rb', line 152 def handle_key?(key) return false unless key == " " toggle_at(list.cursor.position) true end |
#inspect_details ⇒ ::Array[String]
Adds value=… to Tuile::Component#inspect, omitted while the value is nil.
12678 |
# File 'sig/tuile.rbs', line 12678
def inspect_details: () -> ::Array[String]
|
#items ⇒ ::Array[untyped]
@return — the presented items.
104 |
# File 'lib/tuile/component/checkbox_group.rb', line 104 def items = list.items |
#items=(new_items) ⇒ void
This method returns an undefined value.
Replaces the presented rows, leaving #value untouched.
@param new_items
114 115 116 |
# File 'lib/tuile/component/checkbox_group.rb', line 114 def items=(new_items) list.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.
202 203 204 205 |
# File 'lib/tuile/component/checkbox_group.rb', line 202 def label_for(item) label = @item_label.call(item) label.is_a?(StyledString) ? label : label.to_s end |
#on_error_message_change ⇒ Listeners
Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set. The container that paints the message registers here, and so may an app painting its own: the list takes both, which a single slot could not.
12684 |
# File 'sig/tuile.rbs', line 12684
def on_error_message_change: () -> Listeners
|
#on_value_change ⇒ Listeners
Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.
12642 |
# File 'sig/tuile.rbs', line 12642
def on_value_change: () -> Listeners
|
#relayout ⇒ void
This method returns an undefined value.
93 |
# File 'lib/tuile/component/checkbox_group.rb', line 93 def relayout = list.rect = local_rect |
#render_row(item, _text_width) ⇒ StyledString
185 186 187 |
# File 'lib/tuile/component/checkbox_group.rb', line 185 def render_row(item, _text_width) StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item) end |
#set_value(new_value, from_user:) ⇒ void
This method returns an undefined value.
Replaces the selection, firing HasValue#on_value_change when it really
changed. Stores a frozen Set copy, so a set the caller goes on
mutating can't reach in.
@param new_value — nil selects nothing.
@param from_user — see HasValue#set_value.
136 137 138 139 140 141 142 143 144 |
# File 'lib/tuile/component/checkbox_group.rb', line 136 def set_value(new_value, from_user:) selected = coerce(new_value) # HasValue#set_value no-ops on an unchanged value; this guard is what also # skips the row rebuild. return if value == selected super(selected, from_user:) list.refresh_rows end |
#shown_message ⇒ StyledString, ...
The message a consumer with cells of its own paints beside the field: the verdict here, and HasBadInput widens it to prefer the field's own report, which outranks a verdict computed a pass ago.
field.on_error_message_change { message_label.caption = field.shown_message.to_s }
field.on_bad_input_change { message_label.caption = field.shown_message.to_s }
Register on both: two channels with two writers, either of which moves it.
@return — nil when there is nothing to show;
a plain String when it is the field's own bad-input report.
12711 |
# File 'sig/tuile.rbs', line 12711
def shown_message: () -> (StyledString | String)?
|
#toggle(item) ⇒ void
175 176 177 |
# File 'lib/tuile/component/checkbox_group.rb', line 175 def toggle(item) set_value(value.include?(item) ? value - [item] : value + [item], from_user: true) end |
#toggle_at(index) ⇒ void
This method returns an undefined value.
Flips membership of the item on row index; an index outside #items is
ignored — List::Cursor::None's -1 would otherwise toggle the last
item.
@param index
166 167 168 169 170 |
# File 'lib/tuile/component/checkbox_group.rb', line 166 def toggle_at(index) return unless index.between?(0, items.size - 1) toggle(items[index]) end |
#value ⇒ Object
@return — the current value; nil until first set.
12645 |
# File 'sig/tuile.rbs', line 12645
def value: () -> Object
|
#value= ⇒ void
This method returns an undefined value.
A programmatic write: #set_value with from_user: false. Defined
here once and never overridden — override #set_value instead, since a
setter has no call syntax for the keyword.
@param new_value
12652 |
# File 'sig/tuile.rbs', line 12652
def value=: (Object new_value) -> void
|