Class: Tuile::Component::Select

Inherits:
Component
  • Object
show all
Includes:
HasValue
Defined in:
lib/tuile/component/select.rb,
sig/tuile.rbs

Overview

A closed-choice field on one row: the selected item's label plus a affordance, dropping open a ListDropdown of the options. Enter, Space or Down opens it; the arrows (and PgUp/PgDn) move the highlight; Enter or Space commits; ESC dismisses without committing.

warn                ▾     <- the face: one row, on a field well
debug                    <- the dropdown, measured to the widest label
info                        (the one-column gutters are {List}'s)
warn                     <- highlighted: the value's row, on open
error

sel = Component::Select.new(items: LogLevel.all)
sel.item_label = ->(l) { l.name }          # item -> shown label; default :to_s
sel.on_value_change = ->(l) { relog(l) }   # fires on commit, with the item
sel.value = LogLevel::WARN                 # selects it; the face shows its label

Use it for an enum — labels the developer authored, a closed set known when the code is written: log level, sort order, line endings, Yes/No/Ask. For items the app supplies at runtime with labels you don't control (countries, users, branches) reach for ComboBox instead, where filtering is the navigation. Item count is a symptom, not the criterion; book ch7 has the widget-choice table.

#value is the selected item, of whatever type #items holds, never its label; nil — a blank face — is the initial state and stays legal, so an optional enum field needs no placeholder. As on ComboBox, #items= is chrome: it never touches #value, never fires HasValue#on_value_change, and a value absent from #items survives intact while rendering nothing selected. Keeping the two in sync is the app's job.

It claims no printable key but Space

Enter, Space, ESC, ListDropdown::MOVE_KEYS and the mouse. Every other printable key bubbles past it (key-dispatch rung 3), so a form's s-to-save and a layout's 1/2/3 pane jumps keep working while a Select has focus — the one capability no ComboBox configuration can offer, since a text field eats printables unconditionally. Space is the single exception, and it forecloses nothing: every activatable widget in the gem already claims it. Home/End are declined too, so they stay available app-wide.

There is no type-ahead: a hidden prefix buffer is the ComboBox query with the feedback removed (design/decisions.md D_select). Which is also why labels need no prefix-disambiguation.

Implementation details

A leaf widget: it paints its own row (the face is derived from #value each paint, never a synced copy) and owns the dropdown as an overlay, which is not a child — like ComboBox's. The well is read from Screen#theme at paint time, so it tracks a theme flip with no hook.

The dropdown is at least as wide as the face and grows to fit the widest label, so the labels are never the thing that ellipsizes. It is not opened at all when #items is empty: an item-less Select is a programming bug, and an empty tinted panel reads as a broken list rather than as "nothing to pick". Enter/Space/Down are claimed either way.

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: [], value: nil) ⇒ Select

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

@param value — the initially selected item. Seeds the backing ivar directly, so no listener fires and assignment order doesn't matter to a form helper.

Parameters:

  • items: (::Array[untyped]) (defaults to: [])
  • value: (Object, nil) (defaults to: nil)


68
69
70
71
72
73
74
75
76
77
78
79
80
# File 'lib/tuile/component/select.rb', line 68

def initialize(items: [], value: nil)
  super()
  @items = items.to_a
  @item_label = :to_s.to_proc
  @value = value
  @on_value_change = nil
  @overlay = ListDropdown.new
  # Outside-click dismissal spans the owner chain, so a click on this
  # select's dropdown must not dismiss a dialog the select sits in.
  @overlay.owner = self
  @overlay.renderer = method(:label_for)
  @overlay.on_item_chosen = ->(_index, item) { commit(item) }
end

Instance Attribute Details

#item_labelProc, Method

@return — item -> shown label (a String or StyledString); :to_s by default. Never called with nil — an unselected Select renders a blank face.

Returns:

  • (Proc, Method)


88
89
90
# File 'lib/tuile/component/select.rb', line 88

def item_label
  @item_label
end

#items::Array[untyped]

@return — the options.

Returns:

  • (::Array[untyped])


83
84
85
# File 'lib/tuile/component/select.rb', line 83

def items
  @items
end

Instance Method Details

#active=(flag) ⇒ void

This method returns an undefined value.

Closes the dropdown when the Select leaves the focus chain, so tabbing away doesn't strand an open menu. Safe against re-entrancy: focus never sits inside the (non-focusable) ListDropdown, so closing it repairs no focus.

@param flag

Parameters:

  • flag (Boolean)


129
130
131
132
133
# File 'lib/tuile/component/select.rb', line 129

def active=(flag)
  was = active?
  super
  close_menu if was && !active?
end

#anchorvoid

This method returns an undefined value.



238
# File 'lib/tuile/component/select.rb', line 238

def anchor = @overlay.anchor_to(extent_rect, rows: @items.size, width: menu_width)

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



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

def clear: () -> void

#close_menuvoid

This method returns an undefined value.



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

def close_menu = (@overlay.close if @overlay.open?)

#commit(item) ⇒ void

This method returns an undefined value.

Adopts the chosen item as #value and closes the dropdown.

@param item

Parameters:

  • item (Object)


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

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

#default_bg_colorColor

The field well this Select's face sits on — Theme#active_bg_color while on the focus chain, Theme#input_bg_color otherwise. A Select has no caret, so the focus shade is its only indicator: an app that flattens it with a plain Tuile::Component#bg_color is choosing that, and can keep the pair with bg_color = { normal: …, active: … }.

Returns:



193
# File 'lib/tuile/component/select.rb', line 193

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

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


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

def empty?: () -> bool

#empty_valueObject

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

Returns:

  • (Object)


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

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:



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

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)


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

