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 (design/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
Attributes included from HasValidation
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.
-
#default_bg_color ⇒ Color
The field well this Select's face sits on — Theme#active_bg_color while on the focus chain, Theme#input_bg_color otherwise.
-
#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 ⇒ Object
-
#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. -
#extent ⇒ Size
The one row this Select paints — the full width, at the top of #rect.
-
#face_row ⇒ StyledString
The painted row: the value's label padded across all but the last column, then the
▾. -
#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_down?(event) ⇒ Boolean
Toggles the dropdown on a left press anywhere in #extent — a field's affordance is its whole row, as the well advertises.
-
#initialize(items: [], value: nil) ⇒ Select
constructor
@param
items— the options (any type); also settable via #items=. -
#inspect_details ⇒ ::Array[String]
Adds
value=…to #inspect, omitted while the value is nil. -
#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.
238 |
# File 'lib/tuile/component/select.rb', line 238 def anchor = @overlay.anchor_to(extent_rect, rows: @items.size, width: ) |
#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 #value= that returns early on a no-op
set — is a clear that leaves the garbage on screen.
5414 |
# File 'sig/tuile.rbs', line 5414
def clear: () -> void
|
#close_menu ⇒ void
This method returns an undefined value.
227 |
# File 'lib/tuile/component/select.rb', line 227 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
232 233 234 235 |
# File 'lib/tuile/component/select.rb', line 232 def commit(item) self.value = item end |
#default_bg_color ⇒ Color
The field well this Select's face sits on — Theme#active_bg_color
while on the focus chain, Theme#input_bg_color otherwise. A Select has
no caret, so the focus shade is its only indicator: an app that flattens
it with a plain Tuile::Component#bg_color is choosing that, and can keep the
pair with bg_color = { normal: …, active: … }.
193 |
# File 'lib/tuile/component/select.rb', line 193 def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color |
#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.
5406 |
# File 'sig/tuile.rbs', line 5406
def empty?: () -> bool
|
#empty_value ⇒ Object
5418 |
# File 'sig/tuile.rbs', line 5418
def empty_value: () -> Object
|
#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).
5449 |
# File 'sig/tuile.rbs', line 5449
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.
5454 |
# File 'sig/tuile.rbs', line 5454
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
5432 |
# File 'sig/tuile.rbs', line 5432
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
5442 |
# File 'sig/tuile.rbs', line 5442
def error_message=: ((String | StyledString)? new_message) -> void
|
#extent ⇒ Size
The one row this Select paints — the full width, at the top of Tuile::Component#rect. A single-slot container (Window, Popup) hands its content the whole inner rect, so a Select is routinely assigned more height than it uses; #repaint clears that tail, a press in it never reaches #handle_mouse_down?, and the dropdown hangs under this rather than under the unused space.
166 |
# File 'lib/tuile/component/select.rb', line 166 def extent = Size.new(rect.width, 1) |
#face_row ⇒ StyledString
The painted row: the value's label padded across all but the last column,
then the ▾. The well underneath is #default_bg_color, applied by
Tuile::Component#draw_text — so a label span carrying its own background keeps
it, where the old override-all fill flattened it.
202 203 204 205 206 |
# File 'lib/tuile/component/select.rb', line 202 def face_row width = [rect.width - 1, 0].max label = label_for(value).ellipsize(width) label + StyledString.plain("#{" " * (width - label.display_width)}▾") 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 (design/decisions.md
D_integer_field).
5425 |
# File 'sig/tuile.rbs', line 5425
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_down?(event) ⇒ Boolean
Toggles the dropdown on a left press anywhere in #extent — a field's affordance is its whole row, as the well advertises.
@param event
172 173 174 175 176 177 |
# File 'lib/tuile/component/select.rb', line 172 def handle_mouse_down?(event) return false unless event. == :left @overlay.open? ? : true end |
#inspect_details ⇒ ::Array[String]
Adds value=… to Tuile::Component#inspect, omitted while the value is nil.
5428 |
# File 'sig/tuile.rbs', line 5428
def inspect_details: () -> ::Array[String]
|
#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.
260 261 262 263 264 265 |
# File 'lib/tuile/component/select.rb', line 260 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.
251 252 253 254 255 |
# File 'lib/tuile/component/select.rb', line 251 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.
224 |
# File 'lib/tuile/component/select.rb', line 224 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.
211 212 213 214 215 216 217 218 219 220 221 |
# File 'lib/tuile/component/select.rb', line 211 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.
180 181 182 183 184 185 |
# File 'lib/tuile/component/select.rb', line 180 def repaint super return if rect.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.
5394 |
# File 'sig/tuile.rbs', line 5394
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
5399 |
# File 'sig/tuile.rbs', line 5399
def value=: (Object new_value) -> void
|