Class: Tuile::Component::ListDropdown

Inherits:
Popup
  • 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: 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).

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

Attributes inherited from Popup

#close_on_outside_click, #on_close, #owner, #size

Attributes included from HasContent

#content

Instance Method Summary collapse

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

#initializeListDropdown

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

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

Returns:

  • (Boolean)


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

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

#cursorList::Cursor

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

Returns:



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

Parameters:



102
103
104
# File 'lib/tuile/component/list_dropdown.rb', line 102

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.

Returns:



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.

Returns:

  • (::Array[untyped])


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

Parameters:

  • items (::Array[untyped])


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.

Parameters:

  • key (String)

Returns:

  • (Boolean)


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.

Parameters:

  • proc (Proc, Method, nil)


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.

Parameters:

  • proc (Proc, Method, nil)


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.

Parameters:

  • proc (Proc, Method)


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.

Parameters:

  • index (Integer)

Returns:

  • (Boolean)


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

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