def error_ink?: () -> bool

#error_messageStyledString?

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

Returns:



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

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:



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

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

#extentSize

The one row this Select paints — the full width, at the top of Tuile::Component#rect. A single-slot container (Window, Popup) hands its content the whole inner rect, so a Select is routinely assigned more height than it uses; #repaint clears that tail, a press in it never reaches #handle_mouse_down?, and the dropdown hangs under this rather than under the unused space.

Returns:



166
# File 'lib/tuile/component/select.rb', line 166

def extent = Size.new(rect.width, 1)

#face_rowStyledString

The painted row: the value's label padded across all but the last column, then the . The well underneath is #default_bg_color, applied by Tuile::Component#draw_text — so a label span carrying its own background keeps it, where the old override-all fill flattened it.

Returns:



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

def face_row
  width = [rect.width - 1, 0].max
  label = label_for(value).ellipsize(width)
  label + StyledString.plain("#{" " * (width - label.display_width)}")
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 (design/decisions.md D_integer_field).

Returns:

  • (Boolean)


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

def focusable?: () -> bool

#handle_key?(key) ⇒ Boolean

Opens the dropdown on Enter, Space or Down; while it is open, forwards ListDropdown::MOVE_KEYS to it, commits the highlight on Enter or Space, and dismisses on ESC. Everything else — every other printable included — is left unhandled so it bubbles to an ancestor.

@param key

Parameters:

  • key (String)

Returns:

  • (Boolean)


141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
# File 'lib/tuile/component/select.rb', line 141

def handle_key?(key)
  if @overlay.open?
    return true if @overlay.move(key)

    case key
    when Keys::ENTER, " " then @overlay.choose
    when Keys::ESC then close_menu
    else return false
    end
    true
  elsif [Keys::ENTER, " ", Keys::DOWN_ARROW].include?(key)
    open_menu
    true
  else
    false
  end
end

#handle_mouse_down?(event) ⇒ Boolean

Toggles the dropdown on a left press anywhere in #extent — a field's affordance is its whole row, as the well advertises.

@param event

Parameters:

Returns:

  • (Boolean)


172
173
174
175
176
177
# File 'lib/tuile/component/select.rb', line 172

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

  @overlay.open? ? close_menu : open_menu
  true
end

#inspect_details::Array[String]

Adds value=… to Tuile::Component#inspect, omitted while the value is nil.

Returns:

  • (::Array[String])


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

def inspect_details: () -> ::Array[String]

#label_for(item) ⇒ StyledString

@param item

@returnitem's label, or empty for nil — so #value being unset never reaches an #item_label that assumes an item.

Parameters:

  • item (Object)

Returns:



260
261
262
263
264
265
# File 'lib/tuile/component/select.rb', line 260

def label_for(item)
  return StyledString::EMPTY if item.nil?

  label = @item_label.call(item)
  label.is_a?(StyledString) ? label : StyledString.parse(label.to_s)
end

The dropdown's width: the widest label plus List's two row gutters, plus the scrollbar column when the rows can't all be shown at once — but never narrower than the Select itself, so both edges line up with the face and the panel reads as belonging to it. Only a label that needs more pushes it wider.

A dropdown the screen clamps shorter than ListDropdown::MAX_VISIBLE_ROWS scrolls without having bought that column, ellipsizing its labels one early — the ComboBox trade, in the one case measuring can't predict the height.

Returns:

  • (Integer)


251
252
253
254
255
# File 'lib/tuile/component/select.rb', line 251

def menu_width
  widest = @items.map { |item| label_for(item).display_width }.max || 0
  measured = widest + 2 + (@items.size > ListDropdown::MAX_VISIBLE_ROWS ? 1 : 0)
  [measured, rect.width].max
end

#open_menuvoid

This method returns an undefined value.



224
# File 'lib/tuile/component/select.rb', line 224

def open_menu = refill

#rect=(new_rect) ⇒ void

This method returns an undefined value.

Re-anchors the (open) dropdown after a move or resize.

@param new_rect

Parameters:



118
119
120
121
# File 'lib/tuile/component/select.rb', line 118

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

#refillvoid

This method returns an undefined value.

Rebuilds the dropdown's rows, highlight and geometry, opening it if needed; closes it instead when there is nothing to show.



211
212
213
214
215
216
217
218
219
220
221
# File 'lib/tuile/component/select.rb', line 211

def refill
  if @items.empty?
    close_menu
    return
  end

  @overlay.items = @items
  @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
  @overlay.open unless @overlay.open?
  anchor
end

#repaintvoid

This method returns an undefined value.



180
181
182
183
184
185
# File 'lib/tuile/component/select.rb', line 180

def repaint
  super
  return if rect.empty?

  draw_text(rect.left, rect.top, face_row)
end

#tab_stop?Boolean

Returns:

  • (Boolean)


90
# File 'lib/tuile/component/select.rb', line 90

def tab_stop? = true

#valueObject

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

Returns:

  • (Object)


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

def value: () -> Object

#value=void

This method returns an undefined value.

No-op (no repaint, no listener) when equal to the current value.

@param new_value

Parameters:

  • new_value (Object)


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

def value=: (Object new_value) -> void