Put the gesture reference in the application
The document the previous commit generates is for somebody reading the repository. The person who needs it most is holding a tablet, has just discovered that a hold does something, and has nowhere to ask what else does. So the same scan writes a table the application draws: a "Gestures" button beside Settings, a sheet with the same scrim and dismissal as the ones that file and name, and every gesture grouped by where it applies with its touch, pointer and keyboard routes side by side. Not the `why` — that is the argument for the design and belongs in the document; on a phone-sized card it would bury the one line the sheet was opened to read. The sheet's file knows nothing about what a gesture is. It draws the rows it is handed, and the rows come from the generated table, because a help screen with its text typed into it is a second description of one behaviour — and the second description is always the one that goes stale. The commit before this deleted a gesture; a hand-kept sheet would still be describing it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
|
||||
///
|
||||
@@ -4512,6 +4512,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 {
|
||||
@@ -617,6 +619,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);
|
||||
@@ -1589,6 +1594,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