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:
@@ -0,0 +1,147 @@
|
||||
# How the application is driven
|
||||
|
||||
<!-- GENERATED FILE — do not edit by hand. -->
|
||||
<!-- Regenerate: cargo run -p traceability -- gestures -->
|
||||
|
||||
Every entry here is extracted from the comment beside the code that implements it, so this file cannot describe a gesture the application does not have. Add one by writing a `GESTURE:` block next to the implementation; there is nowhere else to write it.
|
||||
|
||||
15 gestures, in 2 places.
|
||||
|
||||
## People
|
||||
|
||||
### Pull a face out of the wrong person
|
||||
|
||||
- **Touch** — Tap the faces that do not belong, then "Split off"
|
||||
- **Pointer** — Click the faces that do not belong, then "Split off"
|
||||
|
||||
Grouping over-merges on siblings, on parents and children, and on the same person a decade apart, so splitting is as prominent as merging. A tool that can only merge makes its own errors permanent.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:130`</sub>
|
||||
|
||||
### Rule on a suggested face
|
||||
|
||||
- **Touch** — Tick to confirm it, cross to reject it
|
||||
- **Pointer** — Tick to confirm it, cross to reject it
|
||||
|
||||
A face is either the system's guess or the user's judgement, and the two are never conflated. A rejection is remembered, so the face is not suggested for that person again.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:150`</sub>
|
||||
|
||||
### See a person's photographs
|
||||
|
||||
- **Touch** — Choose them in the rail, then "Show photos"
|
||||
- **Pointer** — Choose them in the rail, then "Show photos"
|
||||
|
||||
This is the point of having identified anybody. Without it the screen is a filing cabinet with no drawer handles.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:545`</sub>
|
||||
|
||||
### Change how faces are grouped
|
||||
|
||||
- **Touch** — "Grouping…", move the dials, then Regroup
|
||||
- **Pointer** — "Grouping…", move the dials, then Regroup
|
||||
|
||||
The right match confidence is a property of your library, not of the model. "What would this do?" answers for this library without writing anything; names, confirmations and the groups you have set aside are kept whatever the dials say.
|
||||
|
||||
<sub>`ui/dr-ui/ui/identity.slint:582`</sub>
|
||||
|
||||
## Library grid
|
||||
|
||||
### Start selecting several photographs
|
||||
|
||||
- **Touch** — Press and hold a photograph, or press Select in the header
|
||||
- **Pointer** — Ctrl-click, or press Select in the header
|
||||
|
||||
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>
|
||||
|
||||
### Add or remove one photograph
|
||||
|
||||
- **Touch** — While selecting, tap it
|
||||
- **Pointer** — Ctrl-click it
|
||||
|
||||
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>
|
||||
|
||||
### Leave selecting
|
||||
|
||||
- **Touch** — Press Done in the header
|
||||
- **Pointer** — Press Done in the header
|
||||
- **Keyboard** — Escape
|
||||
|
||||
<sub>`ui/dr-ui/ui/library.slint:1276`</sub>
|
||||
|
||||
### Select a range
|
||||
|
||||
- **Touch** — While selecting, press "Select to…", then tap the last photograph of the run
|
||||
- **Pointer** — Shift-click the last photograph of the run
|
||||
|
||||
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>
|
||||
|
||||
### Find photographs with two people in them
|
||||
|
||||
- **Touch** — Open the People chip on the filter bar, tap each name, then switch the chip beside them to "all of them"
|
||||
- **Pointer** — Open the People chip on the filter bar, click each name, then switch the chip beside them to "all of them"
|
||||
|
||||
"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>
|
||||
|
||||
### Drop the selection but keep selecting
|
||||
|
||||
- **Touch** — Press Clear in the selection strip
|
||||
- **Pointer** — Press Clear in the selection strip
|
||||
|
||||
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>
|
||||
|
||||
### Select everything the grid is showing
|
||||
|
||||
- **Touch** — While selecting, press "Select all"
|
||||
- **Pointer** — While selecting, press "Select all"
|
||||
|
||||
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>
|
||||
|
||||
### Resize the thumbnails
|
||||
|
||||
- **Touch** — Pinch the grid with two fingers
|
||||
- **Pointer** — Ctrl and the scroll wheel
|
||||
|
||||
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>
|
||||
|
||||
### File photographs in a collection
|
||||
|
||||
- **Touch** — Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.
|
||||
- **Pointer** — Drag a photograph — or a whole selection — onto a collection in the sidebar
|
||||
|
||||
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>
|
||||
|
||||
### Open a photograph
|
||||
|
||||
- **Touch** — Tap it — a single tap, any length
|
||||
- **Pointer** — Click it
|
||||
|
||||
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>
|
||||
|
||||
### Rate a photograph without opening it
|
||||
|
||||
- **Touch** — Tap a star on the cell
|
||||
- **Pointer** — Hover the cell, then click a star
|
||||
- **Keyboard** — 0 to 5 on the selection
|
||||
|
||||
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>
|
||||
+66
-66
File diff suppressed because one or more lines are too long
@@ -0,0 +1,596 @@
|
||||
//! TRACES: FR-UI-4 | NFR-OPS-1
|
||||
//! Interaction documentation, extracted from the code that implements it.
|
||||
//!
|
||||
//! # Why this exists at all
|
||||
//!
|
||||
//! FR-UI-4 says a gesture with no visible counterpart is a feature only its
|
||||
//! author knows about. The application has grown a real gesture vocabulary —
|
||||
//! hold to select, arm a range and tap its end, pinch to resize the grid, drag
|
||||
//! a selection onto a collection — and every one of them was documented only in
|
||||
//! the comment beside the `TouchArea` that implements it. Excellent comments,
|
||||
//! and unreachable by anyone who is not reading the source.
|
||||
//!
|
||||
//! Writing them out a second time, in a hand-kept help page, is the failure
|
||||
//! this file is built to avoid. Two descriptions of one gesture drift, and the
|
||||
//! one that drifts is always the prose: the code is exercised every time
|
||||
//! somebody uses the application and the page is exercised never. A help screen
|
||||
//! that confidently describes a double-tap the grid stopped honouring six
|
||||
//! months ago is worse than no help screen.
|
||||
//!
|
||||
//! So the comment beside the implementation is the only copy. This scans it out
|
||||
//! into two artefacts — `docs/gestures.md` for a reader, and a generated Rust
|
||||
//! table the application itself displays — and CI fails if either has drifted
|
||||
//! from the source. The same discipline, and the same gate, the requirements
|
||||
//! matrix already runs under.
|
||||
//!
|
||||
//! # Why it lives in the traceability crate
|
||||
//!
|
||||
//! It is the same operation on the same input: walk the source tree, pull
|
||||
//! structured tags out of comments, render, and fail if the committed artefact
|
||||
//! no longer matches. Everything but the tag vocabulary is shared — the file
|
||||
//! walk, the extension list, the exclusions — and a second crate would have had
|
||||
//! to duplicate all of it to add one parser.
|
||||
//!
|
||||
//! # The tag
|
||||
//!
|
||||
//! ```text
|
||||
//! // GESTURE: Select a range
|
||||
//! // where: Library grid
|
||||
//! // touch: "Select to…", then tap the last photograph
|
||||
//! // pointer: Shift-click
|
||||
//! // why: A finger sweep runs out at the edge of the screen, and the
|
||||
//! // ranges that hurt are longer than a screenful.
|
||||
//! ```
|
||||
//!
|
||||
//! Works in `.rs` and `.slint` alike, because both comment with `//` and the
|
||||
//! 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
|
||||
//! 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
|
||||
//! 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
|
||||
//! quietly cost a gesture its desktop half.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// One documented interaction.
|
||||
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
|
||||
pub struct Gesture {
|
||||
/// What it does, in the user's terms — "Select a range", not "arm ranging".
|
||||
pub title: String,
|
||||
/// The place it applies, and the heading it is grouped under.
|
||||
pub section: String,
|
||||
/// What a finger does. `None` where the gesture is pointer-only.
|
||||
pub touch: Option<String>,
|
||||
/// What a mouse or trackpad does.
|
||||
pub pointer: Option<String>,
|
||||
/// The keyboard route, where there is one.
|
||||
pub keys: Option<String>,
|
||||
/// Why it works this way. Shown in the document, not in the application:
|
||||
/// a user consulting the help sheet wants the gesture, and a reader of the
|
||||
/// document wants the argument.
|
||||
pub why: Option<String>,
|
||||
pub file: String,
|
||||
pub line: usize,
|
||||
}
|
||||
|
||||
impl Gesture {
|
||||
/// Every way in, longest-lived first.
|
||||
///
|
||||
/// Touch leads because that is the modality the gesture vocabulary was
|
||||
/// grown for: ctrl and shift already documented themselves by existing on
|
||||
/// every other application, and the hold-and-tap sequences did not.
|
||||
pub fn routes(&self) -> Vec<(&'static str, &str)> {
|
||||
let mut out = Vec::new();
|
||||
if let Some(t) = &self.touch {
|
||||
out.push(("Touch", t.as_str()));
|
||||
}
|
||||
if let Some(p) = &self.pointer {
|
||||
out.push(("Pointer", p.as_str()));
|
||||
}
|
||||
if let Some(k) = &self.keys {
|
||||
out.push(("Keyboard", k.as_str()));
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// A key that may appear in a gesture block.
|
||||
const KEYS: &[&str] = &["where", "touch", "pointer", "keys", "why"];
|
||||
|
||||
/// Something wrong with a tag, reported rather than dropped.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GestureProblem {
|
||||
pub file: String,
|
||||
pub line: usize,
|
||||
pub what: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for GestureProblem {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}:{}: {}", self.file, self.line, self.what)
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract every gesture block from one file's text.
|
||||
///
|
||||
/// Returns what parsed and what did not. Both, deliberately: a malformed tag
|
||||
/// that merely vanished would leave a gesture undocumented and everything
|
||||
/// looking fine, which is the failure mode this whole file exists to prevent.
|
||||
pub fn extract_from_text(text: &str, path: &str) -> (Vec<Gesture>, Vec<GestureProblem>) {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let mut out = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
|
||||
let mut i = 0;
|
||||
while i < lines.len() {
|
||||
let Some(pos) = lines[i].find("GESTURE:") else {
|
||||
i += 1;
|
||||
continue;
|
||||
};
|
||||
// Only in a comment. A `GESTURE:` inside a string literal — this very
|
||||
// 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;
|
||||
continue;
|
||||
}
|
||||
|
||||
let start = i;
|
||||
let title = lines[i][pos + "GESTURE:".len()..].trim().to_string();
|
||||
|
||||
let mut fields: BTreeMap<String, String> = BTreeMap::new();
|
||||
let mut last: Option<String> = None;
|
||||
i += 1;
|
||||
while i < lines.len() {
|
||||
let Some(body) = comment_body(lines[i]) else {
|
||||
break;
|
||||
};
|
||||
if body.trim().is_empty() {
|
||||
break;
|
||||
}
|
||||
// The next gesture ends this one. Without this, two blocks written
|
||||
// back to back — which is how a screen's gestures naturally get
|
||||
// listed — had the first swallow the second's title as a
|
||||
// continuation of its last field, and the second vanished.
|
||||
if body.contains("GESTURE:") {
|
||||
break;
|
||||
}
|
||||
match split_key(body) {
|
||||
Some((key, value)) => {
|
||||
if !KEYS.contains(&key.as_str()) {
|
||||
problems.push(GestureProblem {
|
||||
file: path.to_string(),
|
||||
line: i + 1,
|
||||
what: format!(
|
||||
"unknown gesture key `{key}` (expected one of {})",
|
||||
KEYS.join(", ")
|
||||
),
|
||||
});
|
||||
}
|
||||
fields.insert(key.clone(), value.trim().to_string());
|
||||
last = Some(key);
|
||||
}
|
||||
// A continuation of the field above it, joined with a single
|
||||
// space: the source wraps at the column the file wraps at, and
|
||||
// those line breaks are not part of the sentence.
|
||||
None => match &last {
|
||||
Some(key) => {
|
||||
let entry = fields.entry(key.clone()).or_default();
|
||||
if !entry.is_empty() {
|
||||
entry.push(' ');
|
||||
}
|
||||
entry.push_str(body.trim());
|
||||
}
|
||||
None => break,
|
||||
},
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
|
||||
let take = |k: &str| fields.get(k).filter(|v| !v.is_empty()).cloned();
|
||||
let g = Gesture {
|
||||
title: title.clone(),
|
||||
section: take("where").unwrap_or_default(),
|
||||
touch: take("touch"),
|
||||
pointer: take("pointer"),
|
||||
keys: take("keys"),
|
||||
why: take("why"),
|
||||
file: path.to_string(),
|
||||
line: start + 1,
|
||||
};
|
||||
|
||||
// Validated here rather than at the gate, so the problem names the line
|
||||
// that caused it.
|
||||
if g.title.is_empty() {
|
||||
problems.push(GestureProblem {
|
||||
file: path.to_string(),
|
||||
line: start + 1,
|
||||
what: "gesture has no title".into(),
|
||||
});
|
||||
}
|
||||
if g.section.is_empty() {
|
||||
problems.push(GestureProblem {
|
||||
file: path.to_string(),
|
||||
line: start + 1,
|
||||
what: format!("gesture `{}` has no `where:`", g.title),
|
||||
});
|
||||
}
|
||||
if g.routes().is_empty() {
|
||||
problems.push(GestureProblem {
|
||||
file: path.to_string(),
|
||||
line: start + 1,
|
||||
what: format!(
|
||||
"gesture `{}` says how to do it in no modality — needs at least one of \
|
||||
touch, pointer or keys",
|
||||
g.title
|
||||
),
|
||||
});
|
||||
}
|
||||
out.push(g);
|
||||
}
|
||||
|
||||
(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.
|
||||
///
|
||||
/// `///` and `//!` are stripped to the same thing: a gesture block written as a
|
||||
/// doc comment on the function that implements it is the natural place for one,
|
||||
/// and it should not need a different tag.
|
||||
fn comment_body(line: &str) -> Option<&str> {
|
||||
let t = line.trim_start();
|
||||
let rest = t.strip_prefix("//")?;
|
||||
Some(
|
||||
rest.strip_prefix('!')
|
||||
.or_else(|| rest.strip_prefix('/'))
|
||||
.unwrap_or(rest),
|
||||
)
|
||||
}
|
||||
|
||||
/// Split ` key: value` into its halves.
|
||||
///
|
||||
/// The key must be a bare word against the left of the comment body, which is
|
||||
/// what keeps a colon inside prose — "so: the run is resolved by the catalog" —
|
||||
/// from being read as a new field. A continuation is indented past its key, and
|
||||
/// the key it continues is a single word; requiring both is enough to tell them
|
||||
/// apart without counting spaces.
|
||||
fn split_key(body: &str) -> Option<(String, String)> {
|
||||
let (head, tail) = body.split_once(':')?;
|
||||
let key = head.trim();
|
||||
if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
return None;
|
||||
}
|
||||
// A key sits at the start of the comment body, under a shallow indent. A
|
||||
// continuation line is pushed further right to line up under its value.
|
||||
let indent = body.len() - body.trim_start().len();
|
||||
if indent > 5 {
|
||||
return None;
|
||||
}
|
||||
Some((key.to_ascii_lowercase(), tail.to_string()))
|
||||
}
|
||||
|
||||
/// Group gestures under their `where:`, keeping the order the sections were
|
||||
/// first seen so the document reads in the order a user meets them.
|
||||
pub fn by_section(gestures: &[Gesture]) -> Vec<(String, Vec<&Gesture>)> {
|
||||
let mut order: Vec<String> = Vec::new();
|
||||
let mut map: BTreeMap<String, Vec<&Gesture>> = BTreeMap::new();
|
||||
for g in gestures {
|
||||
if !map.contains_key(&g.section) {
|
||||
order.push(g.section.clone());
|
||||
}
|
||||
map.entry(g.section.clone()).or_default().push(g);
|
||||
}
|
||||
order
|
||||
.into_iter()
|
||||
.map(|s| {
|
||||
let v = map.remove(&s).unwrap_or_default();
|
||||
(s, v)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
// ── rendering ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// The reader's artefact: `docs/gestures.md`.
|
||||
///
|
||||
/// Carries `why` where the application's sheet does not. Someone reading the
|
||||
/// document is deciding whether a gesture is right; someone holding the tablet
|
||||
/// has already decided and wants the move.
|
||||
pub fn render_markdown(gestures: &[Gesture]) -> String {
|
||||
let mut m = String::new();
|
||||
m.push_str("# How the application is driven\n\n");
|
||||
m.push_str("<!-- GENERATED FILE — do not edit by hand. -->\n");
|
||||
m.push_str("<!-- Regenerate: cargo run -p traceability -- gestures -->\n\n");
|
||||
m.push_str(
|
||||
"Every entry here is extracted from the comment beside the code that implements it, \
|
||||
so this file cannot describe a gesture the application does not have. Add one by \
|
||||
writing a `GESTURE:` block next to the implementation; there is nowhere else to \
|
||||
write it.\n\n",
|
||||
);
|
||||
let places = by_section(gestures).len();
|
||||
m.push_str(&format!(
|
||||
"{} gesture{}, in {} place{}.\n",
|
||||
gestures.len(),
|
||||
if gestures.len() == 1 { "" } else { "s" },
|
||||
places,
|
||||
if places == 1 { "" } else { "s" },
|
||||
));
|
||||
|
||||
for (section, list) in by_section(gestures) {
|
||||
m.push_str(&format!("\n## {section}\n"));
|
||||
for g in list {
|
||||
m.push_str(&format!("\n### {}\n\n", g.title));
|
||||
for (modality, how) in g.routes() {
|
||||
m.push_str(&format!("- **{modality}** — {how}\n"));
|
||||
}
|
||||
if let Some(why) = &g.why {
|
||||
m.push_str(&format!("\n{why}\n"));
|
||||
}
|
||||
m.push_str(&format!("\n<sub>`{}:{}`</sub>\n", g.file, g.line));
|
||||
}
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The application's artefact: a Rust table it displays.
|
||||
///
|
||||
/// Generated and *committed*, not built in a `build.rs`. Two reasons. A
|
||||
/// generated file that only exists in `OUT_DIR` cannot be read in review, and
|
||||
/// this one is user-facing text that should be reviewed like any other. And the
|
||||
/// scanner walks the whole source tree, which is not something to do on every
|
||||
/// incremental build of the crate being scanned.
|
||||
///
|
||||
/// `why` is deliberately not carried across. It is the argument for the design
|
||||
/// and belongs in the document; putting it on a phone-sized help sheet would
|
||||
/// bury the one line the user opened the sheet to read.
|
||||
pub fn render_rust(gestures: &[Gesture]) -> String {
|
||||
let mut r = String::new();
|
||||
r.push_str("// @generated by `cargo run -p traceability -- gestures`. Do not edit.\n");
|
||||
r.push_str("//\n");
|
||||
r.push_str("// TRACES: FR-UI-4\n");
|
||||
r.push_str("//! The gesture vocabulary, as the application states it.\n");
|
||||
r.push_str("//!\n");
|
||||
r.push_str("//! Extracted from the tagged comments beside the code that implements each\n");
|
||||
r.push_str("//! one, so the help sheet cannot describe a gesture the application does not\n");
|
||||
r.push_str("//! have. Edit the comment beside the implementation, then regenerate with\n");
|
||||
r.push_str("//! `cargo run -p traceability -- gestures`.\n\n");
|
||||
|
||||
r.push_str("/// One documented interaction, as drawn.\n");
|
||||
r.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n");
|
||||
r.push_str("pub struct Gesture {\n");
|
||||
r.push_str(" pub title: &'static str,\n");
|
||||
r.push_str(" /// The place it applies, and the heading it is grouped under.\n");
|
||||
r.push_str(" pub section: &'static str,\n");
|
||||
r.push_str(" /// Empty where the gesture has no counterpart in that modality.\n");
|
||||
r.push_str(" pub touch: &'static str,\n");
|
||||
r.push_str(" pub pointer: &'static str,\n");
|
||||
r.push_str(" pub keys: &'static str,\n");
|
||||
r.push_str("}\n\n");
|
||||
|
||||
r.push_str("/// Every gesture, in the order the sections were first seen in the source.\n");
|
||||
r.push_str("pub const GESTURES: &[Gesture] = &[\n");
|
||||
for (_, list) in by_section(gestures) {
|
||||
for g in list {
|
||||
r.push_str(" Gesture {\n");
|
||||
r.push_str(&format!(" title: {},\n", quote(&g.title)));
|
||||
r.push_str(&format!(" section: {},\n", quote(&g.section)));
|
||||
r.push_str(&format!(
|
||||
" touch: {},\n",
|
||||
quote(g.touch.as_deref().unwrap_or(""))
|
||||
));
|
||||
r.push_str(&format!(
|
||||
" pointer: {},\n",
|
||||
quote(g.pointer.as_deref().unwrap_or(""))
|
||||
));
|
||||
r.push_str(&format!(
|
||||
" keys: {},\n",
|
||||
quote(g.keys.as_deref().unwrap_or(""))
|
||||
));
|
||||
r.push_str(" },\n");
|
||||
}
|
||||
}
|
||||
r.push_str("];\n");
|
||||
r
|
||||
}
|
||||
|
||||
/// A Rust string literal for arbitrary gesture text.
|
||||
///
|
||||
/// The text is prose written by hand and routinely contains quotation marks —
|
||||
/// `"Select to…", then tap the last photograph` — so escaping is not optional.
|
||||
fn quote(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
match c {
|
||||
'"' => out.push_str("\\\""),
|
||||
'\\' => out.push_str("\\\\"),
|
||||
_ => out.push(c),
|
||||
}
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The shape every real tag is written in.
|
||||
const SAMPLE: &str = "\
|
||||
// GESTURE: Select a range
|
||||
// where: Library grid
|
||||
// touch: \"Select to…\", then tap the last photograph
|
||||
// pointer: Shift-click
|
||||
// why: A finger sweep runs out at the edge of the
|
||||
// screen.
|
||||
property <bool> ranging: false;
|
||||
";
|
||||
|
||||
#[test]
|
||||
fn a_block_parses_every_field() {
|
||||
let (g, p) = extract_from_text(SAMPLE, "library.slint");
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
assert_eq!(g.len(), 1);
|
||||
assert_eq!(g[0].title, "Select a range");
|
||||
assert_eq!(g[0].section, "Library grid");
|
||||
assert_eq!(
|
||||
g[0].touch.as_deref(),
|
||||
Some("\"Select to…\", then tap the last photograph")
|
||||
);
|
||||
assert_eq!(g[0].pointer.as_deref(), Some("Shift-click"));
|
||||
assert_eq!(g[0].line, 1);
|
||||
}
|
||||
|
||||
/// The source wraps where the file wraps; the sentence does not.
|
||||
#[test]
|
||||
fn a_continuation_joins_with_one_space() {
|
||||
let (g, _) = extract_from_text(SAMPLE, "x.slint");
|
||||
assert_eq!(
|
||||
g[0].why.as_deref(),
|
||||
Some("A finger sweep runs out at the edge of the screen.")
|
||||
);
|
||||
}
|
||||
|
||||
/// A colon in prose is not a new field, or `why` could never contain one.
|
||||
#[test]
|
||||
fn a_colon_inside_a_continuation_is_not_a_key() {
|
||||
let text = "\
|
||||
// GESTURE: Hold to select
|
||||
// where: Library grid
|
||||
// touch: Press and hold
|
||||
// why: The platform convention: every gallery on the device
|
||||
// opens a selection this way.
|
||||
";
|
||||
let (g, p) = extract_from_text(text, "x.rs");
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
assert_eq!(
|
||||
g[0].why.as_deref(),
|
||||
Some(
|
||||
"The platform convention: every gallery on the device opens a selection this way."
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/// A typo must not quietly cost a gesture its desktop half.
|
||||
#[test]
|
||||
fn an_unknown_key_is_reported() {
|
||||
let text = "\
|
||||
// GESTURE: Open a photograph
|
||||
// where: Library grid
|
||||
// touch: Tap it
|
||||
// pointr: Click it
|
||||
";
|
||||
let (_, p) = extract_from_text(text, "x.rs");
|
||||
assert_eq!(p.len(), 1, "{p:?}");
|
||||
assert!(p[0].what.contains("pointr"), "{}", p[0].what);
|
||||
}
|
||||
|
||||
/// A gesture nobody can perform is a documentation bug, not a gesture.
|
||||
#[test]
|
||||
fn a_gesture_with_no_modality_is_reported() {
|
||||
let text = "// GESTURE: Do a thing\n// where: Somewhere\n";
|
||||
let (_, p) = extract_from_text(text, "x.rs");
|
||||
assert!(p.iter().any(|x| x.what.contains("no modality")), "{p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gesture_with_no_place_is_reported() {
|
||||
let text = "// GESTURE: Do a thing\n// touch: Tap\n";
|
||||
let (_, p) = extract_from_text(text, "x.rs");
|
||||
assert!(p.iter().any(|x| x.what.contains("`where:`")), "{p:?}");
|
||||
}
|
||||
|
||||
/// The tag inside this crate's own doc comments and test fixtures must not
|
||||
/// become a gesture in the user's help sheet.
|
||||
#[test]
|
||||
fn a_tag_inside_a_string_literal_is_not_a_gesture() {
|
||||
let text = "let s = \"GESTURE: not a real one\";\n";
|
||||
let (g, p) = extract_from_text(text, "x.rs");
|
||||
assert!(g.is_empty(), "{g:?}");
|
||||
assert!(p.is_empty());
|
||||
}
|
||||
|
||||
/// A blank comment line ends the block, so ordinary prose beneath a gesture
|
||||
/// is not swallowed into its `why`.
|
||||
#[test]
|
||||
fn a_blank_comment_line_ends_the_block() {
|
||||
let text = "\
|
||||
// GESTURE: Tap to open
|
||||
// where: Library grid
|
||||
// touch: Tap a photograph
|
||||
//
|
||||
// This paragraph is about something else entirely.
|
||||
";
|
||||
let (g, _) = extract_from_text(text, "x.rs");
|
||||
assert_eq!(g[0].touch.as_deref(), Some("Tap a photograph"));
|
||||
assert!(g[0].why.is_none(), "{:?}", g[0].why);
|
||||
}
|
||||
|
||||
/// A doc comment is the natural place for a gesture on the function that
|
||||
/// implements it, and must not need a different tag.
|
||||
#[test]
|
||||
fn a_doc_comment_carries_a_gesture_too() {
|
||||
let text = "\
|
||||
/// GESTURE: Pinch the grid
|
||||
/// where: Library grid
|
||||
/// touch: Pinch to resize the thumbnails
|
||||
pub fn zoom() {}
|
||||
";
|
||||
let (g, p) = extract_from_text(text, "x.rs");
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
assert_eq!(g[0].title, "Pinch the grid");
|
||||
}
|
||||
|
||||
/// Two blocks back to back, which is how a screen's gestures get listed.
|
||||
#[test]
|
||||
fn one_block_does_not_swallow_the_next() {
|
||||
let text = "\
|
||||
// GESTURE: First
|
||||
// where: Grid
|
||||
// touch: Tap
|
||||
// GESTURE: Second
|
||||
// where: Grid
|
||||
// touch: Hold
|
||||
";
|
||||
let (g, p) = extract_from_text(text, "x.rs");
|
||||
assert!(p.is_empty(), "{p:?}");
|
||||
assert_eq!(g.len(), 2, "{g:?}");
|
||||
assert_eq!(g[0].touch.as_deref(), Some("Tap"));
|
||||
assert_eq!(g[1].title, "Second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sections_keep_the_order_they_were_first_seen() {
|
||||
let text = "\
|
||||
// GESTURE: B
|
||||
// where: Second
|
||||
// touch: x
|
||||
// GESTURE: A
|
||||
// where: First
|
||||
// touch: x
|
||||
// GESTURE: C
|
||||
// where: Second
|
||||
// touch: x
|
||||
";
|
||||
let (g, _) = extract_from_text(text, "x.rs");
|
||||
let grouped = by_section(&g);
|
||||
assert_eq!(grouped[0].0, "Second");
|
||||
assert_eq!(grouped[0].1.len(), 2);
|
||||
assert_eq!(grouped[1].0, "First");
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,8 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
pub mod gestures;
|
||||
|
||||
/// Requirement ID prefixes that participate in coverage.
|
||||
///
|
||||
/// Test identifiers (UT, IT) are a separate taxonomy: they are *evidence* for
|
||||
|
||||
@@ -4,8 +4,13 @@
|
||||
//! traces report # write docs/traceability.md
|
||||
//! traces json # machine-readable, to stdout
|
||||
//! traces check # gate: non-zero exit on failure
|
||||
//! traces gestures # write docs/gestures.md and the app's gesture table
|
||||
//! traces gestures-check # gate: non-zero exit if either has drifted
|
||||
//! ```
|
||||
//!
|
||||
//! The gesture half scans a different tag out of the same files — see
|
||||
//! [`traceability::gestures`] for what it is and why it lives here.
|
||||
//!
|
||||
//! The gate fails hard on a *misconfigured run* — zero requirements parsed, or
|
||||
//! zero source files scanned — rather than reporting a plausible-looking 0%.
|
||||
//! A gate that cannot distinguish "nothing is tagged" from "I read nothing" is
|
||||
@@ -32,10 +37,36 @@ const SOURCE_ROOTS: &[&str] = &["core", "ui", "platform", "apps", "tools"];
|
||||
/// Structural failures below are unconditional and do not depend on this.
|
||||
const MIN_COVERAGE: f64 = 0.0;
|
||||
|
||||
/// Where the extracted gesture vocabulary is written.
|
||||
///
|
||||
/// Two artefacts from one scan: the document a person reads, and the table the
|
||||
/// application draws its help sheet from. Both committed, both gated, so
|
||||
/// neither can quietly stop describing the code.
|
||||
const GESTURE_DOC: &str = "docs/gestures.md";
|
||||
const GESTURE_TABLE: &str = "ui/dr-ui/src/gesture_book.rs";
|
||||
|
||||
/// Directories scanned for gestures.
|
||||
///
|
||||
/// Narrower than `SOURCE_ROOTS`, and not merely as an optimisation. A gesture
|
||||
/// is something a *user* performs, so it can only be declared where there is an
|
||||
/// interface to perform it on: `ui` and the two application shells. `core` has
|
||||
/// no pointer and no finger.
|
||||
///
|
||||
/// Excluding `tools` is what stops this scanner reading its own documentation.
|
||||
/// The tag has to appear in this crate — in the doc comment that teaches the
|
||||
/// format, and in the fixtures that test the parser — and every one of those
|
||||
/// appearances was being extracted as a broken gesture. A scanner that indexes
|
||||
/// its own examples is a scanner nobody can document.
|
||||
const GESTURE_ROOTS: &[&str] = &["ui", "apps"];
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let base = repo_root()?;
|
||||
let mode = std::env::args().nth(1).unwrap_or_else(|| "report".into());
|
||||
|
||||
if mode.starts_with("gestures") {
|
||||
return run_gestures(&base, mode == "gestures-check");
|
||||
}
|
||||
|
||||
let req_path = base.join("docs/requirements.md");
|
||||
let markdown = std::fs::read_to_string(&req_path)
|
||||
.with_context(|| format!("reading {}", req_path.display()))?;
|
||||
@@ -86,6 +117,80 @@ fn main() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Scan the gesture vocabulary, and either write it or check it has not moved.
|
||||
///
|
||||
/// Writing and checking share every step but the last, so they are one function
|
||||
/// with a flag rather than two that could come to disagree about what the
|
||||
/// artefact should contain — which is the only way a gate like this fails
|
||||
/// dishonestly.
|
||||
fn run_gestures(base: &Path, check: bool) -> Result<()> {
|
||||
let files = collect_sources(base, GESTURE_ROOTS);
|
||||
if files.is_empty() {
|
||||
bail!("no source files scanned — misconfigured, not zero gestures");
|
||||
}
|
||||
|
||||
let mut found = Vec::new();
|
||||
let mut problems = Vec::new();
|
||||
for file in &files {
|
||||
let text = std::fs::read_to_string(file).unwrap_or_default();
|
||||
let rel = file
|
||||
.strip_prefix(base)
|
||||
.unwrap_or(file)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
// Never the table this command writes. It quotes the tag in its own
|
||||
// header, so scanning it fed every generated gesture back in as a
|
||||
// malformed one — and a generator that consumes its own output cannot
|
||||
// converge.
|
||||
if rel == GESTURE_TABLE {
|
||||
continue;
|
||||
}
|
||||
let (g, p) = gestures::extract_from_text(&text, &rel);
|
||||
found.extend(g);
|
||||
problems.extend(p);
|
||||
}
|
||||
|
||||
println!("files scanned {}", files.len());
|
||||
println!("gestures found {}", found.len());
|
||||
println!("places {}", gestures::by_section(&found).len());
|
||||
|
||||
if !problems.is_empty() {
|
||||
for p in &problems {
|
||||
println!(" {p}");
|
||||
}
|
||||
bail!("{} malformed gesture tag(s)", problems.len());
|
||||
}
|
||||
|
||||
let doc = gestures::render_markdown(&found);
|
||||
let table = gestures::render_rust(&found);
|
||||
|
||||
if check {
|
||||
// Read back rather than trusting a timestamp: the artefacts are
|
||||
// committed, and what matters is whether the file in the tree says what
|
||||
// the source says, however it got there.
|
||||
let mut stale = Vec::new();
|
||||
for (path, want) in [(GESTURE_DOC, &doc), (GESTURE_TABLE, &table)] {
|
||||
let have = std::fs::read_to_string(base.join(path)).unwrap_or_default();
|
||||
if have != *want {
|
||||
stale.push(path);
|
||||
}
|
||||
}
|
||||
if !stale.is_empty() {
|
||||
bail!(
|
||||
"{} is stale — run: cargo run -p traceability -- gestures",
|
||||
stale.join(" and ")
|
||||
);
|
||||
}
|
||||
println!("\ngesture gate: PASS");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
std::fs::write(base.join(GESTURE_DOC), &doc)?;
|
||||
std::fs::write(base.join(GESTURE_TABLE), &table)?;
|
||||
println!("\nwrote {GESTURE_DOC} and {GESTURE_TABLE}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Structural checks that fail regardless of the coverage threshold.
|
||||
fn gate(files: &[PathBuf], defined: &DefinedRequirements, cov: &Coverage) -> Result<()> {
|
||||
// A run that parsed nothing is misconfigured, not passing.
|
||||
|
||||
+52
-270
@@ -157,16 +157,6 @@ pub struct CollectionsController {
|
||||
/// develop view on the second ctrl-click. Slint does not report modifiers on
|
||||
/// `clicked`, so the press records them and the click consults this.
|
||||
modified_press: std::cell::Cell<bool>,
|
||||
/// TRACES: FR-UI-4
|
||||
/// When the press that began the current gesture landed, and whether a
|
||||
/// finger did it.
|
||||
///
|
||||
/// Read by the click that follows, to tell a tap from a graze — see
|
||||
/// [`CollectionsController::press_was_a_graze`]. `None` between gestures,
|
||||
/// which a click with no press before it is treated as: it cannot have been
|
||||
/// deliberate if nothing pressed.
|
||||
pressed_at: std::cell::Cell<Option<std::time::Instant>>,
|
||||
touch_press: std::cell::Cell<bool>,
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Whether a tap in the grid selects rather than opens.
|
||||
///
|
||||
@@ -343,38 +333,6 @@ impl CollectionsController {
|
||||
self.modified_press.get()
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// Whether the contact that is ending was too brief to have meant anything.
|
||||
///
|
||||
/// A hand crossing a tablet on its way to the scroll it intended produces a
|
||||
/// press and a release a few tens of milliseconds apart, in the same place —
|
||||
/// indistinguishable, to a `TouchArea`, from a tap, and it was opening
|
||||
/// whichever photograph happened to be under the knuckle. Travel is already
|
||||
/// answered (the Flickable claims the pointer and the press is cancelled);
|
||||
/// what was left is the contact that does not travel and does not last.
|
||||
///
|
||||
/// **Only a finger is held to this.** A mouse click is a discrete decision
|
||||
/// made by a button and is routinely over in thirty milliseconds; applying
|
||||
/// a dwell to it would make the desktop feel broken to fix a problem the
|
||||
/// desktop does not have.
|
||||
///
|
||||
/// And it only withholds the *open*. The press has already selected the
|
||||
/// cell under the finger, which is the right failure mode: a graze leaves
|
||||
/// something visible and reversible on screen rather than silently doing
|
||||
/// nothing, and rather than throwing the user into develop.
|
||||
pub fn press_was_a_graze(&self) -> bool {
|
||||
if !self.touch_press.get() {
|
||||
return false;
|
||||
}
|
||||
match self.pressed_at.get() {
|
||||
Some(at) => at.elapsed() < std::time::Duration::from_millis(TAP_MIN_MS),
|
||||
// A click with no press recorded before it. Not a graze — there is
|
||||
// nothing to say it was one — and refusing it would be the one way
|
||||
// this rule could make a photograph unopenable.
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Selected image ids, in a stable order.
|
||||
pub fn selected(&self) -> Vec<ImageId> {
|
||||
self.selection.borrow().iter().copied().collect()
|
||||
@@ -554,29 +512,6 @@ pub fn press_remembering_anchor(
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Extend the selection to `row` — the touch form of a shift-click.
|
||||
///
|
||||
/// Added to the selection rather than replacing it: this is reached only from
|
||||
/// selection mode, which the user entered deliberately, and a gesture that
|
||||
/// silently discarded the run they gathered a moment ago would make gathering
|
||||
/// two runs impossible.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn extend_to_row(
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
previous: Option<usize>,
|
||||
ids: &[ImageId],
|
||||
offset: usize,
|
||||
row: usize,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
// Falling back to the current anchor makes a double tap with no history
|
||||
// select just that cell, which is what a double tap already did.
|
||||
*anchor = previous.or(*anchor);
|
||||
apply_press(selection, anchor, ids, offset, row, true, true, span);
|
||||
}
|
||||
|
||||
/// Apply a press and push the result into the grid — the whole of what a
|
||||
/// click, or an arrow key, does to the selection.
|
||||
///
|
||||
@@ -1074,22 +1009,6 @@ const SPRING_DELAY_MS: u64 = 500;
|
||||
/// the hand lets go first, having concluded nothing was going to happen.
|
||||
pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// How long a finger must stay down before letting go counts as opening a
|
||||
/// photograph.
|
||||
///
|
||||
/// **The floor under a tap, where `HOLD_DELAY_MS` is the ceiling.** Between the
|
||||
/// two is a tap; below is a graze that only selects; above is a hold that
|
||||
/// starts a selection. The three have to be one scale or the gesture set stops
|
||||
/// being learnable.
|
||||
///
|
||||
/// 120 ms is a tenth of the hold and about twice a brush. It costs nothing in
|
||||
/// felt latency because it does not *delay* anything — the open still happens
|
||||
/// on release, and this only decides whether that release counted — so the
|
||||
/// error it can make is one-sided: an unusually quick deliberate tap selects
|
||||
/// instead of opening, and the photograph is one further tap away.
|
||||
pub(crate) const TAP_MIN_MS: u64 = 120;
|
||||
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Start the timer that turns a held cell into a selection.
|
||||
///
|
||||
@@ -1559,17 +1478,13 @@ pub fn wire<S, R, P, C>(
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let visible = visible_ids.clone();
|
||||
window.on_library_cell_pressed(move |row, ctrl_held, shift_held, touch| {
|
||||
window.on_library_cell_pressed(move |row, ctrl_held, shift_held| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ids = visible();
|
||||
|
||||
// Consulted by the click that follows: a modified press is building
|
||||
// a selection and must not also navigate to develop.
|
||||
ctl.modified_press.set(ctrl_held || shift_held);
|
||||
// And so is this: how long the contact lasts is what separates a
|
||||
// tap from a graze, and only the press knows when it started.
|
||||
ctl.pressed_at.set(Some(std::time::Instant::now()));
|
||||
ctl.touch_press.set(touch);
|
||||
|
||||
// Where the window starts, so the press is recorded as the ordinal
|
||||
// it is rather than as a row that stops meaning this photograph on
|
||||
@@ -1591,52 +1506,6 @@ pub fn wire<S, R, P, C>(
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-UI-2 | FR-UI-4
|
||||
// A double tap in selection mode: take everything between the cell the
|
||||
// selection started from and this one.
|
||||
//
|
||||
// This is shift-click, reached by the one gesture touch has left. Hold to
|
||||
// start selecting, double-tap the far end, and a run of forty photographs
|
||||
// is three touches — then the whole selection drags onto a collection as
|
||||
// one, which is the thing this sequence exists to make possible.
|
||||
//
|
||||
// The range is *added*, not replaced, so a second run can be picked up
|
||||
// without losing the first — ctrl+shift's behaviour, and the right one
|
||||
// here: a mode the user entered deliberately should accumulate rather than
|
||||
// throw away what they have already gathered.
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let visible = visible_ids.clone();
|
||||
window.on_library_cell_double_clicked(move |row| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
// Outside selection mode a double tap is two ordinary clicks, and
|
||||
// the first has already opened the image. Nothing to do.
|
||||
if !ctl.select_mode.get() {
|
||||
return;
|
||||
}
|
||||
// The hold that would have fired mid-double-tap.
|
||||
*ctl.hold_timer.borrow_mut() = None;
|
||||
|
||||
let ids = visible();
|
||||
let offset = w.get_library_offset().max(0) as usize;
|
||||
|
||||
// Extended from the cell the user started at, not from the one the
|
||||
// two taps just moved the anchor onto.
|
||||
extend_to_row(
|
||||
&mut ctl.selection.borrow_mut(),
|
||||
&mut ctl.anchor.borrow_mut(),
|
||||
ctl.previous_anchor.get(),
|
||||
&ids,
|
||||
offset,
|
||||
row as usize,
|
||||
&|first, last| ctl.span(first, last),
|
||||
);
|
||||
ctl.set_cursor(Some(offset + row as usize));
|
||||
sync_selection(&w, &ctl, &ids);
|
||||
});
|
||||
}
|
||||
|
||||
// TRACES: FR-UI-2 | FR-UI-4
|
||||
// The press ended — lifted, or taken by the Flickable when the finger
|
||||
// travelled. Either way the hold is off.
|
||||
@@ -2015,6 +1884,15 @@ pub fn wire<S, R, P, C>(
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
let ids = visible();
|
||||
|
||||
// TRACES: FR-UI-4
|
||||
// A drag is not a hold, and the timer must not outlive the press
|
||||
// that armed it. Grabbing a cell and moving inside 450 ms left the
|
||||
// hold armed underneath the drag, so it fired mid-gesture and put
|
||||
// the grid into selection mode the user had not asked for — the
|
||||
// drag finished into a mode that changed what every later tap
|
||||
// meant. The pinch already cancels for exactly this reason.
|
||||
*ctl.hold_timer.borrow_mut() = None;
|
||||
|
||||
// Dragging an *unselected* cell carries only that one, and makes it
|
||||
// the selection — otherwise the images that travel are not the ones
|
||||
// the user grabbed. Dragging a selected cell carries the whole
|
||||
@@ -2801,52 +2679,6 @@ mod tests {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// A hand brushing the tablet on its way to a scroll must not open a
|
||||
/// photograph. It still *selects* one — the press did that, and something
|
||||
/// visible and reversible is the right thing to be left with.
|
||||
#[test]
|
||||
fn a_graze_does_not_open_a_photograph() {
|
||||
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
|
||||
ctl.touch_press.set(true);
|
||||
ctl.pressed_at.set(Some(std::time::Instant::now()));
|
||||
assert!(
|
||||
ctl.press_was_a_graze(),
|
||||
"a release in the same instant as the press is not a tap"
|
||||
);
|
||||
}
|
||||
|
||||
/// And a finger that stayed down is a tap, not a graze.
|
||||
#[test]
|
||||
fn a_deliberate_tap_opens_a_photograph() {
|
||||
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
|
||||
ctl.touch_press.set(true);
|
||||
ctl.pressed_at.set(Some(
|
||||
std::time::Instant::now() - std::time::Duration::from_millis(TAP_MIN_MS + 10),
|
||||
));
|
||||
assert!(!ctl.press_was_a_graze());
|
||||
}
|
||||
|
||||
/// The desktop is not held to the dwell. A mouse button is a discrete
|
||||
/// decision and is routinely down for thirty milliseconds.
|
||||
#[test]
|
||||
fn a_mouse_click_is_never_a_graze() {
|
||||
let ctl = CollectionsController::new(crate::activity::ActivityLog::new());
|
||||
ctl.touch_press.set(false);
|
||||
ctl.pressed_at.set(Some(std::time::Instant::now()));
|
||||
assert!(!ctl.press_was_a_graze());
|
||||
}
|
||||
|
||||
/// The floor has to sit under the ceiling, or there is no tap between them:
|
||||
/// every press would be either a graze or a hold.
|
||||
#[test]
|
||||
fn a_tap_has_room_between_a_graze_and_a_hold() {
|
||||
// A `const` block, so this is a compile error rather than a test
|
||||
// failure: the two numbers are constants, and a gesture set with no
|
||||
// room for a tap in it should not get as far as being run.
|
||||
const { assert!(TAP_MIN_MS < HOLD_DELAY_MS) };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dragging_a_collection_onto_another_moves_it() {
|
||||
// The gesture the tree rearrangement exists for: no images carried, a
|
||||
@@ -2977,6 +2809,48 @@ mod tests {
|
||||
assert_eq!(sel.len(), 3, "rows 10..=12");
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on one cell put it back where it started, and take nothing else.
|
||||
///
|
||||
/// This is the behaviour that replaced the double-tap range. That gesture
|
||||
/// selected everything between the cell and wherever the selection began —
|
||||
/// silently, with no visible state, from a thing a hand does by accident.
|
||||
/// "Select to…" does the same job and says so first, so a double tap is now
|
||||
/// two toggles and nothing more: the only outcome a user can predict from
|
||||
/// what is on the screen.
|
||||
#[test]
|
||||
fn two_taps_on_one_cell_cancel_out_and_take_no_range() {
|
||||
let all = ids(30);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
let mut previous = None;
|
||||
|
||||
// Selecting began somewhere else, so a range gesture would have had an
|
||||
// anchor to sweep from.
|
||||
press_remembering_anchor(
|
||||
&mut sel,
|
||||
&mut anchor,
|
||||
&mut previous,
|
||||
&all,
|
||||
0,
|
||||
4,
|
||||
false,
|
||||
false,
|
||||
&span,
|
||||
);
|
||||
assert_eq!(sel.len(), 1);
|
||||
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||
|
||||
assert_eq!(
|
||||
sel.iter().copied().collect::<Vec<_>>(),
|
||||
vec![ImageId(5)],
|
||||
"a double tap took a run instead of toggling one cell twice"
|
||||
);
|
||||
}
|
||||
|
||||
/// One tap in selection mode: a press reported as ctrl-held, which is what
|
||||
/// `library.slint` sends while the mode is on.
|
||||
fn tap(
|
||||
@@ -2999,98 +2873,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_tap_takes_the_run_between_it_and_where_selecting_began() {
|
||||
// The whole touch gesture, in the order a finger performs it: hold one
|
||||
// photograph to start selecting, then double-tap the far end of the run.
|
||||
// Both taps of that double tap land on the same cell — the first turns
|
||||
// it on, the second turns it off — and the double tap that follows has
|
||||
// to select the range anyway.
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
let mut previous = None;
|
||||
|
||||
// The long press: an ordinary plain press, which is what the cell got
|
||||
// before the hold timer fired.
|
||||
press_remembering_anchor(
|
||||
&mut sel,
|
||||
&mut anchor,
|
||||
&mut previous,
|
||||
&all,
|
||||
0,
|
||||
4,
|
||||
false,
|
||||
false,
|
||||
&span,
|
||||
);
|
||||
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 11);
|
||||
assert!(
|
||||
!sel.contains(&ImageId(12)),
|
||||
"the two taps cancelled out, which is what makes the double tap's \
|
||||
job to select the range rather than to add one cell"
|
||||
);
|
||||
|
||||
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 11, &span);
|
||||
|
||||
assert_eq!(sel.len(), 8, "rows 4..=11");
|
||||
assert!(sel.contains(&ImageId(5)) && sel.contains(&ImageId(12)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_tap_keeps_a_run_gathered_earlier() {
|
||||
// Two runs, which is why the extension unions rather than replaces: a
|
||||
// user in selection mode is gathering, and the second gesture must not
|
||||
// throw away the first.
|
||||
let all = ids(30);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
let mut previous = None;
|
||||
|
||||
press_remembering_anchor(
|
||||
&mut sel,
|
||||
&mut anchor,
|
||||
&mut previous,
|
||||
&all,
|
||||
0,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
&span,
|
||||
);
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 3);
|
||||
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 3, &span);
|
||||
assert_eq!(sel.len(), 4, "rows 0..=3");
|
||||
|
||||
// A second run, begun with a plain tap somewhere else.
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 20);
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
|
||||
tap(&mut sel, &mut anchor, &mut previous, &all, 25);
|
||||
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 25, &span);
|
||||
|
||||
assert_eq!(sel.len(), 10, "rows 0..=3 and 20..=25");
|
||||
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(26)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_double_tap_with_nothing_to_extend_from_selects_only_that_cell() {
|
||||
// The degenerate case: selection mode entered from the header's button
|
||||
// rather than by holding a cell, so nothing has anchored yet.
|
||||
let all = ids(10);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
let previous = None;
|
||||
|
||||
extend_to_row(&mut sel, &mut anchor, previous, &all, 0, 6, &span);
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(7)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_shift_adds_a_second_range_to_the_selection() {
|
||||
// Picking up a second run without losing the first: the one case where
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @generated by `cargo run -p traceability -- gestures`. Do not edit.
|
||||
//
|
||||
// TRACES: FR-UI-4
|
||||
//! The gesture vocabulary, as the application states it.
|
||||
//!
|
||||
//! Extracted from the tagged comments beside the code that implements each
|
||||
//! one, so the help sheet cannot describe a gesture the application does not
|
||||
//! have. Edit the comment beside the implementation, then regenerate with
|
||||
//! `cargo run -p traceability -- gestures`.
|
||||
|
||||
/// One documented interaction, as drawn.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Gesture {
|
||||
pub title: &'static str,
|
||||
/// The place it applies, and the heading it is grouped under.
|
||||
pub section: &'static str,
|
||||
/// Empty where the gesture has no counterpart in that modality.
|
||||
pub touch: &'static str,
|
||||
pub pointer: &'static str,
|
||||
pub keys: &'static str,
|
||||
}
|
||||
|
||||
/// Every gesture, in the order the sections were first seen in the source.
|
||||
pub const GESTURES: &[Gesture] = &[
|
||||
Gesture {
|
||||
title: "Pull a face out of the wrong person",
|
||||
section: "People",
|
||||
touch: "Tap the faces that do not belong, then \"Split off\"",
|
||||
pointer: "Click the faces that do not belong, then \"Split off\"",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Rule on a suggested face",
|
||||
section: "People",
|
||||
touch: "Tick to confirm it, cross to reject it",
|
||||
pointer: "Tick to confirm it, cross to reject it",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "See a person's photographs",
|
||||
section: "People",
|
||||
touch: "Choose them in the rail, then \"Show photos\"",
|
||||
pointer: "Choose them in the rail, then \"Show photos\"",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Change how faces are grouped",
|
||||
section: "People",
|
||||
touch: "\"Grouping…\", move the dials, then Regroup",
|
||||
pointer: "\"Grouping…\", move the dials, then Regroup",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Start selecting several photographs",
|
||||
section: "Library grid",
|
||||
touch: "Press and hold a photograph, or press Select in the header",
|
||||
pointer: "Ctrl-click, or press Select in the header",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Add or remove one photograph",
|
||||
section: "Library grid",
|
||||
touch: "While selecting, tap it",
|
||||
pointer: "Ctrl-click it",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Leave selecting",
|
||||
section: "Library grid",
|
||||
touch: "Press Done in the header",
|
||||
pointer: "Press Done in the header",
|
||||
keys: "Escape",
|
||||
},
|
||||
Gesture {
|
||||
title: "Select a range",
|
||||
section: "Library grid",
|
||||
touch: "While selecting, press \"Select to…\", then tap the last photograph of the run",
|
||||
pointer: "Shift-click the last photograph of the run",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Find photographs with two people in them",
|
||||
section: "Library grid",
|
||||
touch: "Open the People chip on the filter bar, tap each name, then switch the chip beside them to \"all of them\"",
|
||||
pointer: "Open the People chip on the filter bar, click each name, then switch the chip beside them to \"all of them\"",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Drop the selection but keep selecting",
|
||||
section: "Library grid",
|
||||
touch: "Press Clear in the selection strip",
|
||||
pointer: "Press Clear in the selection strip",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Select everything the grid is showing",
|
||||
section: "Library grid",
|
||||
touch: "While selecting, press \"Select all\"",
|
||||
pointer: "While selecting, press \"Select all\"",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Resize the thumbnails",
|
||||
section: "Library grid",
|
||||
touch: "Pinch the grid with two fingers",
|
||||
pointer: "Ctrl and the scroll wheel",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "File photographs in a collection",
|
||||
section: "Library grid",
|
||||
touch: "Drag a photograph — or a whole selection — onto a collection in the sidebar. Starting a drag stops the press becoming a hold, so it cannot leave you in selection mode.",
|
||||
pointer: "Drag a photograph — or a whole selection — onto a collection in the sidebar",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Open a photograph",
|
||||
section: "Library grid",
|
||||
touch: "Tap it — a single tap, any length",
|
||||
pointer: "Click it",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Rate a photograph without opening it",
|
||||
section: "Library grid",
|
||||
touch: "Tap a star on the cell",
|
||||
pointer: "Hover the cell, then click a star",
|
||||
keys: "0 to 5 on the selection",
|
||||
},
|
||||
];
|
||||
@@ -4550,13 +4550,7 @@ pub fn wire<F>(
|
||||
if coll_for_click.press_was_modified() {
|
||||
return;
|
||||
}
|
||||
// TRACES: FR-UI-4
|
||||
// And a graze is not a tap. The press has already selected the cell,
|
||||
// so the user sees what they touched; what they do not get is a
|
||||
// photograph opened by a hand on its way past.
|
||||
if coll_for_click.press_was_a_graze() {
|
||||
return;
|
||||
}
|
||||
|
||||
let path = ctl.paths.borrow().get(i as usize).cloned();
|
||||
if let Some(path) = path {
|
||||
// Leave the grid for the develop view. The status bar's
|
||||
|
||||
@@ -544,7 +544,6 @@ export component AppWindow inherits Window {
|
||||
callback library-cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on a cell: in selection mode, the far end of a range.
|
||||
callback library-cell-double-clicked(int);
|
||||
/// Renaming started on a row, by id — a double-click, or `F2`.
|
||||
callback collection-rename-start(int);
|
||||
/// A rename was committed: the collection's id and the new name.
|
||||
@@ -594,7 +593,7 @@ export component AppWindow inherits Window {
|
||||
callback library-drag-finished();
|
||||
in property <int> library-selected-count: 0;
|
||||
|
||||
callback library-cell-pressed(int, bool, bool, bool);
|
||||
callback library-cell-pressed(int, bool, bool);
|
||||
callback library-remove-from-collection();
|
||||
|
||||
// The keyboard cursor: where the arrows are in the library, as an image
|
||||
@@ -1568,11 +1567,10 @@ in property <bool> panel-visible: true;
|
||||
open-settings() => { root.settings-open(); }
|
||||
open-people() => { root.identity-open(); }
|
||||
|
||||
cell-pressed(i, ctrl, shift, touch) => {
|
||||
root.library-cell-pressed(i, ctrl, shift, touch);
|
||||
cell-pressed(i, ctrl, shift) => {
|
||||
root.library-cell-pressed(i, ctrl, shift);
|
||||
}
|
||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||
cell-double-clicked(i) => { root.library-cell-double-clicked(i); }
|
||||
select-mode: root.library-select-mode;
|
||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||
// The sidebar's rows, not a second model: the sheet files
|
||||
|
||||
@@ -127,6 +127,14 @@ component FaceCell inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// GESTURE: Pull a face out of the wrong person
|
||||
// where: People
|
||||
// touch: Tap the faces that do not belong, then "Split off"
|
||||
// pointer: Click the faces that do not belong, then "Split off"
|
||||
// why: Grouping over-merges on siblings, on parents and
|
||||
// children, and on the same person a decade apart, so
|
||||
// splitting is as prominent as merging. A tool that can
|
||||
// only merge makes its own errors permanent.
|
||||
TouchArea {
|
||||
clicked => { root.toggle-pick(); }
|
||||
}
|
||||
@@ -139,6 +147,14 @@ component FaceCell inherits Rectangle {
|
||||
// Only a suggestion needs ruling on. Once confirmed, the pair
|
||||
// collapses to the label — there is nothing left to decide, and
|
||||
// leaving the buttons would invite an accidental un-confirm.
|
||||
// GESTURE: Rule on a suggested face
|
||||
// where: People
|
||||
// touch: Tick to confirm it, cross to reject it
|
||||
// pointer: Tick to confirm it, cross to reject it
|
||||
// why: A face is either the system's guess or the user's
|
||||
// judgement, and the two are never conflated. A
|
||||
// rejection is remembered, so the face is not suggested
|
||||
// for that person again.
|
||||
if !face.confirmed: IconButton {
|
||||
icon: "check";
|
||||
clicked => { root.confirm(); }
|
||||
@@ -526,6 +542,13 @@ export component IdentityScreen inherits Rectangle {
|
||||
// The way back from a face to the photographs. This is the
|
||||
// point of having identified anybody, and without it the
|
||||
// screen is a filing cabinet with no drawer handles.
|
||||
// GESTURE: See a person's photographs
|
||||
// where: People
|
||||
// touch: Choose them in the rail, then "Show photos"
|
||||
// pointer: Choose them in the rail, then "Show photos"
|
||||
// why: This is the point of having identified anybody.
|
||||
// Without it the screen is a filing cabinet with no
|
||||
// drawer handles.
|
||||
if root.selected-person >= 0: Button {
|
||||
text: "Show photos";
|
||||
clicked => { root.show-photos(root.selected-person, false); }
|
||||
@@ -556,6 +579,15 @@ export component IdentityScreen inherits Rectangle {
|
||||
// next to the button that applies them and the rail that shows
|
||||
// what they did, and a value changed three screens away from
|
||||
// its effect is a value nobody can tune.
|
||||
// GESTURE: Change how faces are grouped
|
||||
// where: People
|
||||
// touch: "Grouping…", move the dials, then Regroup
|
||||
// pointer: "Grouping…", move the dials, then Regroup
|
||||
// why: The right match confidence is a property of your
|
||||
// library, not of the model. "What would this do?"
|
||||
// answers for this library without writing
|
||||
// anything; names, confirmations and the groups you
|
||||
// have set aside are kept whatever the dials say.
|
||||
Button {
|
||||
text: "Grouping…";
|
||||
active: root.grouping-open;
|
||||
|
||||
+137
-25
@@ -1240,15 +1240,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// also why there is no badge position to compute here any more.
|
||||
/// Modifier state at press time, so Rust can decide replace / add / extend
|
||||
/// without the .slint file encoding the selection policy.
|
||||
///
|
||||
/// The last argument is **whether a finger did it**, and it is here because
|
||||
/// a finger and a pointer need different rules about what counts as a tap.
|
||||
/// A mouse click is over in tens of milliseconds and means it; a hand
|
||||
/// brushing a tablet on its way somewhere else produces exactly the same
|
||||
/// press-and-release, and used to land the user in develop. Rust holds the
|
||||
/// rule (`collections_ui::TAP_MIN_MS`) beside the hold timer it has to sit
|
||||
/// between — this file only reports which kind of contact it was.
|
||||
callback cell-pressed(int, bool, bool, bool);
|
||||
callback cell-pressed(int, bool, bool);
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// The press on a cell ended — lifted, or taken away by the Flickable when
|
||||
/// the finger travelled. Cancels the long-press timer that would otherwise
|
||||
@@ -1257,12 +1249,6 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// visible cell.
|
||||
callback cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Two taps on the same cell. In selection mode this is the touch form of
|
||||
/// shift-click: everything from where the selection started to here. Rust
|
||||
/// decides — outside that mode a double tap is two ordinary clicks and the
|
||||
/// first has already opened the image.
|
||||
callback cell-double-clicked(int);
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Whether a tap selects rather than opens.
|
||||
///
|
||||
/// Held in Rust beside the selection it modifies, so the long press and the
|
||||
@@ -1270,6 +1256,28 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// can disagree. In this mode a plain tap toggles a cell — exactly what
|
||||
/// ctrl-click does with a pointer, which is why the press below passes it
|
||||
/// as the ctrl flag rather than as a third selection policy.
|
||||
// GESTURE: Start selecting several photographs
|
||||
// where: Library grid
|
||||
// touch: Press and hold a photograph, or press Select in the header
|
||||
// pointer: Ctrl-click, or press Select in the header
|
||||
// why: 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.
|
||||
//
|
||||
// GESTURE: Add or remove one photograph
|
||||
// where: Library grid
|
||||
// touch: While selecting, tap it
|
||||
// pointer: Ctrl-click it
|
||||
// why: 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.
|
||||
//
|
||||
// GESTURE: Leave selecting
|
||||
// where: Library grid
|
||||
// touch: Press Done in the header
|
||||
// pointer: Press Done in the header
|
||||
// keys: Escape
|
||||
in property <bool> select-mode: false;
|
||||
callback toggle-select-mode();
|
||||
/// TRACES: FR-CAT-5
|
||||
@@ -1326,6 +1334,17 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// no state for it, because it arrives as `cell-pressed`'s shift argument
|
||||
/// and lands in `apply_press` as the shift-click it already knows how to
|
||||
/// apply.
|
||||
// GESTURE: Select a range
|
||||
// where: Library grid
|
||||
// touch: While selecting, press "Select to…", then tap the last
|
||||
// photograph of the run
|
||||
// pointer: Shift-click the last photograph of the run
|
||||
// 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
|
||||
// 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.
|
||||
property <bool> ranging: false;
|
||||
|
||||
// A range armed against nothing has no anchor to extend from, and the strip
|
||||
@@ -2054,6 +2073,18 @@ export component LibraryGrid inherits Rectangle {
|
||||
// belongs on the filter bar; this chip is the whole feature's
|
||||
// front door and the tray below is where both terms and the
|
||||
// any/all choice actually are.
|
||||
// GESTURE: Find photographs with two people in them
|
||||
// where: Library grid
|
||||
// touch: Open the People chip on the filter bar, tap each
|
||||
// name, then switch the chip beside them to "all of
|
||||
// them"
|
||||
// pointer: Open the People chip on the filter bar, click each
|
||||
// name, then switch the chip beside them to "all of
|
||||
// them"
|
||||
// why: "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.
|
||||
FilterChip {
|
||||
icon: root.people-tray ? "chevron-down" : "chevron-right";
|
||||
label: "People";
|
||||
@@ -2333,6 +2364,13 @@ export component LibraryGrid inherits Rectangle {
|
||||
// The way out that is not "undo every tap". Distinct from
|
||||
// "Done", which leaves select mode entirely: clearing keeps the
|
||||
// mode, so the next selection can start straight away.
|
||||
// GESTURE: Drop the selection but keep selecting
|
||||
// where: Library grid
|
||||
// touch: Press Clear in the selection strip
|
||||
// pointer: Press Clear in the selection strip
|
||||
// why: Distinct from Done, which leaves the mode
|
||||
// entirely. Clearing keeps it, so the next
|
||||
// selection can start straight away.
|
||||
if !root.ranging: Button {
|
||||
text: "Clear";
|
||||
y: (parent.height - self.height) / 2;
|
||||
@@ -2343,6 +2381,14 @@ export component LibraryGrid inherits Rectangle {
|
||||
// do by hand: 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.
|
||||
// GESTURE: Select everything the grid is showing
|
||||
// where: Library grid
|
||||
// touch: While selecting, press "Select all"
|
||||
// pointer: While selecting, press "Select all"
|
||||
// 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
|
||||
// took to say it.
|
||||
if !root.ranging: Button {
|
||||
text: "Select all";
|
||||
y: (parent.height - self.height) / 2;
|
||||
@@ -2776,6 +2822,13 @@ export component LibraryGrid inherits Rectangle {
|
||||
// tall sat at the top of a viewport thousands of rows long and
|
||||
// was under the fingers only while the grid had not been
|
||||
// scrolled. Anywhere else the gesture found nothing to land on.
|
||||
// GESTURE: Resize the thumbnails
|
||||
// where: Library grid
|
||||
// touch: Pinch the grid with two fingers
|
||||
// pointer: Ctrl and the scroll wheel
|
||||
// why: 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.
|
||||
grid-pinch := ScaleRotateGestureHandler {
|
||||
width: 100%;
|
||||
height: parent.viewport-height;
|
||||
@@ -2918,6 +2971,17 @@ export component LibraryGrid inherits Rectangle {
|
||||
visible: cell.period-heading != "";
|
||||
}
|
||||
|
||||
// GESTURE: File photographs in a collection
|
||||
// where: Library grid
|
||||
// touch: Drag a photograph — or a whole selection — onto a
|
||||
// collection in the sidebar. Starting a drag stops
|
||||
// the press becoming a hold, so it cannot leave you
|
||||
// in selection mode.
|
||||
// pointer: Drag a photograph — or a whole selection — onto a
|
||||
// collection in the sidebar
|
||||
// why: The selection is what the drag carries, which is
|
||||
// why selecting several is worth the mode: forty
|
||||
// photographs file in one gesture.
|
||||
for cell[i] in root.cells: DragArea {
|
||||
// Cells are positioned at their **absolute** place in the
|
||||
// library, not their index in the loaded window: the window
|
||||
@@ -3137,13 +3201,47 @@ export component LibraryGrid inherits Rectangle {
|
||||
// `clicked` fires before the `up` that follows it, so
|
||||
// it can only raise a flag — the decision to open has
|
||||
// to wait for the event that names the finger.
|
||||
// GESTURE: Open a photograph
|
||||
// where: Library grid
|
||||
// touch: Tap it — a single tap, any length
|
||||
// pointer: Click it
|
||||
// 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
|
||||
// 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.
|
||||
property <int> down-finger: -1;
|
||||
property <bool> click-pending: false;
|
||||
|
||||
// Where the press landed, so the release can tell how
|
||||
// far the finger travelled.
|
||||
//
|
||||
// The Flickable already cancels a press that becomes a
|
||||
// scroll, but only once it has claimed the gesture,
|
||||
// which takes more travel than a graze has. This
|
||||
// catches the rest: a contact that slid across the cell
|
||||
// and lifted was reaching for something else.
|
||||
property <length> press-x: 0;
|
||||
property <length> press-y: 0;
|
||||
|
||||
/// How far a finger may slide and still be a tap.
|
||||
///
|
||||
/// A thumb's contact patch is wider than this, so no
|
||||
/// stationary tap approaches it; a hand moving across
|
||||
/// the glass passes it within one frame. Below about
|
||||
/// this the number stops describing intent and starts
|
||||
/// describing how steady the user's hand is.
|
||||
property <length> tap-slop: 12px;
|
||||
|
||||
pointer-event(ev) => {
|
||||
if (ev.kind == PointerEventKind.down) {
|
||||
self.down-finger = ev.touch-finger-id;
|
||||
self.click-pending = false;
|
||||
self.press-x = self.mouse-x;
|
||||
self.press-y = self.mouse-y;
|
||||
// A finger, not a pointer — see `touched`.
|
||||
if (ev.touch-finger-id != 0) {
|
||||
root.touched = true;
|
||||
@@ -3164,10 +3262,6 @@ export component LibraryGrid inherits Rectangle {
|
||||
i,
|
||||
ev.modifiers.control || root.select-mode,
|
||||
ev.modifiers.shift || root.ranging,
|
||||
// Finger id 0 is the mouse — the same
|
||||
// convention the pinch arbitration a
|
||||
// few lines up already relies on.
|
||||
ev.touch-finger-id != 0,
|
||||
);
|
||||
// One shot. The range was between two taps
|
||||
// and the second has landed; left armed, it
|
||||
@@ -3187,7 +3281,9 @@ export component LibraryGrid inherits Rectangle {
|
||||
// unmodified.
|
||||
if (self.click-pending
|
||||
&& !root.pinching
|
||||
&& ev.touch-finger-id == self.down-finger) {
|
||||
&& ev.touch-finger-id == self.down-finger
|
||||
&& abs(self.mouse-x - self.press-x) < self.tap-slop
|
||||
&& abs(self.mouse-y - self.press-y) < self.tap-slop) {
|
||||
root.cell-clicked(i);
|
||||
}
|
||||
self.click-pending = false;
|
||||
@@ -3204,12 +3300,28 @@ export component LibraryGrid inherits Rectangle {
|
||||
}
|
||||
|
||||
clicked => { self.click-pending = true; }
|
||||
// The far end of a range, in selection mode. Slint
|
||||
// delivers `clicked` for the first tap as well, which
|
||||
// is why the toggling is idempotent-by-union in Rust
|
||||
// rather than this file trying to swallow one of them.
|
||||
double-clicked => { root.cell-double-clicked(i); }
|
||||
// **No `double-clicked` here, deliberately.** A double
|
||||
// tap used to take a range in selection mode. It was
|
||||
// the only gesture touch had for shift-click, and
|
||||
// "Select to…" replaced it with a control that says
|
||||
// what it is about to do. Leaving both meant two quick
|
||||
// taps on one cell — a thing a hand does by accident —
|
||||
// silently selecting a run of forty photographs, with
|
||||
// no visible state to explain where they came from.
|
||||
//
|
||||
// Now two taps are two toggles and land back where they
|
||||
// started, which is the only thing a user can predict
|
||||
// from what is on screen.
|
||||
|
||||
// GESTURE: Rate a photograph without opening it
|
||||
// where: Library grid
|
||||
// touch: Tap a star on the cell
|
||||
// pointer: Hover the cell, then click a star
|
||||
// keys: 0 to 5 on the selection
|
||||
// why: A star has to take the press without it
|
||||
// also reaching the cell, or every rating
|
||||
// throws the user into develop.
|
||||
//
|
||||
// --- the rating strip, INSIDE the cell's hit area ---
|
||||
//
|
||||
// **A child of `cell-touch`, not a sibling of it.** Two
|
||||
|
||||
Reference in New Issue
Block a user