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 { |e| relog(e.value) } # 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.
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
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.
-
#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.
- #open_menu ⇒ void
-
#refill ⇒ void
Rebuilds the dropdown's rows, highlight and geometry, opening it if needed; closes it instead when there is nothing to show.
-
#repaint(canvas) ⇒ void
@param
canvas— see #repaint. -
#set_value ⇒ void
Writes the value and fires #on_value_change carrying
from_user. -
#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.
- #tab_stop? ⇒ Boolean
-
#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) ⇒ 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 81 82 83 |
# 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 @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 = ->(item, _text_width) { label_for(item) } @overlay.list.on_item_chosen { |e| commit(e.item) } # A Select has no caret, so the focus shade is its only indicator: an app # flattening it with a plain bg_color is choosing that, and can keep the # pair with `bg_color = { normal: …, active: … }`. bg.default_color = ComponentBackground::INPUT_WELL 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.
91 92 93 |
# File 'lib/tuile/component/select.rb', line 91 def item_label @item_label end |
#items ⇒ ::Array[untyped]
@return — the options.
86 87 88 |
# File 'lib/tuile/component/select.rb', line 86 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
122 123 124 125 126 |
# File 'lib/tuile/component/select.rb', line 122 def active=(flag) was = active? super if was && !active? end |
#anchor ⇒ void
This method returns an undefined value.
222 |
# File 'lib/tuile/component/select.rb', line 222 def anchor = @overlay.anchor_to(self, width: method(:menu_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 #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.
6775 |
# File 'sig/tuile.rbs', line 6775
def clear: () -> void
|
#close_menu ⇒ void
This method returns an undefined value.
211 |
# File 'lib/tuile/component/select.rb', line 211 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
216 217 218 219 |
# File 'lib/tuile/component/select.rb', line 216 def commit(item) set_value(item, from_user: true) 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.
6766 |
# File 'sig/tuile.rbs', line 6766
def empty?: () -> bool
|
#empty_value ⇒ Object
6779 |
# File 'sig/tuile.rbs', line 6779
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).
6829 |
# File 'sig/tuile.rbs', line 6829
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.
6834 |
# File 'sig/tuile.rbs', line 6834
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
6799 |
# File 'sig/tuile.rbs', line 6799
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
6809 |
# File 'sig/tuile.rbs', line 6809
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.
159 |
# File 'lib/tuile/component/select.rb', line 159 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 Tuile::ComponentBackground::INPUT_WELL, applied by
Tuile::Canvas#set_text — so a label span carrying its own background keeps
it, where the old override-all fill flattened it.
188 189 190 191 |
# File 'lib/tuile/component/select.rb', line 188 def face_row width = [rect.width - 1, 0].max label_for(value).ellipsize(width).ljust(width) + StyledString.plain("▾") 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).
6786 |
# File 'sig/tuile.rbs', line 6786
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
134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 |
# File 'lib/tuile/component/select.rb', line 134 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
165 166 167 168 169 170 |
# File 'lib/tuile/component/select.rb', line 165 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.
6789 |
# File 'sig/tuile.rbs', line 6789
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.
244 245 246 247 248 249 |
# File 'lib/tuile/component/select.rb', line 244 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.
235 236 237 238 239 |
# File 'lib/tuile/component/select.rb', line 235 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 |
#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.
6795 |
# File 'sig/tuile.rbs', line 6795
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.
6731 |
# File 'sig/tuile.rbs', line 6731
def on_value_change: () -> Listeners
|
#open_menu ⇒ void
This method returns an undefined value.
208 |
# File 'lib/tuile/component/select.rb', line 208 def = refill |
#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 |
# 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) anchor end |
#repaint(canvas) ⇒ void
This method returns an undefined value.
@param canvas — see Tuile::Component#repaint.
174 175 176 177 178 179 |
# File 'lib/tuile/component/select.rb', line 174 def repaint(canvas) super return if rect.empty? canvas.set_text(0, 0, face_row) end |
#set_value ⇒ void
This method returns an undefined value.
Writes the value and fires #on_value_change carrying from_user.
No-op (no repaint, no listener) when equal to the current value.
field.set_value(Date.today, from_user: true) # a "Today" button's click
The flag is the writer's claim, never derived from key dispatch and
never checked: pass true when the write is a user's gesture — the
gem's own key and mouse handlers do, as does Testing.set_value — and
false for what the app decides alone. A wrong flag is silent, and
false is the side that fails safe (D_from_user).
The override point for an includer: call super with from_user:.
@param new_value
@param from_user
6759 |
# File 'sig/tuile.rbs', line 6759
def set_value: (Object new_value, from_user: bool) -> void
|
#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.
6822 |
# File 'sig/tuile.rbs', line 6822
def shown_message: () -> (StyledString | String)?
|
#tab_stop? ⇒ Boolean
93 |
# File 'lib/tuile/component/select.rb', line 93 def tab_stop? = true |
#value ⇒ Object
@return — the current value; nil until first set.
6734 |
# File 'sig/tuile.rbs', line 6734
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
6741 |
# File 'sig/tuile.rbs', line 6741
def value=: (Object new_value) -> void
|