Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Bounded-scalar keyboard navigation

Seven widgets hold one bounded number and let the keyboard move it — Slider, SpinBox, ScrollBar, the colour picker's hue and alpha strips, a Splitter handle and a dock resize handle. This page is their keyboard contract, and the places Teksilo knowingly departs from a platform.

Before the shared module they gave four different answers for the same four keys: the slider had no paging, the spin box no Home/End, the strips both, the handles neither. None of them looked at the modifiers, so every one of them answered Ctrl+Home and reported the key handled — swallowing a chord the application had bound.

The chord table lives in one module, common/range_nav.rs, as a pure function over (key, modifiers, kind, axis, direction). Each widget keeps only its own arithmetic. It is the bounded-scalar sibling of common/list_nav.rs and data-view-keyboard.md, deliberately the same shape.

The three topologies

The discriminator is not the widget's name but two questions: does the control have a range the user can page through, and does something else already own Home/End?

  • Scalar — the whole range belongs to the keyboard: Slider, HueStrip, AlphaStrip, ScrollBar.
  • Divider — a boundary whose "value" is a position: SplitterHandle, DockResizeHandle.
  • TextEditable — a number behind an editable field: SpinBox.

The bindings

ChordScalarDividerTextEditable
/ ± one step (horizontal or both axes)± one step (horizontal divider)(the caret's)
/ ± one step (vertical or both axes)± one step (vertical divider)± one step
PageUp / PageDown± one page± one page
Home / Endminimum / maximumthe two limits(the caret's)
Shift + any of the abovethe same chordthe same chordthe same chord
Ctrl / Alt / Super + anyfalls throughfalls throughfalls through
Entertoggle the adjacent collapsible panecommit

Enter sits under the modifier rule like everything else in the table. It was matched before the modifier test on both handles, so Ctrl+Enter collapsed a splitter pane or hid a dock side and reported the key handled — the one row of this table those two did not honour, while every other key on them did.

The modifier rule, stated once

A chord holding Ctrl, Alt or Super is not the control's, and reaches the global Shortcut/Action pipeline instead. Before this, every one of the seven widgets consumed such a chord: Ctrl+Home drove a slider to its minimum, Ctrl+ArrowUp stepped a spin box, Ctrl+PageDown scrolled a scroll bar — each reporting the key handled, so no ancestor ever saw it.

Shift is deliberately not rejected. It is not a distinct chord on any bounded scalar — QAbstractSlider and Blink's range input never inspect the modifiers — and Teksilo's single-line field binds nothing to Shift+↑, so rejecting it would make the chord dead rather than deferential. Callers that give Shift their own meaning (the date and time editors use it as a ×10 multiplier) read it themselves.

This is the same rule, for the same reason, as list_nav::tree_chord and MenuList's type-ahead guard — and it is now literally the same function, range_nav::is_accelerator_chord. It had been spelled out at five call sites, and they had already diverged.

AltGr is the one exception, and it belongs to text, not to this table. The third-level shift on every non-US layout reaches an application as Ctrl+Alt on Windows, X11 and Wayland alike — it is how a German keyboard types @, a French one , a Polish one ą. Refusing it in a type-ahead arm made every character behind AltGr dead while the unshifted ones kept working, so the feature half-worked in exactly the locales that needed it most. That question is range_nav::is_text_entry_chord, deliberately not the negation of the accelerator test: an AltGr+↓ is still not this table's chord, because an arrow key is not a character. macOS has no AltGr — it composes with Option, and the OS rewrites the keystroke before the application sees it, exactly as the menu bar's mnemonics document — so neither predicate needs a platform branch.

range_nav also owns disclosure_chord, the Alt+↓ / Alt+↑ / F4 table that shows and hides a drop-down. ComboBox, DateEdit and PopoverWidget each hand-rolled it, and disagreed about F4: it is a drop-down field's chord, and PopoverWidget — which also backs toolbar chevrons and menu buttons — deliberately leaves that variant unmatched.

Step sizes

A page is not required to be a multiple of a step. ScrollBar measures its own, exactly as NavMove::Page is measured from the row-offset table.

WidgetFine stepCoarse step
Sliderstep, default 1 % of the rangepage_step, default 10 × step ⇒ 10 % of the range
SpinBoxsingle_step, default 1page_step, default 10 × single_step
HueStrip15°
AlphaStrip0.010.10
ScrollBarstep_sizeone measured viewport, degrading to the step where the content barely overflows
SplitterHandlekeyboard_step_px
DockResizeHandlea fixed keyboard step

Slider's default lands on 10 % of the range, which is simultaneously QAbstractSlider::pageStep, GtkScale's page increment and what WebKit and Blink give <input type=range>.

The page keys name a direction in the content, not on screen: PageUp is one viewport back and PageDown one forward on either axis. Unlike the arrows they therefore do not follow a scroll bar's orientation — reading them geometrically made a horizontal bar's PageUp scroll forward, the opposite of the vertical bar beside it.

Why this carries no platform branch

Like list_nav and unlike text_nav, there is nothing to branch on. Qt's QAbstractSlider::keyPressEvent, GTK4's GtkScale, the Win32 trackbar and <input type=range> in both WebKit and Blink bind these keys identically on all three platforms; none has a platform guard.

Three deliberate deviations

Recorded here so there is a link to hand whoever files them.

macOS has no jump-to-extremum key, and Teksilo binds one anyway

StandardKeyBinding.dict spends Home/End on scrollToBeginningOfDocument: and scrollToEndOfDocument:, and PageUp/PageDown on scrollPageUp: / scrollPageDown:. NSStepper answers only the arrows. On a laptop keyboard all four need Fn+arrow to press at all. So a Mac has no key that drives a slider to either end.

Teksilo does not reproduce that, for the same reason it does not reproduce NSTableView's reading of the same four keys (see data-view-keyboard.md): it would ship a slider a keyboard cannot drive to its own bounds, and no cross-platform toolkit does it.

A SpinBox does not bind Home/End, against the ARIA pattern

The W3C ARIA spinbutton pattern lists Home → minimum and End → maximum as required, and concedes in the same breath that a text-editable spinbutton also honours the platform's single-line text-editing keys. Every desktop implementation resolves that tension the same way Teksilo does:

ToolkitArrowsPageHome / End
Qt QAbstractSpinBox±1 (Ctrl ⇒ ×10)stepBy(±10)→ the inner QLineEdit; only Shift+Home/End is special-cased, to keep the selection out of the prefix and suffix
WinUI 3 NumberBoxSmallChangeLargeChange, default 10not bound
Blink HandleKeydownEventForSpinButtononly /not boundnot bound
Avalonia NumericUpDown/, Enternot boundnot bound
jQuery UI Spinner/page, default 10not bound
GTK4 GtkSpinButtonsteppagebare → the entry; Ctrl+Home/Ctrl+End → min/max

In a text field Home and End are the caret's, and no value is unreachable without them: typing the number always works, and the AT SetValue action is serviced. GTK's Ctrl+Home/Ctrl+End is the one outlier, and Teksilo declines it: Ctrl+Home is the document chord on the two platforms that have one, so a spin box inside a scrollable form would steal it — and it would be the single binding in this table that fires with an accelerator, one line below the rule that says accelerators fall through.

A dock divider's extremes are hide and show

DockResizeHandle maps Home to hiding its side and End to showing it. For a side that can collapse, "give everything to the centre" is what the low extreme means; there is no numeric minimum short of it. Home falls through when collapsing is locked by policy.

Right-to-left

/ mirror with the layout direction. /, Home/End and the page keys do not: they name points in value space rather than on screen, so the minimum is the minimum in either direction. Qt flips on isRightToLeft(), and only the horizontal pair.

A control mirrors in all of its readings or in none of them. Mirroring the keys alone would leave the key and a leftward drag moving the thumb in opposite directions, which is worse than not mirroring at all — so each of these moved its paint, its pointer mapping and its arrows together.

WidgetMirrors?What that meant
SplitterHandleyesIts pane order and drag math had been mirrored since it shipped; only the keyboard was left behind, so pulled the divider left.
DockResizeHandleyesIts pointer path already inverted per side (side_main); the keyboard did not.
SlideryesThe minimum sits at the leading edge, so the fill grows leftward from a thumb that travels the other way. Read from PaintContext::layout_direction at paint time and EventContext::is_rtl at event time, so a locale flip needs no rebuild. A custom SliderStyle must do the same — the widget cannot enforce it, and the trait says so.
ScrollBar (horizontal)yesScrollArea had already mirrored: it anchors content to the right and grows scroll_x leftward. The thumb did not, so at scroll_x = 0 the content showed its beginning while the thumb sat at the far end of the track. The hit-test, the drag delta and the track-click direction key off one predicate — and so does the painted thumb, which was mirrored last: a ScrollBarStyle that offsets from bounds.x unconditionally draws the thumb at one end of the track while the grab region sits at the other, so it jumps the moment it is touched. The trait says so, the way SliderStyle's does.
HueStrip, AlphaStripn/aOnly ever built vertical (their orientation builder is pub(crate) and the colour picker passes Vertical), and the vertical axis has no leading/trailing to mirror. Nothing to do until a horizontal strip exists.

The vertical axis puts the maximum at the top

Independent of the layout direction, and now uniform across every vertical bounded scalar in the framework: Slider, HueStrip and AlphaStrip all place their minimum at the bottom. Qt's QSlider, GTK4's GtkScale, the Win32 trackbar and <input type=range> agree, and it is what makes ArrowUp — which this table reports as an increase — raise the thumb.

All three grew their value downward before, so ArrowUp increased the value and moved the thumb down: the keys and the pointer drove the control in opposite directions on screen. The geometry moved rather than the chord table, because the chord table is the part every other toolkit agrees on. For the hue strip that also reverses the rainbow texture, so hue 0 is at the bottom.

A vertical ScrollBar is not an exception to this and never was: a scroll offset grows downward by definition, which is why range_nav::towards_trailing exists to convert an increase in the value into a direction on screen.

Chords Teksilo deliberately does not bind

ChordWhy not
Ctrl+Home / Ctrl+End on a spin boxGTK alone binds it. Ctrl+Home is the document chord where one exists, so a spin box in a scrollable form would steal it, and typing the number already reaches either end.
PageUp / PageDown on a dividerNo unit a page could be a multiple of. The ARIA window-splitter pattern asks only for Home/End, and QSplitterHandle binds no page keys.
/ on a spin boxThe caret's, on every platform.
/ on a horizontal scroll barThe vertical bar beside it owns them.
Ctrl+↑ / Ctrl+↓ as a ×10 step (Qt's stepModifier)Redundant with PageUp/PageDown, which apply the same ×10 by default — and on macOS those two chords are Mission Control and Application Windows.
Ctrl+Alt + anythingOwned by NVDA and JAWS for table reading, and Ctrl+Option+… by VoiceOver. Same row as in data-view-keyboard.md.
Escape to revert a slider or a spin boxNo platform documents it, and Escape already means cancel-edit → close-popup → close-dialog.

Accessibility

Every one of these publishes numeric_value, min_numeric_value, max_numeric_value and numeric_value_step; the ones with a coarse step also publish numeric_value_jump, so an assistive technology can announce both distances.

An assistive technology's write lands on the same grid the arrows walk: the advertised numeric_value_step, an arrow press and Increment all move by the control's effective step, so a write that snapped to something else produced a value no other path could reach — and one a screen reader would then announce. A Slider that configures no step gets the 1 %-of-range grid its arrows already use, a hundred positions, which is what <input type=range> gives a stepless range too.

A write the widget refuses reports the action unhandled. SetValue on a text-projected composite used to answer Handled whatever the host's parse said, so "twelve" in Orca's value entry and in macOS's setAccessibilityValue: both read back as success while the field quietly reverted. A read-only field refuses the two writing actions outright — on the inner text node as well as on the composite root, because not advertising an action is not the same as refusing it: an adapter dispatches what the technology asks for, and AT-SPI publishes EditableText off the interface set rather than off the action list.

Slider and SpinBox service Action::SetValue in both payload shapes, because both are sent in the field: a number (macOS setAccessibilityValue: with an NSNumber; AT-SPI's Value.SetCurrentValue, which is how Orca sets a slider or a spin button) and a string (an NSString; Teksilo's own automation set_value tool, which sends only strings). AT-SPI publishes the Value interface off numeric_value alone, so SetCurrentValue reaches these widgets whether or not the action is advertised; macOS instead gates AXValue settability on the advertisement, which is why both widgets advertise it — and why a read-only SpinBox advertises none of the three mutating actions rather than claiming a settability it does not have.

A composite publishes two nodes — the Role::SpinButton root and the Role::TextInput beneath it — and an assistive technology may resolve either. Setting the root goes through the widget's own handler; setting the field used to replace the displayed string and stop there, leaving the typed value stale until the next blur and never firing on_value_changed. The field now commits through its host when the host asks it to (TextInputField::on_access_set_value, which SpinBox and the date and time editors install), so both nodes land the same value by the same parse. The host is handed the string rather than left to re-read the bound signal, which the field syncs only on the next frame tick. A plain TextInput installs nothing: its bound Signal<String> is the value, so the write has already landed.

ScrollBar is the exception: its node is set_hidden() and it is focusable(false), because assistive technology scrolls through the parent ScrollView's Scroll* actions. Nothing in the framework focuses a scroll bar, so its key handler is reachable today only by a programmatic WidgetTree::focus — a test, or an application that opts in. It is routed through the shared table anyway, so the behaviour is correct if that ever changes.

Testing both directions from one host

range_move takes rtl as a parameter rather than reading a cfg! constant, so both branches are reachable from one host's test run — the same split text_nav and list_nav::mac_alias already use. The chord table is unit-tested in isolation; the widget end goes through WidgetTree::press_key(key, modifiers), with WidgetTree::set_layout_direction covering the mirrored case.