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).
Constant Summary collapse
- MAX_VISIBLE_ROWS =
Most matches shown before the dropdown scrolls.
10
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
Sizes and positions the dropdown against the field: full combo width,
min(matches, 10)rows, below the field — flipped above when it won't fit beneath, clamped (with the list scrolling) when it fits neither. -
#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.
Sizes and positions the dropdown against the field: full combo width,
min(matches, 10) rows, below the field — flipped above when it won't
fit beneath, clamped (with the list scrolling) when it fits neither.
258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 |
# File 'lib/tuile/component/combo_box.rb', line 258 def anchor desired = [@filtered.size, MAX_VISIBLE_ROWS].min below = screen.size.height - (rect.top + 1) above = rect.top if desired <= below top = rect.top + 1 height = desired elsif above >= below height = [desired, above].min top = rect.top - height else height = below top = rect.top + 1 end @overlay.size = Size.new(rect.width, height) @overlay.rect = Rect.new(rect.left, top, rect.width, height) end |
#clear ⇒ void
This method returns an undefined value.
Resets #value to #empty_value.
2633 |
# File 'sig/tuile.rbs', line 2633
def clear: () -> void
|
#close_menu ⇒ void
This method returns an undefined value.
230 |
# File 'lib/tuile/component/combo_box.rb', line 230 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
220 221 222 223 224 |
# File 'lib/tuile/component/combo_box.rb', line 220 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.
252 |
# File 'lib/tuile/component/combo_box.rb', line 252 def display_for(item) = item.nil? ? "" : @item_label.call(item).to_s |
#empty? ⇒ Boolean
@return — true iff #value equals #empty_value.
2630 |
# File 'sig/tuile.rbs', line 2630
def empty?: () -> bool
|
#empty_value ⇒ Object
2637 |
# File 'sig/tuile.rbs', line 2637
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.
166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 |
# File 'lib/tuile/component/combo_box.rb', line 166 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).
2644 |
# File 'sig/tuile.rbs', line 2644
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).
@param field
153 154 155 |
# File 'lib/tuile/component/combo_box.rb', line 153 def layout(field) field.rect = Rect.new(rect.left, rect.top, [rect.width - 1, 0].max, 1) 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
209 210 211 212 213 214 |
# File 'lib/tuile/component/combo_box.rb', line 209 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.
2646 |
# File 'sig/tuile.rbs', line 2646
def on_focus: () -> void
|
#open_menu ⇒ void
This method returns an undefined value.
227 |
# File 'lib/tuile/component/combo_box.rb', line 227 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.
192 193 194 195 196 197 198 199 200 201 202 |
# File 'lib/tuile/component/combo_box.rb', line 192 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.
233 |
# File 'lib/tuile/component/combo_box.rb', line 233 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
242 243 244 245 246 247 248 |
# File 'lib/tuile/component/combo_box.rb', line 242 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.
2627 |
# File 'sig/tuile.rbs', line 2627
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 |