Fail CI when a key is bound but not in the gesture book, or listed but not bound

The gesture book is generated from GESTURE tags, so it could not describe a
gesture nobody tagged, but nothing made anyone tag one. The arrow keys, Enter,
P, X, U, Delete, F1 and F2 all worked in the grid with no line in the help
sheet, and a tag could name a key whose handler had gone.

Key handlers now compare one canonical string, Keys.chord(event) == "Ctrl+Z",
instead of reading event.text and the modifiers themselves. keys.slint folds
the key and its modifiers into that spelling, so the literal in the handler is
the whole binding and the checker reads exactly what the handler dispatches
on. Each handler carries a KEYMAP comment naming the gesture-book section its
keys belong to, and a tag's keys field names its keys between backticks.
gestures-check now fails when a handler binds a key no tag in that section
names, when a tag names a key no handler there binds, when any .slint file
other than keys.slint reads event.text, when a compared literal is not
canonical, and when keys.slint's named keys drift from the Rust list.

Spellings are normalised in one place, chord.rs: Ctrl+z, Control+Z and
LeftArrow all mean what the handler's "Ctrl+Z" and "Left" mean. Shift and Alt
count only for letters and named keys, because on the French layout every
digit needs shift and a 6 has to be a 6 however it was typed.

A Rust keymap that both dispatched and was read by the generator was the
alternative. It would have moved the handlers' decisions away from the Slint
state they depend on, and a window that forgot to install it would have had
no working keys at all.

