Class: Tuile::Component::ListDropdown
- 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_rowsdefault. 10
Instance Attribute Summary
Attributes inherited from Overlay
#close_on_outside_click, #on_close, #owner
Attributes included from HasContent
Instance Method Summary collapse
-
#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. -
#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 whenrowswon't fit below, clamped — with the list scrolling — when neither side has room. -
#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.
-
#cursor ⇒ List::Cursor
@return — the list's cursor (the current highlight).
-
#cursor=(cursor) ⇒ void
@param
cursor— the highlight; see Tuile::Component::List#cursor=. -
#cursor_row_rect ⇒ Rect?
The highlighted row's rect on screen — what a cascading submenu anchors against, via #anchor_beside.
-
#initialize ⇒ ListDropdown
constructor
A new instance of ListDropdown.
-
#items ⇒ ::Array[untyped]
@return — the items currently shown.
-
#items=(items) ⇒ void
@param
items— the items to show, one row each; see Tuile::Component::List#items=. -
#move(key) ⇒ Boolean
Forwards a cursor-movement key to the list.
-
#on_cursor_changed=(proc) ⇒ void
@param
proc— highlight-moved callback; see Tuile::Component::List#on_cursor_changed. -
#on_item_chosen=(proc) ⇒ void
@param
proc— commit callback; see Tuile::Component::List#on_item_chosen. -
#renderer=(proc) ⇒ void
@param
proc— item -> row; see Tuile::Component::List#renderer. -
#select(index) ⇒ Boolean
Moves the highlight to the item at
index, scrolling it into view; see Tuile::Component::List#select.
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
Constructor Details
#initialize ⇒ ListDropdown
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. = 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. = rows > height ? :visible : :gone 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).
256 |
# File 'lib/tuile/component/list_dropdown.rb', line 256 def choose = @list.handle_key?(Keys::ENTER) |
#cursor ⇒ List::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_rect ⇒ Rect?
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) |