Data-view keyboard navigation
The five data views, ListView,
TreeView,
TableView,
TreeTableView and
GridView, answer one keyboard
contract. This page is that contract, plus the three places Teksilo knowingly
departs from a platform, a toolkit or an ARIA pattern, and why.
The chord table itself lives in one module,
common/list_nav.rs, as
pure functions over (key, modifiers, view kind). Each view keeps only its own
index arithmetic. Before that module the five views had three different answers
for Home.
The bindings
Linear = ListView, TreeView. TileGrid = GridView. CellGrid = TableView /
TreeTableView in a cell-selection mode; in a row-selection mode those two read
as Linear.
| Chord | Linear | TileGrid | CellGrid |
|---|---|---|---|
Home / End | first / last item | first / last item | first / last cell of the row |
Ctrl+Home / End | the same, selection untouched¹ | the same, selection untouched¹ | the corner (TableView) or the same column (TreeTableView), selection untouched¹ |
PageUp / PageDown | ± one viewport of rows | ± one viewport of rows | ± one viewport of rows, column kept |
Shift+any of the above | extend from the anchor | " | " |
Ctrl+Shift+any | extend additively | " | " |
Ctrl+↑/↓ | move the cursor only¹ | move the cursor only¹ | move the cursor only¹ |
Space | check the row if it has a checkbox, else toggle (Multi) / select (Single) | check the tile if it has a checkbox, else toggle (Multi) / select (Single) | check the cell if it has a checkbox, else toggle its selection |
Ctrl+Space | toggle the focused row | toggle the focused tile | select the column (MultiCell) |
Shift+Space | , | , | select the row (MultiCell) |
Enter | activate | activate | activate the row |
Ctrl+A / Ctrl+Shift+A | select all / deselect all (Multi only) | " | " |
Escape | , | clear the focus ring | end the edit, else clear the ring |
F2 | , | , | begin editing |
* / + / - | tree only: expand the subtree / one level / collapse | , | tree table: the same |
← / → | tree only: collapse-or-ascend / expand-then-descend | ±1 tile | ±1 cell (tree column: expand / collapse) |
¹ In a multiple selection. A single selection moves with the cursor on every navigation key; see the next section.
A ListView grouped with .sections(..) keeps this table unchanged. Its
section headers are not items, so every chord moves between items and steps
over the headers, and a page key whose distance ends on a header lands on the
first item under it. An item revealed by a key brings its section's header
into view when it is the first item of the section, and stops below the pinned
header when .pinned_section_headers(true) is set.
Since the cursor never lands on a header, a control in one (a "play all"
button, say) cannot be reached the way a row's checkbox is, through Space on
the cursor's row. It is a Tab stop instead, while its header is on screen:
Tab goes from the list to the controls of the headers in view, top to
bottom. The pinned copy takes no Tab stop, and neither does the header it
covers; moving the cursor to the section's first item brings that header back
into view.
A TreeTableView full-width row (.full_width_row(..)) is one cell across
every column. The cursor keeps its column on it, so stepping on to an ordinary
row lands back in that column, but Home and End leave it where it is,
Tab passes it in one press, the expand and collapse arrows act on it from
any column as on the tree column (← on a collapsed band moves up to its
parent), and no column of it opens an editor. With
.pinned_ancestors(depth), a row revealed by a key stops below the ancestors
pinned over it.
The modifier rules, stated once
In a multiple selection, Ctrl (⌘ on macOS) on a navigation key never
changes the selection. It may still change the destination, in a cell
grid Ctrl+Home escalates from the row's start to the table's corner, but it
suppresses the selection update. GTK4's GtkListBase registers every
navigation key with a (select, modify, extend) triple, and its Control
variant clears select, which skips the selection call; Qt's
extendedSelectionCommand returns NoUpdate for any navigation key with
Control held. Teksilo's arrows already followed this rule; the edge and
page keys now do too, which makes it the general case rather than an exception.
In a single selection, every navigation key moves the selection with the
cursor, the accelerator included. Moving the cursor alone is there to
assemble a multiple selection, one Ctrl+Space at a time. A view that holds
one entry has nothing to assemble, and would be left with two current rows:
the cursor, which the view publishes as its active descendant and a screen
reader announces, and the selection, which the application's actions read.
This is Qt's SingleSelection, whose selectionCommand answers a key press
onto an unselected index with ClearAndSelect, Control held or not. The ARIA
single-select listbox defines no Control chords, so nothing in it asks for a
cursor-only move. It applies wherever the selection holds one entry: a
SelectionMode::Single model, a TableSelectionMode::SingleRow or
SingleCell table, and a MultiRow or MultiCell table handed a model that
is itself single. GTK4 reads it differently; see the deviations below.
And the cursor moves with the selection. A selection the application sets , a list landing on today's entry each time it takes focus, a model shifting the selected row past an insert, moves the cursor onto it, in the same views. Otherwise the row the user last reached would stay the one announced, and the next arrow key would start from it. A multiple selection keeps its cursor where the user left it, and so does a selection with no row the view shows (emptied, or keyed on a row this view filters out). A grid or a table with no cursor yet gets none this way; its cursor appears with the first key or click.
A TreeView over an index SelectionModel gets no delta from a structural
change, an insert above, an expand, to shift the selection by. Its cursor
follows the row by identity, and in a single selection it takes the selection
along, so the row selected stays the row the user was on, as it would in a
ListView or under keyed_selection. A multiple selection keeps its indices.
A selected row that disappears leaves the selection where it was.
A row's own controls
A row, cell or tile is not a focus target, the container is, and all five views take those subtrees out of the Tab order. A listbox is one Tab stop with a cursor moving inside it, and a per-row stop would be worse than merely non-conforming: only realized rows exist, so the number of Tab stops, and which rows they belong to, would follow the scroll position.
That leaves anything interactive inside a row with no keyboard route, so a row
publishes one: BuildContext::set_keyboard_toggle names what Space should do
when the row holds the cursor. Checkbox publishes its own, so a checkbox
anywhere in a row, cell or tile is reachable with no wiring at all, whether it
came from StandardListItem or from a hand-written cell delegate. Any other
control calls set_keyboard_toggle to opt in.
The scope is the smallest navigable unit: ListView and TreeView look in the
focused row, TableView and TreeTableView in the focused cell (a
table can carry more than one checkbox column, so "the row's checkbox" would be
arbitrary), and GridView in the focused tile.
Space therefore means "check this" on a row that has a checkbox, what Windows
does for a checkbox list view, and what a visible checkbox looks like it should
answer to, and Ctrl+Space keeps meaning "toggle the selection". A row
without a checkbox publishes nothing, so Space still moves the selection there.
A tristate row goes Checked ↔ Unchecked and never sets Indeterminate, which
belongs to the model's descendant aggregation rather than to a keystroke.
Ctrl+↑/↓ and Ctrl+Space read physical Control on every platform,
including macOS, where the rest of the family reads ⌘. ⌘Space is Spotlight, and
⌘↑/⌘↓ already mean something else in a Finder list, so this Explorer-style
cursor pair has no ⌘ counterpart to move to. The asymmetry is deliberate, and
it is what leaves ⌘↑/⌘↓ free for the macOS aliases below.
A Shift range is recomputed from the anchor, never accumulated. Reversing
the gesture shrinks it: Shift+End then Shift+Home leaves one range, not the
whole collection. Ctrl+Shift keeps whatever the previous gesture selected, so
a second disjoint range can be built without losing the first. The anchor moves
on a plain click or arrow and on Ctrl+Space, in either direction, which
is the clause that makes Ctrl+arrow away → Ctrl+Space → Shift+arrow
extend from the row just picked. See
SelectionModel.
Scope: what Home is scoped to
A row is a Home target iff ←/→ move a cell cursor. That is the
discriminator every stack uses, and it is not the widget's name:
- Lists and trees have no column cursor, so
Homeis the first item, the ARIA listbox and tree patterns, Qt'sQListView/QTreeView, GTK'sGtkListBase. - A
TableViewin a row-selection mode has no column cursor either, so itsHomeis the first row. This is what Explorer's details view does. Before this,Homemoved the column in every mode, which madeShift+Homean effective no-op in the defaultMultiRow. - A
TableViewin a cell mode does have one, soHomeis the row's start andCtrl+Homethe corner, the ARIA grid pattern and Qt'sQTableView. ATreeTableViewin a cell mode keeps the column onCtrl+Home: the ARIA treegrid pattern says "the cell in the first row in the same column as the cell that had focus", where the grid pattern says the corner. The two disagree deliberately, and both widgets share one keyboard module, so this is the one place it branches on which pattern the table is. - A
GridView's rows are a reflow artifact: they change with the window width, so the same keypress would land somewhere different after a resize.Homeis therefore the absolute first tile, which is whatGtkGridViewand Qt'sQListViewin icon mode both do. See the deviations below.
Paging
Every view pages by geometry, not by index ± page_size: the row-offset
table (common::row_metrics) for the four row views, the layout strategy's
real tile rectangles for GridView. With variable or auto-measured heights the
two answers differ, and the estimate drifts as measurements converge. Paging
always moves at least one row, even when a single row is taller than the
viewport.
Three deliberate deviations
All three are recorded here so there is a link to hand whoever files them.
macOS: Home/End/PageUp/PageDown move the selection
AppKit does not. NSTableView implements scrollToBeginningOfDocument: and
not moveToBeginningOfDocument:, so on a Mac these four keys scroll the
view and leave the selection where it is, and Shift+Home/Shift+End are dead
chords with nothing in the responder chain to service them. The native
consequence is that no key selects the first row.
Teksilo does not reproduce that, for the same reason no other cross-platform
toolkit does: GTK4, Qt's item views, wxWidgets, Slint and VS Code all bind these
four identically on all three platforms. GTK4's entire list key-binding block
contains exactly one #ifdef __APPLE__, and it wraps select-all. A framework
that shipped AppKit's reading would ship a list a keyboard cannot reach the top
of.
What Teksilo does add on macOS is a short list of chords that are dead otherwise and mean something specific there:
| Chord | Meaning | Precedent |
|---|---|---|
⌘↓ | activate the focused row | Finder "Command–Down Arrow: Open the selected item"; VS Code's list.select macOS secondary |
⌘↑ | collapse, else ascend to the parent | Finder's enclosing folder; VS Code's list.collapse macOS secondary |
⌥→ / ⌥← | expand / collapse a whole subtree | AppKit's expandItem:expandChildren:, documented as the Option-click twin |
⌘↑/⌘↓ are deliberately not first/last. That is the text idiom; in a
Mac list those chords already mean parent and open, so binding them to the ends
of the collection would collide with the platform rather than conform to it.
⌥→ is macOS-only for the mirror-image reason: on Windows Alt+Right is
history-forward.
A single selection: the accelerator selects
GTK4 does not. GtkListBase binds Control with every navigation key to a move
that skips the selection, whatever the selection model, so a GtkListView over
a GtkSingleSelection can walk its cursor away from the selected row. Teksilo
keeps that move only where it assembles a multiple selection. In a single one
it leaves a screen reader announcing a row that no action uses, and a
ListView or TreeView row paints nothing to show where the cursor went.
Teksilo follows Qt's SingleSelection instead, in all five views.
GridView: Home ignores the ARIA grid rule
The ARIA grid pattern says Home moves "to the first cell in the row that
contains focus". That rule is written for a data grid, where a row is a
semantic unit. In a wrapped tile grid it is not: the row exists only because
the tiles wrapped at the current width. GtkGridView and QListView in icon
mode both resolve Home absolutely, and so does Teksilo. Row and column
indices are still published accurately, so assistive technology can still
report a tile's position.
Chords Teksilo deliberately does not bind
| Chord | Why not |
|---|---|
Backspace = go to parent | GTK and the Win32 tree view both do it, but the Explorer shell takes it for history-back and it clears the type-ahead buffer. ← already covers it. |
Alt+Right = recursive expand | History-forward on Windows. macOS gets ⌥→, where no such conflict exists. |
Escape = clear the selection | No platform documents it. Escape is cancel-edit → cancel type-ahead → close popup. |
Ctrl+Alt+arrows/Home/End/Page | Owned by NVDA and JAWS for table reading, and Ctrl+Option+… by VoiceOver. |
Numpad-only * / + / - | Microsoft's own interaction guidelines say not to bind keypad-only keys; both key locations produce the same character anyway. Note the corollary: Shift is not rejected on these three, because * and + are shifted keys on every Latin layout and the modifier state still reports it. |
Type-ahead
Opt-in per view via .type_ahead_label(…). A printable character with no
Ctrl/Alt/Super (Shift is allowed) moves the cursor to the next row
whose label starts with the accumulated term; repeating the same character
cycles among the matches rather than growing the prefix, which is what the ARIA
patterns and every desktop implementation do. Case folding is full Unicode, so
an accented label is reachable.
The 500 ms reset (.type_ahead_timeout) sits between Qt's 400 ms and Dolphin's
1000 ms. Windows derives its own from the double-click time, 4× natively,
2× in WPF, so there is no single number to match.
In a table, a printable key opens the cell editor instead where the column
declares EditTriggers::ANY_KEY. A grid cannot have both type-to-search and
type-to-edit on bare letters, and the editor wins; the WinForms default
(EditOnKeystrokeOrF2) makes the same choice.
Accessibility
- A selectable
TableViewannouncesRole::Grid, notRole::Table. AccessKit's consumer will not treatTableas a selection container, so a multi-select table announced that way exposed no selection at all to UIA.Role::Tableis kept forTableSelectionMode::None, where the static-structure role is the right one. - In a cell-selection mode the cells announce
Role::GridCell, which is the only cell role AccessKit gives the UIASelectionItempattern. - Focus and selection are published as two independent facts.
Ctrl+arrow exists precisely to make them disagree, and binding active-descendant to the selection would hide disjoint selection from assistive technology. In a single selection neither a navigation key, nor a selection the application sets, nor a structural change makes them disagree. Only a selection on a row the view does not show can, since the cursor has no row to go to. - All five views advertise and answer
Action::ScrollIntoView, the one scroll action every AccessKit adapter consumes. Advertising is the load-bearing half: each adapter gates its scroll pattern on the node supporting the action, so a handler installed without one is invisible to a real screen reader. The tree views answerAction::ExpandandAction::Collapse, on branch rows only, a leaf that advertised them would be the same advertised-and-inert bug one level down.
Known upstream limitations. A selected row does not report IsSelected
on Windows: Role::Row is absent from accesskit_windows' selection-item list,
which carries its own // TODO: tables (#29). And Role::TreeGrid maps to
NSAccessibilityTableRole rather than OutlineRole, so a TreeTableView's
hierarchy reads flat under VoiceOver while a TreeView's does not.
Testing both platforms from one host
ListNavConvention::CURRENT is a cfg! constant, so a Linux test run would
otherwise only ever see half of the rule, and the half it could not see is the
one the rule exists for. Every convention-dependent function therefore has a
_for twin taking the convention explicitly, the same split
common/text_nav.rs and
Modifiers::with_command_convention already
use. What the twins cannot test is delivery: that Fn+← arrives as
NamedKey::Home on a Mac is only observable on one.