The keys that were already bound and undocumented are now tagged.
This commit is contained in:
2026-09-24 23:42:25 -04:00
parent 150e53e878
commit 9d1e31ffbb
15 changed files with 1696 additions and 258 deletions
+105 -56
View File
@@ -9,6 +9,7 @@
// must not look identical (FR-NC-6c).
import { Theme } from "theme.slint";
import { Keys } from "keys.slint";
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, ProgressBar, Icon, Field } from "widgets.slint";
// The filing sheet lists the same rows the sidebar draws, from the same model:
// two lists of collections that could disagree about what exists is one list
@@ -1649,7 +1650,7 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Press Done in the header
// pointer: Press Done in the header
// keys: Escape
// keys: `Escape`, or `Back`; an open sheet closes first
// manual: selecting-several
in property <bool> select-mode: false;
callback toggle-select-mode();
@@ -1747,6 +1748,9 @@ export component LibraryGrid inherits Rectangle {
// touch: While selecting, press "Select to…", then tap the last
// photograph of the run
// pointer: Shift-click the last photograph of the run
// keys: Shift with any key that walks the grid — `Shift+Left`,
// `Shift+Right`, `Shift+Up`, `Shift+Down`, `Shift+PageUp`,
// `Shift+PageDown`, `Shift+Home`, `Shift+End`
// why: This replaced a double tap, which had no visible state and
// could take forty photographs by accident. The run is resolved
// by the catalog rather than by what is on screen, so the grid
@@ -3024,10 +3028,11 @@ export component LibraryGrid inherits Rectangle {
property <int> f-first: -1;
property <bool> f-used: false;
// KEYMAP: Library grid
key-released(event) => {
// Tapping F on its own clears the star filter; having
// tapped digits under it, letting go only ends the chord.
if (self.f-held && (event.text == "f" || event.text == "F")) {
if (self.f-held && Keys.chord(event) == "F") {
if (!self.f-used) {
root.filter-rating-range(0, 5);
}
@@ -3043,6 +3048,7 @@ export component LibraryGrid inherits Rectangle {
// the first keystroke of a culling session is swallowed.
init => { self.focus(); }
// KEYMAP: Library grid
key-pressed(event) => {
// Declined, not swallowed, so Escape reaches the shell
// and closes the sheet rather than judging under it.
@@ -3055,7 +3061,7 @@ export component LibraryGrid inherits Rectangle {
// "leave the library". On Android that is the system Back
// button, and a sheet it walked straight past would leave
// the user out of the grid with their selection gone.
if (event.text == Key.Back || event.text == Key.Escape) {
if (Keys.chord(event) == "Back" || Keys.chord(event) == "Escape") {
if (root.keywording) {
root.keywording = false;
return accept;
@@ -3078,7 +3084,7 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Select them, then Export in the selection bar
// pointer: Select them, then Export in the selection bar
// keys: Ctrl+Shift+E, or Ctrl+E to see the export
// keys: `Ctrl+Shift+E`, or `Ctrl+E` to see the export
// settings first
// why: Lightroom's and darktable's chords. Every
// export runs on the saved defaults, so the
@@ -3090,13 +3096,12 @@ export component LibraryGrid inherits Rectangle {
// TRACES: FR-EXP-7 | FR-UI-5
// Before the bare keys below: a chord must not also be read
// as the letter it is spelled with.
if (event.modifiers.control
&& (event.text == "e" || event.text == "E")) {
if (event.modifiers.shift) {
root.export-selection();
} else {
root.open-export-options();
}
if (Keys.chord(event) == "Ctrl+Shift+E") {
root.export-selection();
return accept;
}
if (Keys.chord(event) == "Ctrl+E") {
root.open-export-options();
return accept;
}
// GESTURE: Paste copied settings onto the selection
@@ -3104,21 +3109,19 @@ export component LibraryGrid inherits Rectangle {
// touch: Select them, then "Paste to N" in the
// selection bar
// pointer: Select them, then "Paste to N"
// keys: Ctrl+V
// keys: `Ctrl+V`
// manual: copying-settings
//
// TRACES: FR-DEV-6 | FR-UI-5
// Silent with nothing copied, as the button is disabled.
if (event.modifiers.control && root.settings-armed
&& (event.text == "v" || event.text == "V")) {
if (root.settings-armed && Keys.chord(event) == "Ctrl+V") {
root.paste-settings-to-selection();
return accept;
}
// TRACES: FR-CAT-5 | FR-UI-5
// Ctrl+A takes everything the grid shows, as "Select all"
// in the selection strip does.
if (event.modifiers.control
&& (event.text == "a" || event.text == "A")) {
if (Keys.chord(event) == "Ctrl+A") {
root.select-all();
return accept;
}
@@ -3126,9 +3129,10 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Tap a star chip in the filter bar
// pointer: Click a star chip in the filter bar
// keys: Hold F and tap a digit for exactly that many
// stars, or two digits for everything between
// them; tap F alone to show every rating again
// keys: Hold `F` and tap a digit, `0`–`5`, for exactly
// that many stars, or two digits for everything
// between them; tap `F` alone to show every
// rating again
// why: The chips say "this many or more". A range
// with a ceiling — the twos and threes still
// to be decided — is the keyboard's alone, and
@@ -3140,8 +3144,7 @@ export component LibraryGrid inherits Rectangle {
// digit filters as it lands. Any other key ends the chord,
// in case its release went to a window that had focus by
// then and the grid never heard it.
if (!event.modifiers.control
&& (event.text == "f" || event.text == "F")) {
if (Keys.chord(event) == "F") {
// Key repeat while held says nothing new.
if (!self.f-held) {
self.f-held = true;
@@ -3151,34 +3154,34 @@ export component LibraryGrid inherits Rectangle {
return accept;
}
if (self.f-held) {
if (event.text == "0" || event.text == "1"
|| event.text == "2" || event.text == "3"
|| event.text == "4" || event.text == "5") {
if (Keys.chord(event) == "0" || Keys.chord(event) == "1"
|| Keys.chord(event) == "2" || Keys.chord(event) == "3"
|| Keys.chord(event) == "4" || Keys.chord(event) == "5") {
if (self.f-first < 0) {
self.f-first = event.text.to-float();
self.f-first = Keys.chord(event).to-float();
root.filter-rating-range(self.f-first, self.f-first);
} else {
root.filter-rating-range(self.f-first,
event.text.to-float());
Keys.chord(event).to-float());
}
self.f-used = true;
return accept;
}
self.f-held = false;
}
if (event.text == "0") { root.judged(0, -1, root.hover-ordinal); return accept; }
if (event.text == "1") { root.judged(1, -1, root.hover-ordinal); return accept; }
if (event.text == "2") { root.judged(2, -1, root.hover-ordinal); return accept; }
if (event.text == "3") { root.judged(3, -1, root.hover-ordinal); return accept; }
if (event.text == "4") { root.judged(4, -1, root.hover-ordinal); return accept; }
if (event.text == "5") { root.judged(5, -1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "0") { root.judged(0, -1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "1") { root.judged(1, -1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "2") { root.judged(2, -1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "3") { root.judged(3, -1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "4") { root.judged(4, -1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "5") { root.judged(5, -1, root.hover-ordinal); return accept; }
// GESTURE: Give photographs a colour label
// where: Library grid
// touch: Select them, then Label in the selection bar
// and tap a colour
// pointer: Select them, then Label in the selection bar
// and click a colour
// keys: 6 red, 7 yellow, 8 green, 9 blue, with the
// keys: `6` red, `7` yellow, `8` green, `9` blue, with the
// pointer over it or on the selection; the
// same key again takes the label off
// why: Lightroom's keys, so hands that learned them
@@ -3192,30 +3195,51 @@ export component LibraryGrid inherits Rectangle {
// TRACES: FR-CAT-5 | FR-UI-5 | NFR-A11Y-3
// After the F chord above, which has returned for its own
// digits (0-5 only), so these never filter.
if (event.text == "6") { root.labelled(1, root.hover-ordinal); return accept; }
if (event.text == "7") { root.labelled(2, root.hover-ordinal); return accept; }
if (event.text == "8") { root.labelled(3, root.hover-ordinal); return accept; }
if (event.text == "9") { root.labelled(4, root.hover-ordinal); return accept; }
// Case-insensitive: caps lock during a long cull must not
// silently stop the keys working.
if (event.text == "p" || event.text == "P") {
if (Keys.chord(event) == "6") { root.labelled(1, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "7") { root.labelled(2, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "8") { root.labelled(3, root.hover-ordinal); return accept; }
if (Keys.chord(event) == "9") { root.labelled(4, root.hover-ordinal); return accept; }
// GESTURE: Pick or reject a photograph
// where: Library grid
// keys: `P` picks, `X` rejects and `U` takes the flag
// off, with the pointer over it or on the
// selection
// why: The keys every culling tool uses, so muscle
// memory built elsewhere works here.
//
// Case-insensitive, because `Keys.chord` is: caps lock
// during a long cull must not silently stop the keys
// working.
if (Keys.chord(event) == "P") {
root.judged(-1, 1, root.hover-ordinal);
return accept;
}
if (event.text == "x" || event.text == "X") {
if (Keys.chord(event) == "X") {
root.judged(-1, 2, root.hover-ordinal);
return accept;
}
if (event.text == "u" || event.text == "U") {
if (Keys.chord(event) == "U") {
root.judged(-1, 0, root.hover-ordinal);
return accept;
}
// GESTURE: Move photographs to the trash
// where: Library grid
// touch: Tap the bin at the start of a cell's stars
// pointer: Hover the cell and click the bin before its
// stars
// keys: `Delete` or `Backspace`, on the selection
// why: The bin acts on one photograph, so a stray
// click cannot trash a selection; the key acts
// on the selection because that is what every
// file manager's Delete does. Both are undone
// from the trash view.
//
// Delete moves the selection to the trash folder on the
// server. Unlike every other key here it is not metadata —
// it relocates files — but it is also the key every file
// manager binds to exactly this, and the operation is
// reversible from the trash view.
if (event.text == Key.Delete || event.text == Key.Backspace) {
if (Keys.chord(event) == "Delete" || Keys.chord(event) == "Backspace") {
root.trash-selection();
return accept;
}
@@ -3223,20 +3247,43 @@ export component LibraryGrid inherits Rectangle {
// rename key everywhere else, and the reason it is bound
// here is that this scope is what holds focus in library
// mode. Rust ignores it when nothing is scoped.
// GESTURE: Open this list
// where: Library grid
// touch: Press Help in the header
// pointer: Press Help in the header
// keys: `F1`
//
// GESTURE: Rename the collection the grid is showing
// where: Library grid
// touch: Hold it in the sidebar, then "Rename"
// pointer: Double-click it in the sidebar
// keys: `F2`
//
// TRACES: FR-UI-4 | FR-UI-5
// F1 opens the reference the Help button does — the help
// key everywhere else.
if (event.text == Key.F1) {
if (Keys.chord(event) == "F1") {
root.helping = true;
return accept;
}
if (event.text == Key.F2) {
if (Keys.chord(event) == "F2") {
root.rename-scope();
return accept;
}
// --- walking the grid ---------------------------------
//
// GESTURE: Move through the grid
// where: Library grid
// touch: Scroll, and tap a photograph
// pointer: Scroll, and click a photograph
// keys: `Left`, `Right`, `Up` and `Down` move one
// photograph; `PageUp` and `PageDown` a
// screenful; `Home` and `End` to the first and
// the last
// why: The cursor selects what it lands on, so
// walking and judging are one hand's work.
//
// The keys that make a cull possible without the mouse:
// arrows move the cursor, shift extends the selection from
// the anchor, `Return` opens what the cursor is on. The
@@ -3252,28 +3299,28 @@ export component LibraryGrid inherits Rectangle {
// The vertical steps are expressed in columns and rows,
// which only the grid knows: how far "down" is depends on
// how wide the window happens to be.
if (event.text == Key.LeftArrow) {
if (Keys.chord(event) == "Left" || Keys.chord(event) == "Shift+Left") {
root.move-cursor(-1, event.modifiers.shift);
return accept;
}
if (event.text == Key.RightArrow) {
if (Keys.chord(event) == "Right" || Keys.chord(event) == "Shift+Right") {
root.move-cursor(1, event.modifiers.shift);
return accept;
}
if (event.text == Key.UpArrow) {
if (Keys.chord(event) == "Up" || Keys.chord(event) == "Shift+Up") {
root.move-cursor(-root.columns, event.modifiers.shift);
return accept;
}
if (event.text == Key.DownArrow) {
if (Keys.chord(event) == "Down" || Keys.chord(event) == "Shift+Down") {
root.move-cursor(root.columns, event.modifiers.shift);
return accept;
}
if (event.text == Key.PageUp) {
if (Keys.chord(event) == "PageUp" || Keys.chord(event) == "Shift+PageUp") {
root.move-cursor(-root.columns * root.visible-rows,
event.modifiers.shift);
return accept;
}
if (event.text == Key.PageDown) {
if (Keys.chord(event) == "PageDown" || Keys.chord(event) == "Shift+PageDown") {
root.move-cursor(root.columns * root.visible-rows,
event.modifiers.shift);
return accept;
@@ -3281,15 +3328,15 @@ export component LibraryGrid inherits Rectangle {
// A step longer than the library, clamped at the far end.
// `total` is what the grid was told the library holds, so
// this stays honest as it grows.
if (event.text == Key.Home) {
if (Keys.chord(event) == "Home" || Keys.chord(event) == "Shift+Home") {
root.move-cursor(-root.total, event.modifiers.shift);
return accept;
}
if (event.text == Key.End) {
if (Keys.chord(event) == "End" || Keys.chord(event) == "Shift+End") {
root.move-cursor(root.total, event.modifiers.shift);
return accept;
}
if (event.text == Key.Return) {
if (Keys.chord(event) == "Enter") {
root.open-cursor();
return accept;
}
@@ -3852,6 +3899,8 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Tap it — a single tap, any length
// pointer: Click it
// keys: `Enter`, on the photograph the arrow
// keys have walked to
// why: A tap opens; a tap that *moved* does not.
// Travel is what separates a deliberate tap
// from a hand brushing past, and it is the
@@ -3973,7 +4022,7 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: Tap a star on the cell
// pointer: Hover the cell, then click a star
// keys: 0 to 5 with the pointer over it, or
// keys: `0`–`5` with the pointer over it, or
// on the selection
// why: A star has to take the press without it
// also reaching the cell, or every rating
@@ -4801,7 +4850,7 @@ export component LibraryGrid inherits Rectangle {
// where: Library grid
// touch: While selecting, press "Select all"
// pointer: While selecting, press "Select all"
// keys: Ctrl+A
// keys: `Ctrl+A`
// why: A scoped grid of two hundred frames is two hundred
// taps otherwise, and "all of them, except those
// three" is a far more common shape than the taps it