Class: Tuile::Component::ListDropdown

Inherits:
Overlay
  • Object
show all
Defined in:
lib/tuile/component/list_dropdown.rb,
sig/tuile.rbs

Overview

A borderless, tinted, non-focusable floating selection list — the dropdown a driver drops open, drives by forwarding movement keys, and commits a pick from: an Overlay wrapping a List that never takes focus, so focus stays on the driver while the caller refills the rows, moves the highlight, and reads the pick.

drop = Component::ListDropdown.new
drop.renderer = ->(item, _w) { label_for(item) }   # caller renders
drop.list.on_item_chosen { |e| commit(e.item) }    # caller commits
# …then, from the driver's key handler:
drop.items = matches                         # caller filters
drop.anchor_to(self)                         # opens it below the driver, or flipped
return true if drop.move(key)  # Up/Down/PgUp/PgDn/^U/^D → list scroll
drop.choose if key == Keys::ENTER            # commit the highlight

It owns only what every such dropdown shares — placement included, via #anchor_to (below a field) and #anchor_beside (beside a parent row, for a cascading submenu). What stays with the driver: the width policy (neither placement method measures anything itself), filtering, row rendering, the commit action, and ESC/Enter handling. ESC and Enter carry driver-specific tails (ESC may revert a query; Enter may commit via #choose or via a separate submit path), so #move claims neither — the driver calls #choose and Overlay#close from its own branches.

Theming

Borderless, told apart from the content beneath by a background tint — Theme#input_bg_color by default, assigned as a live Theme::Ref so it tracks light/dark flips with no hook. Reassign #bg_color= for a different tint (a Theme.ref(:token) keeps the flip-tracking).

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

Defined Under Namespace

Classes: Anchored, Menu

Constant Summary collapse

MOVE_KEYS =

Cursor-movement keys forwarded to the list by #move: the two vertical arrows, page up/down, and Ctrl+U/D half-page jumps. Deliberately excludes Home/End and j/k — a jump to the first/last row is the driver's call, and both drivers decline it (ComboBox's field needs Home/End for the caret; Select would spend a branch on what a second arrow press already does) — and Enter/ESC, which carry driver-specific tails (see the class docs).

A driver only ever sees the keys its own children decline, so a ComboBox never gets Ctrl+U — its field claims it to clear the query — while Select, wrapping no editor, gets every one of these.

Returns:

  • (Array<String>)
[Keys::UP_ARROW, Keys::DOWN_ARROW, Keys::PAGE_UP, Keys::PAGE_DOWN,
Keys::CTRL_U, Keys::CTRL_D].freeze
MAX_VISIBLE_ROWS =

Most rows shown before the list scrolls; #anchor_to's max_rows default.

Returns:

  • (Integer)
10

Instance Attribute Summary collapse

Attributes inherited from Overlay

#close_on_outside_click, #owner

Attributes included from HasContent

#content

Instance Method Summary collapse

Methods inherited from Overlay

#close, #close_on_outside_click?, #declared_size_in, #default_placement, #focusable?, #handle_detached, #handle_focus, #handle_rect_changed, #modal?, #on_close, #open, #open?, #placement, #placement=, #relayout, #reposition, #tab_stop?, #visible=

Methods included from HasContent

#handle_focus

Constructor Details

#initialize ⇒ ListDropdown

Returns a new instance of ListDropdown.



148
149
150
151
152
153
154
155
# File 'lib/tuile/component/list_dropdown.rb', line 148

def initialize
  @list = Menu.new
  @list.cursor = List::Cursor.new
  @list.show_cursor_when_inactive = true # highlight the selection though focus stays on the driver
  @list.scrollbar_visibility = :auto # the list fills the panel, so the bar is on exactly when the rows outrun it
  super(content: @list)
  self.bg_color = Theme.ref(:input_bg_color)
end

Instance Attribute Details

#list ⇒ List (readonly)

The wrapped list, exposed so a driver can register on Tuile::Component::List#on_item_chosen (its commit) and Tuile::Component::List#on_cursor_changed (a cascading driver drops the panels belonging to the row the highlight just left). A driver tunes it but never supplies it (D_has_content).

