Class: Tuile::Component::ComboBox
- Inherits:
-
Component
- Object
- Component
- Tuile::Component::ComboBox
- Includes:
- HasContent, 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 = ->(u) { open(u) } # 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
non-modal Popup 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 HasValue#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.
UI-thread-confined, like every component (see Screen).
Instance Attribute Summary collapse
-
#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.
Attributes included from HasValue
Attributes included from HasContent
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.
- #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).
-
#display_for(item) ⇒ String
@param
item. -
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
- #empty_value ⇒ Object
-
#field_key(key) ⇒ Boolean
The field's key interceptor: while the dropdown is open forwards movement to it (ListDropdown#move), commits on Enter (ListDropdown#choose), and dismisses on ESC (reverting the query); opens it on Down or Enter when closed.
-
#focusable? ⇒ Boolean
Input fields are focusable by default (overrides #focusable?); a read-only display field could override back to
false. -
#handle_mouse(event) ⇒ void
@param
event. -
#initialize(items: []) ⇒ ComboBox
constructor
@param
items— the candidate items (any type); also settable via #items=. - #keyboard_hint ⇒ String
-
#layout(field) ⇒ void
Field spans the row bar the last column, which the
▾occupies (HasContent layout hook). -
#matching(query) ⇒ ::Array[untyped]
Items whose label contains
query(case-insensitive). - #on_focus ⇒ void
- #open_menu ⇒ void
-
#rect=(new_rect) ⇒ void
Re-anchors the (open) dropdown after HasContent#rect= has resized the field via #layout.
-
#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.
- #repaint ⇒ void
- #revert_query ⇒ void
-
#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=(new_value) ⇒ void
Selects
new_valueprogrammatically: updates the field to its label without opening the dropdown, then fires HasValue#on_value_change.
Constructor Details
#initialize(items: []) ⇒ ComboBox
@param items — the candidate items (any type); also settable via #items=.
40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 |
# File 'lib/tuile/component/combo_box.rb', line 40 def initialize(items: []) super() @value = nil @on_value_change = nil @items = items.to_a @item_label = :to_s.to_proc @filtered = [] @suppressing_filter = false field = TextField.new field.on_change = ->(_text) { refill unless @suppressing_filter } field.on_key = method(:field_key) self.content = field @overlay = ListDropdown.new @overlay.renderer = ->(item) { @item_label.call(item) } @overlay.on_item_chosen = ->(_index, item) { commit(item) } end |
Instance Attribute Details
#item_label ⇒ Proc, Method
@return — item -> shown label (a String or
StyledString); the field shows its #to_s, the list its styled form.
64 65 66 |
# File 'lib/tuile/component/combo_box.rb', line 64 def item_label @item_label end |
#items ⇒ ::Array[untyped]
@return — the candidate items.
60 61 62 |
# File 'lib/tuile/component/combo_box.rb', line 60 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
119 120 121 122 123 124 125 126 |
# File 'lib/tuile/component/combo_box.rb', line 119 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.
263 |
# File 'lib/tuile/component/combo_box.rb', line 263 def anchor = @overlay.anchor_to(rect, rows: @filtered.size) |
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
3241 |
# File 'sig/tuile.rbs', line 3241
def clear: () -> void
|
#close_menu ⇒ void
This method returns an undefined value.
231 |
# File 'lib/tuile/component/combo_box.rb', line 231 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
222 223 224 225 |
# File 'lib/tuile/component/combo_box.rb', line 222 def commit(item) self.value = item end |
#cursor_position ⇒ Point?
@return — the field's caret position (the combo delegates the hardware cursor to its field).
98 |
# File 'lib/tuile/component/combo_box.rb', line 98 def cursor_position = content.cursor_position |
#display_for(item) ⇒ String
@param item
@return — the plain-text label for item, or "" for nil.
256 |
# File 'lib/tuile/component/combo_box.rb', line 256 def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s |
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
3238 |
# File 'sig/tuile.rbs', line 3238
def empty?: () -> bool
|
#empty_value ⇒ Object
3245 |
# File 'sig/tuile.rbs', line 3245
def empty_value: () -> Object
|
#field_key(key) ⇒ Boolean
The field's key interceptor: while the dropdown is open forwards movement to it (ListDropdown#move), commits on Enter (ListDropdown#choose), and dismisses on ESC (reverting the query); opens it on Down or Enter when closed. Everything else (printable keys, editing) falls through to the field, whose AbstractStringField#on_change refilters.
@param key
@return — true if consumed.
168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 |
# File 'lib/tuile/component/combo_box.rb', line 168 def field_key(key) if @overlay.open? if @overlay.move(key) true elsif key == Keys::ENTER @overlay.choose true elsif key == Keys::ESC revert_query true else false end elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key) true else false end 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).
3252 |
# File 'sig/tuile.rbs', line 3252
def focusable?: () -> bool
|
#handle_mouse(event) ⇒ void
This method returns an undefined value.
@param event
130 131 132 133 134 135 136 137 |
# File 'lib/tuile/component/combo_box.rb', line 130 def handle_mouse(event) if content.rect.contains?(event.point) content.handle_mouse(event) elsif event. == :left && rect.contains?(event.point) # the ▾ cell content.focus @overlay.open? ? : end end |
#keyboard_hint ⇒ String
101 |
# File 'lib/tuile/component/combo_box.rb', line 101 def keyboard_hint = "↑↓ #{screen.theme.hint("select")} ⏎ #{screen.theme.hint("accept")}" |
#layout(field) ⇒ void
This method returns an undefined value.
Field spans the row bar the last column, which the ▾ occupies
(HasContent layout hook). 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.
@param field
155 156 157 |
# File 'lib/tuile/component/combo_box.rb', line 155 def layout(field) field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min) end |
#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
211 212 213 214 215 216 |
# File 'lib/tuile/component/combo_box.rb', line 211 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_focus ⇒ void
This method returns an undefined value.
3254 |
# File 'sig/tuile.rbs', line 3254
def on_focus: () -> void
|
#open_menu ⇒ void
This method returns an undefined value.
228 |
# File 'lib/tuile/component/combo_box.rb', line 228 def = refill |
#rect=(new_rect) ⇒ void
This method returns an undefined value.
Re-anchors the (open) dropdown after HasContent#rect= has resized the field via #layout.
@param new_rect
107 108 109 110 |
# File 'lib/tuile/component/combo_box.rb', line 107 def rect=(new_rect) super anchor if @overlay.open? 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.
194 195 196 197 198 199 200 201 202 203 204 |
# File 'lib/tuile/component/combo_box.rb', line 194 def refill @filtered = matching(content.text) if @filtered.empty? else @overlay.items = @filtered @overlay.cursor = List::Cursor.new(position: @filtered.index(value) || 0) @overlay.open unless @overlay.open? anchor end end |
#repaint ⇒ void
This method returns an undefined value.
140 141 142 143 144 145 146 |
# File 'lib/tuile/component/combo_box.rb', line 140 def repaint super return if rect.empty? well = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color draw_char(rect.left + rect.width - 1, rect.top, "▾", StyledString::Style::DEFAULT.with(bg: well)) end |
#revert_query ⇒ void
This method returns an undefined value.
234 |
# File 'lib/tuile/component/combo_box.rb', line 234 def revert_query = sync_field(display_for(value)) |
#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.
Every programmatic write to the field goes through here; a direct
content.text = reaches the field's on_change and pops the dropdown
open on a #value= the user never asked to browse.
Parks the caret at the end: text= 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
246 247 248 249 250 251 252 |
# File 'lib/tuile/component/combo_box.rb', line 246 def sync_field(text) @suppressing_filter = true content.text = text content.caret = content.text.length ensure @suppressing_filter = false end |
#value ⇒ Object
@return — the current value; nil until first set.
3235 |
# File 'sig/tuile.rbs', line 3235
def value: () -> Object
|
#value=(new_value) ⇒ void
This method returns an undefined value.
Selects new_value programmatically: updates the field to its label
without opening the dropdown, then fires HasValue#on_value_change. nil
clears the selection (blank field). The value need not be in #items.
@param new_value
89 90 91 92 93 94 |
# File 'lib/tuile/component/combo_box.rb', line 89 def value=(new_value) return if value == new_value sync_field(display_for(new_value)) super end |