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

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
# 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
  @overlay.on_item_chosen = ->(index, _line) { commit(index) }
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)


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

def item_label
  @item_label
end

#items::Array[untyped]

@return — the options.

Returns:

  • (::Array[untyped])


79
80
81
# File 'lib/tuile/component/select.rb', line 79

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)


126
127
128
129
130
# File 'lib/tuile/component/select.rb', line 126

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

#anchorvoid

This method returns an undefined value.



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

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

#clearvoid

This method returns an undefined value.

Resets #value to #empty_value.



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

def clear: () -> void

#close_menuvoid

This method returns an undefined value.



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

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

#commit(index) ⇒ void

This method returns an undefined value.

Adopts the item on row index as #value and closes the dropdown.

@param index

Parameters:

  • index (Integer)


214
215
216
217
218
# File 'lib/tuile/component/select.rb', line 214

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

#empty?Boolean

@return — true iff #value equals #empty_value.

Returns:

  • (Boolean)


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

def empty?: () -> bool

#empty_valueObject

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

Returns:

  • (Object)


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

def empty_value: () -> Object

#face_rowStyledString

The painted row: the value's label padded across all but the last column, then the , all on the field well — Theme#active_bg_color while on the focus chain, Theme#input_bg_color otherwise.

Returns:



183
184
185
186
187
188
# File 'lib/tuile/component/select.rb', line 183

def face_row
  width = [rect.width - 1, 0].max
  label = label_for(value).ellipsize(width)
  row = label + StyledString.plain("#{" " * (width - label.display_width)}")
  row.with_bg(active? ? screen.theme.active_bg_color : screen.theme.input_bg_color)
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)


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

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)


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

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(event) ⇒ void

This method returns an undefined value.

Toggles the dropdown on a left click anywhere in Tuile::Component#rect — a field's affordance is its whole row, as the well advertises; super runs first, so the click also focuses.

@param event

Parameters:



161
162
163
164
165
166
# File 'lib/tuile/component/select.rb', line 161

def handle_mouse(event)
  super
  return unless event.button == :left && rect.contains?(event.point)

  @overlay.open? ? close_menu : open_menu
end

#keyboard_hintString

Returns:

  • (String)


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

def keyboard_hint = "#{screen.theme.hint("open")}  ↑↓ #{screen.theme.hint("select")}"

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



243
244
245
246
247
248
# File 'lib/tuile/component/select.rb', line 243

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)


234
235
236
237
238
# File 'lib/tuile/component/select.rb', line 234

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.



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

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:



115
116
117
118
# File 'lib/tuile/component/select.rb', line 115

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.



193
194
195
196
197
198
199
200
201
202
203
# File 'lib/tuile/component/select.rb', line 193

def refill
  if @items.empty?
    close_menu
    return
  end

  @overlay.lines = @items.map { |item| label_for(item) }
  @overlay.cursor = List::Cursor.new(position: @items.index(value) || 0)
  @overlay.open unless @overlay.open?
  anchor
end

#repaintvoid

This method returns an undefined value.



169
170
171
172
173
174
175
# File 'lib/tuile/component/select.rb', line 169

def repaint
  return if rect.empty?

  tail = Rect.new(rect.left, rect.top + 1, rect.width, rect.height - 1)
  clear_background(tail) unless tail.empty?
  draw_line(rect.left, rect.top, face_row)
end

#tab_stop?Boolean

Returns:

  • (Boolean)


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

def tab_stop? = true

#valueObject

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

Returns:

  • (Object)


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

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)


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

def value=: (Object new_value) -> void