Class: Tuile::Component::Select
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::Select
- Includes:
- HasValue
- Defined in:
- lib/tuile/component/select.rb,
sig/tuile.rbs
Overview
A closed-choice field on one row: the selected item's label plus a ▾
affordance, dropping open a ListDropdown of the options. Enter, Space or
Down opens it; the arrows (and PgUp/PgDn) move the highlight; Enter or
Space commits; ESC dismisses without committing.
warn ▾ <- the face: one row, on a field well
debug <- the dropdown, measured to the widest label
info (the one-column gutters are {List}'s)
warn <- highlighted: the value's row, on open
error
sel = Component::Select.new(items: LogLevel.all)
sel.item_label = ->(l) { l.name } # item -> shown label; default :to_s
sel.on_value_change = ->(l) { relog(l) } # fires on commit, with the item
sel.value = LogLevel::WARN # selects it; the face shows its label
Use it for an enum — labels the developer authored, a closed set known when the code is written: log level, sort order, line endings, Yes/No/Ask. For items the app supplies at runtime with labels you don't control (countries, users, branches) reach for ComboBox instead, where filtering is the navigation. Item count is a symptom, not the criterion; book ch7 has the widget-choice table.
#value is the selected item, of whatever type #items holds, never its
label; nil — a blank face — is the initial state and stays legal, so an
optional enum field needs no placeholder. As on ComboBox, #items= is
chrome: it never touches #value, never fires HasValue#on_value_change,
and a value absent from #items survives intact while rendering nothing
selected. Keeping the two in sync is the app's job.
It claims no printable key but Space
Enter, Space, ESC, ListDropdown::MOVE_KEYS and the mouse. Every other
printable key bubbles past it (key-dispatch rung 3), so a form's s-to-save
and a layout's 1/2/3 pane jumps keep working while a Select has focus
— the one capability no ComboBox configuration can offer, since a text
field eats printables unconditionally. Space is the single exception, and it
forecloses nothing: every activatable widget in the gem already claims it.
Home/End are declined too, so they stay available app-wide.
There is no type-ahead: a hidden prefix buffer is the ComboBox query with
the feedback removed (DECISIONS.md D-select). Which is also why labels
need no prefix-disambiguation.
Implementation details
A leaf widget: it paints its own row (the face is derived from #value each paint, never a synced copy) and owns the dropdown as an overlay, which is not a child — like ComboBox's. The well is read from Screen#theme at paint time, so it tracks a theme flip with no hook.
The dropdown is at least as wide as the face and grows to fit the widest label, so the labels are never the thing that ellipsizes. It is not opened at all when #items is empty: an item-less Select is a programming bug, and an empty tinted panel reads as a broken list rather than as "nothing to pick". Enter/Space/Down are claimed either way.
UI-thread-confined, like every component (see Screen).
Instance Attribute Summary collapse
-
#item_label ⇒ Proc, Method
@return — item -> shown label (a
Stringor StyledString);:to_sby default. -
#items ⇒ ::Array[untyped]
@return — the options.
Attributes included from HasValue
Instance Method Summary collapse
-
#active=(flag) ⇒ void
Closes the dropdown when the Select leaves the focus chain, so tabbing away doesn't strand an open menu.
- #anchor ⇒ void
-
#clear ⇒ void
Resets #value to #empty_value.
- #close_menu ⇒ void
-
#commit(item) ⇒ void
Adopts the chosen item as #value and closes the dropdown.
-
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
- #empty_value ⇒ Object
-
#face_row ⇒ StyledString
The painted row: the value's label padded across all but the last column, then the
▾, all on the field well — Theme#active_bg_color while on the focus chain, Theme#input_bg_color otherwise. -
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. -
#handle_key(key) ⇒ Boolean
Opens the dropdown on Enter, Space or Down; while it is open, forwards ListDropdown::MOVE_KEYS to it, commits the highlight on Enter or Space, and dismisses on ESC.
-
#handle_mouse(event) ⇒ void
Toggles the dropdown on a left click anywhere in #rect — a field's affordance is its whole row, as the well advertises;
superruns first, so the click also focuses. -
#initialize(items: [], value: nil) ⇒ Select
constructor
@param
items— the options (any type); also settable via #items=. -
#label_for(item) ⇒ StyledString
@param
item. -
#menu_width ⇒ Integer
The dropdown's width: the widest label plus List's two row gutters, plus the scrollbar column when the rows can't all be shown at once — but never narrower than the Select itself, so both edges line up with the face and the panel reads as belonging to it.
- #open_menu ⇒ void
-
#rect=(new_rect) ⇒ void
Re-anchors the (open) dropdown after a move or resize.
-
#refill ⇒ void
Rebuilds the dropdown's rows, highlight and geometry, opening it if needed; closes it instead when there is nothing to show.
- #repaint ⇒ void
- #tab_stop? ⇒ Boolean
-
#value ⇒ Object
@return — the current value;
niluntil first set. -
#value= ⇒ void
No-op (no repaint, no listener) when equal to the current value.
Constructor Details
#initialize(items: [], value: nil) ⇒ Select
@param items — the options (any type); 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.
68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/tuile/component/select.rb', line 68 def initialize(items: [], value: nil) super() @items = items.to_a @item_label = :to_s.to_proc @value = value @on_value_change = nil @overlay = ListDropdown.new # Outside-click dismissal spans the owner chain, so a click on this # select's dropdown must not dismiss a dialog the select sits in. @overlay.owner = self @overlay.renderer = method(:label_for) @overlay.on_item_chosen = ->(_index, item) { commit(item) } end |
Instance Attribute Details
#item_label ⇒ Proc, Method
@return — item -> shown label (a String or
StyledString); :to_s by default. Never called with nil — an
unselected Select renders a blank face.
88 89 90 |
# File 'lib/tuile/component/select.rb', line 88 def item_label @item_label end |
#items ⇒ ::Array[untyped]
@return — the options.
83 84 85 |
# File 'lib/tuile/component/select.rb', line 83 def items @items end |
Instance Method Details
#active=(flag) ⇒ void
This method returns an undefined value.
Closes the dropdown when the Select leaves the focus chain, so tabbing away doesn't strand an open menu. Safe against re-entrancy: focus never sits inside the (non-focusable) ListDropdown, so closing it repairs no focus.
@param flag
129 130 131 132 133 |
# File 'lib/tuile/component/select.rb', line 129 def active=(flag) was = active? super if was && !active? end |
#anchor ⇒ void
This method returns an undefined value.
223 |
# File 'lib/tuile/component/select.rb', line 223 def anchor = @overlay.anchor_to(rect, rows: @items.size, width: ) |
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
3346 |
# File 'sig/tuile.rbs', line 3346
def clear: () -> void
|
#close_menu ⇒ void
This method returns an undefined value.
212 |
# File 'lib/tuile/component/select.rb', line 212 def = (@overlay.close if @overlay.open?) |
#commit(item) ⇒ void
This method returns an undefined value.
Adopts the chosen item as #value and closes the dropdown.
@param item
217 218 219 220 |
# File 'lib/tuile/component/select.rb', line 217 def commit(item) self.value = item end |
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
3343 |
# File 'sig/tuile.rbs', line 3343
def empty?: () -> bool
|
#empty_value ⇒ Object
3350 |
# File 'sig/tuile.rbs', line 3350
def empty_value: () -> Object
|
#face_row ⇒ StyledString
The painted row: the value's label padded across all but the last column,
then the ▾, all on the field well — Theme#active_bg_color while on the
focus chain, Theme#input_bg_color otherwise.
186 187 188 189 190 191 |
# File 'lib/tuile/component/select.rb', line 186 def face_row width = [rect.width - 1, 0].max label = label_for(value).ellipsize(width) row = label + StyledString.plain("#{" " * (width - label.display_width)}▾") row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color) end |
#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).
3357 |
# File 'sig/tuile.rbs', line 3357
def focusable?: () -> bool
|
#handle_key(key) ⇒ Boolean
Opens the dropdown on Enter, Space or Down; while it is open, forwards ListDropdown::MOVE_KEYS to it, commits the highlight on Enter or Space, and dismisses on ESC. Everything else — every other printable included — is left unhandled so it bubbles to an ancestor.
@param key
141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/tuile/component/select.rb', line 141 def handle_key(key) if @overlay.open? return true if @overlay.move(key) case key when Keys::ENTER, " " then @overlay.choose when Keys::ESC then else return false end true elsif [Keys::ENTER, " ", Keys::DOWN_ARROW].include?(key) true else false end end |
#handle_mouse(event) ⇒ void
This method returns an undefined value.
Toggles the dropdown on a left click anywhere in Tuile::Component#rect — a field's
affordance is its whole row, as the well advertises; super runs first,
so the click also focuses.
@param event
164 165 166 167 168 169 |
# File 'lib/tuile/component/select.rb', line 164 def handle_mouse(event) super return unless event. == :left && rect.contains?(event.point) @overlay.open? ? : end |
#label_for(item) ⇒ StyledString
@param item
@return — item's label, or empty for nil — so #value
being unset never reaches an #item_label that assumes an item.
245 246 247 248 249 250 |
# File 'lib/tuile/component/select.rb', line 245 def label_for(item) return StyledString::EMPTY if item.nil? label = @item_label.call(item) label.is_a?(StyledString) ? label : StyledString.parse(label.to_s) end |
#menu_width ⇒ Integer
The dropdown's width: the widest label plus List's two row gutters, plus the scrollbar column when the rows can't all be shown at once — but never narrower than the Select itself, so both edges line up with the face and the panel reads as belonging to it. Only a label that needs more pushes it wider.
A dropdown the screen clamps shorter than ListDropdown::MAX_VISIBLE_ROWS scrolls without having bought that column, ellipsizing its labels one early — the ComboBox trade, in the one case measuring can't predict the height.
236 237 238 239 240 |
# File 'lib/tuile/component/select.rb', line 236 def widest = @items.map { |item| label_for(item).display_width }.max || 0 measured = widest + 2 + (@items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0) [measured, rect.width].max end |
#open_menu ⇒ void
This method returns an undefined value.
209 |
# File 'lib/tuile/component/select.rb', line 209 def = refill |
#rect=(new_rect) ⇒ void
This method returns an undefined value.
Re-anchors the (open) dropdown after a move or resize.
@param new_rect
118 119 120 121 |
# File 'lib/tuile/component/select.rb', line 118 def rect=(new_rect) super anchor if @overlay.open? end |
#refill ⇒ void
This method returns an undefined value.
Rebuilds the dropdown's rows, highlight and geometry, opening it if needed; closes it instead when there is nothing to show.
196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/tuile/component/select.rb', line 196 def refill if @items.empty? return end @overlay.items = @items @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0) @overlay.open unless @overlay.open? anchor end |
#repaint ⇒ void
This method returns an undefined value.
172 173 174 175 176 177 178 |
# File 'lib/tuile/component/select.rb', line 172 def repaint return if rect.empty? tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1) clear_background(tail) unless tail.empty? draw_text(rect.left, rect.top, face_row) end |
#tab_stop? ⇒ Boolean
90 |
# File 'lib/tuile/component/select.rb', line 90 def tab_stop? = true |
#value ⇒ Object
@return — the current value; nil until first set.
3335 |
# File 'sig/tuile.rbs', line 3335
def value: () -> Object
|
#value= ⇒ void
This method returns an undefined value.
No-op (no repaint, no listener) when equal to the current value.
@param new_value
3340 |
# File 'sig/tuile.rbs', line 3340
def value=: (Object new_value) -> void
|