Class: Tuile::Component::ComboBox

Inherits:
Component
  • Object
show all
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.

Returns:

  • (Integer)
10

Instance Attribute Summary collapse

Attributes included from HasValue

#on_value_change

Attributes included from HasContent

#content

Instance Method Summary collapse

Constructor Details

#initialize(items: []) ⇒ ComboBox

@param items — the candidate items (any type); also settable via #items=.

Parameters:

  • items: (::Array[untyped]) (defaults to: [])


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_labelProc, Method

@return — item -> shown label (a String or StyledString); the field shows its #to_s, the list its styled form.

Returns:

  • (Proc, Method)


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.

Returns:

  • (::Array[untyped])


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

Parameters:

  • flag (Boolean)


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?

  close_menu
  revert_query
end

#anchorvoid

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

#clearvoid

This method returns an undefined value.

Resets #value to #empty_value.



2633
# File 'sig/tuile.rbs', line 2633

def clear: () -> void

#close_menuvoid

This method returns an undefined value.



230
# File 'lib/tuile/component/combo_box.rb', line 230

def close_menu = (@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

Parameters:

  • index (Integer)


220
221
222
223
224
# File 'lib/tuile/component/combo_box.rb', line 220

def commit(index)
  item = @filtered[index]
  close_menu
  self.value = item
end

#cursor_positionPoint?

@return — the field's caret position (the combo delegates the hardware cursor to its field).

Returns:



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.

Parameters:

  • item (Object)

Returns:

  • (String)


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.

Returns:

  • (Boolean)


2630
# File 'sig/tuile.rbs', line 2630

def empty?: () -> bool

#empty_valueObject

@return — the value #empty?/#clear treat as empty; nil unless an includer overrides it.

Returns:

  • (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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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
      close_menu
      revert_query
      true
    else
      false
    end
  elsif [Keys::DOWN_ARROW, Keys::ENTER].include?(key)
    open_menu
    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).

Returns:

  • (Boolean)


2644
# File 'sig/tuile.rbs', line 2644

def focusable?: () -> bool

#handle_mouse(event) ⇒ void

This method returns an undefined value.

@param event

Parameters:



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.button == :left && rect.contains?(event.point) # the ▾ cell
    content.focus
    @overlay.open? ? close_menu : open_menu
  end
end

#keyboard_hintString

Returns:

  • (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

Parameters:



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

Parameters:

  • query (String)

Returns:

  • (::Array[untyped])


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_focusvoid

This method returns an undefined value.



2646
# File 'sig/tuile.rbs', line 2646

def on_focus: () -> void

#open_menuvoid

This method returns an undefined value.



227
# File 'lib/tuile/component/combo_box.rb', line 227

def open_menu = 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

Parameters:



106
107
108
109
# File 'lib/tuile/component/combo_box.rb', line 106

def rect=(new_rect)
  super
  anchor if @overlay.open?
end

#refillvoid

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?
    close_menu
  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

#repaintvoid

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_queryvoid

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

Parameters:

  • text (String)


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

#valueObject

@return — the current value; nil until first set.

Returns:

  • (Object)


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

Parameters:

  • new_value (Object)


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