Fit the interface to the system bars, the finger and the back key

Four faults that only show on a device, and one that was hiding on the
desktop too.

The system bars. Target SDK 36 forces edge-to-edge, so the window spans
the display and the develop status strip was drawn underneath the clock
and the wifi icons. Slint already computes the inset from Android's
OnApplyWindowInsetsListener and exposes it as Window.safe-area-insets;
nothing read it. The four views now sit inside a shell placed within the
safe area. Every inset is zero on the desktop, so that layout does not
move.

Sliders under a finger. A Flickable steals any gesture that drifts more
than 8 logical pixels along its scrolling axis within half a second of
the press, and it steals it by cancelling the child. ParamSlider's axis
test correctly declined to claim vertical drags, but nothing told the
Flickable to stand down once a drag was claimed — so an adjustment would
start moving and then be taken away mid-motion. A mouse holds a
horizontal line closely enough to stay under 8px; a finger does not,
which is why these worked on the desktop and not on the tablet. The
claim now sets `interactive: false` for the rest of the gesture.

The tone curve had the same fault and worse: its points are dragged
vertically, which is the Flickable's own axis, so every drag was stolen
— on the desktop as well.

The back gesture. Nothing handled it, so back closed the application
from anywhere in it. Android delivers it as Key.Back to the focused item
and bubbles it up the ancestors, which is the second reason the shell
wraps the views rather than sitting beside them. The order is innermost
first: settings, then crop, then zoom, then develop to the grid, then a
collection scope. Answering false at the top of the stack leaves Android
to close the activity, as it does for every other application there.
Escape does the same on a keyboard.

back_step is a pure function over a flat NavState so the ordering can be
tested without a backend: which of two states is left first is the whole
of the feature, and it is the part that is easy to get subtly wrong when
spelled out in nested ifs over live properties.

Develop's canvas now takes focus on show. Without it the arrow keys did
nothing until the canvas was clicked, and Key.Back had no focus item to
bubble from.

Panels that close. The collections sidebar and the develop column are
collapsible from the grid header and the status strip. The layout class
now supplies only the default: a panel closed to see more of a
photograph stays closed while the window keeps its shape, and the choice
is dropped when the class changes, because rotating a tablet asks a
different question from the one answered in landscape. IMAGE became a
Section, being the only group in the column that could not be put away
and the one whose content is read first and needed least.

Pinch to zoom on the develop canvas, anchored on the midpoint between
the fingers (FR-UI-4). The wheel is the desktop's answer and there is no
wheel on a tablet.

The develop status strip was 28px against the 44px headers on the
library and settings pages either side of it — the one screen where a
way out has to be found was the one drawn smallest. All three now agree.

