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 { |e| relog(e.value) }   # 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

Instance Method Summary collapse

Methods included from HasValue

listener

Methods included from Listeners::Declare

#listener

Methods included from HasValidation

listener

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
81
82
83
# 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
  @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 = ->(item, _text_width) { label_for(item) }
  @overlay.list.on_item_chosen { |e| commit(e.item) }
  # A Select has no caret, so the focus shade is its only indicator: an app
  # flattening it with a plain bg_color is choosing that, and can keep the
  # pair with `bg_color = { normal: …, active: … }`.
  bg.default_color = ComponentBackground::INPUT_WELL
end

Instance Attribute Details

#item_label ⇒ Proc, 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)


91
92
93
# File 'lib/tuile/component/select.rb', line 91

def item_label
  @item_label
end

#items ⇒ ::Array[untyped]

@return — the options.

Returns:

  • (::Array[untyped])


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

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)


122
123
124
125
126
# File 'lib/tuile/component/select.rb', line 122

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

#anchor ⇒ void

This method returns an undefined value.



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

def anchor = @overlay.anchor_to(self, width: method(:menu_width))

#clear ⇒ void

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 #set_value that returns early on a no-op set — is a clear that leaves the garbage on screen. A clear is programmatic: its event says from_user? == false.



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

def clear: () -> void

#close_menu ⇒ void

This method returns an undefined value.



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

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)


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

def commit(item)
  close_menu
  set_value(item, from_user: true)
end

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


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

def empty?: () -> bool

#empty_value ⇒ Object

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

Returns:

  • (Object)


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

def empty_value: () -> Object

#error_bg_color ⇒ Color?

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:



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

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)


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

def error_ink?: () -> bool

#error_message ⇒ StyledString?

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

Returns:



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

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:



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

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

#extent ⇒ Size

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:



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

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

#face_row ⇒ StyledString

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

Returns:



188
189
190
191
# File 'lib/tuile/component/select.rb', line 188

def face_row
  width = [rect.width - 1, 0].max
  label_for(value).ellipsize(width).ljust(width) + StyledString.plain("▾")
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)


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

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)


134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/tuile/component/select.rb', line 134

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)


165
166
167
168
169
170
# File 'lib/tuile/component/select.rb', line 165

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


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

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

#label_for(item) ⇒ StyledString

@param item

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

Parameters:

  • item (Object)

Returns:



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

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)


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

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

#on_error_message_change ⇒ Listeners

Fired with an HasValidation::ErrorMessageChangeEvent whenever #error_message actually changes — never on a no-op set. The container that paints the message registers here, and so may an app painting its own: the list takes both, which a single slot could not.

Returns:



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

def on_error_message_change: () -> Listeners

#on_value_change ⇒ Listeners

Fired with a HasValue::ValueChangeEvent whenever #value actually changes — never on a no-op set.

Returns:



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

def on_value_change: () -> Listeners

#open_menu ⇒ void

This method returns an undefined value.



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

def open_menu = refill

#refill ⇒ void

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.



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

def refill
  if @items.empty?
    close_menu
    return
  end

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

#repaint(canvas) ⇒ void

This method returns an undefined value.

@param canvas — see Tuile::Component#repaint.

Parameters:



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

def repaint(canvas)
  super
  return if rect.empty?

  canvas.set_text(0, 0, face_row)
end

#set_value ⇒ void

This method returns an undefined value.

Writes the value and fires #on_value_change carrying from_user. No-op (no repaint, no listener) when equal to the current value.

field.set_value(Date.today, from_user: true)   # a "Today" button's click

The flag is the writer's claim, never derived from key dispatch and never checked: pass true when the write is a user's gesture — the gem's own key and mouse handlers do, as does Testing.set_value — and false for what the app decides alone. A wrong flag is silent, and false is the side that fails safe (D_from_user).

The override point for an includer: call super with from_user:.

@param new_value

@param from_user

Parameters:

  • new_value (Object)
  • from_user: (Boolean)


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

def set_value: (Object new_value, from_user: bool) -> void

#shown_message ⇒ StyledString, ...

The message a consumer with cells of its own paints beside the field: the verdict here, and HasBadInput widens it to prefer the field's own report, which outranks a verdict computed a pass ago.

field.on_error_message_change { message_label.caption = field.shown_message.to_s }
field.on_bad_input_change     { message_label.caption = field.shown_message.to_s }

Register on both: two channels with two writers, either of which moves it.

@return — nil when there is nothing to show; a plain String when it is the field's own bad-input report.

Returns:



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

def shown_message: () -> (StyledString | String)?

#tab_stop? ⇒ Boolean

Returns:

  • (Boolean)


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

def tab_stop? = true

#value ⇒ Object

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

Returns:

  • (Object)


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

def value: () -> Object

#value= ⇒ void

This method returns an undefined value.

A programmatic write: #set_value with from_user: false. Defined here once and never overridden — override #set_value instead, since a setter has no call syntax for the keyword.

@param new_value

Parameters:

  • new_value (Object)


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

def value=: (Object new_value) -> void