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(index) ⇒ void
Commits the item at the menu's
index: 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 |
# 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.on_item_chosen = ->(index, _line) { commit(index) } 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.
63 64 65 |
# File 'lib/tuile/component/combo_box.rb', line 63 def item_label @item_label end |
#items ⇒ ::Array[untyped]
@return — the candidate items.
59 60 61 |
# File 'lib/tuile/component/combo_box.rb', line 59 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
118 119 120 121 122 123 124 125 |
# File 'lib/tuile/component/combo_box.rb', line 118 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.
260 |
# File 'lib/tuile/component/combo_box.rb', line 260 def anchor = @overlay.anchor_to(rect, rows: @filtered.size) |
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
3194 |
# File 'sig/tuile.rbs', line 3194
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(index) ⇒ void
This method returns an undefined value.
Commits the item at the menu's index: closes the dropdown and adopts
it as #value (which repaints the field with its label).
@param index
221 222 223 224 225 |
# File 'lib/tuile/component/combo_box.rb', line 221 def commit(index) item = @filtered[index] self.value = item end |
#cursor_position ⇒ Point?
@return — the field's caret position (the combo delegates the hardware cursor to its field).
97 |
# File 'lib/tuile/component/combo_box.rb', line 97 def cursor_position = content.cursor_position |
#display_for(item) ⇒ String
@param item
@return — the plain-text label for item, or "" for nil.
253 |
# File 'lib/tuile/component/combo_box.rb', line 253 def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s |
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
3191 |
# File 'sig/tuile.rbs', line 3191
def empty?: () -> bool
|
#empty_value ⇒ Object
3198 |
# File 'sig/tuile.rbs', line 3198
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.
167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 |
# File 'lib/tuile/component/combo_box.rb', line 167 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).
3205 |
# File 'sig/tuile.rbs', line 3205
def focusable?: () -> bool
|
#handle_mouse(event) ⇒ void
This method returns an undefined value.
@param event
129 130 131 132 133 134 135 136 |
# File 'lib/tuile/component/combo_box.rb', line 129 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
100 |
# File 'lib/tuile/component/combo_box.rb', line 100 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
154 155 156 |
# File 'lib/tuile/component/combo_box.rb', line 154 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
210 211 212 213 214 215 |
# File 'lib/tuile/component/combo_box.rb', line 210 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.
3207 |
# File 'sig/tuile.rbs', line 3207
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
106 107 108 109 |
# File 'lib/tuile/component/combo_box.rb', line 106 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.
193 194 195 196 197 198 199 200 201 202 203 |
# File 'lib/tuile/component/combo_box.rb', line 193 def refill @filtered = matching(content.text) if @filtered.empty? else @overlay.lines = @filtered.map { |item| @item_label.call(item) } @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.
139 140 141 142 143 144 145 |
# File 'lib/tuile/component/combo_box.rb', line 139 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.
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
243 244 245 246 247 248 249 |
# File 'lib/tuile/component/combo_box.rb', line 243 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.
3188 |
# File 'sig/tuile.rbs', line 3188
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
88 89 90 91 92 93 |
# File 'lib/tuile/component/combo_box.rb', line 88 def value=(new_value) return if value == new_value sync_field(display_for(new_value)) super end |