Class: Tuile::Component::CheckboxGroup

Inherits:
Component
  • Object
show all
Includes:
HasContent, 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 = ->(set) { filter(set) }   # 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 is its single HasContent child, which is where the cursor, scrolling, the scrollbar and per-row mouse hit-testing come from. 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.

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 an item by index.

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

Attributes included from HasValue

#on_value_change

Attributes included from HasContent

#content

Instance Method Summary collapse

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()
  @items = items.to_a
  @item_label = :to_s.to_proc
  @value = coerce(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.on_item_chosen = ->(index, _line) { toggle_at(index) }
  self.content = list
  rebuild_rows
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)


88
89
90
# File 'lib/tuile/component/checkbox_group.rb', line 88

def item_label
  @item_label
end

#items::Array[untyped]

@return — the presented items.

Returns:

  • (::Array[untyped])


84
85
86
# File 'lib/tuile/component/checkbox_group.rb', line 84

def items
  @items
end

Instance Method Details

#clearvoid

This method returns an undefined value.

Resets #value to #empty_value.



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

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


172
173
174
175
176
177
# File 'lib/tuile/component/checkbox_group.rb', line 172

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

@return — true iff #value equals #empty_value.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_value::Set[untyped]

@return — the frozen empty set — HasValue#empty? means nothing is selected.

Returns:

  • (::Set[untyped])


110
# File 'lib/tuile/component/checkbox_group.rb', line 110

def empty_value = EMPTY_SELECTION

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


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

def focusable?: () -> bool

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


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

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

  toggle_at(content.cursor.position)
  true
end

#handle_mousevoid

This method returns an undefined value.

@param event

Parameters:



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

def handle_mouse: (MouseEvent event) -> void

#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:



182
183
184
185
# File 'lib/tuile/component/checkbox_group.rb', line 182

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/checkbox_group.rb', line 146

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

#on_focusvoid

This method returns an undefined value.



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

def on_focus: () -> void

#rebuild_rowsvoid

This method returns an undefined value.

Re-renders every row from the current items, labels and selection.



163
164
165
166
167
# File 'lib/tuile/component/checkbox_group.rb', line 163

def rebuild_rows
  content.lines = @items.map do |item|
    StyledString.plain(value.include?(item) ? "[x] " : "[ ] ") + label_for(item)
  end
end

#rect=void

This method returns an undefined value.

@param rect

Parameters:



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

def rect=: (Rect rect) -> void

#toggle_at(index) ⇒ void

This method returns an undefined value.

Flips membership of the item on row index; an index outside #items is ignored.

@param index

Parameters:

  • index (Integer)


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

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

  item = @items[index]
  self.value = value.include?(item) ? value - [item] : value + [item]
end

#valueObject

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

Returns:

  • (Object)


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

def value: () -> Object

#value=(new_value) ⇒ 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_valuenil selects nothing.

Parameters:

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


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

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

  super(selected)
  rebuild_rows
end