Class: Tuile::Component::CheckboxGroup

Inherits:
Component
  • Object
show all
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 =

Returns:

  • (Set)
Set.new.freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Methods included from HasValue

listener

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

listener

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.

Parameters:

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


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.

Returns:

  • (Proc, Method)


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).

Returns:



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.

Parameters:

  • new_value (::Enumerable[untyped], nil)

Returns:

  • (::Set[untyped])


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.

Returns:

  • (Boolean)


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.

Returns:

  • (::Set[untyped])


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).

Returns:



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.

Returns:

  • (Boolean)


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.

Returns:



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

Parameters:



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).

Returns:

  • (Boolean)


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

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Returns:

  • (::Array[String])


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

def inspect_details: () -> ::Array[String]

#items ⇒ ::Array[untyped]

@return — the presented items.

Returns:

  • (::Array[untyped])


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

Parameters:

  • new_items (::Array[untyped])


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.

Parameters:

  • item (Object)

Returns:



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.

Returns:



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.

Returns:



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

@param item

@param _text_width — unused: a box plus a label is as wide as it is, and List ellipsizes what will not fit.

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

Parameters:

  • item (Object)
  • _text_width (Integer)

Returns:



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.

Parameters:

  • new_value (::Enumerable[untyped], nil)
  • from_user: (Boolean)


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.

Returns:



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

def shown_message: () -> (StyledString | String)?

#toggle(item) ⇒ void

This method returns an undefined value.

Flips item's membership of #value.

@param item

Parameters:

  • item (Object)


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

Parameters:

  • index (Integer)


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.

Returns:

  • (Object)


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

Parameters:

  • new_value (Object)


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

def value=: (Object new_value) -> void