Class: Tuile::Component::ComboBox

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

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.text = "ap"          # type a query without a real loop

UI-thread-confined, like every component (see Screen).

Instance Attribute Summary collapse

Attributes included from HasValue

#on_value_change

Attributes included from HasValidation

#on_error_message_change

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: [])


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
# File 'lib/tuile/component/combo_box.rb', line 48

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
  # One widget, one surface: this field paints no well of its own, so the
  # composed field's own bg_color reaches the cells the field paints.
  @field.bg_color = BG_INHERIT
  @field.on_change = ->(_text) { refill unless @suppressing_filter }
  # 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.on_escape = -> { @overlay.open? ? dismiss_menu : screen.focused = nil }
  add_child(@field, at: 0)

  @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) { @item_label.call(item) }
  @overlay.on_item_chosen = ->(_index, item) { commit(item) }
end

Instance Attribute Details

#fieldTextField (readonly)

@return — the inner field, holding the query.

Returns:



232
233
234
# File 'lib/tuile/component/combo_box.rb', line 232

def field
  @field
end

#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)


81
82
83
# File 'lib/tuile/component/combo_box.rb', line 81

def item_label
  @item_label
end

#items::Array[untyped]

@return — the candidate items.

Returns:

  • (::Array[untyped])


77
78
79
# File 'lib/tuile/component/combo_box.rb', line 77

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)


155
156
157
158
159
160
161
162
# File 'lib/tuile/component/combo_box.rb', line 155

def active=(flag)
  was = active?
  super
  return unless was && !active?

  close_menu
  revert_query
end

#anchorvoid

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.



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

def anchor = @overlay.anchor_to(extent_rect, rows: @filtered.size)

#clearvoid

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 #value= that returns early on a no-op set — is a clear that leaves the garbage on screen.



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

def clear: () -> void

#clear_inside_extentvoid

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).



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

def clear_inside_extent = nil

#close_menuvoid

This method returns an undefined value.



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

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

Parameters:

  • item (Object)


274
275
276
277
# File 'lib/tuile/component/combo_box.rb', line 274

def commit(item)
  close_menu
  self.value = item
end

#cursor_positionPoint?

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

Returns:



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

def cursor_position = field.cursor_position

#default_bg_colorColor

The field well the whole face sits on — the inner TextField is marked BG_INHERIT so this one covers both it and the (exactly one well per widget), which is what makes Tuile::Component#bg_color on the ComboBox reach the cells the field paints.

Returns:



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

def default_bg_color = active? ? screen.theme.active_bg_color : screen.theme.input_bg_color

#dismiss_menuvoid

This method returns an undefined value.

Dismisses the dropdown and puts the current value's label back in the field, undoing an uncommitted query.



237
238
239
240
# File 'lib/tuile/component/combo_box.rb', line 237

def dismiss_menu
  close_menu
  revert_query
end

#display_for(item) ⇒ String

@param item

@return — the plain-text label for item, or "" for nil.

Parameters:

  • item (Object)

Returns:

  • (String)


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

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.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_valueObject

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

Returns:

  • (Object)


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

def empty_value: () -> Object

#error_bg_colorColor?

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).

Returns:



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

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.

Returns:

  • (Boolean)


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

def error_ink?: () -> bool

#error_messageStyledString?

@return — why the field is invalid, or nil when it is not; nil until something sets it.

Returns:



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

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

Parameters:



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

def error_message=: ((String | StyledString)? new_message) -> void

#extentSize

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.

Returns:



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

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).

Returns:

  • (Boolean)


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

def focusable?: () -> bool

#handle_focusvoid

This method returns an undefined value.



142
143
144
145
146
# File 'lib/tuile/component/combo_box.rb', line 142

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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


175
176
177
178
179
180
181
182
183
184
185
186
187
# File 'lib/tuile/component/combo_box.rb', line 175

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)

    open_menu
  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

Parameters:

Returns:

  • (Boolean)


193
194
195
196
197
198
199
# File 'lib/tuile/component/combo_box.rb', line 193

def handle_mouse_down?(event)
  return false unless event.button == :left

  field.focus
  @overlay.open? ? close_menu : open_menu
  true
end

#inspect_details::Array[String]

Adds placeholder="…" to Tuile::Component#inspect, omitted while unset.

Returns:

  • (::Array[String])


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

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

Parameters:

  • query (String)

Returns:

  • (::Array[untyped])


263
264
265
266
267
268
# File 'lib/tuile/component/combo_box.rb', line 263

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

#open_menuvoid

This method returns an undefined value.



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

def open_menu = refill

#placeholderString?

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".

Returns:

  • (String, nil)


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

def placeholder = field.placeholder

#placeholder=(text) ⇒ void

This method returns an undefined value.

@param text

Parameters:

  • text (String, nil)


126
127
128
# File 'lib/tuile/component/combo_box.rb', line 126

def placeholder=(text)
  field.placeholder = text
end

#rect=(new_rect) ⇒ void

This method returns an undefined value.

Resizes the field and re-anchors the dropdown if it is open.

@param new_rect

Parameters:



133
134
135
136
137
138
139
# File 'lib/tuile/component/combo_box.rb', line 133

def rect=(new_rect)
  super
  # 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(rect.left, rect.top, [rect.width - 1, 0].max, [rect.height, 1].min)
  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.



246
247
248
249
250
251
252
253
254
255
256
# File 'lib/tuile/component/combo_box.rb', line 246

def refill
  @filtered = matching(field.text)
  if @filtered.empty?
    close_menu
  else
    @overlay.items = @filtered
    @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.



202
203
204
205
206
207
# File 'lib/tuile/component/combo_box.rb', line 202

def repaint
  super
  return if rect.empty?

  draw_char(rect.left + rect.width - 1, rect.top, "")
end

#revert_queryvoid

This method returns an undefined value.



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

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 field.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

Parameters:

  • text (String)


298
299
300
301
302
303
304
# File 'lib/tuile/component/combo_box.rb', line 298

def sync_field(text)
  @suppressing_filter = true
  field.text = text
  field.caret = field.text.length
ensure
  @suppressing_filter = false
end

#valueObject

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

Returns:

  • (Object)


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

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)


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

def value=(new_value)
  return if value == new_value

  sync_field(display_for(new_value))
  super
end