Class: Tuile::Component::ComboBox
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::ComboBox
- Includes:
- HasPlaceholder, HasValue
- Defined in:
- lib/tuile/component/combo_box.rb,
sig/tuile.rbs
Overview
A text field with a filtering dropdown: type to narrow the candidates, arrow to move the highlight, Enter (or click) to accept. Its #value is the selected item — of whatever type the items are — not the display string, so a combo over domain objects hands back the object:
combo = Component::ComboBox.new
combo.items = User.all # Array of any type
combo.item_label = ->(u) { u.full_name } # item -> shown text; default :to_s
combo.on_value_change { |e| open(e.value) } # fires on commit, with the item
combo.value = some_user # selects it; field shows its label
It's the assembly you'd otherwise wire by hand — a TextField plus a
an Overlay over a List — promoted to one component. Give it a
single-row #rect; it paints the field across that row with a ▾ in the
last column and floats the dropdown above or below.
The two values
#value (the committed selection) and the field's typed text (a transient query) are deliberately distinct. Keystrokes move the query and refilter the list; only Enter/click commits, and only a commit changes #value and fires #on_value_change. An uncommitted query reverts to the current value's label when the dropdown is dismissed (ESC) or the combo loses focus. Selecting by list index (not by matching the label back) is what lets two items share a label and still resolve to the right object.
The dropdown is a ListDropdown, tinted to read as a floating panel; see it for the theming knob.
The inner field is private machinery
It has no public accessor: its buffer is the query, so swapping the field would break the filtering. What is worth reaching is re-exposed here (#placeholder, #cursor_position); a spec reaches the field itself:
field = Testing.get(Component::TextField, in: combo)
field.value = "ap" # type a query without a real loop
UI-thread-confined, like every component (see Screen).
Instance Attribute Summary collapse
-
#field ⇒ TextField
readonly
@return — the inner field, holding the query.
-
#item_label ⇒ Proc, Method
@return — item -> shown label (a
Stringor StyledString); the field shows its#to_s, the list its styled form. -
#items ⇒ ::Array[untyped]
@return — the candidate items.
Instance Method Summary collapse
-
#active=(flag) ⇒ void
Closes the dropdown and reverts an uncommitted query when the combo leaves the focus chain — so tabbing away doesn't strand an open menu or a half-typed filter.
-
#anchor ⇒ void
Places the dropdown at the combo's own width, so both its edges line up with the field — at the cost of the scrollbar taking its column from the labels, which ellipsize a column earlier once the list scrolls.
-
#clear ⇒ void
Resets #value to #empty_value.
-
#clear_inside_extent(_canvas) ⇒ void
Declines the default's blank:
fieldcovers every column of the face but the last, and this combo paints the▾into that one, so blanking would only dirty a cell it is about to repaint (D_progress_bar). - #close_menu ⇒ void
-
#commit(item) ⇒ void
Commits the item chosen from the menu: closes the dropdown and adopts it as #value (which repaints the field with its label).
-
#cursor_position ⇒ Point?
@return — the field's caret position (the combo delegates the hardware cursor to its field).
-
#dismiss_menu ⇒ void
Dismisses the dropdown and puts the current value's label back in the field, undoing an uncommitted query.
-
#display_for(item) ⇒ String
@param
item. -
#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 combo paints — the full width, at the top of #rect.
-
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. - #handle_focus ⇒ void
-
#handle_key?(key) ⇒ Boolean
Moves the dropdown's highlight (ListDropdown::MOVE_KEYS) and commits on Enter while it is open; opens it on Down or Enter while it is closed.
-
#handle_mouse_down?(event) ⇒ Boolean
Toggles the dropdown on a left press on the ▾ cell — the only cell of this component's own that is not the field's.
-
#initialize(items: []) ⇒ ComboBox
constructor
@param
items— the candidate items (any type); also settable via #items=. -
#inspect_details ⇒ ::Array[String]
Adds
placeholder="…"to #inspect, omitted while unset. -
#matching(query) ⇒ ::Array[untyped]
Items whose label contains
query(case-insensitive). -
#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
-
#placeholder ⇒ String?
The hint the inner field paints while empty (HasPlaceholder) — for a combo that means while nothing is selected and nothing is typed, so it reads as a prompt for the query:
"type to filter". -
#placeholder=(text) ⇒ void
@param
text. -
#refill ⇒ void
Recomputes the matches for the current query, opening the dropdown when there are any (and preselecting the current value's row) or closing it when there are none.
-
#relayout ⇒ void
Resizes the field; the open dropdown follows the combo by itself.
-
#repaint(canvas) ⇒ void
@param
canvas— see #repaint. - #revert_query ⇒ void
-
#set_value(new_value, from_user:) ⇒ void
Selects
new_valueprogrammatically: updates the field to its label without opening the dropdown, then fires #on_value_change. -
#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.
-
#sync_field(text) ⇒ void
Sets the field's text without triggering a refilter — for programmatic value changes and query reverts, which must not spring the dropdown.
-
#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: []) ⇒ ComboBox
@param items — the candidate items (any type); also settable via #items=.
48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/tuile/component/combo_box.rb', line 48 def initialize(items: []) super() @value = nil @items = items.to_a @item_label = :to_s.to_proc @filtered = [] @field = TextField.new # One widget, one surface: the inner field paints no well of its own, so # this combo's well covers it and the `▾` alike, and the combo's own # bg_color reaches the cells the field paints. @field.bg_color = ComponentBackground::INHERIT bg.default_color = ComponentBackground::INPUT_WELL # Only the user's typing filters: a programmatic write to the field # ({#sync_field}) must not spring the dropdown open. @field.on_value_change { |e| refill if e.from_user? } # ESC is the one key this combo wants that the field consumes itself, so # it cannot arrive by bubbling the way {#handle_key?}'s do. With no menu # open it keeps the field's own meaning: cancel text entry. @field.escape_clears_focus = false @field.on_escape { @overlay.open? ? : screen.focused = nil } @overlay = ListDropdown.new # Outside-click dismissal spans the owner chain, so a click on this # combo's dropdown must not dismiss a dialog the combo sits in. @overlay.owner = self @overlay.renderer = ->(item, _text_width) { @item_label.call(item) } @overlay.list.on_item_chosen { |e| commit(e.item) } add_child(@field, at: 0) end |
Instance Attribute Details
#field ⇒ TextField (readonly)
@return — the inner field, holding the query.
228 229 230 |
# File 'lib/tuile/component/combo_box.rb', line 228 def field @field end |
#item_label ⇒ Proc, Method
@return — item -> shown label (a String or
StyledString); the field shows its #to_s, the list its styled form.
84 85 86 |
# File 'lib/tuile/component/combo_box.rb', line 84 def item_label @item_label end |
#items ⇒ ::Array[untyped]
@return — the candidate items.
80 81 82 |
# File 'lib/tuile/component/combo_box.rb', line 80 def items @items end |
Instance Method Details
#active=(flag) ⇒ void
This method returns an undefined value.
Closes the dropdown and reverts an uncommitted query when the combo leaves the focus chain — so tabbing away doesn't strand an open menu or a half-typed filter. Safe against re-entrancy: focus never sits inside the (non-focusable) ListDropdown, so closing the overlay repairs no focus.
@param flag
156 157 158 159 160 161 162 163 |
# File 'lib/tuile/component/combo_box.rb', line 156 def active=(flag) was = active? super return unless was && !active? revert_query end |
#anchor ⇒ void
This method returns an undefined value.
Places the dropdown at the combo's own width, so both its edges line up with the field — at the cost of the scrollbar taking its column from the labels, which ellipsize a column earlier once the list scrolls. That is the trade a measuring driver (Select) makes the other way.
305 |
# File 'lib/tuile/component/combo_box.rb', line 305 def anchor = @overlay.anchor_to(self) |
#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.
8439 |
# File 'sig/tuile.rbs', line 8439
def clear: () -> void
|
#clear_inside_extent(_canvas) ⇒ void
This method returns an undefined value.
Declines the default's blank: field covers every column of the face but
the last, and this combo paints the ▾ into that one, so blanking would
only dirty a cell it is about to repaint (D_progress_bar).
@param _canvas — the surface to paint onto.
223 |
# File 'lib/tuile/component/combo_box.rb', line 223 def clear_inside_extent(_canvas) = nil |
#close_menu ⇒ void
This method returns an undefined value.
278 |
# File 'lib/tuile/component/combo_box.rb', line 278 def = (@overlay.close if @overlay.open?) |
#commit(item) ⇒ void
This method returns an undefined value.
Commits the item chosen from the menu: closes the dropdown and adopts it as #value (which repaints the field with its label).
@param item
269 270 271 272 |
# File 'lib/tuile/component/combo_box.rb', line 269 def commit(item) set_value(item, from_user: true) end |
#cursor_position ⇒ Point?
@return — the field's caret position (the combo delegates the hardware cursor to its field).
119 |
# File 'lib/tuile/component/combo_box.rb', line 119 def cursor_position = field.cursor_position |
#dismiss_menu ⇒ void
This method returns an undefined value.
Dismisses the dropdown and puts the current value's label back in the field, undoing an uncommitted query.
233 234 235 236 |
# File 'lib/tuile/component/combo_box.rb', line 233 def revert_query end |
#display_for(item) ⇒ String
@param item
@return — the plain-text label for item, or "" for nil.
298 |
# File 'lib/tuile/component/combo_box.rb', line 298 def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s |
#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.
8430 |
# File 'sig/tuile.rbs', line 8430
def empty?: () -> bool
|
#empty_value ⇒ Object
8443 |
# File 'sig/tuile.rbs', line 8443
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).
8490 |
# File 'sig/tuile.rbs', line 8490
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.
8495 |
# File 'sig/tuile.rbs', line 8495
def error_ink?: () -> bool
|
#error_message ⇒ StyledString?
@return — why the field is invalid, or nil when it
is not; nil until something sets it.
8460 |
# File 'sig/tuile.rbs', line 8460
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
8470 |
# File 'sig/tuile.rbs', line 8470
def error_message=: ((String | StyledString)? new_message) -> void
|
#extent ⇒ Size
The one row this combo paints — the full width, at the top of Tuile::Component#rect. A single-slot container hands its content the whole inner rect, so a ComboBox is routinely assigned more height than it uses; the dropdown hangs under this rather than under the unused space below it.
216 |
# File 'lib/tuile/component/combo_box.rb', line 216 def extent = Size.new(rect.width, 1) |
#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).
8450 |
# File 'sig/tuile.rbs', line 8450
def focusable?: () -> bool
|
#handle_focus ⇒ void
This method returns an undefined value.
143 144 145 146 147 |
# File 'lib/tuile/component/combo_box.rb', line 143 def handle_focus super # The field is what edits, so it takes the focus the combo was given. screen.focused = field if field.focusable? end |
#handle_key?(key) ⇒ Boolean
Moves the dropdown's highlight (ListDropdown::MOVE_KEYS) and commits on Enter while it is open; opens it on Down or Enter while it is closed.
These arrive by bubbling: the inner field is what holds focus, and it declines every one of them (it claims no Up/Down and no Enter of its own), so they reach this ancestor untouched while printable keys and the editing keys are consumed below and never get here. ESC is the exception — the field consumes it, so the combo takes it through AbstractStringField#on_escape instead.
@param key
@return — true if consumed.
176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/tuile/component/combo_box.rb', line 176 def handle_key?(key) if @overlay.open? return true if @overlay.move(key) return false unless key == Keys::ENTER @overlay.choose else return false unless [Keys::DOWN_ARROW, Keys::ENTER].include?(key) end true end |
#handle_mouse_down?(event) ⇒ Boolean
Toggles the dropdown on a left press on the ▾ cell — the only cell of this component's own that is not the field's.
@param event
194 195 196 197 198 199 200 |
# File 'lib/tuile/component/combo_box.rb', line 194 def handle_mouse_down?(event) return false unless event. == :left field.focus @overlay.open? ? : true end |
#inspect_details ⇒ ::Array[String]
Adds placeholder="…" to Tuile::Component#inspect, omitted while unset.
8409 |
# File 'sig/tuile.rbs', line 8409
def inspect_details: () -> ::Array[String]
|
#matching(query) ⇒ ::Array[untyped]
Items whose label contains query (case-insensitive). A query still
equal to the current value's label — the resting state, or a fresh
open — is treated as "show everything", so Down opens the full list.
@param query
258 259 260 261 262 263 |
# File 'lib/tuile/component/combo_box.rb', line 258 def matching(query) return @items if query.empty? || query == display_for(value) needle = query.downcase @items.select { |item| @item_label.call(item).to_s.downcase.include?(needle) } 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.
8456 |
# File 'sig/tuile.rbs', line 8456
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.
8413 |
# File 'sig/tuile.rbs', line 8413
def on_value_change: () -> Listeners
|
#open_menu ⇒ void
This method returns an undefined value.
275 |
# File 'lib/tuile/component/combo_box.rb', line 275 def = refill |
#placeholder ⇒ String?
The hint the inner field paints while empty (HasPlaceholder) — for a
combo that means while nothing is selected and nothing is typed, so it
reads as a prompt for the query: "type to filter".
125 |
# File 'lib/tuile/component/combo_box.rb', line 125 def placeholder = field.placeholder |
#placeholder=(text) ⇒ void
This method returns an undefined value.
@param text
130 131 132 |
# File 'lib/tuile/component/combo_box.rb', line 130 def placeholder=(text) field.placeholder = text end |
#refill ⇒ void
This method returns an undefined value.
Recomputes the matches for the current query, opening the dropdown when there are any (and preselecting the current value's row) or closing it when there are none.
242 243 244 245 246 247 248 249 250 251 |
# File 'lib/tuile/component/combo_box.rb', line 242 def refill @filtered = matching(field.text) if @filtered.empty? else @overlay.items = @filtered @overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0) anchor end end |
#relayout ⇒ void
This method returns an undefined value.
Resizes the field; the open dropdown follows the combo by itself.
136 137 138 139 140 |
# File 'lib/tuile/component/combo_box.rb', line 136 def relayout # One row, or none at all when the combo itself was given none — a # starved parent must not hand out a rect it doesn't own. field.rect = Rect.new(0, 0, [rect.width - 1, 0].max, [rect.height, 1].min) end |
#repaint(canvas) ⇒ void
This method returns an undefined value.
@param canvas — see Tuile::Component#repaint.
204 205 206 207 208 209 |
# File 'lib/tuile/component/combo_box.rb', line 204 def repaint(canvas) super return if rect.empty? canvas.set_char(rect.width - 1, 0, "▾") end |
#revert_query ⇒ void
This method returns an undefined value.
281 |
# File 'lib/tuile/component/combo_box.rb', line 281 def revert_query = sync_field(display_for(value)) |
#set_value(new_value, from_user:) ⇒ void
This method returns an undefined value.
Selects new_value programmatically: updates the field to its label
without opening the dropdown, then fires #on_value_change. nil
clears the selection (blank field). The value need not be in #items.
@param new_value
@param from_user — see HasValue#set_value.
110 111 112 113 114 115 |
# File 'lib/tuile/component/combo_box.rb', line 110 def set_value(new_value, from_user:) return if value == new_value sync_field(display_for(new_value)) super end |
#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.
8483 |
# File 'sig/tuile.rbs', line 8483
def shown_message: () -> (StyledString | String)?
|
#sync_field(text) ⇒ void
This method returns an undefined value.
Sets the field's text without triggering a refilter — for programmatic
value changes and query reverts, which must not spring the dropdown.
The write is from_user: false, which is all the field's listener
checks. Parks the caret at the end: value= only clamps the caret, so a
shorter query replaced by a longer label would otherwise strand it
mid-word (commit "Go", then pick "Kotlin" → caret after "Ko").
@param text
291 292 293 294 |
# File 'lib/tuile/component/combo_box.rb', line 291 def sync_field(text) field.value = text field.caret = field.text.length end |
#value ⇒ Object
@return — the current value; nil until first set.
8416 |
# File 'sig/tuile.rbs', line 8416
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
8423 |
# File 'sig/tuile.rbs', line 8423
def value=: (Object new_value) -> void
|