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 = method(:label_for)                 # caller renders
drop.on_item_chosen = ->(_index, item) { commit(item) }   # caller commits
# …then, from the driver's key handler:
drop.items = matches                         # caller filters
drop.anchor_to(rect, rows: matches.size)     # below the driver, or flipped
drop.open
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: 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.

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

10

Instance Attribute Summary

Attributes inherited from Overlay

#close_on_outside_click, #on_close, #owner

Attributes included from HasContent

#content

Instance Method Summary collapse

Methods inherited from Overlay

#close, #close_on_outside_click?, #focusable?, #handle_detached, #handle_focus, #layout, #modal?, #open, #open?, #rect=, #reposition, #tab_stop?, #visible=

Methods included from HasContent

#handle_focus, #rect=

Constructor Details

#initializeListDropdown



67
68
69
70
71
72
73
# File 'lib/tuile/component/list_dropdown.rb', line 67

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
  super(content: @list)
  self.bg_color = Theme.ref(:input_bg_color)
end

Instance Method Details

#anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS) ⇒ Object

Sizes and places the dropdown beside anchor — the placement a cascading submenu wants, where #anchor_to is the placement a field's dropdown wants.

sub.anchor_beside(parent.cursor_row_rect, rows: kids.size, 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 — typically the parent dropdown's #cursor_row_rect. Its width is the parent panel's, which is what the submenu clears.

@param rows — how many rows there are to show — the content count, not the height; more than fits turns the scrollbar on. 0 collapses the dropdown to an empty rect (drivers close instead).

@param width — the panel's width in columns, clamped to the screen. Required, with no default: anchor.width is the parent's width 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.



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
# File 'lib/tuile/component/list_dropdown.rb', line 202

def anchor_beside(anchor, rows:, width:, max_rows: MAX_VISIBLE_ROWS)
  height = [rows, max_rows, screen.size.height].min
  width = [width, screen.size.width].min
  right = anchor.left + anchor.width
  left = if right + width <= screen.size.width || (anchor.left - width).negative?
           right
         else
           anchor.left - width
         end
  left = left.clamp(0, [screen.size.width - width, 0].max)
  top = [anchor.top, screen.size.height - height].min.clamp(0, nil)
  self.rect = Rect.new(left, top, width, height)
  # After the geometry, as in {#anchor_to}: the setter rebuilds the list's
  # padded rows against the width it can see.
  @list.scrollbar_visibility = rows > height ? :visible : :gone
end

#anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS) ⇒ Object

Sizes and places the dropdown against anchor: directly beneath it, flipped above when rows won't fit below, clamped — with the list scrolling — when neither side has room. Horizontally the left edges line up, sliding left only far enough to keep the panel on screen.

drop.anchor_to(field.rect, rows: matches.size)            # field width
drop.anchor_to(rect, rows: items.size, width: measured)   # own 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 widget that paints one row but may be assigned more height passes its face, not its rect: ComboBox and Select both do, since a Window content slot hands them the full inner height.

@param anchor — the region the driver occupies, of any height; the dropdown never covers it.

@param rows — how many rows there are to show — the content count, not the height: more than fits turns the scrollbar on. 0 collapses the dropdown to an empty rect (drivers close instead).

@param width — the panel's width in columns, clamped to the screen. Defaults to the anchor's, which lines both edges up with a field; a driver that measured 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.



150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
# File 'lib/tuile/component/list_dropdown.rb', line 150

def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS)
  desired = [rows, max_rows].min
  beneath = anchor.top + anchor.height
  below = screen.size.height - beneath
  above = anchor.top
  if desired <= below
    top = beneath
    height = desired
  elsif above >= below
    height = [desired, above].min
    top = anchor.top - height
  else
    height = below
    top = beneath
  end
  width = [width, screen.size.width].min
  self.rect = Rect.new([anchor.left, screen.size.width - width].min.clamp(0, nil), top, width, height)
  # After the geometry: the setter rebuilds the list's padded rows against
  # the width it can see, and the gutter takes a column off it.
  @list.scrollbar_visibility = rows > height ? :visible : :gone
end

#chooseBoolean

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



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

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

#cursorList::Cursor

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



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

def cursor = @list.cursor

#cursor=(cursor) ⇒ void

This method returns an undefined value.

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



106
107
108
# File 'lib/tuile/component/list_dropdown.rb', line 106

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

#cursor_row_rectRect?

The highlighted row's rect on screen — what a cascading submenu anchors against, via #anchor_beside.

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.



228
229
230
231
232
233
234
235
236
# File 'lib/tuile/component/list_dropdown.rb', line 228

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(@list.rect.left, @list.rect.top + row, @list.rect.width, 1)
end

#items::Array[untyped]

@return — the items currently shown.



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

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



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

def items=(items)
  @list.items = items
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.



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

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

  @list.handle_key?(key)
  true
end

#on_cursor_changed=(proc) ⇒ void

This method returns an undefined value.

@param proc — highlight-moved callback; see Tuile::Component::List#on_cursor_changed. A cascading driver needs it to drop the panels that belonged to the row the highlight just left.



100
101
102
# File 'lib/tuile/component/list_dropdown.rb', line 100

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

#on_item_chosen=(proc) ⇒ void

This method returns an undefined value.

@param proc — commit callback; see Tuile::Component::List#on_item_chosen.



92
93
94
# File 'lib/tuile/component/list_dropdown.rb', line 92

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

#renderer=(proc) ⇒ void

This method returns an undefined value.

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



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

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.



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

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