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,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 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { HistoryPanel, HistoryRow } from "history.slint";
|
||||
import { LaunchScreen } from "launch.slint";
|
||||
import { IdentityScreen, IdentityPerson, IdentityFace } from "identity.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 { CollectionsPanel, CollectionRow, OfflinePrompt } from "collections.slint";
|
||||
import { HistogramPanel, HistogramView } from "histogram.slint";
|
||||
@@ -17,6 +18,7 @@ import { ImportPage } from "import.slint";
|
||||
import { StatusBar, InfoPanel } from "develop.slint";
|
||||
|
||||
export { LibraryCell, TimelineBar, CollectionRow, ActivityRow, HistogramView, PersonChip }
|
||||
export { GestureRow }
|
||||
export { ViewMode, GradientHandle, HandleRole, SpotHandle, SpotRole }
|
||||
|
||||
export component AppWindow inherits Window {
|
||||
@@ -627,6 +629,9 @@ export component AppWindow inherits Window {
|
||||
callback library-filter-person-cleared(int);
|
||||
callback library-filter-people-mode-toggled();
|
||||
/// 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;
|
||||
callback library-people-listed();
|
||||
callback library-filter-person-toggled(int);
|
||||
@@ -1599,6 +1604,7 @@ in property <bool> panel-visible: true;
|
||||
filter-people: root.library-filter-people;
|
||||
filter-people-all: root.library-filter-people-all;
|
||||
people: root.library-people;
|
||||
gestures: root.library-gestures;
|
||||
filter-min-rating: root.library-filter-min-rating;
|
||||
filter-unjudged: root.library-filter-unjudged;
|
||||
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
|
||||
// too many.
|
||||
import { CollectionRow } from "collections.slint";
|
||||
import { GestureSheet, GestureRow } from "gestures.slint";
|
||||
|
||||
// TRACES: FR-CAT-5
|
||||
// One keyword in the keywording sheet, already answered against the selection.
|
||||
@@ -896,6 +897,9 @@ component HeaderActions inherits HorizontalLayout {
|
||||
callback open-import();
|
||||
callback open-settings();
|
||||
callback open-people();
|
||||
/// TRACES: FR-UI-4
|
||||
/// Open the gesture reference.
|
||||
callback open-gestures();
|
||||
|
||||
spacing: Theme.gap;
|
||||
|
||||
@@ -1068,6 +1072,18 @@ component HeaderActions inherits HorizontalLayout {
|
||||
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
|
||||
// with what the grid is showing; settings is always reachable, and a
|
||||
// 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-settings();
|
||||
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 ---
|
||||
//
|
||||
@@ -1900,6 +1923,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
open-import => { root.open-import(); }
|
||||
open-settings => { root.open-settings(); }
|
||||
open-people => { root.open-people(); }
|
||||
open-gestures => { root.helping = true; }
|
||||
}
|
||||
|
||||
// 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-settings => { root.open-settings(); }
|
||||
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) ------------------------------
|
||||
//
|
||||
// Why a sheet at all, rather than the sidebar's rename field: see `naming`
|
||||
|
||||
Reference in New Issue
Block a user