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(index) ⇒ void
Adopts the item on row
indexas #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=. - #keyboard_hint ⇒ String
-
#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 |
# 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 @overlay.on_item_chosen = ->(index, _line) { commit(index) } 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.
84 85 86 |
# File 'lib/tuile/component/select.rb', line 84 def item_label @item_label end |
#items ⇒ ::Array[untyped]
@return — the options.
79 80 81 |
# File 'lib/tuile/component/select.rb', line 79 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
126 127 128 129 130 |
# File 'lib/tuile/component/select.rb', line 126 def active=(flag) was = active? super if was && !active? end |
#anchor ⇒ void
This method returns an undefined value.
221 |
# File 'lib/tuile/component/select.rb', line 221 def anchor = @overlay.anchor_to(rect, rows: @items.size, width: ) |
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
2806 |
# File 'sig/tuile.rbs', line 2806
def clear: () -> void
|
#close_menu ⇒ void
This method returns an undefined value.
209 |
# File 'lib/tuile/component/select.rb', line 209 def = (@overlay.close if @overlay.open?) |
#commit(index) ⇒ void
This method returns an undefined value.
Adopts the item on row index as #value and closes the dropdown.
@param index
214 215 216 217 218 |
# File 'lib/tuile/component/select.rb', line 214 def commit(index) item = @items[index] self.value = item end |
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
2803 |
# File 'sig/tuile.rbs', line 2803
def empty?: () -> bool
|
#empty_value ⇒ Object
2810 |
# File 'sig/tuile.rbs', line 2810
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.
183 184 185 186 187 188 |
# File 'lib/tuile/component/select.rb', line 183 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).
2817 |
# File 'sig/tuile.rbs', line 2817
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
138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 |
# File 'lib/tuile/component/select.rb', line 138 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
161 162 163 164 165 166 |
# File 'lib/tuile/component/select.rb', line 161 def handle_mouse(event) super return unless event. == :left && rect.contains?(event.point) @overlay.open? ? : end |
#keyboard_hint ⇒ String
110 |
# File 'lib/tuile/component/select.rb', line 110 def keyboard_hint = "⏎ #{screen.theme.hint("open")} ↑↓ #{screen.theme.hint("select")}" |
#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.
243 244 245 246 247 248 |
# File 'lib/tuile/component/select.rb', line 243 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.
234 235 236 237 238 |
# File 'lib/tuile/component/select.rb', line 234 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.
206 |
# File 'lib/tuile/component/select.rb', line 206 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
115 116 117 118 |
# File 'lib/tuile/component/select.rb', line 115 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.
193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/tuile/component/select.rb', line 193 def refill if @items.empty? return end @overlay.lines = @items.map { |item| label_for(item) } @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.
169 170 171 172 173 174 175 |
# File 'lib/tuile/component/select.rb', line 169 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_line(rect.left, rect.top, face_row) end |
#tab_stop? ⇒ Boolean
86 |
# File 'lib/tuile/component/select.rb', line 86 def tab_stop? = true |
#value ⇒ Object
@return — the current value; nil until first set.
2795 |
# File 'sig/tuile.rbs', line 2795
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
2800 |
# File 'sig/tuile.rbs', line 2800
def value=: (Object new_value) -> void
|