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:
2026-08-30 09:24:37 +02:00
11 changed files with 523 additions and 92 deletions
+131
View File
@@ -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
);
}
}
}
+5
View File
@@ -27,6 +27,11 @@ mod develop;
mod display_ui;
mod export;
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 histogram;
pub mod identity;
+19 -1
View File
@@ -22,7 +22,7 @@ use dr_types::FormatFilter;
use slint::{ComponentHandle, Model as _};
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.
///
@@ -4535,6 +4535,24 @@ pub fn wire<F>(
// after it, which is the whole point of showing them.
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
// they are two callbacks.
let on_open_image = Rc::new(on_open_image);