Returns:



179
180
181
# File 'lib/tuile/component/list_dropdown.rb', line 179

def list
  @list
end

Instance Method Details

#anchor_beside(anchor, width:, max_rows: MAX_VISIBLE_ROWS) ⇒ void

This method returns an undefined value.

sord duck - #call looks like a duck type, replacing with untyped Opens the dropdown beside anchor — the placement a cascading submenu wants, where #anchor_to is the placement a field's dropdown wants — or moves it there if it is open already.

sub.anchor_beside(parent.cursor_row_rect, width: measured)

Horizontally it sits against anchor's right edge, flipping to its left when the right has no room (and clamping to the screen when neither side does). Vertically it slides: the panel's first row lines up with the anchored row, sliding up only far enough to keep the panel on screen.

The two axes are the mirror image of #anchor_to's, for the same reason: never cover the thing being chosen from. A field's dropdown must not cover the field, so it flips vertically and shares its columns; a submenu must not cover its parent panel, so it flips horizontally and shares its rows.

@param anchor — the row the submenu belongs to, in screen coordinates — typically the parent dropdown's #cursor_row_rect.

@param width — the panel's width in columns, clamped to the screen. Required, with no default: the anchor's width is the parent's and would be meaningless here, so the caller measures (see design/decisions.md D_select on why the width policy stays with the driver).

@param max_rows — rows shown before the list scrolls.

Parameters:

  • anchor (Rect, Component)
  • width: (Integer, untyped)
  • max_rows: (Integer) (defaults to: MAX_VISIBLE_ROWS)


261
262
263
# File 'lib/tuile/component/list_dropdown.rb', line 261

def anchor_beside(anchor, width:, max_rows: MAX_VISIBLE_ROWS)
  anchor_with(Anchored.new(anchor:, side: :beside, width:, max_rows:))
end

#anchor_to(anchor, width: nil, max_rows: MAX_VISIBLE_ROWS) ⇒ void

This method returns an undefined value.

sord duck - #call looks like a duck type, replacing with untyped Opens the dropdown against anchor — directly beneath it, flipped above when its rows won't fit below, clamped (with the list scrolling) when neither side has room — or moves it there if it is open already. Horizontally the left edges line up, sliding left only far enough to keep the panel on screen.

drop.anchor_to(self)                            # follows the field, its width
drop.anchor_to(self, width: method(:menu_width))

Vertical flips but horizontal slides because covering the driver would hide what is being chosen, while sharing its columns is the point.

anchor is the region actually occupied, and may be taller than one row — "beneath" means the row after it, so a multi-row driver (a TextArea carrying an autocomplete menu) is cleared entirely rather than overdrawn from its second row down. A component anchor is read through its Tuile::Component#absolute_extent_rect, so a widget that paints one row but is assigned more height (ComboBox, Select) hangs the panel off its face.

The height is the item count, capped at max_rows, and follows #items=. Settles before it returns, so a driver can forward a key to the list, or read #cursor_row_rect, in the same handler.

@param anchor — the driver, followed wherever it moves, or a fixed region in screen coordinates. Of any height; the dropdown never covers it.

@param width — the panel's width in columns, clamped to the screen, or something answering it; nil (the default) takes the anchor's, which lines both edges up with a field. A driver that measures its labels passes its own. A label wider than the screen clips — Tuile::Component::List has no horizontal scrolling.

@param max_rows — rows shown before the list scrolls.

Parameters:

  • anchor (Component, Rect)
  • width: (Integer, untyped, nil) (defaults to: nil)
  • max_rows: (Integer) (defaults to: MAX_VISIBLE_ROWS)


231
232
233
# File 'lib/tuile/component/list_dropdown.rb', line 231

def anchor_to(anchor, width: nil, max_rows: MAX_VISIBLE_ROWS)
  anchor_with(Anchored.new(anchor:, side: :below, width:, max_rows:))
end

#anchor_with(placement) ⇒ void

This method returns an undefined value.

Opens or moves the panel and settles the pass, because both anchor methods promise a panel that is placed: a driver reads #cursor_row_rect or forwards a key to the list in the same handler, and both measure rects this just assigned.

