Merge master into wave-2
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> # Conflicts: # docs/traceability.md
This commit is contained in:
@@ -82,6 +82,19 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The gesture vocabulary, from the same scanner and under the same rule.
|
||||||
|
#
|
||||||
|
# Blocking, and for a sharper reason than the matrix: these two artefacts
|
||||||
|
# are not only read, one of them is *shown to the user*. A stale
|
||||||
|
# `gesture_book.rs` is a help sheet in the application telling somebody to
|
||||||
|
# perform a gesture that was removed — worse than no help sheet, because
|
||||||
|
# they will conclude the application is broken rather than the page.
|
||||||
|
#
|
||||||
|
# This also fails on a malformed tag, so a typo costs a gesture its
|
||||||
|
# desktop half loudly rather than silently.
|
||||||
|
- name: Regenerate the gesture vocabulary and check it is committed
|
||||||
|
run: cargo run -q -p traceability -- gestures-check
|
||||||
|
|
||||||
# Advisory, not blocking: not every file implements a requirement, and a
|
# Advisory, not blocking: not every file implements a requirement, and a
|
||||||
# tag on every function is noise that rots faster than it helps. Tag the
|
# tag on every function is noise that rots faster than it helps. Tag the
|
||||||
# unit that decides.
|
# unit that decides.
|
||||||
|
|||||||
+32
-5
@@ -1,5 +1,10 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Keep docs/traceability.md in step with the tags in the tree.
|
# Keep the generated artefacts in step with the tags in the tree.
|
||||||
|
#
|
||||||
|
# Two of them now, from the same scanner: the requirements matrix and the
|
||||||
|
# gesture vocabulary. Both are generated *from* the tree and cite line numbers
|
||||||
|
# in it, so both go stale on any commit that moves a line — a `cargo fmt` sweep
|
||||||
|
# above all, but equally a commit that merely adds a paragraph above a tag.
|
||||||
#
|
#
|
||||||
# The gate regenerates the matrix in CI and fails if the result differs from
|
# The gate regenerates the matrix in CI and fails if the result differs from
|
||||||
# what is committed. That is the right check — a matrix that disagrees with the
|
# what is committed. That is the right check — a matrix that disagrees with the
|
||||||
@@ -18,11 +23,13 @@ staged="$(git diff --cached --name-only --diff-filter=ACMR)"
|
|||||||
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
|
if ! grep -qE '\.(rs|slint|yaml|md)$' <<< "${staged}"; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# The matrix is generated from the tree, so regenerating it because it was
|
# The artefacts are generated from the tree, so regenerating them because one
|
||||||
# itself edited would be circular.
|
# was itself edited would be circular.
|
||||||
if [ "$(tr -d '[:space:]' <<< "${staged}")" = "docs/traceability.md" ]; then
|
case "$(tr -d '[:space:]' <<< "${staged}")" in
|
||||||
|
docs/traceability.md | docs/gestures.md | ui/dr-ui/src/gesture_book.rs)
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
repo="$(git rev-parse --show-toplevel)"
|
repo="$(git rev-parse --show-toplevel)"
|
||||||
cd "${repo}"
|
cd "${repo}"
|
||||||
@@ -38,3 +45,23 @@ if ! git diff --quiet -- docs/traceability.md; then
|
|||||||
git add docs/traceability.md
|
git add docs/traceability.md
|
||||||
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
echo "pre-commit: regenerated docs/traceability.md and staged it"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The gesture vocabulary, same discipline.
|
||||||
|
#
|
||||||
|
# **Failure here is reported and not swallowed**, unlike the matrix above. A
|
||||||
|
# matrix that will not build leaves the previous one in place, which is merely
|
||||||
|
# stale; a malformed `GESTURE:` block means a gesture the user is about to be
|
||||||
|
# told about in the wrong words, or not at all. The gate would catch it in CI
|
||||||
|
# either way — this is only about catching it a push earlier.
|
||||||
|
if ! out="$(cargo run -q -p traceability -- gestures 2>&1)"; then
|
||||||
|
echo "pre-commit: the gesture scan failed — the tags below need fixing" >&2
|
||||||
|
echo "${out}" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for f in docs/gestures.md ui/dr-ui/src/gesture_book.rs; do
|
||||||
|
if ! git diff --quiet -- "${f}"; then
|
||||||
|
git add "${f}"
|
||||||
|
echo "pre-commit: regenerated ${f} and staged it"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|||||||
+11
-11
@@ -54,7 +54,7 @@ The right match confidence is a property of your library, not of the model. "Wha
|
|||||||
|
|
||||||
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
Touch has no ctrl, so without a mode there is no way to select a second photograph — the first tap would open it. The hold is the fast way in and the button is the one that can be found.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1259`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1282`</sub>
|
||||||
|
|
||||||
### Add or remove one photograph
|
### Add or remove one photograph
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ Touch has no ctrl, so without a mode there is no way to select a second photogra
|
|||||||
|
|
||||||
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
While selecting, a tap never opens. That is the whole point of the mode: one meaning per gesture at a time. Press Done to get tap-to-open back.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1268`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1291`</sub>
|
||||||
|
|
||||||
### Leave selecting
|
### Leave selecting
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
|||||||
- **Pointer** — Press Done in the header
|
- **Pointer** — Press Done in the header
|
||||||
- **Keyboard** — Escape
|
- **Keyboard** — Escape
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1276`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1299`</sub>
|
||||||
|
|
||||||
### Select a range
|
### Select a range
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ While selecting, a tap never opens. That is the whole point of the mode: one mea
|
|||||||
|
|
||||||
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 can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
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 can scroll between the two taps — the ranges that hurt on a tablet are longer than a screenful, which is exactly where a finger sweep runs out.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:1337`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:1360`</sub>
|
||||||
|
|
||||||
### Find photographs with two people in them
|
### Find photographs with two people in them
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
|||||||
|
|
||||||
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
"Any of them" is a union and "all of them" is an intersection. The tray is where both terms and the choice between them live, because a filter belongs on the filter bar.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2076`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2101`</sub>
|
||||||
|
|
||||||
### Drop the selection but keep selecting
|
### Drop the selection but keep selecting
|
||||||
|
|
||||||
@@ -98,7 +98,7 @@ This replaced a double tap, which had no visible state and could take forty phot
|
|||||||
|
|
||||||
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the next selection can start straight away.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2367`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2392`</sub>
|
||||||
|
|
||||||
### Select everything the grid is showing
|
### Select everything the grid is showing
|
||||||
|
|
||||||
@@ -107,7 +107,7 @@ Distinct from Done, which leaves the mode entirely. Clearing keeps it, so the ne
|
|||||||
|
|
||||||
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 took to say it.
|
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 took to say it.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2384`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2409`</sub>
|
||||||
|
|
||||||
### Resize the thumbnails
|
### Resize the thumbnails
|
||||||
|
|
||||||
@@ -116,7 +116,7 @@ A scoped grid of two hundred frames is two hundred taps otherwise, and "all of t
|
|||||||
|
|
||||||
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
There is no wheel on a tablet, so without the pinch the cell size could only be changed by a control a finger cannot reach.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2825`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2850`</sub>
|
||||||
|
|
||||||
### File photographs in a collection
|
### File photographs in a collection
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@ There is no wheel on a tablet, so without the pinch the cell size could only be
|
|||||||
|
|
||||||
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
The selection is what the drag carries, which is why selecting several is worth the mode: forty photographs file in one gesture.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:2974`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:2999`</sub>
|
||||||
|
|
||||||
### Open a photograph
|
### Open a photograph
|
||||||
|
|
||||||
@@ -134,7 +134,7 @@ The selection is what the drag carries, which is why selecting several is worth
|
|||||||
|
|
||||||
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 only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
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 only thing that does: the two are the same length. An earlier version required the finger to dwell 120 ms instead, and that rejected ordinary taps — a real tap is often quicker than a brush.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3204`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3229`</sub>
|
||||||
|
|
||||||
### Rate a photograph without opening it
|
### Rate a photograph without opening it
|
||||||
|
|
||||||
@@ -144,4 +144,4 @@ A tap opens; a tap that *moved* does not. Travel is what separates a deliberate
|
|||||||
|
|
||||||
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
A star has to take the press without it also reaching the cell, or every rating throws the user into develop.
|
||||||
|
|
||||||
<sub>`ui/dr-ui/ui/library.slint:3316`</sub>
|
<sub>`ui/dr-ui/ui/library.slint:3341`</sub>
|
||||||
|
|||||||
+54
-54
File diff suppressed because one or more lines are too long
@@ -45,8 +45,14 @@
|
|||||||
//! Works in `.rs` and `.slint` alike, because both comment with `//` and the
|
//! Works in `.rs` and `.slint` alike, because both comment with `//` and the
|
||||||
//! gestures live in both — the arbitration in Rust, the affordance in Slint.
|
//! gestures live in both — the arbitration in Rust, the affordance in Slint.
|
||||||
//!
|
//!
|
||||||
//! A block runs from the `GESTURE:` line to the first line that is not a
|
//! **The tag opens its comment or it is not a tag.** A line that merely
|
||||||
//! comment, or to a comment line that is empty after its marker. A line reading
|
//! mentions it — a sentence about the vocabulary, a worked example in a doc
|
||||||
|
//! comment like the one above — is describing the mechanism rather than
|
||||||
|
//! declaring a gesture, and position is the only thing that tells the two
|
||||||
|
//! apart.
|
||||||
|
//!
|
||||||
|
//! A block runs from that line to the first line that is not a comment, or to a
|
||||||
|
//! comment line that is empty after its marker. A line reading
|
||||||
//! `key: value` sets a field; any other line inside the block continues the
|
//! `key: value` sets a field; any other line inside the block continues the
|
||||||
//! field before it, which is what lets `why` run to a sentence. An unrecognised
|
//! field before it, which is what lets `why` run to a sentence. An unrecognised
|
||||||
//! key is an error rather than a silently dropped line: `pointr:` should not
|
//! key is an error rather than a silently dropped line: `pointr:` should not
|
||||||
@@ -127,20 +133,26 @@ pub fn extract_from_text(text: &str, path: &str) -> (Vec<Gesture>, Vec<GesturePr
|
|||||||
|
|
||||||
let mut i = 0;
|
let mut i = 0;
|
||||||
while i < lines.len() {
|
while i < lines.len() {
|
||||||
let Some(pos) = lines[i].find("GESTURE:") else {
|
// **The tag must open its comment**, not merely appear somewhere in
|
||||||
|
// one. Prose that mentions it — "generated from the `GESTURE:` comments
|
||||||
|
// beside the code" — is talking *about* the vocabulary, not declaring a
|
||||||
|
// member of it, and the two are only distinguishable by position. The
|
||||||
|
// gate caught exactly that sentence in `dr-ui`'s own module list, which
|
||||||
|
// is the right outcome for a typo and the wrong one for a description.
|
||||||
|
//
|
||||||
|
// This also excludes a tag inside a string literal, which has code in
|
||||||
|
// front of it and so does not open a comment at all.
|
||||||
|
let Some(body) = comment_body(lines[i]) else {
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
// Only in a comment. A `GESTURE:` inside a string literal — this very
|
let Some(title) = body.trim_start().strip_prefix("GESTURE:") else {
|
||||||
// file's doc comment shows the tag, and so does the scanner's own test
|
|
||||||
// fixture — is not a tag.
|
|
||||||
if !is_comment(lines[i]) {
|
|
||||||
i += 1;
|
i += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
};
|
||||||
|
|
||||||
let start = i;
|
let start = i;
|
||||||
let title = lines[i][pos + "GESTURE:".len()..].trim().to_string();
|
let title = title.trim().to_string();
|
||||||
|
|
||||||
let mut fields: BTreeMap<String, String> = BTreeMap::new();
|
let mut fields: BTreeMap<String, String> = BTreeMap::new();
|
||||||
let mut last: Option<String> = None;
|
let mut last: Option<String> = None;
|
||||||
@@ -236,17 +248,6 @@ pub fn extract_from_text(text: &str, path: &str) -> (Vec<Gesture>, Vec<GesturePr
|
|||||||
(out, problems)
|
(out, problems)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a line is a `//` comment and nothing else of substance before it.
|
|
||||||
///
|
|
||||||
/// Deliberately strict about what precedes the marker: `let s = "// GESTURE:"`
|
|
||||||
/// has code in front of it and is not a comment line. Trailing comments after
|
|
||||||
/// code are not a place gesture blocks are written, so refusing them costs
|
|
||||||
/// nothing and keeps string literals out of the document.
|
|
||||||
fn is_comment(line: &str) -> bool {
|
|
||||||
let t = line.trim_start();
|
|
||||||
t.starts_with("//")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The text of a comment line, marker stripped. `None` for anything else.
|
/// The text of a comment line, marker stripped. `None` for anything else.
|
||||||
///
|
///
|
||||||
/// `///` and `//!` are stripped to the same thing: a gesture block written as a
|
/// `///` and `//!` are stripped to the same thing: a gesture block written as a
|
||||||
@@ -525,6 +526,21 @@ property <bool> ranging: false;
|
|||||||
assert!(p.is_empty());
|
assert!(p.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prose *about* the vocabulary is not a member of it. The gate caught this
|
||||||
|
/// exact sentence in `dr-ui`'s module list, where a comment explaining
|
||||||
|
/// where the generated table comes from named the tag in passing.
|
||||||
|
#[test]
|
||||||
|
fn a_sentence_that_mentions_the_tag_is_not_a_gesture() {
|
||||||
|
let text = "\
|
||||||
|
// Generated from the `GESTURE:` comments beside the code that implements
|
||||||
|
// each one — see `tools/traceability`.
|
||||||
|
mod gesture_book;
|
||||||
|
";
|
||||||
|
let (g, p) = extract_from_text(text, "lib.rs");
|
||||||
|
assert!(g.is_empty(), "{g:?}");
|
||||||
|
assert!(p.is_empty(), "{p:?}");
|
||||||
|
}
|
||||||
|
|
||||||
/// A blank comment line ends the block, so ordinary prose beneath a gesture
|
/// A blank comment line ends the block, so ordinary prose beneath a gesture
|
||||||
/// is not swallowed into its `why`.
|
/// is not swallowed into its `why`.
|
||||||
#[test]
|
#[test]
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
//! TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
//! The gesture help sheet's model.
|
||||||
|
//!
|
||||||
|
//! FR-UI-4's rule is that a gesture with no visible counterpart is a feature
|
||||||
|
//! only its author knows about. Most of the grid's vocabulary now *has* a
|
||||||
|
//! visible counterpart — Select, "Select to…", Select all are buttons — but
|
||||||
|
//! knowing that a hold does the same thing faster, or that two fingers resize
|
||||||
|
//! the thumbnails, still had to be discovered by accident.
|
||||||
|
//!
|
||||||
|
//! This is the list that says so. It is built from [`crate::gesture_book`],
|
||||||
|
//! which is generated from the comment beside each implementation, so a sheet
|
||||||
|
//! describing a gesture the application does not have is not possible to write:
|
||||||
|
//! there is no file to write it in.
|
||||||
|
//!
|
||||||
|
//! # Why the rows are flat
|
||||||
|
//!
|
||||||
|
//! A section heading and a gesture are one model here, distinguished by
|
||||||
|
//! `heading` being non-empty, rather than a list of lists. Slint has no nested
|
||||||
|
//! repeater that keeps its scrolling in one place, and a sheet whose sections
|
||||||
|
//! scrolled independently is not one list — it is several, in a box.
|
||||||
|
|
||||||
|
use crate::gesture_book::{Gesture, GESTURES};
|
||||||
|
|
||||||
|
/// One line of the sheet: a heading, or a gesture with its routes.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||||
|
pub struct Row {
|
||||||
|
/// Non-empty on a section heading, and then nothing else is set.
|
||||||
|
///
|
||||||
|
/// The sheet decides which of the two a row is by asking whether this is
|
||||||
|
/// empty, in Slint, where the drawing happens. There is deliberately no
|
||||||
|
/// `is_heading()` beside it: a predicate here that the drawing code did not
|
||||||
|
/// call would be a second definition of the same distinction, free to drift
|
||||||
|
/// from the one that is actually used.
|
||||||
|
pub heading: String,
|
||||||
|
pub title: String,
|
||||||
|
/// Empty where the gesture has no counterpart in that modality; the sheet
|
||||||
|
/// draws nothing rather than an empty label.
|
||||||
|
pub touch: String,
|
||||||
|
pub pointer: String,
|
||||||
|
pub keys: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the sheet's rows from the generated table.
|
||||||
|
///
|
||||||
|
/// The table is already ordered so a section's gestures are together — the
|
||||||
|
/// generator groups it that way — so this emits a heading whenever the section
|
||||||
|
/// changes rather than sorting again. Two passes that both decide the order is
|
||||||
|
/// how the document and the sheet come to disagree.
|
||||||
|
pub fn rows() -> Vec<Row> {
|
||||||
|
rows_from(GESTURES)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rows_from(gestures: &[Gesture]) -> Vec<Row> {
|
||||||
|
let mut out = Vec::with_capacity(gestures.len() + 4);
|
||||||
|
let mut section = "";
|
||||||
|
for g in gestures {
|
||||||
|
if g.section != section {
|
||||||
|
section = g.section;
|
||||||
|
out.push(Row {
|
||||||
|
heading: g.section.to_string(),
|
||||||
|
..Row::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out.push(Row {
|
||||||
|
heading: String::new(),
|
||||||
|
title: g.title.to_string(),
|
||||||
|
touch: g.touch.to_string(),
|
||||||
|
pointer: g.pointer.to_string(),
|
||||||
|
keys: g.keys.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn g(section: &'static str, title: &'static str) -> Gesture {
|
||||||
|
Gesture {
|
||||||
|
title,
|
||||||
|
section,
|
||||||
|
touch: "Tap",
|
||||||
|
pointer: "",
|
||||||
|
keys: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn each_section_gets_one_heading_before_its_gestures() {
|
||||||
|
let rows = rows_from(&[g("Grid", "A"), g("Grid", "B"), g("People", "C")]);
|
||||||
|
let shape: Vec<&str> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|r| if r.heading.is_empty() { "g" } else { "H" })
|
||||||
|
.collect();
|
||||||
|
assert_eq!(shape, ["H", "g", "g", "H", "g"]);
|
||||||
|
assert_eq!(rows[0].heading, "Grid");
|
||||||
|
assert_eq!(rows[3].heading, "People");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The generated table groups sections together, so a repeat is a bug in
|
||||||
|
/// the generator rather than something to defend against by sorting here —
|
||||||
|
/// but a heading emitted twice would silently split a section in the sheet
|
||||||
|
/// while the document showed it whole, so it is worth stating.
|
||||||
|
#[test]
|
||||||
|
fn a_heading_is_not_repeated_within_a_run() {
|
||||||
|
let rows = rows_from(&[g("Grid", "A"), g("Grid", "B"), g("Grid", "C")]);
|
||||||
|
assert_eq!(rows.iter().filter(|r| !r.heading.is_empty()).count(), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nothing_in_makes_nothing_out() {
|
||||||
|
assert!(rows_from(&[]).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The sheet is only worth opening if there is something in it, and the
|
||||||
|
/// generated table is the only thing that can put something there.
|
||||||
|
#[test]
|
||||||
|
fn the_real_table_is_not_empty_and_every_row_can_be_performed() {
|
||||||
|
let rows = rows();
|
||||||
|
assert!(rows.len() > 5, "the generated gesture table looks empty");
|
||||||
|
for r in rows.iter().filter(|r| r.heading.is_empty()) {
|
||||||
|
assert!(!r.title.is_empty());
|
||||||
|
assert!(
|
||||||
|
!r.touch.is_empty() || !r.pointer.is_empty() || !r.keys.is_empty(),
|
||||||
|
"`{}` tells the user no way to perform it",
|
||||||
|
r.title
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,11 @@ mod develop;
|
|||||||
mod display_ui;
|
mod display_ui;
|
||||||
mod export;
|
mod export;
|
||||||
pub mod faces;
|
pub mod faces;
|
||||||
|
// Generated from the `GESTURE:` comments beside the code that implements each
|
||||||
|
// one — see `tools/traceability`. Regenerate with
|
||||||
|
// `cargo run -p traceability -- gestures`; CI fails if it has drifted.
|
||||||
|
mod gesture_book;
|
||||||
|
mod gestures;
|
||||||
mod gradient;
|
mod gradient;
|
||||||
mod histogram;
|
mod histogram;
|
||||||
pub mod identity;
|
pub mod identity;
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
|
|||||||
use slint::{ComponentHandle, Model as _};
|
use slint::{ComponentHandle, Model as _};
|
||||||
|
|
||||||
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
use crate::library::{self, ScanMessage, ThumbnailMessage};
|
||||||
use crate::{AppWindow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
|
use crate::{AppWindow, GestureRow, KeywordRow, LibraryCell, PersonChip, TimelineBar};
|
||||||
|
|
||||||
/// A screenful before the grid has reported its geometry.
|
/// A screenful before the grid has reported its geometry.
|
||||||
///
|
///
|
||||||
@@ -4535,6 +4535,24 @@ pub fn wire<F>(
|
|||||||
// after it, which is the whole point of showing them.
|
// after it, which is the whole point of showing them.
|
||||||
window.set_library_touched(cfg!(target_os = "android"));
|
window.set_library_touched(cfg!(target_os = "android"));
|
||||||
|
|
||||||
|
// TRACES: FR-UI-4
|
||||||
|
// The gesture reference. Pushed once, here, rather than on demand: the
|
||||||
|
// table is a compiled-in constant, so there is nothing to be fresh about
|
||||||
|
// and nothing to recompute — a callback to fill it would only be a way for
|
||||||
|
// it to be empty the first time the sheet opens.
|
||||||
|
window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from(
|
||||||
|
crate::gestures::rows()
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| GestureRow {
|
||||||
|
heading: r.heading.into(),
|
||||||
|
title: r.title.into(),
|
||||||
|
touch: r.touch.into(),
|
||||||
|
pointer: r.pointer.into(),
|
||||||
|
keys: r.keys.into(),
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
)));
|
||||||
|
|
||||||
// Shared rather than moved: a click and `Return` both open an image, and
|
// Shared rather than moved: a click and `Return` both open an image, and
|
||||||
// they are two callbacks.
|
// they are two callbacks.
|
||||||
let on_open_image = Rc::new(on_open_image);
|
let on_open_image = Rc::new(on_open_image);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { HistoryPanel, HistoryRow } from "history.slint";
|
|||||||
import { LaunchScreen } from "launch.slint";
|
import { LaunchScreen } from "launch.slint";
|
||||||
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.slint";
|
||||||
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
|
import { LibraryGrid, LibraryCell, TimelineBar, PhotoRoll, KeywordRow, PersonChip } from "library.slint";
|
||||||
|
import { GestureRow } from "gestures.slint";
|
||||||
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState, ProgressBar, ActivityRow } from "widgets.slint";
|
||||||
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
import { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||||
@@ -17,6 +18,7 @@ import { ImportPage } from "import.slint";
|
|||||||
import { StatusBar, InfoPanel } from "develop.slint";
|
import { StatusBar, InfoPanel } from "develop.slint";
|
||||||
|
|
||||||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
|
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
|
||||||
|
export { GestureRow }
|
||||||
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||||
|
|
||||||
export component AppWindow inherits Window {
|
export component AppWindow inherits Window {
|
||||||
@@ -627,6 +629,9 @@ export component AppWindow inherits Window {
|
|||||||
callback library-filter-person-cleared(int);
|
callback library-filter-person-cleared(int);
|
||||||
callback library-filter-people-mode-toggled();
|
callback library-filter-people-mode-toggled();
|
||||||
/// Everyone the library knows, for the filter bar's people tray.
|
/// Everyone the library knows, for the filter bar's people tray.
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// The gesture reference's rows, read from the generated table.
|
||||||
|
in property <[GestureRow]> library-gestures;
|
||||||
in property <[PersonChip]> library-people;
|
in property <[PersonChip]> library-people;
|
||||||
callback library-people-listed();
|
callback library-people-listed();
|
||||||
callback library-filter-person-toggled(int);
|
callback library-filter-person-toggled(int);
|
||||||
@@ -1599,6 +1604,7 @@ in property <bool> panel-visible: true;
|
|||||||
filter-people: root.library-filter-people;
|
filter-people: root.library-filter-people;
|
||||||
filter-people-all: root.library-filter-people-all;
|
filter-people-all: root.library-filter-people-all;
|
||||||
people: root.library-people;
|
people: root.library-people;
|
||||||
|
gestures: root.library-gestures;
|
||||||
filter-min-rating: root.library-filter-min-rating;
|
filter-min-rating: root.library-filter-min-rating;
|
||||||
filter-unjudged: root.library-filter-unjudged;
|
filter-unjudged: root.library-filter-unjudged;
|
||||||
filter-flag: root.library-filter-flag;
|
filter-flag: root.library-filter-flag;
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
//
|
||||||
|
// The gesture reference, as a sheet.
|
||||||
|
//
|
||||||
|
// # Why the application carries one at all
|
||||||
|
//
|
||||||
|
// FR-UI-4: a gesture with no visible counterpart is a feature only its author
|
||||||
|
// knows about. Most of the grid's vocabulary now has one — Select, "Select to…"
|
||||||
|
// and Select all are buttons that say what they do — but that a *hold* does the
|
||||||
|
// same thing faster, or that two fingers resize the thumbnails, could still only
|
||||||
|
// be discovered by accident. The buttons make the gestures usable; this makes
|
||||||
|
// them knowable.
|
||||||
|
//
|
||||||
|
// # Why the rows come from Rust and not from this file
|
||||||
|
//
|
||||||
|
// Every line here is generated from the comment beside the code that implements
|
||||||
|
// the gesture (`gesture_book.rs`, and `tools/traceability` that writes it). A
|
||||||
|
// sheet with the text typed into it would be a second description of one
|
||||||
|
// behaviour, and the second description is always the one that goes stale: the
|
||||||
|
// code is exercised whenever somebody uses the application, and the help screen
|
||||||
|
// is exercised never. This file draws whatever it is handed and knows nothing
|
||||||
|
// about what a gesture is.
|
||||||
|
|
||||||
|
import { Theme } from "theme.slint";
|
||||||
|
import { Button, Caption } from "widgets.slint";
|
||||||
|
|
||||||
|
// One line of the sheet: a section heading, or a gesture and its routes.
|
||||||
|
//
|
||||||
|
// Flat, and not a list of lists, because a sheet whose sections scrolled
|
||||||
|
// independently is not one list — it is several, in a box. `heading` non-empty
|
||||||
|
// is what marks the two apart; Slint has no sum type to say it better.
|
||||||
|
export struct GestureRow {
|
||||||
|
heading: string,
|
||||||
|
title: string,
|
||||||
|
// Empty where the gesture has no counterpart in that modality. Drawn as
|
||||||
|
// nothing at all rather than as an empty label, so a touch-only gesture
|
||||||
|
// does not read as one whose pointer half is broken.
|
||||||
|
touch: string,
|
||||||
|
pointer: string,
|
||||||
|
keys: string,
|
||||||
|
}
|
||||||
|
|
||||||
|
// One route: how a modality performs the gesture.
|
||||||
|
component Route inherits HorizontalLayout {
|
||||||
|
in property <string> modality;
|
||||||
|
in property <string> how;
|
||||||
|
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
|
||||||
|
Caption {
|
||||||
|
text: root.modality;
|
||||||
|
// A fixed column so the three routes line up down the sheet. Without
|
||||||
|
// it "Touch", "Pointer" and "Keyboard" each set their own left edge for
|
||||||
|
// the text beside them, and a list of forty reads as ragged prose.
|
||||||
|
width: 64px;
|
||||||
|
horizontal-alignment: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: root.how;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
wrap: word-wrap;
|
||||||
|
horizontal-stretch: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export component GestureSheet inherits Rectangle {
|
||||||
|
in property <[GestureRow]> rows;
|
||||||
|
|
||||||
|
callback close();
|
||||||
|
|
||||||
|
background: #000000CC;
|
||||||
|
|
||||||
|
// Swallows the taps that miss the card, and closes. First, so the card's
|
||||||
|
// own controls sit above it — the same scrim, card and dismissal the
|
||||||
|
// library's other sheets use, because a user who has filed a selection
|
||||||
|
// already knows how this works.
|
||||||
|
TouchArea {
|
||||||
|
clicked => { root.close(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
Rectangle {
|
||||||
|
width: min(520px, parent.width - 2 * Theme.gap-lg);
|
||||||
|
height: min(560px, parent.height - 2 * Theme.gap-lg);
|
||||||
|
x: (parent.width - self.width) / 2;
|
||||||
|
// Centred, unlike the naming sheet: nothing here takes the keyboard, so
|
||||||
|
// there is no keyboard to sit above.
|
||||||
|
y: (parent.height - self.height) / 2;
|
||||||
|
background: Theme.surface;
|
||||||
|
border-radius: Theme.radius;
|
||||||
|
border-width: 1px;
|
||||||
|
border-color: Theme.rule;
|
||||||
|
|
||||||
|
// Stops a press on the card reaching the scrim behind it.
|
||||||
|
TouchArea { }
|
||||||
|
|
||||||
|
VerticalLayout {
|
||||||
|
padding: Theme.gap-lg;
|
||||||
|
spacing: Theme.gap;
|
||||||
|
|
||||||
|
Text {
|
||||||
|
text: "How to drive the grid";
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The list scrolls; the title and the button do not, so a long
|
||||||
|
// vocabulary never pushes the way out off the bottom of the card.
|
||||||
|
Flickable {
|
||||||
|
vertical-stretch: 1;
|
||||||
|
viewport-width: self.width;
|
||||||
|
viewport-height: list.preferred-height;
|
||||||
|
|
||||||
|
list := VerticalLayout {
|
||||||
|
width: parent.viewport-width;
|
||||||
|
spacing: Theme.gap-sm;
|
||||||
|
alignment: start;
|
||||||
|
|
||||||
|
for r[i] in root.rows: VerticalLayout {
|
||||||
|
spacing: 2px;
|
||||||
|
|
||||||
|
// A heading and a gesture are the same row type, so
|
||||||
|
// each half is drawn under its own condition rather
|
||||||
|
// than by two repeaters over one filtered list — which
|
||||||
|
// would be two passes that could disagree about order.
|
||||||
|
// Space above a heading, and none above the first:
|
||||||
|
// the gap is what separates a section from the one
|
||||||
|
// before it, and there is nothing before the first.
|
||||||
|
//
|
||||||
|
// An empty Rectangle rather than a `y` offset on the
|
||||||
|
// heading — a child of a layout may not set its own
|
||||||
|
// `y`, because the layout is already setting it.
|
||||||
|
if r.heading != "" && i > 0: Rectangle {
|
||||||
|
height: Theme.gap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.heading != "": Text {
|
||||||
|
text: r.heading;
|
||||||
|
color: Theme.ink-dim;
|
||||||
|
font-size: Theme.text-sm;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.heading == "": Text {
|
||||||
|
text: r.title;
|
||||||
|
color: Theme.ink;
|
||||||
|
font-size: Theme.text;
|
||||||
|
font-weight: 600;
|
||||||
|
wrap: word-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.touch != "": Route {
|
||||||
|
modality: "Touch";
|
||||||
|
how: r.touch;
|
||||||
|
}
|
||||||
|
if r.pointer != "": Route {
|
||||||
|
modality: "Pointer";
|
||||||
|
how: r.pointer;
|
||||||
|
}
|
||||||
|
if r.keys != "": Route {
|
||||||
|
modality: "Keyboard";
|
||||||
|
how: r.keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HorizontalLayout {
|
||||||
|
alignment: end;
|
||||||
|
Button {
|
||||||
|
text: "Done";
|
||||||
|
primary: true;
|
||||||
|
clicked => { root.close(); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip, Prog
|
|||||||
// two lists of collections that could disagree about what exists is one list
|
// two lists of collections that could disagree about what exists is one list
|
||||||
// too many.
|
// too many.
|
||||||
import { CollectionRow } from "collections.slint";
|
import { CollectionRow } from "collections.slint";
|
||||||
|
import { GestureSheet, GestureRow } from "gestures.slint";
|
||||||
|
|
||||||
// TRACES: FR-CAT-5
|
// TRACES: FR-CAT-5
|
||||||
// One keyword in the keywording sheet, already answered against the selection.
|
// One keyword in the keywording sheet, already answered against the selection.
|
||||||
@@ -896,6 +897,9 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
callback open-import();
|
callback open-import();
|
||||||
callback open-settings();
|
callback open-settings();
|
||||||
callback open-people();
|
callback open-people();
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// Open the gesture reference.
|
||||||
|
callback open-gestures();
|
||||||
|
|
||||||
spacing: Theme.gap;
|
spacing: Theme.gap;
|
||||||
|
|
||||||
@@ -1068,6 +1072,18 @@ component HeaderActions inherits HorizontalLayout {
|
|||||||
clicked => { root.open-people(); }
|
clicked => { root.open-people(); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-4
|
||||||
|
// What the gestures are, for the ones that have no button of their own.
|
||||||
|
// Beside Settings because it is about the application rather than about
|
||||||
|
// the photographs, and unconditional for the same reason Settings is: a
|
||||||
|
// reference you can only reach in some states is one you look for in the
|
||||||
|
// state where you needed it and do not find.
|
||||||
|
Button {
|
||||||
|
text: "Gestures";
|
||||||
|
y: root.centred ? (root.row-height - self.height) / 2 : 0;
|
||||||
|
clicked => { root.open-gestures(); }
|
||||||
|
}
|
||||||
|
|
||||||
// Last in the row, and unconditional. The buttons before it come and go
|
// Last in the row, and unconditional. The buttons before it come and go
|
||||||
// with what the grid is showing; settings is always reachable, and a
|
// with what the grid is showing; settings is always reachable, and a
|
||||||
// control that moved as its neighbours appeared would be hunted for each
|
// control that moved as its neighbours appeared would be hunted for each
|
||||||
@@ -1223,6 +1239,13 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
callback open-import();
|
callback open-import();
|
||||||
callback open-settings();
|
callback open-settings();
|
||||||
callback open-people();
|
callback open-people();
|
||||||
|
/// TRACES: FR-UI-4
|
||||||
|
/// The gesture reference's rows, from Rust — which reads them from the
|
||||||
|
/// generated table. See gestures.slint for why they cannot be written here.
|
||||||
|
in property <[GestureRow]> gestures;
|
||||||
|
/// Whether the reference is up. Local, like `naming` and `filing`: nothing
|
||||||
|
/// in Rust needs to know a sheet is open.
|
||||||
|
property <bool> helping: false;
|
||||||
|
|
||||||
// --- selection and drag ---
|
// --- selection and drag ---
|
||||||
//
|
//
|
||||||
@@ -1900,6 +1923,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
open-import => { root.open-import(); }
|
open-import => { root.open-import(); }
|
||||||
open-settings => { root.open-settings(); }
|
open-settings => { root.open-settings(); }
|
||||||
open-people => { root.open-people(); }
|
open-people => { root.open-people(); }
|
||||||
|
open-gestures => { root.helping = true; }
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compact: one button in place of six. Labelled rather than a
|
// Compact: one button in place of six. Labelled rather than a
|
||||||
@@ -1986,6 +2010,7 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
open-import => { root.open-import(); }
|
open-import => { root.open-import(); }
|
||||||
open-settings => { root.open-settings(); }
|
open-settings => { root.open-settings(); }
|
||||||
open-people => { root.open-people(); }
|
open-people => { root.open-people(); }
|
||||||
|
open-gestures => { root.helping = true; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3800,6 +3825,16 @@ export component LibraryGrid inherits Rectangle {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TRACES: FR-UI-2 | FR-UI-4
|
||||||
|
// The gesture reference. Last of the sheets, and above them all, because it
|
||||||
|
// is the one a user opens *because* another one confused them.
|
||||||
|
if root.helping: GestureSheet {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
rows: root.gestures;
|
||||||
|
close => { root.helping = false; }
|
||||||
|
}
|
||||||
|
|
||||||
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
|
// --- the naming sheet (FR-CAT-5, FR-CAT-7) ------------------------------
|
||||||
//
|
//
|
||||||
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
|
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
|
||||||
|
|||||||
Reference in New Issue
Block a user