gpuikit 0.9.0

A UI toolkit for GPUI applications
Documentation
# List: a selection model, scroll-to-item, and an opt-in non-uniform mode

## What it is

Three upgrades to `src/elements/list.rs`, which today is a `uniform_list` with
fixed-height rows, header entries, and a per-entry `selected` flag:

- **A selection model.** Single and multiple selection as state the caller
  owns, with the element interpreting the gestures — click replaces,
  cmd/ctrl-click toggles, shift-click extends, arrows move a cursor,
  shift-arrows extend from an anchor, cmd/ctrl-A selects all. Today `List` has
  only `ListEntry::selected(bool)` — a drawing instruction, not a model — and
  the showcase's sidebar navigation re-derives it by hand every frame.
- **Scroll-to-item.** A `List`-level API that scrolls an *item* into view.
  `track_scroll` exists but hands the caller a raw `UniformListScrollHandle`
  whose indices count headers as rows, and nothing inside the element ever
  scrolls — which is fine only because there is no keyboard model to need it.
- **An opt-in non-uniform mode.** Variable row heights are not a gap — a list
  with uniform rows is just the way lists work, and `uniform_list` is the right
  default. But gpui ships a second backing, `list()` + `ListState`, that
  measures each row, and `List` can offer it behind one opt-in without changing
  anything else about its API.

## Why it survives triage

`List` is not a row on the #59 roster — it shipped early, as the "Scrollable/
virtualised lists" blocker in `docs/component-triage.md`'s corrected blocker
table — so this is an upgrade issue for a shipped element.

**The selection state lives outside the element.** This is the house pattern,
stated twice already: `src/elements/sidebar.rs`'s module docs ("State is the
caller's — the element stores nothing across frames") and
`src/elements/table.rs`'s ("nothing moves until the caller moves it" — rows
arrive already sorted, the element reports `SortRequest` / `SelectRequest` /
`SelectAllRequest` back). The list selection model is the same shape: the
caller holds the selection, hands it in, and the element reports what the user
asked for. What the element *owns* is gesture interpretation — which click is a
replace, which is a toggle, which is an extension — because that contract is
the component, exactly as `SortRequest::toggle` owns the conventional
sort-toggle so every caller does not re-derive it.

**The non-uniform mode is an API extension, not a rewrite.** The entry model,
the header treatment, the selection model and the scroll API are all
independent of which gpui element draws the rows. One builder method swaps the
backing; everything above it is unchanged.

What is NOT built, said plainly: no data ownership (entries arrive from the
caller each frame, as they do today), no filtering, no sorting, no
drag-to-reorder (see Non-goals), no type-ahead (a chooser affordance;
`Listbox` has it, a file list does not need it in v1), and no tree — an
indented list is not a `TreeView` and this issue does not make it one.

## Prior art

- **WAI-ARIA APG, Listbox pattern** — the keyboard contract, and this issue
  adopts its *recommended* multi-select model (the one that does not require
  modifiers to move): Up/Down move focus, Home/End jump, Space toggles the
  focused option, Shift+Up/Down move-and-toggle, Shift+Space selects from the
  most recently selected item to the focused one, Ctrl/Cmd+A selects all. The
  APG names focus and selection as "functionally distinct" and warns that
  selection-following-focus is a choice, not a default — `List` keeps them
  distinct: plain arrows move the cursor without touching a multi-selection.
- **SwiftUI `List(selection:)`** — the ownership split to copy: the binding is
  the caller's (`Item.ID?` for single, `Set<Item.ID>` for multi) and the
  framework owns cmd-click, shift-click and arrow-key extension. Note what
  SwiftUI does *not* make the caller do: interpret a modifier. Its `onMove` is
  the reorder callback shape for the deferred issue, not this one.
- **`src/elements/table.rs`** — in-house prior art and the consistency
  constraint; see the next section.
- Re-open all of these before implementing.

## What it has to close in this crate

