Merge branch 'master' into feat/library-toolbar
# Conflicts: # docs/gestures.md # docs/traceability.md
This commit is contained in:
+81
-1
@@ -12,6 +12,48 @@
|
||||
//! `OUT_DIR` as an include path makes the generated file answer every
|
||||
//! existing `import { Theme } from "theme.slint"` unchanged. This only works
|
||||
//! while no `ui/theme.slint` exists to shadow it — see the guard below.
|
||||
//!
|
||||
//! # Translations (NFR-A11Y-1)
|
||||
//!
|
||||
//! A string written `@tr("Sign in")` in the markup is **already correct in a
|
||||
//! build with no translation at all**: with neither the `gettext` nor the
|
||||
//! `bundle-translations` path active, Slint's `translate()` formats the
|
||||
//! original and returns it. That is the property that makes converting the
|
||||
//! interface a string at a time possible rather than a flag day — the
|
||||
//! alternative, wiring the machinery first and converting after, means every
|
||||
//! intermediate commit ships an interface half of which cannot be translated
|
||||
//! and none of which can be extracted.
|
||||
//!
|
||||
//! **Extracting.** `slint-tr-extractor` walks the `.slint` files and writes a
|
||||
//! `.pot`:
|
||||
//!
|
||||
//! ```text
|
||||
//! cargo install slint-tr-extractor
|
||||
//! find ui/dr-ui/ui -name '*.slint' | xargs slint-tr-extractor -o dr-ui.pot
|
||||
//! ```
|
||||
//!
|
||||
//! Do **not** pass `--no-default-translation-context`. Slint's default
|
||||
//! context is the enclosing component's name, which is what keeps the two
|
||||
//! senses of a word like "or" apart when the same word is a conjunction on one
|
||||
//! screen and a search operator on another — and the extractor and the
|
||||
//! compiler have to agree about it or every lookup misses silently.
|
||||
//!
|
||||
//! **Delivering.** Two mechanisms exist and this one picks bundling: a `.po`
|
||||
//! per language under `lang/<lang>/LC_MESSAGES/dr-ui.po`, compiled into the
|
||||
//! binary by the block in `main`. The alternative is Slint's `gettext`
|
||||
//! feature, which reads `.mo` files off disk at runtime through the C gettext
|
||||
//! library. Bundling wins here for one reason that outranks the rest:
|
||||
//! **Android**, where there is no filesystem path a `.mo` could sit at that
|
||||
//! the app can reach under ARCH §6.9's storage model, and no C library to
|
||||
//! link. A build with no `lang/` directory takes neither path and keeps every
|
||||
//! original string, which is exactly the state of this crate today.
|
||||
//!
|
||||
//! **What this does not reach.** `@tr()` is markup. The operation and
|
||||
//! parameter labels NFR-A11Y-1 names explicitly are resolved in Rust, by
|
||||
//! `labels.rs`, from the `LocalizedKey`s the core publishes — the core cannot
|
||||
//! depend on a localisation library (ARCH §6.5a), which is the constraint that
|
||||
//! put the catalogue in the UI crate in the first place. Translating those
|
||||
//! needs a second mechanism on the Rust side, and it is not built.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::fmt::Write as _;
|
||||
@@ -21,6 +63,8 @@ use serde_norway::Value;
|
||||
|
||||
const STYLE_YAML: &str = "style.yaml";
|
||||
const GENERATED: &str = "theme.slint";
|
||||
/// Where a `.po` goes, relative to this crate: `lang/<lang>/LC_MESSAGES/`.
|
||||
const LANG_DIR: &str = "lang";
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed={STYLE_YAML}");
|
||||
@@ -72,12 +116,48 @@ fn main() {
|
||||
// The failure is silent either way: a missing image loads as empty.
|
||||
// Embedding costs the size of ui/app-icon.png, the only asset this
|
||||
// reaches, since every UI glyph is a Path rather than a file.
|
||||
let config = slint_build::CompilerConfiguration::new()
|
||||
let mut config = slint_build::CompilerConfiguration::new()
|
||||
.with_include_paths(vec![out_dir.clone(), manifest_dir.join("ui")])
|
||||
.embed_resources(slint_build::EmbedResourcesKind::EmbedFiles);
|
||||
|
||||
// Bundling is asked for only once a translation exists to bundle.
|
||||
//
|
||||
// Enabling it unconditionally would make an empty `lang/` — or a
|
||||
// missing one, which is every checkout today — into a build failure for
|
||||
// everyone, in service of a feature nobody is yet using. Asking the
|
||||
// directory instead means the mechanism is wired and inert: the first
|
||||
// `lang/fr/LC_MESSAGES/dr-ui.po` someone commits turns it on with no
|
||||
// build-system change, which is the point at which a translator can
|
||||
// actually verify their work.
|
||||
if let Some(lang) = translations(&manifest_dir) {
|
||||
println!("cargo:rerun-if-changed={}", lang.display());
|
||||
config = config.with_bundled_translations(lang);
|
||||
}
|
||||
|
||||
slint_build::compile_with_config(entry(&out_dir, live), config).expect("compiling app.slint");
|
||||
}
|
||||
|
||||
/// The translation root, if any language has a catalogue in it.
|
||||
///
|
||||
/// The domain is the crate name — slint-build takes it from `CARGO_PKG_NAME`
|
||||
/// and this reads the same variable, so a rename does not leave the two
|
||||
/// halves looking for different files. Checking for a `.po` rather than
|
||||
/// merely for the directory is deliberate: an empty `lang/` left behind by a
|
||||
/// half-finished translation would otherwise switch bundling on and hand every
|
||||
/// string to a lookup with nothing behind it.
|
||||
fn translations(manifest_dir: &Path) -> Option<PathBuf> {
|
||||
let root = manifest_dir.join(LANG_DIR);
|
||||
let catalogue = format!("{}.po", env!("CARGO_PKG_NAME"));
|
||||
let entries = std::fs::read_dir(&root).ok()?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().join("LC_MESSAGES").join(&catalogue).is_file() {
|
||||
return Some(root);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The file handed to the Slint compiler.
|
||||
///
|
||||
/// Normally `ui/app.slint` itself. Under `live-style` it is a generated
|
||||
|
||||
+311
-153
@@ -50,40 +50,37 @@ use crate::{AppWindow, CollectionRow};
|
||||
struct PressUndo {
|
||||
selection: BTreeSet<ImageId>,
|
||||
anchor: Option<usize>,
|
||||
previous_anchor: Option<usize>,
|
||||
cursor: Option<usize>,
|
||||
}
|
||||
|
||||
impl PressUndo {
|
||||
/// Everything [`press_remembering_anchor`] is about to change.
|
||||
/// Everything [`select_row`] is about to change.
|
||||
///
|
||||
/// The four are the whole of what a press touches, which is the property
|
||||
/// the restore depends on and the reason it is worth a test of its own: an
|
||||
/// press that grew a fifth piece of state would leave that piece behind
|
||||
/// The three are the whole of what a press touches, which is the property
|
||||
/// the restore depends on and the reason it is worth a test of its own: a
|
||||
/// press that grew a fourth piece of state would leave that piece behind
|
||||
/// after a cancel, silently.
|
||||
fn capture(
|
||||
selection: &BTreeSet<ImageId>,
|
||||
anchor: Option<usize>,
|
||||
previous_anchor: Option<usize>,
|
||||
cursor: Option<usize>,
|
||||
) -> Self {
|
||||
Self {
|
||||
selection: selection.clone(),
|
||||
anchor,
|
||||
previous_anchor,
|
||||
cursor,
|
||||
}
|
||||
}
|
||||
|
||||
/// Put it all back. Returns the two the caller holds in `Cell`s.
|
||||
/// Put it all back. Returns the one the caller holds in a `Cell`.
|
||||
fn restore(
|
||||
self,
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
) -> (Option<usize>, Option<usize>) {
|
||||
) -> Option<usize> {
|
||||
*selection = self.selection;
|
||||
*anchor = self.anchor;
|
||||
(self.previous_anchor, self.cursor)
|
||||
self.cursor
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,18 +114,15 @@ pub struct CollectionsController {
|
||||
/// same argument applied to the one index that has to survive a move.
|
||||
anchor: RefCell<Option<usize>>,
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Where the anchor was *before* the press that moved it.
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// A photograph the most recent press asked to take *out* of the
|
||||
/// selection, held until the release says the press was a tap.
|
||||
///
|
||||
/// The touch equivalent of shift-click needs this. A double tap is two
|
||||
/// presses, and both of them move the anchor onto the cell being tapped —
|
||||
/// so by the time the double tap is reported, "the range from the anchor to
|
||||
/// here" describes a single cell. This remembers the cell the user actually
|
||||
/// started from, which is the one they mean.
|
||||
///
|
||||
/// Updated only when a press *moves* the anchor, so the second tap of a
|
||||
/// double tap — which lands on the cell that is already the anchor — leaves
|
||||
/// it pointing where the first tap left it.
|
||||
previous_anchor: std::cell::Cell<Option<usize>>,
|
||||
/// See [`Press::Deferred`] for why removal cannot happen on the press:
|
||||
/// this is the whole of what stops a drag of forty photographs carrying
|
||||
/// one. Overwritten by the next press and dropped by the drag that
|
||||
/// consumes it, so at most one is ever pending.
|
||||
pending_toggle: std::cell::Cell<Option<ImageId>>,
|
||||
/// TRACES: FR-UI-4
|
||||
/// What the selection was immediately before the most recent press, so a
|
||||
/// gesture that turns out not to have been a press can put it back.
|
||||
@@ -368,22 +362,57 @@ impl CollectionsController {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// What a press did, and what is left for the release to do.
|
||||
///
|
||||
/// Only one press has anything left over, and it is the one every multi-image
|
||||
/// drag depends on — see [`Press::Deferred`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use]
|
||||
pub enum Press {
|
||||
/// The selection is already what this press means. Nothing to finish.
|
||||
Applied,
|
||||
/// **Taking a photograph out of the selection waits for the release.**
|
||||
///
|
||||
/// Putting one *in* has to happen on the press: the drag that may follow
|
||||
/// reads the selection to decide what it carries, and by the time the
|
||||
/// finger lifts it is over the sidebar. Taking one out is the opposite —
|
||||
/// nothing between the press and the release needs the image gone, and one
|
||||
/// thing very much needs it to stay.
|
||||
///
|
||||
/// A plain press has said so since selection was written: pressing an
|
||||
/// already-selected cell leaves the selection alone. Ctrl did not, and on a
|
||||
/// tablet *every* press is a ctrl-press — that is what selection mode is.
|
||||
/// So grabbing one of forty selected photographs deselected it on the way
|
||||
/// down; the drag that followed found the cell under the finger no longer
|
||||
/// in the selection, took that to mean an unselected image was being
|
||||
/// dragged, and carried it alone. Forty photographs became one, and the
|
||||
/// only clue was the grabbed cell's ring blinking out.
|
||||
///
|
||||
/// So the removal is handed back for the *click* to apply, and a click
|
||||
/// fires only for a press that stayed put — never for one that became a
|
||||
/// drag. See `tap-slop` in `library.slint`.
|
||||
Deferred(ImageId),
|
||||
}
|
||||
|
||||
/// Apply a press to the selection.
|
||||
///
|
||||
/// Split from the callback so the policy is testable without a window: this is
|
||||
/// the part a user notices being wrong.
|
||||
///
|
||||
/// - **plain** — replace the selection with this one image
|
||||
/// - **ctrl** — toggle this image, keeping the rest, and move the anchor here
|
||||
/// - **ctrl** — add this image to the selection, keeping the rest, and move the
|
||||
/// anchor here; taking one *out* is [deferred](Press::Deferred) to the release
|
||||
/// - **shift** — select the range from the anchor to here, *replacing* what was
|
||||
/// selected; the anchor stays put, so an overshoot is corrected by
|
||||
/// shift-clicking the right cell rather than starting again
|
||||
/// - **ctrl+shift** — the same range, *added* to the selection, for picking up a
|
||||
/// second run without losing the first
|
||||
///
|
||||
/// A plain press on an image that is *already* selected leaves the selection
|
||||
/// alone. That is what makes dragging a multi-selection possible at all — the
|
||||
/// press that begins the drag would otherwise collapse the selection to one.
|
||||
/// A press on an image that is *already* selected never removes it here —
|
||||
/// plainly or with ctrl. That is what makes dragging a multi-selection possible
|
||||
/// at all: the press that begins the drag would otherwise take the grabbed
|
||||
/// photograph out from under it.
|
||||
///
|
||||
/// `ids` is the **loaded window**, `offset` where it starts in the library and
|
||||
/// `row` a position within it; the anchor is kept as `offset + row`, an
|
||||
@@ -402,8 +431,10 @@ pub fn apply_press(
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
let Some(&id) = ids.get(row) else { return };
|
||||
) -> Press {
|
||||
let Some(&id) = ids.get(row) else {
|
||||
return Press::Applied;
|
||||
};
|
||||
let here = offset + row;
|
||||
|
||||
if shift {
|
||||
@@ -413,7 +444,7 @@ pub fn apply_press(
|
||||
selection.clear();
|
||||
selection.insert(id);
|
||||
*anchor = Some(here);
|
||||
return;
|
||||
return Press::Applied;
|
||||
};
|
||||
|
||||
// The anchor deliberately does **not** move. Shift-clicking again
|
||||
@@ -459,15 +490,19 @@ pub fn apply_press(
|
||||
run = ids[lo_row..=hi_row].to_vec();
|
||||
}
|
||||
selection.extend(run);
|
||||
return;
|
||||
return Press::Applied;
|
||||
}
|
||||
|
||||
if ctrl {
|
||||
if !selection.remove(&id) {
|
||||
selection.insert(id);
|
||||
}
|
||||
*anchor = Some(here);
|
||||
return;
|
||||
|
||||
// Taking one out waits for the release — see [`Press::Deferred`].
|
||||
if selection.contains(&id) {
|
||||
return Press::Deferred(id);
|
||||
}
|
||||
|
||||
selection.insert(id);
|
||||
return Press::Applied;
|
||||
}
|
||||
|
||||
// Plain press on something already selected: leave it. The drag that may
|
||||
@@ -475,41 +510,13 @@ pub fn apply_press(
|
||||
// multi-image drag impossible to start.
|
||||
if selection.contains(&id) {
|
||||
*anchor = Some(here);
|
||||
return;
|
||||
return Press::Applied;
|
||||
}
|
||||
|
||||
selection.clear();
|
||||
selection.insert(id);
|
||||
*anchor = Some(here);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
/// Apply a press, and remember where the anchor was before it moved.
|
||||
///
|
||||
/// The bookkeeping a double tap depends on, split out from the callback so the
|
||||
/// touch sequence — hold one cell, double-tap another, get the run between —
|
||||
/// can be tested without a window. See [`CollectionsController::previous_anchor`]
|
||||
/// for why the *previous* anchor is the one a double tap means.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn press_remembering_anchor(
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
previous: &mut Option<usize>,
|
||||
ids: &[ImageId],
|
||||
offset: usize,
|
||||
row: usize,
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
let before = *anchor;
|
||||
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span);
|
||||
// Only a press that *moved* the anchor updates this. The second tap of a
|
||||
// double tap lands on the cell the first tap made the anchor, so it changes
|
||||
// nothing and the origin survives to be extended from.
|
||||
if *anchor != before {
|
||||
*previous = before;
|
||||
}
|
||||
Press::Applied
|
||||
}
|
||||
|
||||
/// Apply a press and push the result into the grid — the whole of what a
|
||||
@@ -533,23 +540,25 @@ pub fn select_row(
|
||||
*ctl.press_undo.borrow_mut() = Some(PressUndo::capture(
|
||||
&ctl.selection.borrow(),
|
||||
*ctl.anchor.borrow(),
|
||||
ctl.previous_anchor.get(),
|
||||
ctl.cursor(),
|
||||
));
|
||||
|
||||
let mut previous = ctl.previous_anchor.get();
|
||||
press_remembering_anchor(
|
||||
// Whatever the last press left pending is answered by this one: two
|
||||
// presses without a click in between means the first never was a tap.
|
||||
ctl.pending_toggle.set(None);
|
||||
|
||||
if let Press::Deferred(id) = apply_press(
|
||||
&mut ctl.selection.borrow_mut(),
|
||||
&mut ctl.anchor.borrow_mut(),
|
||||
&mut previous,
|
||||
ids,
|
||||
offset,
|
||||
row,
|
||||
ctrl,
|
||||
shift,
|
||||
&|first, last| ctl.span(first, last),
|
||||
);
|
||||
ctl.previous_anchor.set(previous);
|
||||
) {
|
||||
ctl.pending_toggle.set(Some(id));
|
||||
}
|
||||
// The cursor follows the press, so an arrow key after a click continues
|
||||
// from the cell that was clicked rather than from wherever the keyboard
|
||||
// was last.
|
||||
@@ -557,6 +566,26 @@ pub fn select_row(
|
||||
sync_selection(window, ctl, ids);
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// Finish a press that turned out to be a tap.
|
||||
///
|
||||
/// The other half of [`Press::Deferred`]: a ctrl-press on an already-selected
|
||||
/// photograph leaves it in, because the drag that may follow has to be able to
|
||||
/// carry it, and this takes it out again once the release has proved there was
|
||||
/// no drag.
|
||||
///
|
||||
/// Called from the click, which Slint reports only for a press that stayed
|
||||
/// within `tap-slop` of where it landed — so a press that became a drag never
|
||||
/// reaches here, and a press taken over by the Flickable never reaches here
|
||||
/// either. Both leave the pending removal to be dropped by the next press.
|
||||
pub fn commit_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||
let Some(id) = ctl.pending_toggle.take() else {
|
||||
return;
|
||||
};
|
||||
ctl.selection.borrow_mut().remove(&id);
|
||||
sync_selection(window, ctl, ids);
|
||||
}
|
||||
|
||||
/// TRACES: FR-UI-4
|
||||
/// Put the selection back as it was before the most recent press.
|
||||
///
|
||||
@@ -568,14 +597,22 @@ pub fn select_row(
|
||||
/// Idempotent, and a no-op when there is nothing to undo, so it is safe to
|
||||
/// call on every gesture start rather than only on the ones that need it.
|
||||
pub fn cancel_press(window: &AppWindow, ctl: &Rc<CollectionsController>, ids: &[ImageId]) {
|
||||
// The press is being unmade, so what it left for the release to finish is
|
||||
// unmade with it. Cleared before the early return: a press that changed
|
||||
// nothing to undo can still have deferred a removal — and a finger that
|
||||
// held long enough to pick a photograph up before the second one landed
|
||||
// has to put it back down, or the ring stays open around a cell nobody is
|
||||
// touching and the grid stays frozen with it.
|
||||
ctl.pending_toggle.set(None);
|
||||
window.set_library_held_row(-1);
|
||||
|
||||
let Some(undo) = ctl.press_undo.borrow_mut().take() else {
|
||||
return;
|
||||
};
|
||||
let (previous_anchor, cursor) = undo.restore(
|
||||
let cursor = undo.restore(
|
||||
&mut ctl.selection.borrow_mut(),
|
||||
&mut ctl.anchor.borrow_mut(),
|
||||
);
|
||||
ctl.previous_anchor.set(previous_anchor);
|
||||
ctl.set_cursor(cursor);
|
||||
sync_selection(window, ctl, ids);
|
||||
}
|
||||
@@ -1022,7 +1059,7 @@ pub(crate) const HOLD_DELAY_MS: u64 = 450;
|
||||
/// press that armed it has *already* selected the cell under the finger, so
|
||||
/// what firing adds is the mode: from here taps toggle rather than open, and
|
||||
/// the header's buttons appear to act on what has been gathered.
|
||||
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
||||
fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>, row: i32) {
|
||||
let timer = slint::Timer::default();
|
||||
let weak = window.as_weak();
|
||||
let ctl_cb = ctl.clone();
|
||||
@@ -1032,8 +1069,22 @@ fn arm_hold(window: &AppWindow, ctl: &Rc<CollectionsController>) {
|
||||
std::time::Duration::from_millis(HOLD_DELAY_MS),
|
||||
move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
ctl_cb.select_mode.set(true);
|
||||
w.set_library_select_mode(true);
|
||||
|
||||
// TRACES: FR-CAT-7
|
||||
// The photograph is in the user's hand: the grid draws a ring
|
||||
// opening around it and stops scrolling underneath it, so the drag
|
||||
// that may follow cannot be lost to a flick. See `held-row` in
|
||||
// `library.slint`.
|
||||
//
|
||||
// This half happens whether or not selection mode was already on —
|
||||
// it is the half a *drag* needs, and a drag out of a selection of
|
||||
// forty starts in a mode that is already on.
|
||||
w.set_library_held_row(row);
|
||||
|
||||
if !ctl_cb.select_mode.get() {
|
||||
ctl_cb.select_mode.set(true);
|
||||
w.set_library_select_mode(true);
|
||||
}
|
||||
|
||||
// The release that follows this hold must not also open the image:
|
||||
// the user asked for a selection and would land in develop instead.
|
||||
@@ -1498,17 +1549,20 @@ pub fn wire<S, R, P, C>(
|
||||
let offset = w.get_library_offset().max(0) as usize;
|
||||
select_row(&w, &ctl, &ids, offset, row as usize, ctrl_held, shift_held);
|
||||
|
||||
// TRACES: FR-UI-2 | FR-UI-4
|
||||
// TRACES: FR-UI-2 | FR-UI-4 | FR-CAT-7
|
||||
// And start counting, in case this press is a hold. The press has
|
||||
// already selected this one cell; what the hold adds is the *mode*,
|
||||
// so the taps that follow go on selecting instead of opening the
|
||||
// next photograph the user touches.
|
||||
// next photograph the user touches — and the *pick-up*, which is
|
||||
// what makes the drag reliable.
|
||||
//
|
||||
// Not started when the mode is already on: it is on, and a second
|
||||
// hold would have nothing to do but suppress the tap that ends it.
|
||||
if !ctl.select_mode.get() {
|
||||
arm_hold(&w, &ctl);
|
||||
}
|
||||
// Armed even when the mode is already on, which it did not used to
|
||||
// be: there was nothing left for the hold to switch on, so it was
|
||||
// skipped. But the mode being on is exactly the state a
|
||||
// multi-image drag starts from, and skipping the hold left that
|
||||
// drag with no pick-up and no cue — the one gesture that most
|
||||
// needed both. See `arm_hold`.
|
||||
arm_hold(&w, &ctl, row);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1856,16 +1910,33 @@ pub fn wire<S, R, P, C>(
|
||||
// Slint owns the gesture (see the preamble). What is left here is the
|
||||
// payload — the image ids the drop will act on — and the spring.
|
||||
|
||||
// The payload is built when the drag starts, so it is the selection as it
|
||||
// stands at that moment rather than whatever it becomes mid-flight.
|
||||
// TRACES: FR-CAT-7
|
||||
// **What arms the drag, not what it carries.**
|
||||
//
|
||||
// `DragArea` declines to start a drag while its `data` is empty, and it
|
||||
// tests that on every pointer event that reaches it — the first one
|
||||
// included, which arrives long before any drag. The binding in
|
||||
// `library.slint` calls this callback, and a binding that calls a callback
|
||||
// has nothing Slint can invalidate it on: it is evaluated once, when a
|
||||
// finger first lands on that cell, and cached. `dragging` is empty at that
|
||||
// moment and stays empty until `drag-started` fires.
|
||||
//
|
||||
// So this is answered at the wrong time, and always will be. That is
|
||||
// harmless only because **`set_user_data` is called unconditionally**: an
|
||||
// empty `Vec` is still user data, so the transfer is never `is_empty()` and
|
||||
// the `DragArea` stays armed. Skipping the call for an empty selection —
|
||||
// which looks like an obvious tidy-up — would disarm every cell a finger
|
||||
// had ever touched outside a drag, and dragging would simply stop working
|
||||
// with nothing to see.
|
||||
//
|
||||
// What the drop actually reads is `dragging`, set by `drag-started` and
|
||||
// read back by `dropped-on`. `user_data` rather than plain text so that
|
||||
// nothing outside the application can interpret it as a paste.
|
||||
{
|
||||
let ctl = ctl.clone();
|
||||
window.on_library_drag_payload(move || {
|
||||
let carried = ctl.dragging.borrow().clone();
|
||||
let mut data = slint::DataTransfer::default();
|
||||
// `user_data` rather than plain text: these are catalog ids for our
|
||||
// own drop handler, not something another application should be
|
||||
// able to interpret as a paste.
|
||||
data.set_user_data(Rc::new(carried));
|
||||
data
|
||||
});
|
||||
@@ -1899,6 +1970,15 @@ pub fn wire<S, R, P, C>(
|
||||
// meant. The pinch already cancels for exactly this reason.
|
||||
*ctl.hold_timer.borrow_mut() = None;
|
||||
|
||||
// TRACES: FR-CAT-7
|
||||
// And this press was not a tap, so it never gets to take anything
|
||||
// out of the selection — see [`Press::Deferred`]. Dropped here as
|
||||
// well as on the next press because the drag reads the selection
|
||||
// one line below, and a removal still pending would be a
|
||||
// photograph the user can see is selected and the drop would not
|
||||
// carry.
|
||||
ctl.pending_toggle.set(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
|
||||
@@ -2062,6 +2142,10 @@ pub fn wire<S, R, P, C>(
|
||||
let landed = ctl.dropped_on.borrow_mut().take();
|
||||
let to_trash = ctl.trash_requested.borrow_mut().take();
|
||||
ctl.dragging.borrow_mut().clear();
|
||||
// The grid clears this itself on the cancel that starts a drag;
|
||||
// this is for the endings that reach no cell — a drop, or a drag
|
||||
// abandoned over nothing.
|
||||
w.set_library_held_row(-1);
|
||||
*ctl.hover_id.borrow_mut() = None;
|
||||
*ctl.spring_timer.borrow_mut() = None;
|
||||
|
||||
@@ -2657,6 +2741,30 @@ fn unique_name(conn: &rusqlite::Connection, parent: Option<CollectionId>) -> Str
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
/// A press and the release that follows it — which is what a click is.
|
||||
///
|
||||
/// These tests are about what a *user* sees, and a user only ever presses
|
||||
/// and lets go. Calling [`apply_press`] alone would drop the half of the
|
||||
/// policy that waits for the release ([`Press::Deferred`]) and quietly
|
||||
/// assert the wrong thing about ctrl.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn click(
|
||||
selection: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
ids: &[ImageId],
|
||||
offset: usize,
|
||||
row: usize,
|
||||
ctrl: bool,
|
||||
shift: bool,
|
||||
span: &dyn Fn(usize, usize) -> Vec<ImageId>,
|
||||
) {
|
||||
if let Press::Deferred(id) =
|
||||
apply_press(selection, anchor, ids, offset, row, ctrl, shift, span)
|
||||
{
|
||||
selection.remove(&id);
|
||||
}
|
||||
}
|
||||
use super::*;
|
||||
|
||||
fn ids(n: u64) -> Vec<ImageId> {
|
||||
@@ -2730,8 +2838,8 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(3)]);
|
||||
}
|
||||
@@ -2743,12 +2851,12 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
assert_eq!(sel.len(), 2);
|
||||
|
||||
// Toggling: a second ctrl-press on the same cell takes it out again.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(1)]);
|
||||
}
|
||||
|
||||
@@ -2759,8 +2867,8 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 6, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 5, "rows 2..=6 inclusive");
|
||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(7)));
|
||||
@@ -2773,8 +2881,8 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 6, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
assert_eq!(sel.len(), 5);
|
||||
}
|
||||
|
||||
@@ -2788,12 +2896,12 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 15, false, true, &span);
|
||||
assert_eq!(sel.len(), 11, "rows 5..=15");
|
||||
|
||||
// Corrected to a shorter range from the same anchor.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 8, false, true, &span);
|
||||
assert_eq!(sel.len(), 4, "rows 5..=8, and nothing from the first range");
|
||||
assert!(!sel.contains(&ImageId(16)), "row 15 is no longer selected");
|
||||
}
|
||||
@@ -2807,9 +2915,9 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 10, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 14, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 12, false, true, &span);
|
||||
|
||||
assert_eq!(anchor, Some(10));
|
||||
assert_eq!(sel.len(), 3, "rows 10..=12");
|
||||
@@ -2830,25 +2938,14 @@ mod tests {
|
||||
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,
|
||||
);
|
||||
click(&mut sel, &mut anchor, &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);
|
||||
tap(&mut sel, &mut anchor, &all, 11);
|
||||
tap(&mut sel, &mut anchor, &all, 11);
|
||||
|
||||
assert_eq!(
|
||||
sel.iter().copied().collect::<Vec<_>>(),
|
||||
@@ -2859,24 +2956,86 @@ mod tests {
|
||||
|
||||
/// One tap in selection mode: a press reported as ctrl-held, which is what
|
||||
/// `library.slint` sends while the mode is on.
|
||||
fn tap(
|
||||
sel: &mut BTreeSet<ImageId>,
|
||||
anchor: &mut Option<usize>,
|
||||
previous: &mut Option<usize>,
|
||||
all: &[ImageId],
|
||||
row: usize,
|
||||
) {
|
||||
press_remembering_anchor(
|
||||
sel,
|
||||
anchor,
|
||||
previous,
|
||||
all,
|
||||
0,
|
||||
row,
|
||||
true,
|
||||
false,
|
||||
&library(all),
|
||||
fn tap(sel: &mut BTreeSet<ImageId>, anchor: &mut Option<usize>, all: &[ImageId], row: usize) {
|
||||
click(sel, anchor, all, 0, row, true, false, &library(all));
|
||||
}
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The bug that made a forty-image drag file one photograph.
|
||||
///
|
||||
/// In selection mode every press arrives as a ctrl-press, so grabbing one
|
||||
/// of the selected cells to drag them all used to *deselect* the cell being
|
||||
/// grabbed. `drag-started` then saw a press on an image that was not in the
|
||||
/// selection, concluded that was the gesture, and carried it alone.
|
||||
#[test]
|
||||
fn grabbing_a_selected_cell_leaves_the_whole_selection_to_drag() {
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
// Three photographs picked in selection mode, which reports ctrl.
|
||||
for row in [2, 5, 9] {
|
||||
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
|
||||
}
|
||||
assert_eq!(sel.len(), 3);
|
||||
|
||||
// The press that begins the drag, on one of the three.
|
||||
let outcome = apply_press(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
Press::Deferred(ImageId(6)),
|
||||
"the removal has to be handed back, not performed"
|
||||
);
|
||||
assert_eq!(
|
||||
sel.len(),
|
||||
3,
|
||||
"the drag reads the selection next, and all three have to still be in it"
|
||||
);
|
||||
assert!(sel.contains(&ImageId(6)), "least of all the one being held");
|
||||
}
|
||||
|
||||
/// And the other half: with no drag, the release still takes it out.
|
||||
///
|
||||
/// A tap in selection mode has to toggle, or there is no way to correct a
|
||||
/// mis-tap short of leaving the mode.
|
||||
#[test]
|
||||
fn a_tap_on_a_selected_cell_still_takes_it_out() {
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
for row in [2, 5, 9] {
|
||||
click(&mut sel, &mut anchor, &all, 0, row, true, false, &span);
|
||||
}
|
||||
|
||||
click(&mut sel, &mut anchor, &all, 0, 5, true, false, &span);
|
||||
|
||||
assert_eq!(
|
||||
sel.iter().copied().collect::<Vec<_>>(),
|
||||
vec![ImageId(3), ImageId(10)],
|
||||
"the tapped photograph is out and the other two stayed"
|
||||
);
|
||||
}
|
||||
|
||||
/// The anchor moves on the press whether or not the removal does.
|
||||
///
|
||||
/// "Select to…" measures from the anchor, and a run taken after tapping a
|
||||
/// selected cell has to start where the user last touched — otherwise the
|
||||
/// gesture sweeps from wherever the anchor happened to be left.
|
||||
#[test]
|
||||
fn a_deferred_press_still_moves_the_anchor() {
|
||||
let all = ids(20);
|
||||
let span = library(&all);
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
let _ = apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
|
||||
assert_eq!(anchor, Some(3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2888,13 +3047,13 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, false, true, &span);
|
||||
assert_eq!(sel.len(), 3);
|
||||
|
||||
// A new anchor by ctrl-click, then a ctrl+shift range from it.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 10, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 12, true, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 6, "rows 0..=2 and 10..=12");
|
||||
assert!(sel.contains(&ImageId(1)) && sel.contains(&ImageId(13)));
|
||||
@@ -2909,13 +3068,13 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 0, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 1, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 2, true, false, &span);
|
||||
assert_eq!(sel.len(), 3);
|
||||
|
||||
// Pressing one of the three to begin a drag.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
assert_eq!(sel.len(), 3, "the selection survived the press");
|
||||
}
|
||||
|
||||
@@ -2932,23 +3091,22 @@ mod tests {
|
||||
let mut anchor = None;
|
||||
|
||||
// A selection built the ordinary way, and the state it leaves behind.
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 1, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, true, false, &span);
|
||||
let before = (sel.clone(), anchor);
|
||||
|
||||
// The finger that opens a pinch.
|
||||
let undo = PressUndo::capture(&sel, anchor, Some(1), Some(3));
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
let undo = PressUndo::capture(&sel, anchor, Some(3));
|
||||
click(&mut sel, &mut anchor, &all, 0, 5, false, false, &span);
|
||||
assert_ne!(
|
||||
(sel.clone(), anchor),
|
||||
before,
|
||||
"the press has to change something, or this proves nothing"
|
||||
);
|
||||
|
||||
let (previous_anchor, cursor) = undo.restore(&mut sel, &mut anchor);
|
||||
let cursor = undo.restore(&mut sel, &mut anchor);
|
||||
|
||||
assert_eq!((sel, anchor), before, "selection and anchor are back");
|
||||
assert_eq!(previous_anchor, Some(1), "and so is the shift-click origin");
|
||||
assert_eq!(cursor, Some(3), "and the keyboard cursor");
|
||||
}
|
||||
|
||||
@@ -2960,7 +3118,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 99, false, false, &span);
|
||||
assert!(sel.is_empty());
|
||||
}
|
||||
|
||||
@@ -2980,12 +3138,12 @@ mod tests {
|
||||
let mut anchor = None;
|
||||
|
||||
// Anchor on the sixth image, in a window starting at the beginning.
|
||||
apply_press(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
||||
click(&mut sel, &mut anchor, &first, 0, 5, false, false, &span);
|
||||
assert_eq!(anchor, Some(5), "the anchor is an ordinal, not a row");
|
||||
|
||||
// The user scrolls — the window now starts four images in — and
|
||||
// shift-clicks the image at ordinal 10.
|
||||
apply_press(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &later, 4, 6, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 6, "ordinals 5..=10");
|
||||
assert!(sel.contains(&ImageId(6)), "the anchored image is still in");
|
||||
@@ -3007,7 +3165,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = Some(0);
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &window, 10, 3, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 14, "ordinals 0..=13, loaded or not");
|
||||
assert!(
|
||||
@@ -3030,7 +3188,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = Some(25);
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &window, 0, 2, false, true, &span);
|
||||
|
||||
assert_eq!(sel.len(), 24, "ordinals 2..=25");
|
||||
assert!(sel.contains(&ImageId(3)) && sel.contains(&ImageId(26)));
|
||||
@@ -3051,7 +3209,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = Some(0);
|
||||
|
||||
apply_press(
|
||||
click(
|
||||
&mut sel,
|
||||
&mut anchor,
|
||||
&window,
|
||||
@@ -3073,7 +3231,7 @@ mod tests {
|
||||
let mut sel = BTreeSet::new();
|
||||
let mut anchor = None;
|
||||
|
||||
apply_press(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
||||
click(&mut sel, &mut anchor, &all, 0, 3, false, true, &span);
|
||||
assert_eq!(sel.iter().copied().collect::<Vec<_>>(), vec![ImageId(4)]);
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,13 @@ pub const GESTURES: &[Gesture] = &[
|
||||
pointer: "Press Done in the header",
|
||||
keys: "Escape",
|
||||
},
|
||||
Gesture {
|
||||
title: "Pick a photograph up to drag it",
|
||||
section: "Library grid",
|
||||
touch: "Press and hold it until a ring opens around it, then drag",
|
||||
pointer: "Drag it",
|
||||
keys: "",
|
||||
},
|
||||
Gesture {
|
||||
title: "Select a range",
|
||||
section: "Library grid",
|
||||
|
||||
@@ -353,6 +353,7 @@ fn fraction(clipped: u32, pixels: u32) -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// TRACES: NFR-A11Y-3
|
||||
/// How much of the frame is gone, as a figure rather than a colour.
|
||||
///
|
||||
/// NFR-A11Y-3 asks that no status be carried by hue alone, and this is the
|
||||
@@ -364,6 +365,8 @@ fn fraction(clipped: u32, pixels: u32) -> f32 {
|
||||
/// Distinguishes "none" from "not none but under a tenth of a percent": those
|
||||
/// are different answers, and rounding the second to `0.0%` would tell a
|
||||
/// photographer their highlights were safe when the indicator beside it is lit.
|
||||
/// `a_clipping_figure_distinguishes_none_from_nearly_none` is the test that
|
||||
/// would fail if this became a colour again.
|
||||
fn percentage(clipped: u32, pixels: u32) -> String {
|
||||
if pixels == 0 || clipped == 0 {
|
||||
return "0%".into();
|
||||
|
||||
@@ -49,6 +49,7 @@ mod net_runtime;
|
||||
mod peaking;
|
||||
mod preset_store;
|
||||
mod presets;
|
||||
mod recovery_ui;
|
||||
mod remote;
|
||||
mod segmentation;
|
||||
mod settings_store;
|
||||
|
||||
+57
-16
@@ -862,7 +862,13 @@ pub fn open(
|
||||
|
||||
// Before the scan, not after it: the grid can be filled from disk now and
|
||||
// the scan is only ever going to add to it.
|
||||
show_catalog_now(window, &ctl, &path, &coll_ctl);
|
||||
//
|
||||
// And it is the gate on the scan, not merely a prelude to it — a damaged
|
||||
// catalog has a question on screen, and a scan writing into it while that
|
||||
// question is unanswered is how the last good copy gets destroyed.
|
||||
if !show_catalog_now(window, &ctl, &path, &coll_ctl) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rx = library::spawn_scan(
|
||||
conn.clone(),
|
||||
@@ -1095,7 +1101,7 @@ fn drain_scan(
|
||||
/// the same operation: a scan is the only request that both proves the server
|
||||
/// is reachable and brings the catalog up to date. Keeping them one function
|
||||
/// is what stops "retry" from quietly becoming a weaker probe than "rescan".
|
||||
fn start_rescan(
|
||||
pub(crate) fn start_rescan(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
@@ -1916,23 +1922,38 @@ fn schedule_reload(window: &AppWindow, ctl: &Rc<LibraryController>) {
|
||||
/// state already says "Scanning…", and an error here would contradict a scan
|
||||
/// that is working perfectly. `Catalog::open` creates the file in that case, so
|
||||
/// what the grid reads is an empty catalog rather than a failure.
|
||||
fn show_catalog_now(
|
||||
/// Returns whether it is safe to go on and scan.
|
||||
///
|
||||
/// `false` means the catalog is damaged and the recovery question is up. The
|
||||
/// caller must not start a scan on that answer: `Catalog::open` succeeds on a
|
||||
/// file whose header survived, so the scan would write ETags and image rows
|
||||
/// into damaged pages while the user is still reading the question — turning a
|
||||
/// file that had a backup into one where the backup is the only copy left.
|
||||
pub(crate) fn show_catalog_now(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
catalog_path: &std::path::Path,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
) -> bool {
|
||||
if ctl.catalog.borrow().is_some() {
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
let cat = match Catalog::open(catalog_path) {
|
||||
// Verified rather than plain: this is the once-per-launch moment where a
|
||||
// full check is affordable and there is a user in front of it who can
|
||||
// answer the question. See `dr_catalog::recovery` for why it is not on
|
||||
// every open.
|
||||
let cat = match Catalog::open_verified(catalog_path) {
|
||||
Ok(cat) => cat,
|
||||
Err(dr_catalog::CatalogError::Corrupt { detail }) => {
|
||||
crate::recovery_ui::offer(window, catalog_path, &detail);
|
||||
return false;
|
||||
}
|
||||
Err(e) => {
|
||||
// Not surfaced: the scan is the thing that has to work, and it is
|
||||
// still running. If it fails too, it reports for both of them.
|
||||
log::info!("no catalog to show before the scan: {e}");
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1964,6 +1985,19 @@ fn show_catalog_now(
|
||||
crate::collections_ui::refresh_tree(window, coll_ctl, &cat);
|
||||
*ctl.catalog.borrow_mut() = Some(cat);
|
||||
load_window(window, ctl);
|
||||
true
|
||||
}
|
||||
|
||||
/// Drop the open catalog, so the next `show_catalog_now` opens the file
|
||||
/// again rather than returning early.
|
||||
///
|
||||
/// Only recovery needs this, and it needs it for a specific reason: the file
|
||||
/// under that connection has been replaced. A handle to the catalog that was
|
||||
/// there before is a handle to a file that no longer has a name, and every
|
||||
/// read through it would return the damaged pages the recovery just moved out
|
||||
/// of the way.
|
||||
pub(crate) fn forget_catalog(ctl: &Rc<LibraryController>) {
|
||||
*ctl.catalog.borrow_mut() = None;
|
||||
}
|
||||
|
||||
/// What one cell of the outgoing model is worth keeping.
|
||||
@@ -4562,6 +4596,12 @@ pub fn wire<F>(
|
||||
let coll_for_click = coll_ctl.clone();
|
||||
let on_open_image = on_open_image.clone();
|
||||
window.on_library_cell_clicked(move |i| {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
|
||||
// The press stayed put, so it was a tap and not a drag: whatever it
|
||||
// held back can be applied now. See `collections_ui::Press`.
|
||||
crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids());
|
||||
|
||||
// A ctrl- or shift-click is a selection gesture. Opening the image
|
||||
// too would throw the user out of the grid mid-selection.
|
||||
if coll_for_click.press_was_modified() {
|
||||
@@ -4572,16 +4612,12 @@ pub fn wire<F>(
|
||||
if let Some(path) = path {
|
||||
// Leave the grid for the develop view. The status bar's
|
||||
// "‹ Library" button comes back here.
|
||||
if let Some(w) = weak.upgrade() {
|
||||
w.set_show_library(false);
|
||||
// Which cell the develop view is now showing, so the photo
|
||||
// roll opens marking it rather than marking nothing.
|
||||
w.set_library_roll_current(i);
|
||||
}
|
||||
w.set_show_library(false);
|
||||
// Which cell the develop view is now showing, so the photo
|
||||
// roll opens marking it rather than marking nothing.
|
||||
w.set_library_roll_current(i);
|
||||
on_open_image(path);
|
||||
if let Some(w) = weak.upgrade() {
|
||||
report_position(&w, &ctl, i as usize);
|
||||
}
|
||||
report_position(&w, &ctl, i as usize);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -5642,6 +5678,11 @@ pub fn wire<F>(
|
||||
start_rescan(&w, &ctl, &coll_ctl);
|
||||
});
|
||||
}
|
||||
|
||||
// Last, and in its own module: the answers to a damaged catalog have
|
||||
// nothing to do with the library view except that they run before it
|
||||
// exists.
|
||||
crate::recovery_ui::wire(window, &ctl, &coll_ctl);
|
||||
}
|
||||
|
||||
/// Reload the grid after the filter changed.
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
//! The two offers made when the catalog turns out to be damaged.
|
||||
//!
|
||||
//! `dr_catalog::recovery` owns the mechanism — the integrity check, the
|
||||
//! backups, the restore, setting the damaged file aside. This module owns the
|
||||
//! *conversation*: what a user is told has happened, which of the two answers
|
||||
//! are available, and what runs afterwards.
|
||||
//!
|
||||
//! # The thing that has to be said first
|
||||
//!
|
||||
//! **The photographs are fine, and so are the edits.** A user told that their
|
||||
//! library database is corrupt will assume they have lost their work, because
|
||||
//! in every other photo application they would have. Here they have not:
|
||||
//! sources are read-only to this application (NFR-R4), and ratings, keywords
|
||||
//! and edit graphs live in sidecars beside the images for every catalogued
|
||||
//! photograph, whether or not an account exists (FR-CAT-8, invariant §5.2.4).
|
||||
//! That sentence is the first line of the dialogue, before the diagnosis,
|
||||
//! because it is the answer to the question the user is actually asking.
|
||||
//!
|
||||
//! # Why the two answers are not interchangeable
|
||||
//!
|
||||
//! A restore brings back **collections**; a rebuild cannot. Every other thing
|
||||
//! the catalog holds has authoritative backing outside it, which is what makes
|
||||
//! a rebuild survivable — but a manual collection is a set of images the user
|
||||
//! assembled by hand and nothing in the filesystem records it
|
||||
//! (`docs/catalog.md` §8.1). So the labels say which one loses them, and the
|
||||
//! rebuild is not given the affirmative styling while a restore is on offer.
|
||||
//!
|
||||
//! # Why the scan is held back
|
||||
//!
|
||||
//! `library_ui::open` shows the catalog and then starts a scan. On a damaged
|
||||
//! catalog the scan is actively harmful: a plain `Catalog::open` on a file
|
||||
//! whose header is intact succeeds, and the scan would then write folder
|
||||
//! ETags and image rows into damaged pages — turning a recoverable file into
|
||||
//! one whose backup is the only copy left, and doing it in the seconds while
|
||||
//! the user is still reading the question. So `show_catalog_now` reports
|
||||
//! whether it is safe to continue, and this module restarts the scan itself
|
||||
//! once the file underneath has been replaced.
|
||||
|
||||
use std::cell::RefCell;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::rc::Rc;
|
||||
|
||||
use dr_catalog::recovery;
|
||||
use slint::ComponentHandle;
|
||||
|
||||
use crate::library_ui::LibraryController;
|
||||
use crate::AppWindow;
|
||||
|
||||
// What the open question is about.
|
||||
//
|
||||
// A thread-local rather than a field on `LibraryController`, because the
|
||||
// question is asked *before* that controller has a catalog and is answered by
|
||||
// callbacks wired at startup. Thread-local is sound here for the same reason
|
||||
// `crate::memory`'s registry is: everything below runs on the Slint event
|
||||
// loop thread, which is the only thread that has an `AppWindow` to show it
|
||||
// on.
|
||||
//
|
||||
// Plain `//` rather than `///`: rustdoc does not document a macro invocation,
|
||||
// and `-D warnings` rejects a doc comment that can never be rendered.
|
||||
thread_local! {
|
||||
static PENDING: RefCell<Option<Pending>> = const { RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// The damaged catalog and what can be done about it.
|
||||
struct Pending {
|
||||
catalog: PathBuf,
|
||||
/// Newest first. Empty is the ordinary case on a young install and is not
|
||||
/// an error — it removes one offer, not both.
|
||||
backups: Vec<recovery::Backup>,
|
||||
}
|
||||
|
||||
/// Ask what should happen to a damaged catalog.
|
||||
///
|
||||
/// Called from `library_ui::show_catalog_now` when the startup integrity check
|
||||
/// fails. `detail` is what SQLite said, carried through verbatim: a diagnosis
|
||||
/// the user can quote into a bug report is worth more than a reassurance they
|
||||
/// cannot check.
|
||||
pub(crate) fn offer(window: &AppWindow, catalog: &Path, detail: &str) {
|
||||
let backups = recovery::backups(catalog);
|
||||
log::error!(
|
||||
"catalog {} failed its integrity check: {detail} ({} backup(s) available)",
|
||||
catalog.display(),
|
||||
backups.len()
|
||||
);
|
||||
|
||||
window.set_recovery_title("This library's index is damaged".into());
|
||||
window.set_recovery_detail(
|
||||
// Two facts and their order matters: what is safe, then what is lost.
|
||||
"Your photographs and your edits are safe — they are in the files \
|
||||
themselves and in the sidecars beside them. What is damaged is only \
|
||||
DarkRoom's index of them, which can be rebuilt."
|
||||
.into(),
|
||||
);
|
||||
window.set_recovery_diagnosis(detail.into());
|
||||
|
||||
match backups.first() {
|
||||
Some(newest) => {
|
||||
window.set_recovery_can_restore(true);
|
||||
window.set_recovery_restore_label(
|
||||
format!(
|
||||
"Restore the backup from {} · keeps your collections",
|
||||
describe_age(newest.taken_at)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
None => {
|
||||
window.set_recovery_can_restore(false);
|
||||
window.set_recovery_restore_label(slint::SharedString::new());
|
||||
}
|
||||
}
|
||||
|
||||
window.set_recovery_rebuild_label(
|
||||
if backups.is_empty() {
|
||||
// Nothing to compare it against, so the label states the cost
|
||||
// rather than the difference.
|
||||
"Rebuild from your photographs · rescans the library"
|
||||
} else {
|
||||
"Rebuild from your photographs · loses your collections"
|
||||
}
|
||||
.into(),
|
||||
);
|
||||
window.set_recovery_busy(false);
|
||||
// The scan was held back, so the "Scanning…" the grid is showing behind
|
||||
// this would be a lie the moment the question is dismissed.
|
||||
window.set_library_scanning(false);
|
||||
|
||||
PENDING.with(|p| {
|
||||
*p.borrow_mut() = Some(Pending {
|
||||
catalog: catalog.to_path_buf(),
|
||||
backups,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
/// Close the question without answering it.
|
||||
///
|
||||
/// Leaves the banner set, because the library genuinely does not work and a
|
||||
/// dialogue that vanishes leaving no trace of why nothing loads is worse than
|
||||
/// no dialogue at all.
|
||||
fn dismiss(window: &AppWindow) {
|
||||
PENDING.with(|p| *p.borrow_mut() = None);
|
||||
window.set_recovery_title(slint::SharedString::new());
|
||||
window.set_library_scanning(false);
|
||||
window.set_library_error(
|
||||
"The library index is damaged. Rescan to rebuild it, or restore a backup.".into(),
|
||||
);
|
||||
}
|
||||
|
||||
/// Install the three answers.
|
||||
///
|
||||
/// Called at the end of `library_ui::wire`, which is where every other
|
||||
/// window-level callback in this area is installed.
|
||||
pub(crate) fn wire(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
) {
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window.on_recovery_restore(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
answer(&w, &ctl, &coll, Answer::Restore);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
let ctl = ctl.clone();
|
||||
let coll = coll_ctl.clone();
|
||||
window.on_recovery_rebuild(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
answer(&w, &ctl, &coll, Answer::Rebuild);
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let weak = window.as_weak();
|
||||
window.on_recovery_dismiss(move || {
|
||||
let Some(w) = weak.upgrade() else { return };
|
||||
dismiss(&w);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Which of the two the user chose.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
enum Answer {
|
||||
Restore,
|
||||
Rebuild,
|
||||
}
|
||||
|
||||
/// Carry out an answer, then get the library going again.
|
||||
///
|
||||
/// Both answers end the same way — the file under `catalog_path` is one this
|
||||
/// build can open — so both continue into the same two steps: open the catalog
|
||||
/// for the grid, and start a scan. A rebuild needs the scan to have anything
|
||||
/// at all; a restore needs it because the backup is by definition older than
|
||||
/// the library.
|
||||
fn answer(
|
||||
window: &AppWindow,
|
||||
ctl: &Rc<LibraryController>,
|
||||
coll_ctl: &Rc<crate::collections_ui::CollectionsController>,
|
||||
which: Answer,
|
||||
) {
|
||||
let Some(pending) = PENDING.with(|p| p.borrow_mut().take()) else {
|
||||
return;
|
||||
};
|
||||
window.set_recovery_busy(true);
|
||||
|
||||
// Before the file moves, not after. There is normally no open catalog here
|
||||
// — `show_catalog_now` returned before storing one — but "normally" is not
|
||||
// a guarantee worth resting a file rename on, and a connection to a file
|
||||
// that has just been renamed out from under it reads the damaged pages
|
||||
// forever.
|
||||
crate::library_ui::forget_catalog(ctl);
|
||||
|
||||
// Synchronous, on the UI thread, and that is a considered choice rather
|
||||
// than an oversight: this is a file copy of a catalog — tens of megabytes
|
||||
// at 50k images — at a moment when there is nothing else on screen to
|
||||
// block, no scan running, and no frame worth keeping smooth. Moving it to
|
||||
// a worker would buy a spinner and cost the guarantee that nothing else
|
||||
// touches the file while it is being replaced.
|
||||
let outcome = match which {
|
||||
Answer::Restore => match pending.backups.first() {
|
||||
Some(b) => recovery::restore(&pending.catalog, &b.path),
|
||||
None => Ok(()),
|
||||
},
|
||||
Answer::Rebuild => recovery::set_aside(&pending.catalog).map(|_| ()),
|
||||
};
|
||||
|
||||
if let Err(e) = outcome {
|
||||
// The question stays up: the *other* answer may still work, and a
|
||||
// failed restore in particular leaves the rebuild untouched.
|
||||
log::error!("recovery failed: {e}");
|
||||
window.set_recovery_busy(false);
|
||||
window.set_recovery_diagnosis(format!("That did not work: {e}").into());
|
||||
PENDING.with(|p| *p.borrow_mut() = Some(pending));
|
||||
return;
|
||||
}
|
||||
|
||||
window.set_recovery_busy(false);
|
||||
window.set_recovery_title(slint::SharedString::new());
|
||||
window.set_library_error(slint::SharedString::new());
|
||||
|
||||
if crate::library_ui::show_catalog_now(window, ctl, &pending.catalog, coll_ctl) {
|
||||
crate::library_ui::start_rescan(window, ctl, coll_ctl);
|
||||
}
|
||||
}
|
||||
|
||||
/// "today", "3 days ago" — enough to choose by, without a date library.
|
||||
///
|
||||
/// The user is deciding how much work a restore costs them, and the answer to
|
||||
/// that is an *age*, not a timestamp: "yesterday" is immediately actionable
|
||||
/// and "1756512000" is not. Whole days, because an hour's precision would
|
||||
/// invite a confidence the backup schedule does not earn.
|
||||
fn describe_age(taken_at: i64) -> String {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0);
|
||||
let days = (now - taken_at).max(0) / 86_400;
|
||||
match days {
|
||||
0 => "today".to_string(),
|
||||
1 => "yesterday".to_string(),
|
||||
d => format!("{d} days ago"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_age_reads_as_an_age() {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
assert_eq!(describe_age(now), "today");
|
||||
assert_eq!(describe_age(now - 86_400), "yesterday");
|
||||
assert_eq!(describe_age(now - 5 * 86_400), "5 days ago");
|
||||
// A clock that has gone backwards must not produce "-2 days ago".
|
||||
assert_eq!(describe_age(now + 86_400), "today");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
// TRACES: NFR-A11Y-2
|
||||
//! Every shared control says what it is, and every slider says what it edits.
|
||||
//!
|
||||
//! NFR-A11Y-2 asks that controls expose names, roles and values to AT-SPI and
|
||||
//! TalkBack. Almost none of that is Rust: it is `accessible-*` properties in
|
||||
//! Slint markup, which no unit test can observe by running the interface —
|
||||
//! there is no accessibility tree without a window, and CI has no display.
|
||||
//!
|
||||
//! So this reads the markup, the way `darkroom-android`'s manifest test reads
|
||||
//! the XML that aapt2 owns and rustc never sees. The property being defended
|
||||
//! is not "the screen reader announces the right words", which needs a device
|
||||
//! and a person; it is the thing that silently regresses instead — **a control
|
||||
//! losing its role or its name in an ordinary refactor**, which compiles,
|
||||
//! renders identically, passes every other test, and is invisible to anyone
|
||||
//! not using a screen reader. That failure has already happened once here: the
|
||||
//! whole application had five `accessible-*` lines in 14,482 lines of markup,
|
||||
//! all of them on one row of the colour mixer, and nothing said so.
|
||||
//!
|
||||
//! ## What is checked, and what deliberately is not
|
||||
//!
|
||||
//! Two properties, both structural:
|
||||
//!
|
||||
//! 1. **Named components declare the roles they promise.** A `Button` that
|
||||
//! stops declaring `accessible-role` stops existing for a screen reader
|
||||
//! entirely, because Slint rejects every other `accessible-*` property that
|
||||
//! is not accompanied by a role — so the role is the load-bearing one and
|
||||
//! the rest fail with it.
|
||||
//!
|
||||
//! 2. **Every `SliderTrack` instantiation supplies a `label`.** This is the
|
||||
//! one that catches a *new* control rather than a broken old one. The track
|
||||
//! cannot name itself — it has no idea what number it is dragging — so an
|
||||
//! unnamed one announces "slider, 0.35" and is the single most-used control
|
||||
//! in the application. A track added without a label is the exact mistake
|
||||
//! this file exists to make loud.
|
||||
//!
|
||||
//! Not checked: whether the words are good, whether they are translated,
|
||||
//! whether contrast passes, or whether Android exposes any of it — spike S13
|
||||
//! is still unrun and no test on this side of it can answer that.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// A component that must carry particular `accessible-*` properties, and the
|
||||
/// properties it must carry.
|
||||
///
|
||||
/// Only the ones whose absence is a behavioural loss are listed. `Button` must
|
||||
/// declare `accessible-action-default` because without it a screen reader can
|
||||
/// read the button and not press it, which is a control that exists and cannot
|
||||
/// be used; it need not declare `accessible-description`, which is a nicety.
|
||||
struct Promise {
|
||||
file: &'static str,
|
||||
component: &'static str,
|
||||
properties: &'static [&'static str],
|
||||
}
|
||||
|
||||
const PROMISES: &[Promise] = &[
|
||||
Promise {
|
||||
file: "widgets.slint",
|
||||
component: "Button",
|
||||
properties: &[
|
||||
"accessible-role",
|
||||
"accessible-label",
|
||||
"accessible-action-default",
|
||||
],
|
||||
},
|
||||
Promise {
|
||||
file: "widgets.slint",
|
||||
component: "IconButton",
|
||||
properties: &[
|
||||
"accessible-role",
|
||||
"accessible-label",
|
||||
"accessible-action-default",
|
||||
],
|
||||
},
|
||||
Promise {
|
||||
file: "widgets.slint",
|
||||
component: "FilterChip",
|
||||
properties: &[
|
||||
"accessible-role",
|
||||
"accessible-label",
|
||||
"accessible-checked",
|
||||
"accessible-action-default",
|
||||
],
|
||||
},
|
||||
Promise {
|
||||
file: "widgets.slint",
|
||||
component: "Section",
|
||||
properties: &["accessible-role", "accessible-label", "accessible-expanded"],
|
||||
},
|
||||
Promise {
|
||||
file: "widgets.slint",
|
||||
component: "ProgressBar",
|
||||
properties: &["accessible-role", "accessible-label", "accessible-value"],
|
||||
},
|
||||
// The label goes on the `TextInput` inside, not on the box around it —
|
||||
// Slint gives that element its role, so the name belongs beside it.
|
||||
Promise {
|
||||
file: "widgets.slint",
|
||||
component: "Field",
|
||||
properties: &["accessible-label", "accessible-placeholder-text"],
|
||||
},
|
||||
Promise {
|
||||
file: "controls.slint",
|
||||
component: "SliderTrack",
|
||||
properties: &[
|
||||
"accessible-role",
|
||||
"accessible-label",
|
||||
"accessible-value",
|
||||
"accessible-value-minimum",
|
||||
"accessible-value-maximum",
|
||||
"accessible-action-increment",
|
||||
"accessible-action-decrement",
|
||||
"accessible-action-set-value",
|
||||
],
|
||||
},
|
||||
Promise {
|
||||
file: "controls.slint",
|
||||
component: "Check",
|
||||
properties: &[
|
||||
"accessible-role",
|
||||
"accessible-label",
|
||||
"accessible-checked",
|
||||
"accessible-action-default",
|
||||
],
|
||||
},
|
||||
Promise {
|
||||
file: "controls.slint",
|
||||
component: "ChoiceChip",
|
||||
properties: &[
|
||||
"accessible-role",
|
||||
"accessible-label",
|
||||
"accessible-checked",
|
||||
"accessible-action-default",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
fn ui_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("ui")
|
||||
}
|
||||
|
||||
fn read(path: &Path) -> String {
|
||||
fs::read_to_string(path).unwrap_or_else(|e| panic!("cannot read {}: {e}", path.display()))
|
||||
}
|
||||
|
||||
/// The body of one top-level component declaration, or `None` if the file
|
||||
/// declares no such component.
|
||||
///
|
||||
/// Every component in these files is declared at column zero, so the block
|
||||
/// runs from its header to the next header or to the end of the file. A
|
||||
/// looser rule than brace matching and a stricter one than "somewhere in the
|
||||
/// file": the point is that a property found for `Button` really is inside
|
||||
/// `Button` and not inside the component below it.
|
||||
fn component_body<'a>(source: &'a str, name: &str) -> Option<&'a str> {
|
||||
let header_starts =
|
||||
|line: &str| line.starts_with("component ") || line.starts_with("export component ");
|
||||
|
||||
let mut start = None;
|
||||
for (offset, line) in line_offsets(source) {
|
||||
if !header_starts(line) {
|
||||
continue;
|
||||
}
|
||||
// `component Foo inherits Bar {` — the word after `component`.
|
||||
let declared = line
|
||||
.trim_start_matches("export ")
|
||||
.trim_start_matches("component ")
|
||||
.split_whitespace()
|
||||
.next();
|
||||
|
||||
match start {
|
||||
None if declared == Some(name) => start = Some(offset),
|
||||
Some(from) => return Some(&source[from..offset]),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
start.map(|from| &source[from..])
|
||||
}
|
||||
|
||||
/// Each line of `source` with its byte offset.
|
||||
fn line_offsets(source: &str) -> impl Iterator<Item = (usize, &str)> {
|
||||
let mut offset = 0;
|
||||
source.lines().map(move |line| {
|
||||
let at = offset;
|
||||
offset += line.len() + 1;
|
||||
(at, line)
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether a block sets a property — the name at the start of a line, followed
|
||||
/// by what Slint puts after a name it is binding to.
|
||||
///
|
||||
/// Three forms, and the third is easy to forget: `name:` for a property,
|
||||
/// `name =>` for a callback, and `name(arg) =>` for a callback that takes one.
|
||||
/// `accessible-action-set-value` is the only member of the third group here and
|
||||
/// leaving it out made this test report the slider as missing the very action
|
||||
/// it declares.
|
||||
///
|
||||
/// Anchored at the start of the trimmed line so that a *mention* in the prose
|
||||
/// above a component does not count. These files carry more comment than code,
|
||||
/// and several of those comments name the very properties asserted here; a
|
||||
/// substring search would pass on the strength of an explanation of the thing
|
||||
/// that had been deleted, which is the failure `ui_names_no_operation.rs`
|
||||
/// documents at length and the android manifest test strips comments to avoid.
|
||||
fn sets(block: &str, property: &str) -> bool {
|
||||
block.lines().any(|line| {
|
||||
let line = line.trim_start();
|
||||
line.strip_prefix(property).is_some_and(|rest| {
|
||||
let rest = rest.trim_start();
|
||||
rest.starts_with(':') || rest.starts_with("=>") || rest.starts_with('(')
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_controls_declare_their_roles() {
|
||||
let ui = ui_dir();
|
||||
let mut failures = Vec::new();
|
||||
|
||||
for promise in PROMISES {
|
||||
let path = ui.join(promise.file);
|
||||
let source = read(&path);
|
||||
|
||||
let Some(body) = component_body(&source, promise.component) else {
|
||||
failures.push(format!(
|
||||
" {} declares no component `{}` — it was renamed or removed, and \
|
||||
this test then checks nothing",
|
||||
promise.file, promise.component
|
||||
));
|
||||
continue;
|
||||
};
|
||||
|
||||
for property in promise.properties {
|
||||
if !sets(body, property) {
|
||||
failures.push(format!(
|
||||
" {}: `{}` does not set `{property}`",
|
||||
promise.file, promise.component
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert!(
|
||||
failures.is_empty(),
|
||||
"\n\nNFR-A11Y-2: controls expose names, roles and values to the platform \
|
||||
accessibility layer.\n\n{}\n\n\
|
||||
Slint rejects every `accessible-*` property that is not accompanied by an \
|
||||
`accessible-role`, so the role is what makes the control exist for AT-SPI \
|
||||
and TalkBack at all, and the actions are what make it usable rather than \
|
||||
merely readable.\n\n\
|
||||
These live on the shared components rather than at the call sites \
|
||||
deliberately: a control annotated where it is used is a control unnamed \
|
||||
everywhere it is used next. See the preamble to widgets.slint.\n",
|
||||
failures.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_slider_track_is_named() {
|
||||
let ui = ui_dir();
|
||||
let mut files: Vec<PathBuf> = fs::read_dir(&ui)
|
||||
.unwrap_or_else(|e| panic!("cannot read {}: {e}", ui.display()))
|
||||
.map(|entry| entry.expect("read dir entry").path())
|
||||
.filter(|p| p.extension().and_then(|e| e.to_str()) == Some("slint"))
|
||||
.collect();
|
||||
files.sort();
|
||||
|
||||
let mut instantiations = 0usize;
|
||||
let mut unnamed = Vec::new();
|
||||
|
||||
for path in &files {
|
||||
let source = read(path);
|
||||
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("?");
|
||||
|
||||
// The declaration itself, in controls.slint, is not an instantiation.
|
||||
for (offset, line) in line_offsets(&source) {
|
||||
if line.trim_start() != "SliderTrack {" {
|
||||
continue;
|
||||
}
|
||||
instantiations += 1;
|
||||
|
||||
let block = &source[offset..offset + block_len(&source[offset..])];
|
||||
if !sets(block, "label") {
|
||||
let number = source[..offset].lines().count() + 1;
|
||||
unnamed.push(format!(" {name}:{number}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A scan that found nothing passes for the wrong reason. Four tracks are
|
||||
// instantiated today — the develop panel's three shapes and the settings
|
||||
// page's row — and the floor sits below that and well above zero, so
|
||||
// removing one is a code change and not a silent scan failure.
|
||||
assert!(
|
||||
instantiations >= 3,
|
||||
"found only {instantiations} `SliderTrack` instantiations — the scan is \
|
||||
matching nothing, and a scan over nothing passes"
|
||||
);
|
||||
|
||||
assert!(
|
||||
unnamed.is_empty(),
|
||||
"\n\nNFR-A11Y-2: a slider that does not say what it adjusts.\n\n{}\n\n\
|
||||
`SliderTrack` cannot name itself — it is handed four numbers and knows \
|
||||
nothing about what they mean — so a track without a `label` announces as \
|
||||
\"slider, 0.35\". The develop column holds thirty-six of them in the \
|
||||
colour mixer alone, which is thirty-six controls a screen reader cannot \
|
||||
tell apart.\n\n\
|
||||
Pass the same string the row is already drawing above the track.\n",
|
||||
unnamed.join("\n")
|
||||
);
|
||||
}
|
||||
|
||||
/// The length of the brace-delimited block beginning at the start of `rest`,
|
||||
/// including both braces.
|
||||
///
|
||||
/// Braces inside string literals are not handled, and do not occur inside a
|
||||
/// `SliderTrack` instantiation — the only strings there are labels and units.
|
||||
fn block_len(rest: &str) -> usize {
|
||||
let mut depth = 0usize;
|
||||
for (i, c) in rest.char_indices() {
|
||||
match c {
|
||||
'{' => depth += 1,
|
||||
'}' => {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return i + 1;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
rest.len()
|
||||
}
|
||||
+56
-10
@@ -140,6 +140,18 @@ component GroupHeading inherits Rectangle {
|
||||
width: 34px;
|
||||
visible: root.has-reset;
|
||||
|
||||
// A `Caption` is a `Text`, which Slint announces as text — so
|
||||
// without this the reset reads as the word "reset" sitting beside
|
||||
// the heading rather than as something that can be pressed.
|
||||
accessible-role: button;
|
||||
accessible-label: "Reset";
|
||||
accessible-enabled: root.has-reset;
|
||||
accessible-action-default => {
|
||||
if (root.has-reset) {
|
||||
root.reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset-touch := TouchArea {
|
||||
width: 100%;
|
||||
height: max(parent.height, Theme.touch-target);
|
||||
@@ -202,6 +214,18 @@ component ParamSlider inherits Rectangle {
|
||||
modified: root.data.value != root.data.default-value;
|
||||
|
||||
SliderTrack {
|
||||
// The same two strings `ControlRow` is drawing above the track.
|
||||
// Drawn text and announced text are separate channels — Slint
|
||||
// associates neither with the control on its own — so the label a
|
||||
// sighted user reads and the label a screen reader hears come from
|
||||
// one expression each, rather than the second being left empty.
|
||||
label: root.data.param-label;
|
||||
readout: Readout.of(root.data);
|
||||
// The descriptor's declared precision, as a step: a parameter in
|
||||
// whole units nudges by one and one in stops by a hundredth,
|
||||
// which is the same quantum the readout is rounded to.
|
||||
step: root.data.precision == 0 ? 1.0 : 0.01;
|
||||
|
||||
value: root.data.value;
|
||||
default-value: root.data.default-value;
|
||||
minimum: root.data.minimum;
|
||||
@@ -245,12 +269,18 @@ component FacetHeading inherits Rectangle {
|
||||
// most of a screen of scrolling with the band name absent from every row of it
|
||||
// anyway.
|
||||
//
|
||||
// **The name is not thrown away, it moves.** It is the row's accessible label,
|
||||
// so a screen reader says "Orange" where the eye reads the colour, and the
|
||||
// catalogue in `labels.rs` is where the mapping is written down for anyone who
|
||||
// cannot separate two squares by eye. A row identified by colour *alone*
|
||||
// **The name is not thrown away, it moves.** It becomes the track's accessible
|
||||
// label, so a screen reader says "Orange" where the eye reads the colour, and
|
||||
// the catalogue in `labels.rs` is where the mapping is written down for anyone
|
||||
// who cannot separate two squares by eye. A row identified by colour *alone*
|
||||
// would be a control some photographers could not use, which is why the
|
||||
// spoken name is part of the design and not an afterthought.
|
||||
//
|
||||
// It used to be announced on this row, with the track below it silent. Now
|
||||
// that every `SliderTrack` names itself (NFR-A11Y-2) the two would nest — a
|
||||
// slider inside a slider, the outer one carrying the value and the inner one
|
||||
// carrying the actions that can change it — so the row stands down and hands
|
||||
// the same three strings to the control that owns the gesture.
|
||||
component SwatchSlider inherits Rectangle {
|
||||
in property <ParamRow> data;
|
||||
callback changed(float);
|
||||
@@ -264,12 +294,6 @@ component SwatchSlider inherits Rectangle {
|
||||
// and the gestures behind it (FR-UI-3).
|
||||
height: Theme.touch-target / 2 + 4px;
|
||||
|
||||
accessible-role: slider;
|
||||
accessible-label: root.data.param-label;
|
||||
accessible-value: Readout.of(root.data);
|
||||
accessible-value-minimum: root.data.minimum;
|
||||
accessible-value-maximum: root.data.maximum;
|
||||
|
||||
HorizontalLayout {
|
||||
padding-left: Theme.gap-sm;
|
||||
spacing: Theme.gap-sm;
|
||||
@@ -284,6 +308,8 @@ component SwatchSlider inherits Rectangle {
|
||||
SliderTrack {
|
||||
horizontal-stretch: 1;
|
||||
|
||||
label: root.data.param-label;
|
||||
readout: Readout.of(root.data);
|
||||
value: root.data.value;
|
||||
default-value: root.data.default-value;
|
||||
minimum: root.data.minimum;
|
||||
@@ -346,6 +372,10 @@ component PlainSlider inherits Rectangle {
|
||||
modified: root.value != root.default-value;
|
||||
|
||||
SliderTrack {
|
||||
label: root.label;
|
||||
readout: (Math.round(root.value * 10) / 10) + root.unit;
|
||||
step: 0.1;
|
||||
|
||||
value: root.value;
|
||||
default-value: root.default-value;
|
||||
minimum: root.minimum;
|
||||
@@ -575,16 +605,26 @@ export component GeometryPanel inherits Rectangle {
|
||||
// Rotation and flips. Icons rather than labels: four controls
|
||||
// named in words would wrap the 280px column, and each of
|
||||
// these shows its own result.
|
||||
//
|
||||
// The words are still written, once each, as `label` — the width
|
||||
// argument is about the column, and a screen reader has no column.
|
||||
// The two flips also declare themselves checkable, which
|
||||
// `IconButton` cannot do on its own: `active` says a toggle is on
|
||||
// and says nothing at all about whether an inactive control is a
|
||||
// toggle that is off, so only these call sites know that the two
|
||||
// rotations are not toggles and these two are.
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap-sm;
|
||||
|
||||
IconButton {
|
||||
icon: "rotate-ccw";
|
||||
label: "Rotate left";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.rotate(-1); }
|
||||
}
|
||||
IconButton {
|
||||
icon: "rotate-cw";
|
||||
label: "Rotate right";
|
||||
enabled: root.enabled;
|
||||
clicked => { root.rotate(1); }
|
||||
}
|
||||
@@ -593,13 +633,19 @@ export component GeometryPanel inherits Rectangle {
|
||||
|
||||
IconButton {
|
||||
icon: "flip-h";
|
||||
label: "Flip horizontally";
|
||||
active: root.flip-h;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: root.flip-h;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.flip-h-toggled(); }
|
||||
}
|
||||
IconButton {
|
||||
icon: "flip-v";
|
||||
label: "Flip vertically";
|
||||
active: root.flip-v;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: root.flip-v;
|
||||
enabled: root.enabled;
|
||||
clicked => { root.flip-v-toggled(); }
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ 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";
|
||||
import { RecoveryPrompt } from "recovery.slint";
|
||||
import { PresetSheet, ScopeChips, ScopeKind } from "presets.slint";
|
||||
import { FocusMarks, FocusPanel } from "peaking.slint";
|
||||
import { SettingsPage } from "settings.slint";
|
||||
@@ -365,6 +366,21 @@ export component AppWindow inherits Window {
|
||||
callback offline-prompt-release();
|
||||
callback offline-prompt-dismiss();
|
||||
|
||||
// The question a damaged catalog asks. Same shape as the prompt above and
|
||||
// for the same reason: an empty title is what closes it, and every word in
|
||||
// it is composed in Rust, which is the only side that knows what SQLite
|
||||
// said and which backups exist.
|
||||
in property <string> recovery-title: "";
|
||||
in property <string> recovery-detail: "";
|
||||
in property <string> recovery-diagnosis: "";
|
||||
in property <string> recovery-restore-label: "";
|
||||
in property <bool> recovery-can-restore: false;
|
||||
in property <string> recovery-rebuild-label: "";
|
||||
in property <bool> recovery-busy: false;
|
||||
callback recovery-restore();
|
||||
callback recovery-rebuild();
|
||||
callback recovery-dismiss();
|
||||
|
||||
in property <string> library-root-label: "";
|
||||
in-out property <[TimelineBar]> library-timeline;
|
||||
in property <string> library-timeline-label: "";
|
||||
@@ -543,6 +559,13 @@ export component AppWindow inherits Window {
|
||||
/// that turns it on. The long press does the same thing without it.
|
||||
in property <bool> library-select-mode: false;
|
||||
callback library-toggle-select-mode();
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The row a press has held long enough to pick up, or `-1`.
|
||||
///
|
||||
/// Set by the same hold that turns on selection mode and cleared by the
|
||||
/// grid when the press ends — `in-out` because both ends write it. See
|
||||
/// `held-row` in `library.slint` for what it draws and what it stops.
|
||||
in-out property <int> library-held-row: -1;
|
||||
/// A press on a cell ended, so the long-press timer can be cancelled.
|
||||
callback library-cell-press-ended();
|
||||
/// TRACES: FR-UI-2 | FR-UI-4
|
||||
@@ -1145,6 +1168,14 @@ in property <bool> panel-visible: true;
|
||||
// would leave the library from behind an open question — the
|
||||
// view changing underneath a modal, which reads as the app
|
||||
// having lost its place.
|
||||
//
|
||||
// The recovery question is asked first because it is drawn
|
||||
// over everything, the offline prompt included: Back must
|
||||
// reach the thing the user can actually see.
|
||||
if (root.recovery-title != "") {
|
||||
root.recovery-dismiss();
|
||||
return accept;
|
||||
}
|
||||
if (root.offline-prompt-title != "") {
|
||||
root.offline-prompt-dismiss();
|
||||
return accept;
|
||||
@@ -1585,6 +1616,7 @@ in property <bool> panel-visible: true;
|
||||
cell-press-ended() => { root.library-cell-press-ended(); }
|
||||
select-mode: root.library-select-mode;
|
||||
toggle-select-mode() => { root.library-toggle-select-mode(); }
|
||||
held-row <=> root.library-held-row;
|
||||
// The sidebar's rows, not a second model: the sheet files
|
||||
// into the same tree the sidebar draws.
|
||||
collections: root.collection-rows;
|
||||
@@ -2670,5 +2702,24 @@ in property <bool> panel-visible: true;
|
||||
release() => { root.offline-prompt-release(); }
|
||||
dismiss() => { root.offline-prompt-dismiss(); }
|
||||
}
|
||||
|
||||
// Last, and therefore over everything including the settings page and
|
||||
// the offline prompt. Not a preference about layering: this is asked
|
||||
// before the grid exists, and nothing else in the window is about a
|
||||
// library that can be read.
|
||||
RecoveryPrompt {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
title: root.recovery-title;
|
||||
detail: root.recovery-detail;
|
||||
diagnosis: root.recovery-diagnosis;
|
||||
restore-label: root.recovery-restore-label;
|
||||
can-restore: root.recovery-can-restore;
|
||||
rebuild-label: root.recovery-rebuild-label;
|
||||
busy: root.recovery-busy;
|
||||
restore() => { root.recovery-restore(); }
|
||||
rebuild() => { root.recovery-rebuild(); }
|
||||
dismiss() => { root.recovery-dismiss(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,16 @@
|
||||
// primitives take plain numbers and strings, and the ParamRow-shaped wrappers
|
||||
// stay in the panel that owns the model. This is the constraint that makes the
|
||||
// file reusable, so it is worth stating rather than merely observing.
|
||||
//
|
||||
// **Accessibility follows the same rule as behaviour** (NFR-A11Y-2, and see
|
||||
// widgets.slint's preamble for the two Slint constraints that shape it): a
|
||||
// control's role, and the actions assistive technology can invoke on it, are
|
||||
// written once here. Its *name* is the one thing that cannot be — a track has
|
||||
// no idea what number it is dragging — so every primitive below takes a
|
||||
// `label`, and the wrappers that do know pass it down. An unnamed control is
|
||||
// the failure mode that matters: a screen reader announcing "slider, 0.35" for
|
||||
// each of the thirty-six controls in the colour mixer has told the user
|
||||
// nothing at all.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Icon, Label, Value, Caption, Field } from "widgets.slint";
|
||||
@@ -49,6 +59,30 @@ export component SliderTrack inherits Rectangle {
|
||||
in property <float> minimum;
|
||||
in property <float> maximum;
|
||||
|
||||
/// What this track adjusts. The track's accessible name.
|
||||
///
|
||||
/// Every wrapper already draws this word somewhere — `ControlRow` puts it
|
||||
/// above, `FieldRow` puts it above and to the left — and none of those
|
||||
/// placements associates it with the control as far as the platform is
|
||||
/// concerned. `SwatchSlider` is the case that makes the point: it draws no
|
||||
/// word at all, only a coloured square, and its name has *always* had to
|
||||
/// travel this way.
|
||||
in property <string> label;
|
||||
/// The value as the user should hear it, already formatted.
|
||||
///
|
||||
/// Empty falls back to the raw number, which is right for a track whose
|
||||
/// caller has nothing better; a caller with a declared precision and a
|
||||
/// unit hands over what it is drawing, so "+1.25 EV" is announced rather
|
||||
/// than "1.2500000298".
|
||||
///
|
||||
/// A string rather than a float for the reason `ControlRow.readout` gives:
|
||||
/// precision belongs to whoever owns the value, and a control that rounded
|
||||
/// on its own would announce a parameter one way and draw it another.
|
||||
in property <string> readout;
|
||||
/// How far one assistive-technology nudge moves the value. Zero takes a
|
||||
/// hundredth of the range, which is the resolution a drag has anyway.
|
||||
in property <float> step: 0;
|
||||
|
||||
/// Live, once per movement. For anything that should follow the drag: a
|
||||
/// readout, a preview, the image itself.
|
||||
callback changed(float);
|
||||
@@ -76,6 +110,47 @@ export component SliderTrack inherits Rectangle {
|
||||
// by nothing and put every position at infinity.
|
||||
property <float> span: max(0.000001, root.maximum - root.minimum);
|
||||
|
||||
property <float> nudge: root.step > 0 ? root.step : root.span / 100;
|
||||
|
||||
// **One nudge is a whole gesture, so it commits.**
|
||||
//
|
||||
// The two callbacks exist because a drag is many movements and one
|
||||
// decision (see `committed` above). An arrow key pressed once is both at
|
||||
// the same time: there is no stream to debounce and no release to wait
|
||||
// for, so a nudge that only fired `changed` would move the photograph and
|
||||
// never be saved by any caller that listens for the end of a drag — which
|
||||
// is every settings-shaped caller in the application.
|
||||
function move-to(v: float) {
|
||||
root.changed(clamp(v, root.minimum, root.maximum));
|
||||
root.committed(clamp(v, root.minimum, root.maximum));
|
||||
}
|
||||
|
||||
// **The only route to this control that is not a pointer.** ui-navigation
|
||||
// D-N2 rules out hover as the sole affordance; a control reachable only by
|
||||
// dragging it is the same objection with the pointer itself as the
|
||||
// modifier. These three actions are what AT-SPI and TalkBack drive a
|
||||
// slider with, and they are what makes the track adjustable rather than
|
||||
// merely readable.
|
||||
accessible-role: slider;
|
||||
accessible-label: root.label;
|
||||
// Both arms of the ternary must be strings — the empty concatenation is
|
||||
// what makes the fallback one.
|
||||
accessible-value: root.readout != "" ? root.readout : (root.value + "");
|
||||
accessible-value-minimum: root.minimum;
|
||||
accessible-value-maximum: root.maximum;
|
||||
accessible-value-step: root.nudge;
|
||||
accessible-action-increment => { root.move-to(root.value + root.nudge); }
|
||||
accessible-action-decrement => { root.move-to(root.value - root.nudge); }
|
||||
accessible-action-set-value(v) => {
|
||||
// `is-float()` for the reason `NumberField` gives at length:
|
||||
// `to-float()` answers 0 for a string it could not parse, and a
|
||||
// screen reader handing over "abc" would silently set the exposure to
|
||||
// zero rather than reject the entry.
|
||||
if (v.is-float()) {
|
||||
root.move-to(v.to-float());
|
||||
}
|
||||
}
|
||||
|
||||
// **Why hover, and not the drag itself.**
|
||||
//
|
||||
// A Flickable does not merely compete for a gesture, it *withholds* the
|
||||
@@ -258,6 +333,9 @@ export component NumberField inherits Rectangle {
|
||||
in property <float> value;
|
||||
in property <float> minimum;
|
||||
in property <float> maximum;
|
||||
/// What the number means. Handed straight to the entry, which is where the
|
||||
/// accessibility tree wants it — see `Field.label`.
|
||||
in property <string> label;
|
||||
/// Decimal places shown, and the precision an entry is held to. Zero for a
|
||||
/// count, two for a value in stops — the same figure a parameter
|
||||
/// descriptor declares.
|
||||
@@ -303,6 +381,7 @@ export component NumberField inherits Rectangle {
|
||||
field := Field {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
label: root.label;
|
||||
text <=> root.text;
|
||||
// Committed on Enter *and* on losing focus, matching `TextRow`: Enter
|
||||
// alone loses the edit the moment the user clicks the next control,
|
||||
@@ -344,6 +423,21 @@ export component Check inherits Rectangle {
|
||||
|
||||
callback toggled(bool);
|
||||
|
||||
// The hint becomes the description rather than part of the name. It exists
|
||||
// to say what a setting *costs* — "location is stripped", "upscaling is
|
||||
// off" — which is the second thing a reader wants and never the first, and
|
||||
// a name that carried it would read the whole sentence back on every pass
|
||||
// through the page.
|
||||
accessible-role: checkbox;
|
||||
accessible-label: root.label;
|
||||
accessible-description: root.hint;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: root.checked;
|
||||
accessible-action-default => {
|
||||
root.checked = !root.checked;
|
||||
root.toggled(root.checked);
|
||||
}
|
||||
|
||||
height: max(row.preferred-height, Theme.control-height);
|
||||
|
||||
touch := TouchArea {
|
||||
@@ -408,6 +502,31 @@ export component ChoiceChip inherits Rectangle {
|
||||
|
||||
callback clicked();
|
||||
|
||||
// `radio-button` and not `button`, because single-selection over a fixed
|
||||
// list is what a radio button *is* — and the difference is audible: a
|
||||
// reader announcing "radio button, selected" has told the user that
|
||||
// picking another one will unpick this, which "button, pressed" has not.
|
||||
// It is the same distinction the prose above draws against `FilterChip`,
|
||||
// said in the vocabulary the platform already has a word for.
|
||||
//
|
||||
// **No `radio-group` around them.** The role exists, and the natural home
|
||||
// for it — `Segmented` — is a `VerticalLayout` rather than the plain root
|
||||
// Slint's own `RadioGroupBase` carries it on, and `Segmented` delegates to
|
||||
// `ChipGrid` for a wrapped set, so the group would either sit on a layout
|
||||
// element or be declared twice and nest. The group's name still reaches a
|
||||
// reader: `FieldRow` draws it as a `Text`, which Slint exposes on its own,
|
||||
// immediately before the chips in traversal order.
|
||||
accessible-role: radio-button;
|
||||
accessible-label: root.label;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: root.selected;
|
||||
accessible-action-default => {
|
||||
if (root.enabled) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
|
||||
height: Theme.control-height;
|
||||
// Wide enough that a one-word label is still a comfortable target, which
|
||||
// is what `control-min-width` exists for — but chips sit several to a row,
|
||||
@@ -612,6 +731,7 @@ export component TextRow inherits VerticalLayout {
|
||||
|
||||
field := Field {
|
||||
width: 100%;
|
||||
label: root.label;
|
||||
text <=> root.text;
|
||||
placeholder: root.placeholder;
|
||||
// Committed on Enter *and* on losing focus. Enter alone loses
|
||||
@@ -755,6 +875,14 @@ export component SliderRow inherits VerticalLayout {
|
||||
// tall where the track is half of one.
|
||||
y: (parent.height - self.height) / 2;
|
||||
|
||||
label: root.label;
|
||||
// A declared precision is a declared step — the argument above,
|
||||
// reused. The nudge an assistive technology makes is therefore the
|
||||
// same quantum a drag snaps to, so arrowing to a value and
|
||||
// dragging to it produce the same number rather than two that
|
||||
// differ in the last place.
|
||||
step: 1.0 / root.step-factor;
|
||||
|
||||
value: root.live;
|
||||
default-value: root.default-value;
|
||||
minimum: root.minimum;
|
||||
@@ -768,6 +896,7 @@ export component SliderRow inherits VerticalLayout {
|
||||
NumberField {
|
||||
// Follows the drag, so the number and the handle never disagree.
|
||||
value: root.live;
|
||||
label: root.label;
|
||||
minimum: root.minimum;
|
||||
maximum: root.maximum;
|
||||
precision: root.precision;
|
||||
|
||||
@@ -134,6 +134,7 @@ component Trace inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: NFR-A11Y-3
|
||||
// A clipping readout: a lit marker and a figure.
|
||||
//
|
||||
// **Two affordances for one fact, and NFR-A11Y-3 is why.** No status in this
|
||||
|
||||
@@ -155,12 +155,19 @@ component FaceCell inherits Rectangle {
|
||||
// judgement, and the two are never conflated. A
|
||||
// rejection is remembered, so the face is not suggested
|
||||
// for that person again.
|
||||
// The gesture note above is the whole label: a tick and a cross
|
||||
// are only "confirm" and "reject" to someone who can see the
|
||||
// suggestion they sit beside, and `IconButton`'s fallback would
|
||||
// announce them as "check" and "cross" — two icon names that say
|
||||
// nothing about which person is being ruled on.
|
||||
if !face.confirmed: IconButton {
|
||||
icon: "check";
|
||||
label: "Confirm this face";
|
||||
clicked => { root.confirm(); }
|
||||
}
|
||||
if !face.confirmed: IconButton {
|
||||
icon: "cross";
|
||||
label: "Reject this face";
|
||||
clicked => { root.reject(); }
|
||||
}
|
||||
if face.confirmed: Text {
|
||||
@@ -750,6 +757,7 @@ export component IdentityScreen inherits Rectangle {
|
||||
Rectangle { }
|
||||
IconButton {
|
||||
icon: "rotate-cw";
|
||||
label: "Recheck coverage";
|
||||
clicked => { root.check-coverage(); }
|
||||
}
|
||||
}
|
||||
|
||||
+75
-31
@@ -7,6 +7,30 @@ import { Check } from "controls.slint";
|
||||
// Deliberately separate from AppWindow. It is the first thing a user sees
|
||||
// with no library configured, and the place they return to in order to sign
|
||||
// out or switch account (FR-NC-1, FR-NC-4).
|
||||
//
|
||||
// **The first screen converted to `@tr()`** (NFR-A11Y-1), and this one first
|
||||
// because it is the one a user cannot get past: an interface they cannot read
|
||||
// is unusable here in a way it is not in a preferences page they could ignore.
|
||||
// The mechanism, the extraction command and the reason a build with no
|
||||
// translation behaves identically are in `build.rs`.
|
||||
//
|
||||
// Four kinds of string are deliberately *not* wrapped, and the distinction is
|
||||
// worth stating because "wrap every literal" is the obvious rule and the wrong
|
||||
// one:
|
||||
//
|
||||
// - **"DarkRoom".** A product name, the same in every language. Translating
|
||||
// it invites a translator to answer the question, and there is no answer.
|
||||
// - **Example values** — `https://cloud.example.com`, `/home/you/Pictures`,
|
||||
// the app-password mask. These are shapes rather than sentences; a
|
||||
// translated hostname would teach the wrong format.
|
||||
// - **`".."`**, the row that leads out of a folder. A filesystem convention,
|
||||
// not a word.
|
||||
// - **`"/"`**, the path separator.
|
||||
//
|
||||
// The headings are wrapped and carry their own capitals — `PanelHeading` draws
|
||||
// what it is given — so a translator supplies "SERVEUR" rather than "Serveur".
|
||||
// That is a real cost of styling in the string, and it is recorded here rather
|
||||
// than discovered by the first person to translate the screen.
|
||||
|
||||
// This screen's buttons are form actions in a single stacked column, not
|
||||
// chrome beside a photograph — they are given the full `touch-target` height
|
||||
@@ -17,11 +41,21 @@ component FormButton inherits Button {
|
||||
}
|
||||
|
||||
// One folder in the picker. The whole row is the target, not just the text.
|
||||
//
|
||||
// A `Rectangle` with a `TouchArea` over it is a button as far as the user is
|
||||
// concerned and a decorated box as far as the platform is, so the name reaches
|
||||
// a screen reader — the `Value` below is a `Text` — while the fact that it can
|
||||
// be entered does not. Saying so is what makes the picker navigable rather
|
||||
// than merely readable (NFR-A11Y-2).
|
||||
component FolderRow inherits Rectangle {
|
||||
in property <string> label;
|
||||
in property <bool> is-parent: false;
|
||||
callback clicked();
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: root.label;
|
||||
accessible-action-default => { root.clicked(); }
|
||||
|
||||
height: Theme.touch-target;
|
||||
background: touch.has-hover ? Theme.surface-raised : transparent;
|
||||
border-radius: 3px;
|
||||
@@ -141,8 +175,8 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
Label {
|
||||
text: root.signed-in
|
||||
? "Connected"
|
||||
: "Connect a Nextcloud account, or open a folder";
|
||||
? @tr("Connected")
|
||||
: @tr("Connect a Nextcloud account, or open a folder");
|
||||
body: true;
|
||||
}
|
||||
}
|
||||
@@ -153,29 +187,36 @@ export component LaunchScreen inherits Rectangle {
|
||||
if !root.signed-in && root.login-url == "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "SERVER"; }
|
||||
PanelHeading { text: @tr("SERVER"); }
|
||||
|
||||
server-input := Field {
|
||||
// The `PanelHeading` above each of these entries names
|
||||
// it on screen and names nothing at all as far as the
|
||||
// accessibility tree is concerned — Slint associates a
|
||||
// heading with the control below it only if something
|
||||
// says so. `Field.label` is that something, so the word
|
||||
// is written twice on purpose.
|
||||
label: @tr("Server address");
|
||||
text: root.server-url;
|
||||
placeholder: "https://cloud.example.com";
|
||||
accepted(url) => { root.sign-in(url); }
|
||||
}
|
||||
|
||||
if !root.can-remember: Caption {
|
||||
text: "No system keyring found — you will need to sign in each time.";
|
||||
text: @tr("No system keyring found — you will need to sign in each time.");
|
||||
warn: true;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: root.busy ? "Connecting…" : "Sign in";
|
||||
text: root.busy ? @tr("Connecting…") : @tr("Sign in");
|
||||
primary: true;
|
||||
enabled: !root.busy && server-input.text != "";
|
||||
clicked => { root.sign-in(server-input.text); }
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Sign-in happens in your browser. DarkRoom never sees your password.";
|
||||
text: @tr("Sign-in happens in your browser. DarkRoom never sees your password.");
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
@@ -193,7 +234,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
background: Theme.rule;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Caption { text: "or"; }
|
||||
Caption { text: @tr("or"); }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
@@ -201,14 +242,16 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
PanelHeading { text: "USERNAME"; }
|
||||
PanelHeading { text: @tr("USERNAME"); }
|
||||
user-input := Field {
|
||||
label: @tr("Username");
|
||||
text: "";
|
||||
placeholder: "your Nextcloud username";
|
||||
placeholder: @tr("your Nextcloud username");
|
||||
}
|
||||
|
||||
PanelHeading { text: "APP PASSWORD"; }
|
||||
PanelHeading { text: @tr("APP PASSWORD"); }
|
||||
pass-input := Field {
|
||||
label: @tr("App password");
|
||||
text: "";
|
||||
placeholder: "xxxxx-xxxxx-xxxxx-xxxxx-xxxxx";
|
||||
secret: true;
|
||||
@@ -218,7 +261,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: root.busy ? "Connecting…" : "Connect directly";
|
||||
text: root.busy ? @tr("Connecting…") : @tr("Connect directly");
|
||||
enabled: !root.busy
|
||||
&& server-input.text != ""
|
||||
&& user-input.text != ""
|
||||
@@ -233,7 +276,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.";
|
||||
text: @tr("Create one in Nextcloud under Settings › Security › Devices & sessions. It is device-scoped and can be revoked on its own.");
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
@@ -251,7 +294,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
background: Theme.rule;
|
||||
horizontal-stretch: 1;
|
||||
}
|
||||
Caption { text: "or"; }
|
||||
Caption { text: @tr("or"); }
|
||||
Rectangle {
|
||||
height: 1px;
|
||||
background: Theme.rule;
|
||||
@@ -259,21 +302,22 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
PanelHeading { text: "FOLDER"; }
|
||||
PanelHeading { text: @tr("FOLDER"); }
|
||||
folder-input := Field {
|
||||
label: @tr("Library folder");
|
||||
text: root.folder-path;
|
||||
placeholder: "/home/you/Pictures";
|
||||
accepted(path) => { root.use-folder(path); }
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: "Open folder";
|
||||
text: @tr("Open folder");
|
||||
enabled: !root.busy && folder-input.text != "";
|
||||
clicked => { root.use-folder(folder-input.text); }
|
||||
}
|
||||
|
||||
Caption {
|
||||
text: "Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.";
|
||||
text: @tr("Any folder this machine can read: a local disk, a network mount, or one your Nextcloud client already syncs. Nothing is uploaded and no password is needed.");
|
||||
wrap: word-wrap;
|
||||
}
|
||||
}
|
||||
@@ -282,7 +326,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
if root.login-url != "": VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "APPROVE IN YOUR BROWSER"; }
|
||||
PanelHeading { text: @tr("APPROVE IN YOUR BROWSER"); }
|
||||
|
||||
Panel {
|
||||
Label {
|
||||
@@ -294,18 +338,18 @@ export component LaunchScreen inherits Rectangle {
|
||||
}
|
||||
|
||||
FormButton {
|
||||
text: "Copy link";
|
||||
text: @tr("Copy link");
|
||||
clicked => { root.copy-login-url(); }
|
||||
}
|
||||
|
||||
Caption { text: "Waiting for approval…"; }
|
||||
Caption { text: @tr("Waiting for approval…"); }
|
||||
}
|
||||
|
||||
// --- folder picker ---
|
||||
if root.signed-in && root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "CHOOSE LIBRARY FOLDER"; }
|
||||
PanelHeading { text: @tr("CHOOSE LIBRARY FOLDER"); }
|
||||
|
||||
// Current location, so it is always clear what
|
||||
// "Use this folder" would select.
|
||||
@@ -326,7 +370,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
border-color: Theme.rule;
|
||||
|
||||
if root.browse-loading: Caption {
|
||||
text: "Loading…";
|
||||
text: @tr("Loading…");
|
||||
horizontal-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -357,7 +401,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
if !root.browse-loading && root.browse-entries.length == 0
|
||||
&& root.browse-path != "": Caption {
|
||||
text: "No subfolders here";
|
||||
text: @tr("No subfolders here");
|
||||
horizontal-alignment: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -367,12 +411,12 @@ export component LaunchScreen inherits Rectangle {
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
FormButton {
|
||||
text: "Cancel";
|
||||
text: @tr("Cancel");
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.browse-cancel(); }
|
||||
}
|
||||
FormButton {
|
||||
text: "Use this folder";
|
||||
text: @tr("Use this folder");
|
||||
primary: true;
|
||||
horizontal-stretch: 1;
|
||||
clicked => { root.browse-confirm(); }
|
||||
@@ -384,22 +428,22 @@ export component LaunchScreen inherits Rectangle {
|
||||
if root.signed-in && !root.browsing: VerticalLayout {
|
||||
spacing: Theme.gap;
|
||||
|
||||
PanelHeading { text: "ACCOUNT"; }
|
||||
PanelHeading { text: @tr("ACCOUNT"); }
|
||||
Value { text: root.account; }
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
PanelHeading { text: "LIBRARY FOLDER"; }
|
||||
PanelHeading { text: @tr("LIBRARY FOLDER"); }
|
||||
HorizontalLayout {
|
||||
spacing: Theme.gap;
|
||||
Value {
|
||||
text: root.library-root == "" ? "(not chosen)" : root.library-root;
|
||||
text: root.library-root == "" ? @tr("(not chosen)") : root.library-root;
|
||||
placeholder: root.library-root == "";
|
||||
horizontal-stretch: 1;
|
||||
overflow: elide;
|
||||
}
|
||||
FormButton {
|
||||
text: "Choose…";
|
||||
text: @tr("Choose…");
|
||||
width: 110px;
|
||||
clicked => { root.choose-folder(); }
|
||||
}
|
||||
@@ -407,7 +451,7 @@ export component LaunchScreen inherits Rectangle {
|
||||
|
||||
Rectangle { height: Theme.gap-sm; }
|
||||
|
||||
PanelHeading { text: "SCAN FOR"; }
|
||||
PanelHeading { text: @tr("SCAN FOR"); }
|
||||
|
||||
for label[i] in root.format-labels: Check {
|
||||
label: label;
|
||||
@@ -418,13 +462,13 @@ export component LaunchScreen inherits Rectangle {
|
||||
Rectangle { height: Theme.gap; }
|
||||
|
||||
FormButton {
|
||||
text: root.busy ? "Scanning…" : "Open library";
|
||||
text: root.busy ? @tr("Scanning…") : @tr("Open library");
|
||||
primary: true;
|
||||
enabled: !root.busy && root.library-root != "";
|
||||
clicked => { root.open-library(); }
|
||||
}
|
||||
FormButton {
|
||||
text: "Sign out";
|
||||
text: @tr("Sign out");
|
||||
clicked => { root.sign-out(); }
|
||||
}
|
||||
}
|
||||
|
||||
+141
-9
@@ -649,6 +649,7 @@ export component PhotoRoll inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: NFR-A11Y-3
|
||||
// A row of five stars, readable at a glance and clickable to set a rating.
|
||||
//
|
||||
// **Filled versus empty carries the meaning, not colour.** NFR-A11Y-3 forbids
|
||||
@@ -796,6 +797,7 @@ export component StarStrip inherits Rectangle {
|
||||
}
|
||||
}
|
||||
|
||||
// TRACES: NFR-A11Y-3
|
||||
// The pick/reject mark.
|
||||
//
|
||||
// A shape rather than a colour, for the same NFR-A11Y-3 reason as the stars:
|
||||
@@ -1203,6 +1205,40 @@ export component LibraryGrid inherits Rectangle {
|
||||
// keys: Escape
|
||||
in property <bool> select-mode: false;
|
||||
callback toggle-select-mode();
|
||||
|
||||
/// TRACES: FR-CAT-7 | FR-UI-4
|
||||
/// The photograph a press has held long enough to pick up, or `-1`.
|
||||
///
|
||||
/// **The visible half of a gesture that was folklore.** A finger on a cell
|
||||
/// is ambiguous — it may be starting a scroll or taking hold of a
|
||||
/// photograph — and Slint resolves that by giving the `Flickable` the first
|
||||
/// half-second: any press that travels more than a few pixels vertically
|
||||
/// inside it becomes a scroll, and the drag never begins. Only a fast
|
||||
/// sideways flick, or waiting the half-second out, ever picked a
|
||||
/// photograph up, and nothing on the screen said so. The user's account of
|
||||
/// it was that dragging "sometimes works".
|
||||
///
|
||||
/// So the wait is given a mark. The same hold that turns on selection mode
|
||||
/// sets this, a ring opens outward around the cell, and from that moment
|
||||
/// the drag is the only thing the finger can be doing — the grid below is
|
||||
/// no longer `interactive`, so there is no scroll left to lose to. The cue
|
||||
/// can only ever arrive *after* the ambiguity has passed, which is the
|
||||
/// honest direction: once the ring is open, dragging works.
|
||||
///
|
||||
/// A row of the loaded window, like every other row here. It is cleared
|
||||
/// when the press ends and when a drag finishes, and the grid cannot
|
||||
/// scroll while it is set, so it cannot outlive the window it indexes.
|
||||
// GESTURE: Pick a photograph up to drag it
|
||||
// where: Library grid
|
||||
// touch: Press and hold it until a ring opens around it, then drag
|
||||
// pointer: Drag it
|
||||
// why: A finger on a photograph might be starting a scroll, and for
|
||||
// the first half-second the grid assumes it is. Holding says
|
||||
// otherwise, and the ring is the grid saying it heard — from
|
||||
// there the drag cannot be lost to a scroll. A mouse never
|
||||
// waits: the cursor is precise enough that a sideways drag is
|
||||
// unambiguous from the first pixel.
|
||||
in-out property <int> held-row: -1;
|
||||
/// TRACES: FR-CAT-5
|
||||
// --- reordering a manual collection (FR-CAT-7) --------------------------
|
||||
//
|
||||
@@ -1285,15 +1321,29 @@ export component LibraryGrid inherits Rectangle {
|
||||
/// selected. The name arrives from the sheet below rather than being
|
||||
/// invented by Rust and corrected afterwards — see `naming`.
|
||||
callback collection-from-selection(string);
|
||||
/// The drag payload: the selected image ids, wrapped by Rust. Called when a
|
||||
/// drag starts, so it always reflects the selection as it is at that moment.
|
||||
/// **What arms the drag, not what it carries.**
|
||||
///
|
||||
/// `DragArea` refuses to begin a drag while its `data` is empty, and it
|
||||
/// asks on every pointer event — including the first one, long before
|
||||
/// anything is being dragged. A binding that calls a callback is evaluated
|
||||
/// once and cached, because there is nothing for Slint to invalidate it on,
|
||||
/// so whatever this answers the first time a finger touches a cell is what
|
||||
/// that cell's `DragArea` believes for the rest of its life.
|
||||
///
|
||||
/// It is therefore *not* the payload the drop reads, whatever it looks
|
||||
/// like: what travels is `dragging` in `collections_ui`, recorded by
|
||||
/// `drag-started` and read back by `dropped-on`. See the Rust side for why
|
||||
/// this has to answer something non-empty unconditionally.
|
||||
pure callback drag-payload() -> data-transfer;
|
||||
/// What travels under the cursor: the dragged thumbnail, or a fanned stack
|
||||
/// of them where several are being carried. Composited in Rust, because
|
||||
/// Slint accepts one bitmap here and cannot draw a pile of images into it.
|
||||
in property <image> drag-image;
|
||||
/// A drag began on this cell. Lets Rust promote an unselected cell to the
|
||||
/// selection before the payload is read.
|
||||
/// A drag began on this cell.
|
||||
///
|
||||
/// This, not `drag-payload`, is where what the drag carries is decided: it
|
||||
/// fires with the selection as it stands at the moment the drag starts, and
|
||||
/// lets Rust promote an unselected cell into the selection first.
|
||||
callback drag-started(int);
|
||||
/// The drag ended — dropped or cancelled. Clears the transient UI state.
|
||||
callback drag-finished();
|
||||
@@ -2516,11 +2566,27 @@ export component LibraryGrid inherits Rectangle {
|
||||
|
||||
// --- the grid -----------------------------------------------------
|
||||
//
|
||||
// `interactive` stays true: `DragArea` and `Flickable` arbitrate
|
||||
// properly, so dragging a cell drags the cell and dragging the
|
||||
// background still flicks the grid. (This is the part a hand-rolled
|
||||
// TouchArea gesture could not do — see the drag comments above.)
|
||||
// **Scrolls until a photograph has been picked up, and not after.**
|
||||
//
|
||||
// `DragArea` and `Flickable` do arbitrate, but not evenly: the
|
||||
// Flickable claims any press that travels more than eight pixels
|
||||
// along its own axis within half a second of landing, and it holds
|
||||
// that claim until the finger lifts. That is right for the ordinary
|
||||
// case — a finger that moves is almost always scrolling — and it is
|
||||
// why dragging the background still flicks the grid.
|
||||
//
|
||||
// It is wrong once the user has said otherwise. `held-row` is that
|
||||
// saying: a press that has stayed put long enough to be a pick-up,
|
||||
// marked on the cell so the user can see it. From there this stops
|
||||
// being interactive and the drag has nothing left to lose to.
|
||||
//
|
||||
// Today the hold outlasts the Flickable's window anyway, so this
|
||||
// mostly makes an accident into a guarantee — the arbitration stops
|
||||
// depending on two constants in different crates staying in the
|
||||
// order they happen to be in. The wheel is unaffected: `interactive`
|
||||
// does not gate it.
|
||||
if root.total > 0: grid-scroll := Flickable {
|
||||
interactive: root.held-row < 0;
|
||||
// Ctrl+wheel resizes the cells; a plain wheel is declined and
|
||||
// falls through to the Flickable's own scrolling. Two jobs on
|
||||
// one gesture, distinguished by the modifier — the convention
|
||||
@@ -2740,6 +2806,7 @@ export component LibraryGrid inherits Rectangle {
|
||||
// is what a join table means, and it is why the modifier-free
|
||||
// gesture must not be `move`.
|
||||
allow-copy: true;
|
||||
// Not the payload — the arming. See `drag-payload`.
|
||||
data: root.drag-payload();
|
||||
// What travels under the cursor is the photograph itself — and
|
||||
// where several are being dragged, a stack of them. Composited
|
||||
@@ -2815,7 +2882,19 @@ export component LibraryGrid inherits Rectangle {
|
||||
// photograph, which is exactly the wrong feedback for a
|
||||
// gesture whose whole job is to say "this one". One
|
||||
// treatment, drawn inside, in one place.
|
||||
border-width: cell-touch.has-hover ? 1px : 0px;
|
||||
//
|
||||
// **Not drawn at all once there has been a finger.** A
|
||||
// hand has no hover to give, so on a tablet this ring can
|
||||
// only ever be wrong — and it is wrong in the worst way,
|
||||
// because it is the selection ring's own colour a pixel
|
||||
// thinner. `has-hover` is not reliably cleared on touch:
|
||||
// a release normally brings an `Exit` with it, but the one
|
||||
// that ends a pinch does not, so every pinch to resize the
|
||||
// thumbnails left a ring around whichever cell a finger
|
||||
// happened to have started on. The grid then showed boxes
|
||||
// around photographs that were not selected, with no way
|
||||
// to tell them from ones that were. See `touched`.
|
||||
border-width: cell-touch.has-hover && !root.touched ? 1px : 0px;
|
||||
border-color: Theme.selected-ring;
|
||||
clip: true;
|
||||
|
||||
@@ -3055,6 +3134,8 @@ export component LibraryGrid inherits Rectangle {
|
||||
root.cell-clicked(i);
|
||||
}
|
||||
self.click-pending = false;
|
||||
// Down again, whether or not it was ever up.
|
||||
root.held-row = -1;
|
||||
root.cell-press-ended();
|
||||
}
|
||||
// `cancel` is the important ending: the Flickable
|
||||
@@ -3063,6 +3144,12 @@ export component LibraryGrid inherits Rectangle {
|
||||
// would come to rest as a long press and select it.
|
||||
if (ev.kind == PointerEventKind.cancel) {
|
||||
self.click-pending = false;
|
||||
// Including the cancel a starting drag sends:
|
||||
// by then the `DragArea` has the gesture and
|
||||
// `lifted` is the mark that matters, so there
|
||||
// is no scroll left to lose and nothing to
|
||||
// keep the ring open for.
|
||||
root.held-row = -1;
|
||||
root.cell-press-ended();
|
||||
}
|
||||
}
|
||||
@@ -3236,6 +3323,51 @@ export component LibraryGrid inherits Rectangle {
|
||||
border-radius: 1.5px;
|
||||
}
|
||||
}
|
||||
|
||||
// **The photograph is in your hand now.**
|
||||
//
|
||||
// A ring that opens outward around the cell a press has held
|
||||
// long enough to pick up (see `held-row`), so the wait the
|
||||
// gesture needs has something to end in. Without it the user
|
||||
// is holding a finger on glass with no way to know whether
|
||||
// anything has happened — which is what made dragging feel
|
||||
// like a coin toss.
|
||||
//
|
||||
// **Drawn after the cells, not on one.** Cells are one `for`,
|
||||
// and z-order inside a `for` is the loop order — a cell that
|
||||
// grew past its bounds would stand over its left and top
|
||||
// neighbours and be cut off by its right and bottom ones,
|
||||
// which reads as a rendering fault rather than as a lift. One
|
||||
// element after the loop is above every cell by construction,
|
||||
// and there is only ever one photograph in the hand.
|
||||
//
|
||||
// `visible` rather than `if`, so it has somewhere to animate
|
||||
// *from*: an `if` builds the ring at its final size and it
|
||||
// would appear rather than open.
|
||||
//
|
||||
// `Theme.active` and 3px, deliberately unlike the selection
|
||||
// ring inside the cell — two marks that meant different things
|
||||
// in one colour is the mistake the hover ring made.
|
||||
Rectangle {
|
||||
property <length> pitch: root.cell-size + Theme.gap;
|
||||
property <length> reach: root.held-row >= 0 ? 5px : 0px;
|
||||
|
||||
visible: root.held-row >= 0;
|
||||
x: Theme.gap
|
||||
+ mod(root.held-row + root.offset, root.columns) * self.pitch
|
||||
- self.reach;
|
||||
y: Theme.gap
|
||||
+ floor((root.held-row + root.offset) / root.columns) * self.pitch
|
||||
- self.reach;
|
||||
width: root.cell-size + 2 * self.reach;
|
||||
height: root.cell-size + 2 * self.reach;
|
||||
animate x, y, width, height { duration: 120ms; easing: ease-out; }
|
||||
|
||||
background: transparent;
|
||||
border-width: 3px;
|
||||
border-color: Theme.active;
|
||||
border-radius: Theme.radius;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// TRACES: FR-CULL-3
|
||||
// TRACES: FR-CULL-3 | NFR-A11Y-3
|
||||
// The focus-peaking switch, and the two choices it exposes.
|
||||
//
|
||||
// **An instrument, not an operation**, exactly as the histogram above it is:
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// The question asked when the catalog turns out to be damaged.
|
||||
//
|
||||
// # Why this is a modal, when almost nothing else here is
|
||||
//
|
||||
// The house rule in this interface is to put the consequence in the button's
|
||||
// label rather than to raise a dialogue — "Export 40", "Empty trash · 128" —
|
||||
// and a genuine modal is kept for the two cases where the answer commits
|
||||
// gigabytes. This is the third case, and it earns it for a different reason:
|
||||
// there is nothing behind it to interact with. The grid cannot be drawn, the
|
||||
// scan must not run (it would write into the damage), and every control in the
|
||||
// window is about a library that cannot be read. A banner over an empty grid
|
||||
// would be a question the user could scroll away from and then wonder why
|
||||
// nothing worked.
|
||||
//
|
||||
// # Why the backdrop does not dismiss it
|
||||
//
|
||||
// Every other overlay here closes on a tap outside, and this one deliberately
|
||||
// does not. A stray tap that loses the two offers leaves the application in a
|
||||
// state with no way forward and no obvious way back to the question. There is
|
||||
// a "Leave it for now" button instead, which says what it does.
|
||||
//
|
||||
// # Why the destructive answer is not the primary one
|
||||
//
|
||||
// A restore keeps the user's collections; a rebuild cannot, because a manual
|
||||
// collection is a set of images the user assembled by hand and nothing in the
|
||||
// filesystem records it (docs/catalog.md §8.1). So the two answers are not
|
||||
// interchangeable, the difference is stated in the button rather than in a
|
||||
// second dialogue after it, and the rebuild is the plain button even when it
|
||||
// is the only one available.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Button } from "widgets.slint";
|
||||
|
||||
export component RecoveryPrompt inherits Rectangle {
|
||||
/// What went wrong, in the user's terms. Empty closes the prompt — one
|
||||
/// source for "is this open", rather than a bool that can disagree with
|
||||
/// the words beside it.
|
||||
in property <string> title;
|
||||
/// What is safe and what is not, which is the part that determines whether
|
||||
/// the next minute is frightening.
|
||||
in property <string> detail;
|
||||
/// What SQLite actually said, kept because a bug report needs it and
|
||||
/// because a diagnosis the user can read is worth more than a reassurance
|
||||
/// they cannot check.
|
||||
in property <string> diagnosis;
|
||||
/// The restore offer, naming the backup's date. Empty when there is no
|
||||
/// backup to restore from, which is the case a fresh install is in.
|
||||
in property <string> restore-label;
|
||||
in property <bool> can-restore: false;
|
||||
/// The rebuild offer, naming what it costs — a full rescan, and the
|
||||
/// collections it cannot bring back.
|
||||
in property <string> rebuild-label;
|
||||
/// Set while a restore or rebuild is running, so neither can be started
|
||||
/// twice against the same file.
|
||||
in property <bool> busy: false;
|
||||
|
||||
callback restore();
|
||||
callback rebuild();
|
||||
callback dismiss();
|
||||
|
||||
visible: root.title != "";
|
||||
background: #000000E0;
|
||||
|
||||
// Swallows everything that misses the card, and answers nothing. See the
|
||||
// header: losing this by a stray tap leaves nowhere to go.
|
||||
TouchArea { }
|
||||
|
||||
Rectangle {
|
||||
width: min(460px, parent.width - 2 * Theme.gap-lg);
|
||||
height: min(card.preferred-height, parent.height - 2 * Theme.gap-lg);
|
||||
x: (parent.width - self.width) / 2;
|
||||
y: (parent.height - self.height) / 2;
|
||||
background: Theme.surface;
|
||||
border-radius: Theme.radius;
|
||||
border-width: 1px;
|
||||
border-color: Theme.rule;
|
||||
|
||||
TouchArea { }
|
||||
|
||||
card := VerticalLayout {
|
||||
padding: Theme.gap-lg;
|
||||
spacing: Theme.gap;
|
||||
|
||||
Text {
|
||||
text: "Recover library";
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
font-weight: 700;
|
||||
letter-spacing: 1.2px;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.title;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text-lg;
|
||||
font-weight: 600;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Text {
|
||||
text: root.detail;
|
||||
color: Theme.ink-dim;
|
||||
font-size: Theme.text;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
// Wrapped rather than elided: this is the one line a bug report
|
||||
// needs verbatim, and a truncated SQLite message is no message.
|
||||
Text {
|
||||
text: root.diagnosis;
|
||||
color: Theme.ink-faint;
|
||||
font-size: Theme.text-sm;
|
||||
wrap: word-wrap;
|
||||
}
|
||||
|
||||
Rectangle { height: 1px; background: Theme.rule; }
|
||||
|
||||
// Stacked, not a row: each label carries what its answer costs —
|
||||
// a date, a count of photographs — and three of those side by side
|
||||
// elide away exactly the part that lets the user choose.
|
||||
if root.can-restore: Button {
|
||||
text: root.busy ? "Working…" : root.restore-label;
|
||||
primary: true;
|
||||
enabled: !root.busy;
|
||||
clicked => { root.restore(); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: root.busy ? "Working…" : root.rebuild-label;
|
||||
// Primary only when it is the only answer there is. A rebuild
|
||||
// discards collections, so it does not get the emphasis while
|
||||
// a restore that keeps them is on the table.
|
||||
primary: !root.can-restore;
|
||||
enabled: !root.busy;
|
||||
clicked => { root.rebuild(); }
|
||||
}
|
||||
|
||||
Button {
|
||||
text: "Leave it for now";
|
||||
enabled: !root.busy;
|
||||
clicked => { root.dismiss(); }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,26 @@ export component ToolRail inherits Rectangle {
|
||||
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
||||
}
|
||||
|
||||
// **The word below is drawn; this is what makes it a control.**
|
||||
// Without these the rail reaches a screen reader as four pieces of
|
||||
// static text — the labels get through, because a `Text` announces
|
||||
// itself, and nothing says any of them can be pressed. That is the
|
||||
// develop view's primary navigation reduced to a caption.
|
||||
//
|
||||
// `checkable` unconditionally, unlike `Button`'s: a rail entry is
|
||||
// always a held-or-not state, so an unheld one should say "not
|
||||
// pressed" rather than pass for an ordinary button. The action
|
||||
// repeats the click handler rather than calling it, because a
|
||||
// `TouchArea`'s `clicked` is raised by the pointer and cannot be
|
||||
// raised from here.
|
||||
accessible-role: button;
|
||||
accessible-label: tool.label;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: entry.on;
|
||||
accessible-action-default => {
|
||||
root.picked(entry.on ? ViewMode.photo : tool.mode);
|
||||
}
|
||||
|
||||
// The lit tile, and the only marker there is. Inset from the
|
||||
// rail's edges so the run of four reads as four things rather than
|
||||
// as one striped column.
|
||||
|
||||
@@ -13,6 +13,21 @@
|
||||
// `surface` is; only this file says what a *panel heading* is — and until it
|
||||
// did, four screens each re-derived one, which is exactly how a single accent
|
||||
// colour reached forty call sites with no single place to change it.
|
||||
//
|
||||
// **The same argument decides where accessibility lives** (NFR-A11Y-2). What
|
||||
// AT-SPI and TalkBack are handed — a role, a name, a value, and an action they
|
||||
// can invoke — is a property of *what a control is*, not of where it happens
|
||||
// to be used, so it is declared once here and at the call site only where the
|
||||
// call site knows something the component cannot. A button annotated in forty
|
||||
// places is a button unnamed in thirty-nine of them, which is the failure this
|
||||
// file was written to stop, one layer down.
|
||||
//
|
||||
// Two Slint rules shape what that can look like. `accessible-role` must be a
|
||||
// *constant* — a ternary over a runtime property is a compile error — so a
|
||||
// component that would need two roles is two components. And every other
|
||||
// `accessible-*` property is rejected unless a role is set beside it or on the
|
||||
// element it inherits from; that inheritance is what lets a call site add
|
||||
// `accessible-checkable` to a `Button` whose role was set here.
|
||||
|
||||
import { Theme } from "theme.slint";
|
||||
import { Icon } from "icons.slint";
|
||||
@@ -42,10 +57,31 @@ export component Button inherits Rectangle {
|
||||
/// Sustained state — a toggle that is currently on, not a press. The same
|
||||
/// meaning [`IconButton`] gives it, so a labelled toggle and an icon
|
||||
/// toggle read alike.
|
||||
///
|
||||
/// **Deliberately not exposed to assistive technology from here.** Only
|
||||
/// the call site knows whether a button that is currently *not* active is
|
||||
/// a toggle that is off or an ordinary button that has no such state, and
|
||||
/// announcing every button in the application as an unpressed toggle is
|
||||
/// worse than announcing none of them. A call site that means a toggle
|
||||
/// says so — `accessible-checkable: true; accessible-checked: <state>;` —
|
||||
/// which Slint permits because the role below is inherited.
|
||||
in property <bool> active: false;
|
||||
|
||||
callback clicked();
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: root.text;
|
||||
accessible-enabled: root.enabled;
|
||||
// The action a screen reader invokes, and it goes around the TouchArea
|
||||
// rather than through it — so the `enabled` gate the TouchArea applies to
|
||||
// a pointer has to be applied again here, or a disabled button would be
|
||||
// pressable by exactly the users who cannot see that it is greyed out.
|
||||
accessible-action-default => {
|
||||
if (root.enabled) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
|
||||
height: Theme.control-height;
|
||||
// A minimum rather than a fixed width: callers that set `width` or hand
|
||||
// this to a stretching layout still win, and a long label is not clipped.
|
||||
@@ -108,12 +144,40 @@ export component Button inherits Rectangle {
|
||||
export component IconButton inherits Rectangle {
|
||||
/// A name from the [`Icon`] vocabulary.
|
||||
in property <string> icon;
|
||||
/// What the drawing means, in words.
|
||||
///
|
||||
/// A `Button` gets its accessible name for nothing, out of the text it was
|
||||
/// already drawing. This control draws no text at all, so the name has to
|
||||
/// be given — and an icon button without one is not a degraded experience
|
||||
/// for a screen-reader user, it is an unusable one.
|
||||
///
|
||||
/// Left empty it falls back to the icon's own name, which is a bad label
|
||||
/// ("chevron-right") and still a better answer than silence. That is the
|
||||
/// bargain `labels::resolve` already strikes for a key nobody has
|
||||
/// catalogued, and it is struck here for the same reason: a control added
|
||||
/// today should be reachable before someone has written its word.
|
||||
in property <string> label;
|
||||
in property <bool> enabled: true;
|
||||
/// Sustained state — a toggle that is currently on, not a press.
|
||||
///
|
||||
/// Not announced from here, for the reason [`Button`]'s copy of this
|
||||
/// property gives: the component cannot tell an off toggle from a button
|
||||
/// with no state, so the call site that means a toggle sets
|
||||
/// `accessible-checkable: true` and `accessible-checked` beside its
|
||||
/// `active`.
|
||||
in property <bool> active: false;
|
||||
|
||||
callback clicked();
|
||||
|
||||
accessible-role: button;
|
||||
accessible-label: root.label != "" ? root.label : root.icon;
|
||||
accessible-enabled: root.enabled;
|
||||
accessible-action-default => {
|
||||
if (root.enabled) {
|
||||
root.clicked();
|
||||
}
|
||||
}
|
||||
|
||||
width: Theme.control-height;
|
||||
height: Theme.control-height;
|
||||
horizontal-stretch: 0;
|
||||
@@ -178,6 +242,21 @@ export component FilterChip inherits Rectangle {
|
||||
|
||||
callback clicked();
|
||||
|
||||
// Unlike [`Button`], this one *can* say it is a toggle without asking the
|
||||
// call site, because being one is the whole of what distinguishes it —
|
||||
// "it is *state*, not an action", two paragraphs up. So `checkable` is
|
||||
// unconditional and an inactive chip announces as unpressed rather than
|
||||
// as an ordinary button.
|
||||
//
|
||||
// The count is not folded into the name. It is drawn as a `Text`, which
|
||||
// Slint already exposes as its own node, so a reader reaches it by moving
|
||||
// one step further rather than by hearing a bare number glued to a word.
|
||||
accessible-role: button;
|
||||
accessible-label: root.label;
|
||||
accessible-checkable: true;
|
||||
accessible-checked: root.active;
|
||||
accessible-action-default => { root.clicked(); }
|
||||
|
||||
height: Theme.control-height - 4px;
|
||||
// A floor on a content-sized chip, expressed as one property: Slint rejects
|
||||
// `width` and `min-width` together, and the floor is what keeps a chip
|
||||
@@ -304,6 +383,15 @@ export component Section inherits Rectangle {
|
||||
/// Whether this section's contents can be reset at all.
|
||||
in property <bool> has-reset: true;
|
||||
|
||||
accessible-role: groupbox;
|
||||
accessible-label: root.title;
|
||||
accessible-expandable: true;
|
||||
accessible-expanded: root.expanded;
|
||||
accessible-action-expand => {
|
||||
root.expanded = !root.expanded;
|
||||
root.toggled(root.expanded);
|
||||
}
|
||||
|
||||
background: transparent;
|
||||
// Own height comes from the layout below, so a collapsed section shrinks
|
||||
// to its header.
|
||||
@@ -372,6 +460,22 @@ export component Section inherits Rectangle {
|
||||
Rectangle {
|
||||
width: 28px;
|
||||
|
||||
// The reset is drawn only under the pointer, which
|
||||
// ui-navigation.md D-N2 rules out as the *only* route to
|
||||
// a control. Announcing it whenever it is live gives a
|
||||
// screen reader the route the ink withholds — so this is
|
||||
// not a translation of the visual affordance so much as
|
||||
// the honest version of it, and the gate is `has-reset &&
|
||||
// modified` rather than the hover the `Text` below adds.
|
||||
accessible-role: button;
|
||||
accessible-label: "Reset";
|
||||
accessible-enabled: root.has-reset && root.modified;
|
||||
accessible-action-default => {
|
||||
if (root.has-reset && root.modified) {
|
||||
root.op-reset();
|
||||
}
|
||||
}
|
||||
|
||||
reset-touch := TouchArea {
|
||||
// Sits after `header-touch` in the tree, so it takes
|
||||
// the press first and the section does not toggle out
|
||||
@@ -598,6 +702,20 @@ export component Panel inherits Rectangle {
|
||||
// token is for.
|
||||
export component Field inherits Rectangle {
|
||||
in-out property <string> text;
|
||||
/// What this entry is for, in words.
|
||||
///
|
||||
/// The visible caption belongs to whatever row wraps the field — `TextRow`
|
||||
/// draws one above, the launch screen draws one beside — and none of those
|
||||
/// is a thing Slint associates with the entry on its own. So the name is
|
||||
/// carried a second time, here, where the accessibility tree can attach it
|
||||
/// to the control the user is actually typing into.
|
||||
///
|
||||
/// That second copy is not redundant even where the caption renders. It is
|
||||
/// the *only* copy where the caption does not: `TextRow`'s label draws
|
||||
/// behind its own field on the settings page and has done since 0.9.0, so
|
||||
/// a sighted user reading that page today has less to go on than a screen
|
||||
/// reader does.
|
||||
in property <string> label;
|
||||
in property <string> placeholder;
|
||||
/// Masks the entry, for a credential that should not be readable over the
|
||||
/// user's shoulder. The placeholder still shows while the field is empty.
|
||||
@@ -655,6 +773,13 @@ export component Field inherits Rectangle {
|
||||
input := TextInput {
|
||||
text <=> root.text;
|
||||
edited => { root.edited(self.text); }
|
||||
// Slint gives a TextInput its role, its value, its enabled state and
|
||||
// its set-value action for free; the name and the placeholder are the
|
||||
// two it cannot guess. They go on the entry rather than on the box
|
||||
// around it so there is one node in the tree and not a nameless
|
||||
// rectangle wrapping a nameless input.
|
||||
accessible-label: root.label;
|
||||
accessible-placeholder-text: root.placeholder;
|
||||
color: Theme.ink;
|
||||
font-size: Theme.text;
|
||||
vertical-alignment: center;
|
||||
@@ -677,6 +802,12 @@ export component Field inherits Rectangle {
|
||||
x: Theme.gap;
|
||||
height: 100%;
|
||||
visible: input.text == "";
|
||||
// Drawn text, not content. The same words already reach the tree as
|
||||
// the entry's `accessible-placeholder-text`, where a reader can
|
||||
// announce them as a prompt rather than as a value the field holds —
|
||||
// which is what a second text node beside an empty entry would look
|
||||
// like. `lineedit-base.slint` in Slint's own widgets does exactly this.
|
||||
accessible-role: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,6 +824,22 @@ export component Field inherits Rectangle {
|
||||
export component ProgressBar inherits Rectangle {
|
||||
in property <float> fraction: 0;
|
||||
in property <bool> indeterminate: false;
|
||||
/// What is progressing. A bar with no name announces as "progress
|
||||
/// indicator, 40%", which says how far along an unnamed something is.
|
||||
in property <string> label;
|
||||
|
||||
// An indeterminate bar reports no value at all rather than 0%. It has one
|
||||
// — the sweep — but it is not a position, and a reader that announced 0%
|
||||
// for a directory walk that is half done would be stating a falsehood in
|
||||
// the one place the interface was careful not to (see the two modes
|
||||
// above). Silence is the honest answer to "how far".
|
||||
accessible-role: progress-indicator;
|
||||
accessible-label: root.label;
|
||||
accessible-value: root.indeterminate
|
||||
? ""
|
||||
: Math.round(clamp(root.fraction, 0, 1) * 100) + "%";
|
||||
accessible-value-minimum: 0;
|
||||
accessible-value-maximum: 100;
|
||||
|
||||
height: 3px;
|
||||
background: Theme.rule;
|
||||
|
||||
Reference in New Issue
Block a user