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: a non-modal Popup 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 Popup#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). [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 Popup
#close_on_outside_click, #on_close, #owner, #size
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 Popup
#center, #close, #close_on_outside_click?, #focusable?, #handle_key, #handle_mouse, #layout, #modal?, #on_detached, #on_focus, #open, #open?, #rect=, #reposition
Methods included from HasContent
#handle_mouse, #on_focus, #rect=
Constructor Details
#initialize ⇒ ListDropdown
Returns a new instance of ListDropdown.
63 64 65 66 67 68 69 |
# File 'lib/tuile/component/list_dropdown.rb', line 63 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, modal: false) 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 DECISIONS.md D-select on why the width policy stays with the driver).
@param max_rows — rows shown before the list scrolls.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 |
# File 'lib/tuile/component/list_dropdown.rb', line 189 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.size = Size.new(width, height) 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.
@param anchor — the driver's rect; 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.
137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 |
# File 'lib/tuile/component/list_dropdown.rb', line 137 def anchor_to(anchor, rows:, width: anchor.width, max_rows: MAX_VISIBLE_ROWS) desired = [rows, max_rows].min below = screen.size.height - (anchor.top + 1) above = anchor.top if desired <= below top = anchor.top + 1 height = desired elsif above >= below height = [desired, above].min top = anchor.top - height else height = below top = anchor.top + 1 end width = [width, screen.size.width].min self.size = Size.new(width, height) 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).
244 |
# File 'lib/tuile/component/list_dropdown.rb', line 244 def choose = @list.handle_key(Keys::ENTER) |
#cursor ⇒ List::Cursor
@return — the list's cursor (the current highlight).
107 |
# File 'lib/tuile/component/list_dropdown.rb', line 107 def cursor = @list.cursor |
#cursor=(cursor) ⇒ void
This method returns an undefined value.
@param cursor — the highlight; see Tuile::Component::List#cursor=.
102 103 104 |
# File 'lib/tuile/component/list_dropdown.rb', line 102 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.
216 217 218 219 220 221 222 223 224 |
# File 'lib/tuile/component/list_dropdown.rb', line 216 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.
78 |
# File 'lib/tuile/component/list_dropdown.rb', line 78 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=.
73 74 75 |
# File 'lib/tuile/component/list_dropdown.rb', line 73 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.
232 233 234 235 236 237 |
# File 'lib/tuile/component/list_dropdown.rb', line 232 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.
96 97 98 |
# File 'lib/tuile/component/list_dropdown.rb', line 96 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.
88 89 90 |
# File 'lib/tuile/component/list_dropdown.rb', line 88 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.
82 83 84 |
# File 'lib/tuile/component/list_dropdown.rb', line 82 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.
114 |
# File 'lib/tuile/component/list_dropdown.rb', line 114 def select(index) = @list.select(index) |