- **The List/Listbox relationship — reuse the helpers, not the component.**
  `src/elements/listbox.rs` is `pub(crate)`, a chooser popup with a *transient
  highlight* separate from one *chosen* row, two focus arrangements
  (`ListboxFocus`), and a dismissal on commit. `List` is a persistent surface:
  selection persists, may be plural, and choosing does not close anything —
  they are different state machines and merging them would rebuild the
  `Select`-on-`Dropdown` entanglement that `docs/menus-and-listboxes.md` was
  written to end. What *is* shared, and already deliberately factored for
  sharing: `listbox::wrapped_index` (already used by `command`), the
  free-function row-a11y shape (`listbox::option_a11y` — a test can read it
  without laying anything out), the "one place the highlight moves, so
  `scroll_to_item` cannot be forgotten" discipline (`Listbox::highlight`), and
  the container-focus + `active_descendant` arrangement. One behavioural
  difference to get right: `wrapped_index` wraps at both ends, which is
  correct for a chooser popup and wrong for shift-extension — a selection
  extended past the last row must clamp, not wrap to row 0 — so `List`'s
  cursor movement clamps and the shared helper is the arithmetic, not the
  policy.
- **Consistency with Table's selection.** `Table` already answered the
  ownership question: `Row::selected(bool)` draws, `SelectRequest { index,
  selected }` and `SelectAllRequest` report, the caller applies. `List` keeps
  that ownership split exactly and differs only where the interaction model
  differs: `Table`'s gestures are checkboxes (every click is a toggle, select-
  all is a header checkbox with the indeterminate middle), `List`'s are
  pointer modifiers and arrows. So `List` reports a request that carries the
  *interpreted* gesture — replace / toggle / extend-to / all — plus a caller-
  owned selection value type with an `apply(request)` convenience, the same
  courtesy `SortRequest::toggle` extends. Selection is addressed by item
  index within the entries handed in this frame, as `Table`'s `SelectRequest.
  index` is; a caller with stable ids owns that mapping, because the caller
  owns the data view — SwiftUI selects by id precisely because its `List`
  owns the data, and this one does not.
- **A keyboard, which `List` today has none of.** No focus handle, no key
  context, no bindings. Following `listbox.rs`: an `actions!` set, a
  `LIST_CONTEXT`, and a `bind_list_keys` that `crate::init` calls — an app
  assembling its own keymap calls it itself. Unlike the popup (a transient
  overlay that must not be a tab stop, so it declines `A11y::focusable` and
  calls `track_focus` by hand), a persistent `List` is a tab stop, so
  `A11y::focusable` applies cleanly through the convention.
- **Scroll-to-item as a `List` API, in item coordinates.** The keyboard cursor
  must scroll itself into view (the `Listbox::highlight` lesson), and the
  caller needs the same verb. Indices must be *item* indices — today a caller
  reaching through `track_scroll` scrolls to entry ix where headers count,
  which is wrong the moment a header is inserted. The two backings differ:
  `UniformListScrollHandle::scroll_to_item(ix, ScrollStrategy)` offers
  Top/Center/Bottom/Nearest (verified against gpui-unofficial 1.14.2), while
  the non-uniform `ListState` offers `scroll_to_reveal_item(ix)` — Nearest
  only — plus `scroll_to(ListOffset)`. Expose the intersection honestly: a
  reveal-item API everywhere, strategy where the uniform backing can honour
  it, not a strategy enum the non-uniform mode silently ignores.
- **The non-uniform opt-in, priced.** gpui's `list()` measures each row and
  caches heights in a SumTree; `ListState::new(item_count, ListAlignment,
  overdraw)` is *retained state the caller must hold across frames* — it is
  also the scroll handle — and it must be told via `splice`/`reset` when
  entries change, where `uniform_list` needs nothing retained. So the opt-in
  takes a caller-held state handle (the `track_scroll` precedent), and the
  element owes the caller the splice bookkeeping story in its docs. The
  default stays `uniform_list`; fixed-height rows remain just the way lists
  work.
- **`Sidebar`'s prescription becomes real.** `sidebar.rs` names `List` as its
  content, and the showcase's own navigation is the worked example of what is
  missing: `examples/showcase.rs` re-clones every `ListEntry` per frame just
  to set `.selected(...)`, and arrow-key navigation of the nav list does not
  exist. Single-selection with a caller-owned current item is the sidebar
  case; it must fall out of this model without the multi-select machinery
  tagging along.
- **`ListEntry::selected` stays.** It is the drawing input the model feeds; a
  second way to say "this row draws selected" would be the two-sources
  problem.

## Accessibility

Through `src/a11y.rs`'s mechanism — `Accessible` + one `.announce(a11y)` —
and nothing else; `a11y::tests::no_element_calls_gpuis_a11y_builders_directly`
enforces it.

- **Container:** `Role::ListBox`, named. `Role::ListBox` is deliberately
  absent from `role_requires_a_name` (that absence is argued in
  `src/elements/select.rs` — the popup names itself after its trigger), so a
  persistent `List` takes a `.label(...)` the way `Sidebar` does, as its own
  decision.
- **Rows:** `Role::ListBoxOption` with `selected`, `position_in_set` /
  `size_of_set` counted over items — headers are not options and must not
  inflate either number — following `listbox::option_a11y`'s shape.
- **Cursor:** `src/a11y.rs`'s `role_requires_keyboard_focus` docs name the
  missing roving-focus convention and list `List` among the elements that
  want it. Do not invent it here: use the arrangement the crate already has —
  the container holds the one tab stop and the cursor row claims
  `A11y::active_descendant`, which gpui honours under a focused ancestor.
  That is exactly the `ListboxFocus::Popup` arrangement, and it is why a
  focused `List` can announce its cursor while `Combobox`'s popup cannot.
- **Upstream gap, named the way the triage doc names `aria_sort`:** gpui has
  no `aria_multiselectable`. `accesskit::Node::set_multiselectable` exists;
  `div`'s builders stop short of it, so a multi-select `List` cannot declare
  itself one. Same policy as `aria_disabled` / `aria_sort` in `src/a11y.rs`
  §3: an upstream ask, not a silent field on `A11y`. When gpui grows the
  builder, `A11y` grows the field and this element uses it.

## Sizing

`list.rs` predates the shared scale and names its own constants —
`DEFAULT_ITEM_HEIGHT: f32 = 27.0`, `DEFAULT_FONT_SIZE: f32 = 13.0`. This issue
migrates it: `ControlSized`, row height and text size resolved from a
`ControlSize` rung through `Themeable::control`, with `item_height(px)`
retained as the explicit override. Anything list-specific (the header's
bottom-aligned inset) stays in this file, keyed off the rung, per the "What
belongs here" note atop `src/theme/control.rs`.

## Showcase

A showcase page is a build requirement, not a convention — `showcase_coverage`
in `src/elements.rs` fails the build without one. `List` already has a page;
this issue rebuilds it to demonstrate: multi-select with click / cmd-click /
shift-click and the full keyboard contract; a single-select variant; a
scroll-to-item control (a button that jumps to a named item in a long list,
proving item-vs-entry indices); and a non-uniform section with rows of
genuinely different heights. The showcase's own sidebar navigation adopts
single selection, replacing its per-frame `.selected(...)` re-derivation —
the same acceptance-test role its hand-rolled sidebar played for `Sidebar`.

## Non-goals

- **Reordering.** Drag-to-reorder (SwiftUI's `onMove`) is deferred to its own
  issue, deliberately: it needs gpui's drag primitives, a keyboard equivalent,
  a drop-indicator treatment, and an answer to "does dragging one selected row
  drag the whole selection" — each a decision this issue would otherwise take
  half of. It *depends on* this issue (a reorder callback wants the selection
  model's coordinates) and must not be folded into it. `onMove`'s
  indices-plus-offset callback is the shape to start from when it is written.
- **Type-ahead.** `Listbox` has it for choosers; a persistent list can grow it
  later without API breakage.
- **Data ownership, filtering, sorting.** The caller's, per the house pattern.
- **Tree semantics.** Disclosure, nesting and `TreeItem` are a different
  component.
- **Replacing `uniform_list` as the default.** The non-uniform mode is the
  opt-in; fixed-height rows stay the default and the fast path.