Verified with cargo test -p dr-ui (192 passing, 6 new), clippy at
-D warnings, and an arm64-v8a release build packaged to an APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-16 11:59:31 +02:00
co-authored by Claude Opus 5
parent 4d78041d1d
commit 94a2686dcb
7 changed files with 1231 additions and 699 deletions
+27 -27
View File
@@ -9,17 +9,17 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n
| Metric | Value |
|---|---|
| Source files scanned | 92 |
| TRACES tags found | 130 |
| Source files scanned | 93 |
| TRACES tags found | 137 |
| Requirements defined | 149 |
| Requirements covered | 69 |
| **Coverage** | **46.3%** (69/149) |
| Requirements covered | 70 |
| **Coverage** | **47.0%** (70/149) |
### By type
| Type | Covered | Defined |
|---|---|---|
| FR | 53 | 95 |
| FR | 54 | 95 |
| NFR | 14 | 48 |
| R | 2 | 6 |
@@ -33,21 +33,21 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
| ID | Tagged in |
|---|---|
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:181`](../core/dr-types/src/lib.rs#L181), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
| FR-CAT-11 | [`ui/dr-ui/src/library.rs:138`](../ui/dr-ui/src/library.rs#L138) |
| FR-CAT-1 | [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473), [`tools/traceability/src/lib.rs:505`](../tools/traceability/src/lib.rs#L505), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1) |
| FR-CAT-11 | [`ui/dr-ui/src/library.rs:150`](../ui/dr-ui/src/library.rs#L150) |
| FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108) |
| FR-CAT-1a | [`core/dr-types/src/lib.rs:43`](../core/dr-types/src/lib.rs#L43) |
| FR-CAT-1a | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) |
| FR-CAT-2 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1), [`tools/traceability/src/lib.rs:473`](../tools/traceability/src/lib.rs#L473) |
| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1), [`core/dr-sync/src/scan.rs:69`](../core/dr-sync/src/scan.rs#L69), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
| FR-CAT-4 | [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
| FR-CAT-5 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-decode/src/lib.rs:231`](../core/dr-decode/src/lib.rs#L231), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125) |
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:165`](../ui/dr-ui/src/library.rs#L165) |
| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/lib.rs:1`](../core/dr-catalog/src/lib.rs#L1), [`core/dr-catalog/src/query.rs:1`](../core/dr-catalog/src/query.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177) |
| FR-CAT-7 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:277`](../ui/dr-ui/src/library.rs#L277) |
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:100`](../core/dr-types/src/lib.rs#L100), [`ui/dr-ui/src/library.rs:126`](../ui/dr-ui/src/library.rs#L126), [`ui/dr-ui/src/library.rs:182`](../ui/dr-ui/src/library.rs#L182), [`ui/dr-ui/src/library.rs:2118`](../ui/dr-ui/src/library.rs#L2118), [`ui/dr-ui/src/library.rs:987`](../ui/dr-ui/src/library.rs#L987), [`ui/dr-ui/src/library_ui.rs:1243`](../ui/dr-ui/src/library_ui.rs#L1243), [`ui/dr-ui/src/library_ui.rs:1398`](../ui/dr-ui/src/library_ui.rs#L1398), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1683`](../ui/dr-ui/src/library_ui.rs#L1683), [`ui/dr-ui/src/library_ui.rs:1757`](../ui/dr-ui/src/library_ui.rs#L1757), [`ui/dr-ui/src/library_ui.rs:1867`](../ui/dr-ui/src/library_ui.rs#L1867), [`ui/dr-ui/src/library_ui.rs:2712`](../ui/dr-ui/src/library_ui.rs#L2712), [`ui/dr-ui/src/library_ui.rs:2740`](../ui/dr-ui/src/library_ui.rs#L2740), [`ui/dr-ui/src/library_ui.rs:281`](../ui/dr-ui/src/library_ui.rs#L281), [`ui/dr-ui/src/library_ui.rs:938`](../ui/dr-ui/src/library_ui.rs#L938), [`ui/dr-ui/src/library_ui.rs:980`](../ui/dr-ui/src/library_ui.rs#L980) |
| FR-CAT-8 | [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
| FR-CAT-9 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/scan.rs:1`](../core/dr-catalog/src/scan.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`ui/dr-ui/src/library.rs:136`](../ui/dr-ui/src/library.rs#L136), [`ui/dr-ui/src/library.rs:194`](../ui/dr-ui/src/library.rs#L194), [`ui/dr-ui/src/library.rs:2125`](../ui/dr-ui/src/library.rs#L2125), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1240`](../ui/dr-ui/src/library_ui.rs#L1240), [`ui/dr-ui/src/library_ui.rs:1391`](../ui/dr-ui/src/library_ui.rs#L1391), [`ui/dr-ui/src/library_ui.rs:165`](../ui/dr-ui/src/library_ui.rs#L165), [`ui/dr-ui/src/library_ui.rs:1682`](../ui/dr-ui/src/library_ui.rs#L1682), [`ui/dr-ui/src/library_ui.rs:1756`](../ui/dr-ui/src/library_ui.rs#L1756), [`ui/dr-ui/src/library_ui.rs:1870`](../ui/dr-ui/src/library_ui.rs#L1870), [`ui/dr-ui/src/library_ui.rs:2717`](../ui/dr-ui/src/library_ui.rs#L2717), [`ui/dr-ui/src/library_ui.rs:2745`](../ui/dr-ui/src/library_ui.rs#L2745), [`ui/dr-ui/src/library_ui.rs:283`](../ui/dr-ui/src/library_ui.rs#L283), [`ui/dr-ui/src/library_ui.rs:936`](../ui/dr-ui/src/library_ui.rs#L936), [`ui/dr-ui/src/library_ui.rs:978`](../ui/dr-ui/src/library_ui.rs#L978) |
| FR-CULL-1 | [`core/dr-decode/src/preview.rs:96`](../core/dr-decode/src/preview.rs#L96) |
| FR-CULL-2 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123) |
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:165`](../ui/dr-ui/src/library.rs#L165), [`ui/dr-ui/src/library.rs:277`](../ui/dr-ui/src/library.rs#L277) |
| FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:125`](../core/dr-pipeline/src/sidecar.rs#L125), [`ui/dr-ui/src/library.rs:177`](../ui/dr-ui/src/library.rs#L177), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
| FR-DEV-3 | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) |
| FR-DEV-3a | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:138`](../core/dr-pipeline/src/graph.rs#L138), [`core/dr-pipeline/src/graph.rs:15`](../core/dr-pipeline/src/graph.rs#L15), [`core/dr-pipeline/src/graph.rs:39`](../core/dr-pipeline/src/graph.rs#L39), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) |
| FR-DEV-3b | [`core/dr-pipeline/src/descriptor.rs:91`](../core/dr-pipeline/src/descriptor.rs#L91), [`core/dr-pipeline/src/graph.rs:39`](../core/dr-pipeline/src/graph.rs#L39), [`core/dr-pipeline/src/operation.rs:104`](../core/dr-pipeline/src/operation.rs#L104) |
@@ -55,7 +55,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
| FR-DEV-3d | [`core/dr-pipeline/src/framing.rs:174`](../core/dr-pipeline/src/framing.rs#L174) |
| FR-DEV-3e | [`core/dr-decode/src/lib.rs:442`](../core/dr-decode/src/lib.rs#L442), [`core/dr-decode/src/lib.rs:562`](../core/dr-decode/src/lib.rs#L562) |
| FR-DEV-4 | [`core/dr-gpu/src/lib.rs:123`](../core/dr-gpu/src/lib.rs#L123) |
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:43`](../ui/dr-ui/src/lib.rs#L43) |
| FR-DSP-1 | [`ui/dr-ui/src/lib.rs:44`](../ui/dr-ui/src/lib.rs#L44) |
| FR-EXP-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-2 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-3 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
@@ -64,28 +64,29 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
| FR-EXP-6 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-8 | [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1) |
| FR-EXP-9 | [`core/dr-decode/src/lib.rs:359`](../core/dr-decode/src/lib.rs#L359) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:95`](../core/dr-sync-nextcloud/src/session.rs#L95), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:95`](../core/dr-sync-nextcloud/src/session.rs#L95) |
| FR-NC-1 | [`core/dr-sync-nextcloud/src/auth.rs:132`](../core/dr-sync-nextcloud/src/auth.rs#L132), [`core/dr-sync-nextcloud/src/auth.rs:44`](../core/dr-sync-nextcloud/src/auth.rs#L44), [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`ui/dr-ui/src/launch.rs:256`](../ui/dr-ui/src/launch.rs#L256), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49), [`ui/dr-ui/src/launch_ui.rs:344`](../ui/dr-ui/src/launch_ui.rs#L344) |
| FR-NC-12 | [`core/dr-sync-nextcloud/src/lib.rs:34`](../core/dr-sync-nextcloud/src/lib.rs#L34), [`core/dr-sync-nextcloud/src/lib.rs:811`](../core/dr-sync-nextcloud/src/lib.rs#L811), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/lib.rs:38`](../core/dr-sync/src/lib.rs#L38), [`core/dr-sync/src/reachability.rs:1`](../core/dr-sync/src/reachability.rs#L1) |
| FR-NC-2 | [`core/dr-sync-nextcloud/src/session.rs:128`](../core/dr-sync-nextcloud/src/session.rs#L128), [`core/dr-sync-nextcloud/src/session.rs:34`](../core/dr-sync-nextcloud/src/session.rs#L34) |
| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:123`](../core/dr-decode/src/preview.rs#L123), [`core/dr-sync/src/capability.rs:41`](../core/dr-sync/src/capability.rs#L41), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`ui/dr-ui/src/library.rs:1`](../ui/dr-ui/src/library.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../ui/dr-ui/src/library_ui.rs#L1) |
| FR-NC-4 | [`core/dr-sync-nextcloud/src/propfind.rs:100`](../core/dr-sync-nextcloud/src/propfind.rs#L100), [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51), [`core/dr-sync/src/capability.rs:6`](../core/dr-sync/src/capability.rs#L6), [`core/dr-sync/src/lib.rs:155`](../core/dr-sync/src/lib.rs#L155), [`core/dr-sync/src/scan.rs:93`](../core/dr-sync/src/scan.rs#L93), [`ui/dr-ui/src/launch.rs:49`](../ui/dr-ui/src/launch.rs#L49) |
| FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../core/dr-sync-nextcloud/src/propfind.rs#L51) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:635`](../core/dr-catalog/src/schema.rs#L635), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1281`](../ui/dr-ui/src/lib.rs#L1281), [`ui/dr-ui/src/lib.rs:876`](../ui/dr-ui/src/lib.rs#L876), [`ui/dr-ui/src/library.rs:813`](../ui/dr-ui/src/library.rs#L813), [`ui/dr-ui/src/library.rs:826`](../ui/dr-ui/src/library.rs#L826), [`ui/dr-ui/src/library.rs:987`](../ui/dr-ui/src/library.rs#L987), [`ui/dr-ui/src/library_ui.rs:1043`](../ui/dr-ui/src/library_ui.rs#L1043), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:252`](../ui/dr-ui/src/library_ui.rs#L252), [`ui/dr-ui/src/library_ui.rs:2729`](../ui/dr-ui/src/library_ui.rs#L2729), [`ui/dr-ui/src/library_ui.rs:292`](../ui/dr-ui/src/library_ui.rs#L292), [`ui/dr-ui/src/library_ui.rs:307`](../ui/dr-ui/src/library_ui.rs#L307), [`ui/dr-ui/src/library_ui.rs:338`](../ui/dr-ui/src/library_ui.rs#L338), [`ui/dr-ui/src/library_ui.rs:701`](../ui/dr-ui/src/library_ui.rs#L701), [`ui/dr-ui/src/library_ui.rs:802`](../ui/dr-ui/src/library_ui.rs#L802), [`ui/dr-ui/src/library_ui.rs:905`](../ui/dr-ui/src/library_ui.rs#L905), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:783`](../ui/dr-ui/ui/library.slint#L783) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:100`](../core/dr-types/src/lib.rs#L100), [`core/dr-types/src/lib.rs:182`](../core/dr-types/src/lib.rs#L182) |
| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-catalog/src/schema.rs:631`](../core/dr-catalog/src/schema.rs#L631), [`core/dr-types/src/selector.rs:1`](../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/lib.rs:1315`](../ui/dr-ui/src/lib.rs#L1315), [`ui/dr-ui/src/lib.rs:875`](../ui/dr-ui/src/lib.rs#L875), [`ui/dr-ui/src/library.rs:813`](../ui/dr-ui/src/library.rs#L813), [`ui/dr-ui/src/library.rs:836`](../ui/dr-ui/src/library.rs#L836), [`ui/dr-ui/src/library.rs:994`](../ui/dr-ui/src/library.rs#L994), [`ui/dr-ui/src/library_ui.rs:1041`](../ui/dr-ui/src/library_ui.rs#L1041), [`ui/dr-ui/src/library_ui.rs:173`](../ui/dr-ui/src/library_ui.rs#L173), [`ui/dr-ui/src/library_ui.rs:184`](../ui/dr-ui/src/library_ui.rs#L184), [`ui/dr-ui/src/library_ui.rs:193`](../ui/dr-ui/src/library_ui.rs#L193), [`ui/dr-ui/src/library_ui.rs:242`](../ui/dr-ui/src/library_ui.rs#L242), [`ui/dr-ui/src/library_ui.rs:252`](../ui/dr-ui/src/library_ui.rs#L252), [`ui/dr-ui/src/library_ui.rs:2734`](../ui/dr-ui/src/library_ui.rs#L2734), [`ui/dr-ui/src/library_ui.rs:294`](../ui/dr-ui/src/library_ui.rs#L294), [`ui/dr-ui/src/library_ui.rs:309`](../ui/dr-ui/src/library_ui.rs#L309), [`ui/dr-ui/src/library_ui.rs:340`](../ui/dr-ui/src/library_ui.rs#L340), [`ui/dr-ui/src/library_ui.rs:699`](../ui/dr-ui/src/library_ui.rs#L699), [`ui/dr-ui/src/library_ui.rs:800`](../ui/dr-ui/src/library_ui.rs#L800), [`ui/dr-ui/src/library_ui.rs:903`](../ui/dr-ui/src/library_ui.rs#L903), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/ui/library.slint:799`](../ui/dr-ui/ui/library.slint#L799) |
| FR-NC-6c | [`core/dr-sync-nextcloud/src/desktop_client.rs:30`](../core/dr-sync-nextcloud/src/desktop_client.rs#L30), [`core/dr-types/src/lib.rs:104`](../core/dr-types/src/lib.rs#L104), [`core/dr-types/src/lib.rs:186`](../core/dr-types/src/lib.rs#L186) |
| FR-NC-7 | [`core/dr-sync-nextcloud/src/lib.rs:95`](../core/dr-sync-nextcloud/src/lib.rs#L95), [`ui/dr-ui/src/derived_sync.rs:1`](../ui/dr-ui/src/derived_sync.rs#L1) |
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:277`](../ui/dr-ui/src/library.rs#L277) |
| FR-NC-8 | [`core/dr-pipeline/src/sidecar.rs:108`](../core/dr-pipeline/src/sidecar.rs#L108), [`core/dr-pipeline/src/sidecar.rs:89`](../core/dr-pipeline/src/sidecar.rs#L89), [`ui/dr-ui/src/library.rs:289`](../ui/dr-ui/src/library.rs#L289) |
| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/sync.rs:1`](../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:212`](../core/dr-pipeline/src/sidecar.rs#L212) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:43`](../core/dr-types/src/lib.rs#L43) |
| FR-PLAT-AND-1 | [`core/dr-types/src/lib.rs:47`](../core/dr-types/src/lib.rs#L47) |
| FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| FR-PLAT-LIN-1 | [`core/dr-types/src/settings.rs:1`](../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/settings_store.rs:1`](../ui/dr-ui/src/settings_store.rs#L1) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:189`](../core/dr-decode/src/lib.rs#L189), [`core/dr-types/src/lib.rs:110`](../core/dr-types/src/lib.rs#L110), [`core/dr-types/src/lib.rs:181`](../core/dr-types/src/lib.rs#L181) |
| FR-RAW-1 | [`core/dr-decode/src/lib.rs:189`](../core/dr-decode/src/lib.rs#L189), [`core/dr-types/src/lib.rs:114`](../core/dr-types/src/lib.rs#L114), [`core/dr-types/src/lib.rs:185`](../core/dr-types/src/lib.rs#L185) |
| FR-RAW-3 | [`core/dr-decode/src/lib.rs:359`](../core/dr-decode/src/lib.rs#L359), [`core/dr-decode/src/lib.rs:85`](../core/dr-decode/src/lib.rs#L85) |
| FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| FR-RAW-5 | [`core/dr-decode/src/lib.rs:113`](../core/dr-decode/src/lib.rs#L113) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:51`](../ui/dr-ui/src/lib.rs#L51) |
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:51`](../ui/dr-ui/src/lib.rs#L51) |
| FR-UI-1 | [`ui/dr-ui/src/lib.rs:1397`](../ui/dr-ui/src/lib.rs#L1397), [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) |
| FR-UI-2 | [`ui/dr-ui/src/lib.rs:52`](../ui/dr-ui/src/lib.rs#L52) |
| FR-UI-3 | [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| FR-UI-4 | [`ui/dr-ui/ui/app.slint:966`](../ui/dr-ui/ui/app.slint#L966) |
| FR-UI-5 | [`ui/dr-ui/src/collections_ui.rs:1`](../ui/dr-ui/src/collections_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1431`](../ui/dr-ui/src/lib.rs#L1431), [`ui/dr-ui/ui/collections.slint:4`](../ui/dr-ui/ui/collections.slint#L4) |
| NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../core/dr-catalog/src/jobs.rs#L1) |
| NFR-ARCH-4 | [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-thumbs/src/error.rs:1`](../core/dr-thumbs/src/error.rs#L1) |
| NFR-OPS-1 | [`tools/traceability/src/lib.rs:266`](../tools/traceability/src/lib.rs#L266) |
@@ -97,7 +98,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
| NFR-R5 | [`core/dr-catalog/src/collections.rs:1`](../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/error.rs:1`](../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../core/dr-catalog/src/schema.rs#L1) |
| NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-R8 | [`core/dr-gpu/src/error.rs:1`](../core/dr-gpu/src/error.rs#L1) |
| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:43`](../ui/dr-ui/src/lib.rs#L43) |
| NFR-RES-1 | [`ui/dr-ui/src/lib.rs:44`](../ui/dr-ui/src/lib.rs#L44) |
| NFR-RES-4 | [`core/dr-catalog/src/cache.rs:1`](../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/schema.rs:230`](../core/dr-catalog/src/schema.rs#L230), [`core/dr-thumbs/src/codec.rs:1`](../core/dr-thumbs/src/codec.rs#L1), [`core/dr-thumbs/src/lib.rs:1`](../core/dr-thumbs/src/lib.rs#L1), [`core/dr-thumbs/src/lib.rs:341`](../core/dr-thumbs/src/lib.rs#L341) |
| NFR-SEC-1 | [`core/dr-decode/src/error.rs:1`](../core/dr-decode/src/error.rs#L1) |
| R1 | [`tools/traceability/src/lib.rs:489`](../tools/traceability/src/lib.rs#L489), [`tools/traceability/src/lib.rs:493`](../tools/traceability/src/lib.rs#L493) |
@@ -105,7 +106,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
## Not yet tagged
80 of 149 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
79 of 149 requirements have no implementation tag. Expected while the codebase is young; each should gain one as it is built.
<details><summary>Show untagged requirements</summary>
@@ -148,7 +149,6 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc
- FR-PLAT-LIN-2
- FR-PLAT-LIN-3
- FR-RAW-2
- FR-UI-4
- FR-UI-6
- FR-UI-7
- NFR-A11Y-1
+296 -25
View File
@@ -16,16 +16,16 @@
mod collections_ui;
mod derived_sync;
mod net_runtime;
mod develop;
mod labels;
mod library;
mod library_ui;
#[cfg(live_style)]
mod live_style;
mod net_runtime;
mod settings_store;
mod settings_ui;
mod trash;
#[cfg(live_style)]
mod live_style;
use std::cell::RefCell;
use std::path::{Path, PathBuf};
@@ -63,6 +63,13 @@ const EXPANDED_MIN_WIDTH: f32 = 820.0;
/// point where a photographer would notice waiting for the sharp frame.
const SETTLE_DELAY: std::time::Duration = std::time::Duration::from_millis(120);
/// Re-render the current session into the canvas; `true` asks for a draft.
///
/// Shared rather than passed by reference because most of the callbacks in
/// `run` need it and they each outlive the call that built them, so every one
/// holds its own handle.
type Render = Rc<dyn Fn(&AppWindow, bool)>;
/// Everything loaded for the currently displayed image.
struct Loaded {
/// A develop session. `None` only where the file could not be opened for
@@ -421,8 +428,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// Set once `show` exists; see where the library grid is wired below.
#[allow(clippy::type_complexity)]
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> =
Rc::new(RefCell::new(None));
let open_from_library: Rc<RefCell<Option<Rc<dyn Fn(String)>>>> = Rc::new(RefCell::new(None));
// Before anything binds to a token: the compiled palette is already in
// place, so this only overwrites what style.yaml currently says.
@@ -442,10 +448,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// a folder goes straight to their images (FR-NC-1).
{
let controller = launch_ui::LaunchController::new();
let startup = controller
.model
.borrow()
.startup_action(!paths.is_empty());
let startup = controller.model.borrow().startup_action(!paths.is_empty());
window.set_show_launch(startup == launch::Startup::ShowLaunchScreen);
let library = library.clone();
@@ -457,18 +460,13 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// A click before then is a no-op rather than a panic — the grid cannot
// be reached until the window is running, by which point it is set.
let open_from_library = open_from_library.clone();
library_ui::wire(
&window,
library.clone(),
collections.clone(),
move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
},
);
library_ui::wire(&window, library.clone(), collections.clone(), move |path| {
let Some(f) = open_from_library.borrow().clone() else {
log::warn!("open requested before the viewer was ready: {path}");
return;
};
f(path);
});
// The collections sidebar shares the library's catalog handle rather
// than opening its own: one SQLite connection, so an edit here is
@@ -619,7 +617,7 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
//
// Called on every slider change, so it must do no more than run the
// adjust pass — the demosaic is not repeated.
let render_now: Rc<dyn Fn(&AppWindow, bool)> = {
let render_now: Render = {
let session = session.clone();
let viewport = viewport.clone();
Rc::new(move |window: &AppWindow, draft: bool| {
@@ -1258,17 +1256,52 @@ pub fn run(paths: Vec<PathBuf>) -> Result<()> {
// FR-UI-1: layout class from window width. Computed here rather than in
// Slint because a property that both derives from and feeds the layout is
// a binding loop.
let panels = std::rc::Rc::new(PanelChoices::default());
{
let weak = window.as_weak();
let panels = panels.clone();
window.on_window_resized(move |width| {
let Some(window) = weak.upgrade() else { return };
apply_layout_class(&window, width);
apply_layout_class(&window, width, &panels);
});
}
{
let size = window.window().size();
let scale = window.window().scale_factor().max(0.01);
apply_layout_class(&window, size.width as f32 / scale);
apply_layout_class(&window, size.width as f32 / scale, &panels);
}
// FR-UI-2: the two collapsible columns, opened and closed by hand.
{
let weak = window.as_weak();
let panels = panels.clone();
window.on_toggle_panel(move || {
let Some(w) = weak.upgrade() else { return };
let open = !w.get_panel_visible();
panels.panel.set(Some(open));
w.set_panel_visible(open);
});
}
{
let weak = window.as_weak();
let panels = panels.clone();
window.on_toggle_collections(move || {
let Some(w) = weak.upgrade() else { return };
let open = !w.get_collections_visible();
panels.collections.set(Some(open));
w.set_collections_visible(open);
});
}
// Android's back gesture, and Escape on a keyboard.
{
let weak = window.as_weak();
window.on_back_requested(move || {
let Some(w) = weak.upgrade() else {
return false;
};
back_one_step(&w)
});
}
if !entries.borrow().is_empty() {
@@ -1361,16 +1394,254 @@ fn describe_exposure(m: &Metadata) -> String {
parts.join(" ")
}
fn apply_layout_class(window: &AppWindow, width: f32) {
/// TRACES: FR-UI-1
/// Which collapsible columns the user has opened or closed by hand.
///
/// The layout class supplies each panel's default; this records where the user
/// disagreed, so a panel closed to see more of a photograph stays closed while
/// the window keeps its shape.
///
/// `class` is what makes that "while": a choice is remembered *within* a layout
/// class and dropped when the class changes. Rotating a tablet into portrait
/// asks a different question from the one answered in landscape, and carrying
/// the landscape answer across is how a user ends up with 232px of sidebar on a
/// screen that has no room for it and no memory of having asked.
#[derive(Default)]
struct PanelChoices {
class: std::cell::Cell<Option<bool>>,
panel: std::cell::Cell<Option<bool>>,
collections: std::cell::Cell<Option<bool>>,
}
fn apply_layout_class(window: &AppWindow, width: f32, panels: &PanelChoices) {
let expanded = width >= EXPANDED_MIN_WIDTH;
window.set_expanded(expanded);
window.set_layout_class(if expanded { "expanded" } else { "compact" }.into());
if panels.class.get() != Some(expanded) {
panels.class.set(Some(expanded));
panels.panel.set(None);
panels.collections.set(None);
}
window.set_panel_visible(panels.panel.get().unwrap_or(expanded));
window.set_collections_visible(panels.collections.get().unwrap_or(expanded));
}
/// TRACES: FR-UI-5
/// One step back, and whether there was one to take.
///
/// The Escape half is FR-UI-5's "keyboard shortcuts cover navigation". The
/// Android back gesture answers to the same handler and has no numbered
/// requirement of its own — the register was written before phones and tablets
/// had a platform section, and §1.3 still lists no navigation requirement.
///
/// This is what Android's back gesture and the Escape key both resolve to. The
/// order is the order the states were entered in, innermost first: a mode
/// within a view is left before the view is, because that is what the user
/// most recently did and so what they most likely mean to undo.
///
/// Returning `false` means this is the top of the stack. The shell passes that
/// straight back to the platform as an unhandled key, which on Android closes
/// the activity — the behaviour every application there has, and the reason
/// this answers with a bool rather than swallowing the gesture.
fn back_one_step(w: &AppWindow) -> bool {
let state = NavState {
settings: w.get_show_settings(),
launch: w.get_show_launch(),
browsing: w.get_launch_browsing(),
library: w.get_show_library(),
crop: w.get_crop_mode(),
zoomed: w.get_zoomed(),
// Files named on the command line have no grid behind them — the same
// condition the status strip uses to decide whether to offer the way
// back at all.
has_grid: w.get_library_total() > 0,
scoped: w.get_collection_selected() != 0,
};
let Some(step) = back_step(state) else {
return false;
};
match step {
BackStep::CloseSettings => w.invoke_settings_close(),
BackStep::CancelBrowse => w.invoke_launch_browse_cancel(),
BackStep::LeaveCrop => w.invoke_crop_mode_toggled(false),
BackStep::ResetZoom => w.invoke_zoom_reset(),
BackStep::ToLibrary => w.invoke_back_to_library(),
BackStep::ClearScope => w.invoke_collection_select(0),
}
true
}
/// Where the interface is, as far as going back is concerned.
///
/// A flat snapshot rather than the window itself, so the ordering below can be
/// stated and tested without a Slint backend: which of two states is left first
/// is the whole of this feature, and it is the part that is easy to get subtly
/// wrong when it is spelled out in nested `if`s over live properties.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct NavState {
settings: bool,
launch: bool,
browsing: bool,
library: bool,
crop: bool,
zoomed: bool,
has_grid: bool,
scoped: bool,
}
/// What one step back does, or `None` at the top of the stack.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum BackStep {
CloseSettings,
CancelBrowse,
LeaveCrop,
ResetZoom,
ToLibrary,
ClearScope,
}
fn back_step(s: NavState) -> Option<BackStep> {
// Settings is drawn over everything, so it is left first whatever is
// behind it.
if s.settings {
return Some(BackStep::CloseSettings);
}
if s.launch {
// The folder picker is a step inside the launch screen; the launch
// screen itself is where the application starts and has nothing behind.
return s.browsing.then_some(BackStep::CancelBrowse);
}
if !s.library {
// Develop. Crop is a mode and zoom is a view state; both are left
// before the image is.
if s.crop {
return Some(BackStep::LeaveCrop);
}
if s.zoomed {
return Some(BackStep::ResetZoom);
}
return s.has_grid.then_some(BackStep::ToLibrary);
}
// The grid. A collection scoping it is a step in: back widens to the whole
// library before it considers leaving.
//
// And it does not leave: the grid is home, so back from here closes the
// application as it does in every other Android app. Signing out is a
// deliberate act reached from "Change library", not somewhere a stray swipe
// should land.
s.scoped.then_some(BackStep::ClearScope)
}
#[cfg(test)]
mod tests {
use super::*;
/// Develop with a grid behind it — the state most of the back tests vary.
fn developing() -> NavState {
NavState {
has_grid: true,
..NavState::default()
}
}
#[test]
fn back_closes_settings_before_anything_underneath_it() {
// Settings is reachable from both the grid and develop, and is drawn
// over whichever it was opened from. Whatever is behind must wait.
let from_grid = NavState {
settings: true,
library: true,
scoped: true,
..developing()
};
assert_eq!(back_step(from_grid), Some(BackStep::CloseSettings));
let from_develop = NavState {
settings: true,
crop: true,
..developing()
};
assert_eq!(back_step(from_develop), Some(BackStep::CloseSettings));
}
#[test]
fn back_leaves_a_mode_before_it_leaves_the_image() {
// Crop then zoom then the view: innermost first, because that is the
// order they were entered in.
let cropping = NavState {
crop: true,
zoomed: true,
..developing()
};
assert_eq!(back_step(cropping), Some(BackStep::LeaveCrop));
let zoomed = NavState {
zoomed: true,
..developing()
};
assert_eq!(back_step(zoomed), Some(BackStep::ResetZoom));
assert_eq!(back_step(developing()), Some(BackStep::ToLibrary));
}
#[test]
fn back_from_an_image_with_no_grid_behind_it_is_the_top_of_the_stack() {
// Files named on the command line: there is no library to return to,
// and the status strip does not offer one either.
let standalone = NavState {
has_grid: false,
..developing()
};
assert_eq!(back_step(standalone), None);
}
#[test]
fn back_widens_a_scoped_grid_before_it_would_leave_the_grid() {
let scoped = NavState {
library: true,
scoped: true,
has_grid: true,
..Default::default()
};
assert_eq!(back_step(scoped), Some(BackStep::ClearScope));
}
#[test]
fn back_from_the_whole_grid_closes_the_application() {
// The grid is home. Nothing here may navigate to the launch screen:
// that is where signing out lives, and a stray back swipe must not
// land on it.
let home = NavState {
library: true,
has_grid: true,
..Default::default()
};
assert_eq!(back_step(home), None);
}
#[test]
fn back_cancels_the_folder_picker_but_never_leaves_the_launch_screen() {
let picking = NavState {
launch: true,
browsing: true,
..Default::default()
};
assert_eq!(back_step(picking), Some(BackStep::CancelBrowse));
let launch = NavState {
launch: true,
..Default::default()
};
assert_eq!(back_step(launch), None);
}
fn meta() -> Metadata {
Metadata {
make: Some("Canon".into()),
+34
View File
@@ -68,6 +68,10 @@ component ParamSlider inherits Rectangle {
in property <ParamRow> data;
callback changed(float);
callback reset();
/// Whether a drag has been claimed as a slider gesture. The panel listens
/// so it can stand its Flickable down for the rest of the gesture — see
/// the note on `claimed` below.
callback drag-changed(bool);
height: 46px;
@@ -170,7 +174,21 @@ component ParamSlider inherits Rectangle {
// approach — makes every attempt to scroll from a slider
// jump its value first, which is destructive and happens
// constantly given how much of the panel is sliders.
//
// **Claiming has to stand the Flickable down, not just gate
// this handler.** Slint's Flickable steals any gesture that
// wanders more than 8 logical pixels along its scrolling axis
// within half a second of the press, and it steals it by
// cancelling the child — so a drag that began here is taken
// away mid-motion. A mouse tracks a horizontal line closely
// enough to stay under that; a finger does not, which is
// exactly why these worked on the desktop and not on the
// tablet. Reporting the claim lets the panel set
// `interactive: false` for the rest of the gesture, after
// which the Flickable stops arbitrating and the drag survives
// however far the finger drifts.
property <bool> claimed: false;
changed claimed => { root.drag-changed(self.claimed); }
function value-at(px: length) -> float {
return clamp(
@@ -479,9 +497,16 @@ component CurveEditor inherits Rectangle {
callback point-moved(int, float, float);
callback reset();
/// A point is being dragged. The panel stands its Flickable down while it
/// is, for the reason spelled out on `ParamSlider`'s `claimed` — and more
/// acutely here, because a curve point is dragged *vertically*, which is
/// the Flickable's own axis and so is stolen every single time.
callback drag-changed(bool);
property <int> point-count: root.data.points.length / 2;
changed active-point => { root.drag-changed(root.active-point >= 0); }
// The point the pointer is over, or -1. Set by the grab targets below,
// and used only to highlight the marker.
in-out property <int> hovered-point: -1;
@@ -642,6 +667,12 @@ export component AdjustPanel inherits Rectangle {
background: Theme.surface;
// A slider below has claimed the current gesture, so this panel must stop
// competing for it. See the long note on `ParamSlider`'s `claimed`: without
// this the Flickable takes any drag that drifts 8px vertically, which under
// a finger is every drag.
private property <bool> slider-dragging: false;
VerticalLayout {
padding: Theme.gap;
spacing: Theme.gap-sm;
@@ -666,6 +697,7 @@ export component AdjustPanel inherits Rectangle {
if root.enabled: Flickable {
viewport-height: content.preferred-height;
interactive: !root.slider-dragging;
content := VerticalLayout {
spacing: 0px;
@@ -703,6 +735,7 @@ export component AdjustPanel inherits Rectangle {
if entry.kind == "scalar": ParamSlider {
data: entry;
drag-changed(on) => { root.slider-dragging = on; }
changed(v) => {
root.param-changed(
entry.op-index, entry.param-index, v);
@@ -715,6 +748,7 @@ export component AdjustPanel inherits Rectangle {
if entry.kind == "curve": CurveEditor {
data: entry;
samples: root.curve-samples;
drag-changed(on) => { root.slider-dragging = on; }
// A point carries two parameters, so the
// parameter index is the row's base plus the
// point's offset. This component still knows
+834 -646
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -135,6 +135,14 @@ export component LaunchScreen inherits Rectangle {
background: Theme.ground;
// Somewhere for a key to start from — see the identical holder in
// `settings.slint` for why a screen with only text fields needs one.
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
alignment: center;
padding: Theme.gap-lg;
+17 -1
View File
@@ -9,7 +9,7 @@
// must not look identical (FR-NC-6c).
import { Theme } from "theme.slint";
import { Button, Label, Value, Caption, EmptyState, FilterChip } from "widgets.slint";
import { Button, IconButton, Label, Value, Caption, EmptyState, FilterChip } from "widgets.slint";
// One bar of the capture-time histogram.
export struct TimelineBar {
@@ -580,6 +580,12 @@ export component LibraryGrid inherits Rectangle {
/// the grid because that is what holds focus in library mode, but the
/// rename itself happens in the sidebar.
callback rename-scope();
/// Whether the collections sidebar is currently shown. The toggle for it
/// lives here rather than in the sidebar itself for the obvious reason: a
/// control inside a closed panel cannot reopen it.
in property <bool> collections-visible: true;
callback toggle-collections();
callback rescan();
/// Back to the launch screen, to change library or account.
callback change-library();
@@ -710,6 +716,16 @@ export component LibraryGrid inherits Rectangle {
padding-right: Theme.gap;
spacing: Theme.gap;
// The sidebar toggle, leading the header — the place every
// interface with a collapsible sidebar puts one, and the only
// place that stays put whichever way the panel is.
IconButton {
glyph: "☰";
active: root.collections-visible;
y: (parent.height - self.height) / 2;
clicked => { root.toggle-collections(); }
}
Value {
// The collection being shown takes the title when the grid
// is scoped to one: that is what the user narrowed to, and
+15
View File
@@ -296,6 +296,21 @@ export component SettingsPage inherits Rectangle {
background: Theme.ground;
// Somewhere for a key to start from.
//
// Slint delivers a key to the focused item and bubbles it up the ancestors;
// with nothing focused there is no chain at all and the event is dropped.
// This page has only text fields, none of which claims focus on show — so
// without this holder Android's back gesture would find no focus item here
// and close the application instead of closing the page. Zero height, so it
// takes no room in the column, and it handles nothing itself: the shell
// above answers `Key.Back`.
FocusScope {
width: 0px;
height: 0px;
init => { self.focus(); }
}
VerticalLayout {
// --- header ----------------------------------------------------
//