@param placement

Parameters:



313
314
315
316
# File 'lib/tuile/component/list_dropdown.rb', line 313

def anchor_with(placement)
  open? ? self.placement = placement : self.open(placement)
  flush_layout
end

#choose ⇒ Boolean

Commits the highlighted row by firing Tuile::Component::List#on_item_chosen, exactly as pressing Enter on the focused list would — the driver calls this from its own Enter branch.

@return — true iff a row was chosen (false when the cursor is off-content).

Returns:

  • (Boolean)


303
# File 'lib/tuile/component/list_dropdown.rb', line 303

def choose = @list.handle_key?(Keys::ENTER)

#cursor ⇒ List::Cursor

@return — the list's cursor (the current highlight).

Returns:



188
# File 'lib/tuile/component/list_dropdown.rb', line 188

def cursor = @list.cursor

#cursor=(cursor) ⇒ void

This method returns an undefined value.

@param cursor — the highlight; see Tuile::Component::List#cursor=.

Parameters:



183
184
185
# File 'lib/tuile/component/list_dropdown.rb', line 183

def cursor=(cursor)
  @list.cursor = cursor
end

#cursor_row_rect ⇒ Rect?

The highlighted row's rect on screen — what a cascading submenu anchors against, via #anchor_beside. Screen coordinates because the submenu is a sibling overlay rather than a child (D_relative_rect).

It lives here rather than in the driver because Tuile::Component::ListDropdown owns the list's geometry: a driver computing top + position - scroll_top_row itself would have to reach through to the private list.

@return — one row spanning the panel's width, or nil when the cursor is off-content (Tuile::Component::List::Cursor::None, an empty list) or its row is scrolled out of the viewport.

Returns:



275
276
277
278
279
280
281
282
283
# File 'lib/tuile/component/list_dropdown.rb', line 275

def cursor_row_rect
  return nil if @list.rect.empty?
  return nil unless @list.cursor.position.between?(0, @list.items.size - 1)

  row = @list.cursor.position - @list.scroll_top_row
  return nil unless row.between?(0, @list.rect.height - 1)

  Rect.new(0, 0, @list.rect.width, 1).at(@list.to_screen(Point.new(0, row)))
end

#items ⇒ ::Array[untyped]

@return — the items currently shown.

Returns:

  • (::Array[untyped])


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

def items = @list.items

#items=(items) ⇒ void

This method returns an undefined value.

@param items — the items to show, one row each; see Tuile::Component::List#items=. An open dropdown resizes to the new count on the next settle.

Parameters:

  • items (::Array[untyped])


160
161
162
163
# File 'lib/tuile/component/list_dropdown.rb', line 160

def items=(items)
  @list.items = items
  reposition
end

#move(key) ⇒ Boolean

Forwards a cursor-movement key to the list. The driver calls this from its own key handler; a truthy return means "consumed — stop here", falsy means "not mine — proceed with normal editing/dispatch". Only MOVE_KEYS are claimed, and only while open.

@param key

@return — true iff the key was consumed.

Parameters:

  • key (String)

Returns:

  • (Boolean)


291
292
293
294
295
296
# File 'lib/tuile/component/list_dropdown.rb', line 291

def move(key)
  return false unless open? && MOVE_KEYS.include?(key)

  @list.handle_key?(key)
  true
end

#renderer=(proc) ⇒ void

This method returns an undefined value.

@param proc — (item, text_width) -> row; see Tuile::Component::List#renderer.

Parameters:

  • proc (Proc, Method)


170
171
172
# File 'lib/tuile/component/list_dropdown.rb', line 170

def renderer=(proc)
  @list.renderer = proc
end

#select(index) ⇒ Boolean

Moves the highlight to the item at index, scrolling it into view; see Tuile::Component::List#select. The positional counterpart of #move, for a driver that picked a row by something other than a key — a mnemonic letter, say.

@param index

@return — whether the highlight moved there.

Parameters:

  • index (Integer)

Returns:

  • (Boolean)


195
# File 'lib/tuile/component/list_dropdown.rb', line 195

def select(index) = @list.select(index)