From 94a2686dcbe832bc0494a4557539d852851a09da Mon Sep 17 00:00:00 2001 From: Duncan Tourolle Date: Sun, 16 Aug 2026 11:59:31 +0200 Subject: [PATCH] Fit the interface to the system bars, the finger and the back key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/traceability.md | 54 +- ui/dr-ui/src/lib.rs | 321 +++++++- ui/dr-ui/ui/adjust.slint | 34 + ui/dr-ui/ui/app.slint | 1480 ++++++++++++++++++++---------------- ui/dr-ui/ui/launch.slint | 8 + ui/dr-ui/ui/library.slint | 18 +- ui/dr-ui/ui/settings.slint | 15 + 7 files changed, 1231 insertions(+), 699 deletions(-) diff --git a/docs/traceability.md b/docs/traceability.md index 432fc8e..7d5e91a 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -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.
Show untagged requirements @@ -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 diff --git a/ui/dr-ui/src/lib.rs b/ui/dr-ui/src/lib.rs index 1969006..42a2542 100644 --- a/ui/dr-ui/src/lib.rs +++ b/ui/dr-ui/src/lib.rs @@ -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; + /// 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) -> Result<()> { // Set once `show` exists; see where the library grid is wired below. #[allow(clippy::type_complexity)] - let open_from_library: Rc>>> = - Rc::new(RefCell::new(None)); + let open_from_library: Rc>>> = 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) -> 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) -> 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) -> 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 = { + 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) -> 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>, + panel: std::cell::Cell>, + collections: std::cell::Cell>, +} + +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 { + // 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()), diff --git a/ui/dr-ui/ui/adjust.slint b/ui/dr-ui/ui/adjust.slint index ffb1cb2..73ebad9 100644 --- a/ui/dr-ui/ui/adjust.slint +++ b/ui/dr-ui/ui/adjust.slint @@ -68,6 +68,10 @@ component ParamSlider inherits Rectangle { in property 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 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 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 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 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 diff --git a/ui/dr-ui/ui/app.slint b/ui/dr-ui/ui/app.slint index f167c7d..b12a9dd 100644 --- a/ui/dr-ui/ui/app.slint +++ b/ui/dr-ui/ui/app.slint @@ -2,7 +2,7 @@ import { Theme } from "theme.slint"; import { AdjustPanel, GeometryPanel, ParamRow } from "adjust.slint"; import { LaunchScreen } from "launch.slint"; import { LibraryGrid, LibraryCell, TimelineBar } from "library.slint"; -import { Button, PanelHeading, Label, Value, Caption, Panel, EmptyState } from "widgets.slint"; +import { Button, Label, Value, Caption, Section, EmptyState } from "widgets.slint"; import { CollectionsPanel, CollectionRow } from "collections.slint"; import { SettingsPage } from "settings.slint"; @@ -22,13 +22,27 @@ component StatusBar inherits Rectangle { /// Only offered where there is a library to go back to — with files named /// on the command line there is no grid behind this view. in property can-return-to-library: false; + /// Whether the develop column is currently shown, for the toggle's label. + in property panel-visible: true; callback back-to-library(); callback open-settings(); + callback toggle-panel(); - // Tall enough for a Button to sit in without the strip growing: the - // control height and this strip are both 28px by design. - height: Theme.control-height; + // 44px and `surface`, the same bar the library and settings draw. + // + // It was 28px — the control height — which was a reasonable size for a + // readout and the wrong size for the thing it actually is: this strip + // carries the only way out of develop, and it was half the height of the + // identical control on the two screens either side of it. Three screens + // whose headers do not agree read as three applications, and the one place + // the user needs to find a way back was the one drawn smallest. + // + // Controls keep their own 28px and are centred in it, exactly as the + // library header centres the same buttons: a `Button` sets a fixed height, + // which a HorizontalLayout parks at the top of the row rather than + // centring for it. + height: 44px; background: Theme.surface; HorizontalLayout { @@ -43,6 +57,7 @@ component StatusBar inherits Rectangle { // loops in this file (see the panel below). Button { text: "‹ Library"; + y: (parent.height - self.height) / 2; visible: root.can-return-to-library; clicked => { root.back-to-library(); } } @@ -61,11 +76,24 @@ component StatusBar inherits Rectangle { Caption { text: root.layout-class; } + // Show or hide the develop column. On a tablet the panel is 280px of a + // screen that is mostly photograph, and the whole point of opening an + // image is to look at it — so being able to put the instruments away + // and bring them back is worth a control of its own. The glyph points + // the way the panel will move. + Button { + text: root.panel-visible ? "Hide panel ›" : "‹ Panel"; + active: root.panel-visible; + y: (parent.height - self.height) / 2; + clicked => { root.toggle-panel(); } + } + // Reachable from develop as well as from the grid: export defaults are // most likely to be wanted with a finished photograph on screen, which // is exactly where this bar is and the library header is not. Button { text: "Settings"; + y: (parent.height - self.height) / 2; clicked => { root.open-settings(); } } @@ -88,31 +116,52 @@ component StatusBar inherits Rectangle { // Capture metadata. Read-only; the adjustment controls live in AdjustPanel, // which is generated from pipeline capabilities rather than written here. +// +// A `Section` rather than the flat `Panel` it used to be, so this collapses +// like everything else in the column does. It was the one group that could not +// be put away, and it is also the one whose content the user stops needing +// first: which body took the photograph is read once and then occupies the top +// of the panel for the rest of the edit. Structurally identical to +// `GeometryPanel` below — same wrapper, same height binding — so the two read +// as the same kind of thing, which they now are. +// +// `has-reset` is off: there is nothing here to undo. The section's heading +// keeps its own collapse state across model updates, which is what lets a +// closed IMAGE stay closed while a slider elsewhere is dragged. component InfoPanel inherits Rectangle { in property camera; in property exposure; in property dimensions; background: transparent; - height: panel.preferred-height; + height: layout.preferred-height; - panel := Panel { - // Flat: this abuts the adjust panel below it and the rule between - // them is drawn by the column that stacks the two. - flat: true; - width: 100%; + layout := VerticalLayout { + spacing: 0px; + alignment: start; - PanelHeading { text: "IMAGE"; } + Section { + title: "IMAGE"; + has-reset: false; - Value { - text: root.camera == "" ? "—" : root.camera; - placeholder: root.camera == ""; - wrap: word-wrap; + VerticalLayout { + spacing: Theme.gap-sm; + padding-top: Theme.gap-sm; + padding-bottom: Theme.gap-sm; + padding-left: Theme.gap-sm; + padding-right: Theme.gap-sm; + + Value { + text: root.camera == "" ? "—" : root.camera; + placeholder: root.camera == ""; + wrap: word-wrap; + } + + Label { text: root.exposure; } + + Caption { text: root.dimensions; } + } } - - Label { text: root.exposure; } - - Caption { text: root.dimensions; } } } @@ -319,8 +368,10 @@ export component AppWindow inherits Window { in property collection-selected: 0; in property collection-scope-label: ""; in property collection-error: ""; - /// Whether the sidebar is shown at all. Collapsed in the compact layout, - /// where 232px of the window is most of the photograph (FR-UI-1). + /// Whether the sidebar is shown at all. Closed by default in the compact + /// layout, where 232px of the window is most of the photograph (FR-UI-1), + /// and openable from the grid header either way — see `panel-visible` for + /// how a default and a user's answer to it are kept apart. in property collections-visible: true; /// Which collection is being renamed in the sidebar, by id. 0 is none. @@ -490,712 +541,849 @@ export component AppWindow inherits Window { // Set from Rust rather than derived from `root.width` here: a property // read inside the layout and also feeding it creates a binding loop, // which Slint warns about and which can panic at runtime. + // + // Nothing below reads `expanded` directly any more. It decides what the + // two collapsible columns *default* to, and Rust applies that to + // `panel-visible` and `collections-visible`; binding a column's width to + // the class itself would mean a panel the user opened snapped shut again + // on the next resize. in property expanded: true; in property layout-class: "expanded"; + /// Whether the develop column — image info, geometry, adjust — is shown. + /// + /// Distinct from `expanded`, which is the *layout class* and only supplies + /// this its default: a panel the user closed on a wide window stays closed, + /// and one they opened on a narrow one stays open. Rust owns both for the + /// reason given above — a property read inside the layout and also feeding + /// it is a binding loop. + in property panel-visible: true; + /// Show or hide the develop column. + callback toggle-panel(); + /// Show or hide the collections sidebar. + callback toggle-collections(); + + /// Android's back gesture, or Escape. Returns whether there was anywhere to + /// go: `false` means this is the top of the stack, and the platform is left + /// to close the application as it normally would. + callback back-requested() -> bool; + callback canvas-resized(int, int); callback window-resized(length); + /// The width the interface actually has to lay out in. + /// + /// Not the window's: on Android the window spans the display and the + /// left/right insets are given over to system furniture, so choosing the + /// layout class from `width` would promise the layout space it cannot use. + property shell-width: + root.width - root.safe-area-insets.left - root.safe-area-insets.right; + // One-way: report width outward, never read layout back into it. - changed width => { root.window-resized(self.width); } + changed shell-width => { root.window-resized(self.shell-width); } - // Settings, over everything. First in the file and first in z-order so the - // conditions below can be read as "and settings is not open". - if root.show-settings: SettingsPage { - width: 100%; - height: 100%; + // Everything the interface draws lives inside this, for two reasons that + // happen to want the same element. + // + // **The safe area.** From target SDK 35 Android draws applications behind + // the status and navigation bars and offers no way out, so the window's + // own coordinates include the strips those bars occupy — which is why the + // develop status strip came out underneath the clock and the wifi icon. + // `safe-area-insets` is what the platform reports as covered; the shell is + // placed inside it and only the window background paints behind the bars. + // Every inset is zero on the desktop, so nothing about that layout moves. + // + // **The back gesture.** Android's back button and back swipe arrive as + // `Key.Back`, delivered to whatever holds focus and then bubbled up its + // ancestors — so the one handler that answers for the whole application + // has to be above all four views rather than beside them. Accepting the + // key tells the platform we consumed it; rejecting it lets Android do what + // it does by default and close the activity, which is exactly what should + // happen at the top of the stack. + shell := FocusScope { + x: root.safe-area-insets.left; + y: root.safe-area-insets.top; + width: root.shell-width; + height: root.height - root.safe-area-insets.top + - root.safe-area-insets.bottom; - original-budget: root.settings-original-budget; - original-unlimited: root.settings-original-unlimited; - thumbnail-budget: root.settings-thumbnail-budget; - thumbnail-unlimited: root.settings-thumbnail-unlimited; - keep-opened: root.settings-keep-opened; - cache-usage: root.settings-cache-usage; + // Deliberately does *not* focus itself. + // + // A key needs a focus item to bubble from, so each of the four views + // below focuses a scope of its own on show — the grid's judgement keys, + // develop's arrow keys, and a zero-sized holder on the two form pages + // that would otherwise focus nothing. Claiming focus here as a fallback + // would race with those: Slint does not promise whether a parent's + // `init` runs before or after its children's, so on the wrong ordering + // this would take focus back off the grid and silently break culling. - original-budget-changed(t) => { root.settings-original-budget-changed(t); } - original-unlimited-toggled(on) => { - root.settings-original-unlimited-toggled(on); + key-pressed(event) => { + // Escape alongside Back: the same "go up one" on a keyboard, and + // the inner scopes that use it for something more specific — a + // rename in the sidebar — see it first and accept it there. + if (event.text == Key.Back || event.text == Key.Escape) { + return root.back-requested() ? accept : reject; + } + return reject; } - thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); } - thumbnail-unlimited-toggled(on) => { - root.settings-thumbnail-unlimited-toggled(on); + + // Settings, over everything. First in the file and first in z-order so the + // conditions below can be read as "and settings is not open". + if root.show-settings: SettingsPage { + width: 100%; + height: 100%; + + original-budget: root.settings-original-budget; + original-unlimited: root.settings-original-unlimited; + thumbnail-budget: root.settings-thumbnail-budget; + thumbnail-unlimited: root.settings-thumbnail-unlimited; + keep-opened: root.settings-keep-opened; + cache-usage: root.settings-cache-usage; + + original-budget-changed(t) => { root.settings-original-budget-changed(t); } + original-unlimited-toggled(on) => { + root.settings-original-unlimited-toggled(on); + } + thumbnail-budget-changed(t) => { root.settings-thumbnail-budget-changed(t); } + thumbnail-unlimited-toggled(on) => { + root.settings-thumbnail-unlimited-toggled(on); + } + keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); } + + format-labels: root.settings-format-labels; + format-selected: root.settings-format-selected; + quality: root.settings-quality; + quality-enabled: root.settings-quality-enabled; + colour-labels: root.settings-colour-labels; + colour-selected: root.settings-colour-selected; + sizing-labels: root.settings-sizing-labels; + sizing-selected: root.settings-sizing-selected; + sizing-value: root.settings-sizing-value; + sizing-has-value: root.settings-sizing-has-value; + sizing-unit: root.settings-sizing-unit; + allow-upscaling: root.settings-allow-upscaling; + sharpening-labels: root.settings-sharpening-labels; + sharpening-selected: root.settings-sharpening-selected; + filename-template: root.settings-filename-template; + collision-labels: root.settings-collision-labels; + collision-selected: root.settings-collision-selected; + strip-location: root.settings-strip-location; + destination: root.settings-destination; + error: root.settings-error; + + format-picked(i) => { root.settings-format-picked(i); } + quality-changed(q) => { root.settings-quality-changed(q); } + colour-picked(i) => { root.settings-colour-picked(i); } + sizing-picked(i) => { root.settings-sizing-picked(i); } + sizing-value-changed(t) => { root.settings-sizing-value-changed(t); } + upscaling-toggled(on) => { root.settings-upscaling-toggled(on); } + sharpening-picked(i) => { root.settings-sharpening-picked(i); } + template-changed(t) => { root.settings-template-changed(t); } + collision-picked(i) => { root.settings-collision-picked(i); } + strip-location-toggled(on) => { root.settings-strip-location-toggled(on); } + destination-changed(t) => { root.settings-destination-changed(t); } + + close() => { root.settings-close(); } + reset-defaults() => { root.settings-reset(); } } - keep-opened-toggled(on) => { root.settings-keep-opened-toggled(on); } - format-labels: root.settings-format-labels; - format-selected: root.settings-format-selected; - quality: root.settings-quality; - quality-enabled: root.settings-quality-enabled; - colour-labels: root.settings-colour-labels; - colour-selected: root.settings-colour-selected; - sizing-labels: root.settings-sizing-labels; - sizing-selected: root.settings-sizing-selected; - sizing-value: root.settings-sizing-value; - sizing-has-value: root.settings-sizing-has-value; - sizing-unit: root.settings-sizing-unit; - allow-upscaling: root.settings-allow-upscaling; - sharpening-labels: root.settings-sharpening-labels; - sharpening-selected: root.settings-sharpening-selected; - filename-template: root.settings-filename-template; - collision-labels: root.settings-collision-labels; - collision-selected: root.settings-collision-selected; - strip-location: root.settings-strip-location; - destination: root.settings-destination; - error: root.settings-error; + // The launch screen replaces the whole window rather than overlaying it: + // there is no library to look at until an account is configured. + if !root.show-settings && root.show-launch: LaunchScreen { + width: 100%; + height: 100%; + signed-in: root.launch-signed-in; + account: root.launch-account; + library-root: root.launch-root; + server-url: root.launch-server; + busy: root.launch-busy; + status: root.launch-status; + error: root.launch-error; + login-url: root.launch-login-url; + can-remember: root.launch-can-remember; + format-labels: root.launch-format-labels; + format-checked: root.launch-format-checked; - format-picked(i) => { root.settings-format-picked(i); } - quality-changed(q) => { root.settings-quality-changed(q); } - colour-picked(i) => { root.settings-colour-picked(i); } - sizing-picked(i) => { root.settings-sizing-picked(i); } - sizing-value-changed(t) => { root.settings-sizing-value-changed(t); } - upscaling-toggled(on) => { root.settings-upscaling-toggled(on); } - sharpening-picked(i) => { root.settings-sharpening-picked(i); } - template-changed(t) => { root.settings-template-changed(t); } - collision-picked(i) => { root.settings-collision-picked(i); } - strip-location-toggled(on) => { root.settings-strip-location-toggled(on); } - destination-changed(t) => { root.settings-destination-changed(t); } + sign-in(server) => { root.launch-sign-in(server); } + sign-in-direct(server, user, pw) => { + root.launch-sign-in-direct(server, user, pw); + } + sign-out() => { root.launch-sign-out(); } + choose-folder() => { root.launch-choose-folder(); } + open-library() => { root.launch-open-library(); } + format-toggled(i, on) => { root.launch-format-toggled(i, on); } + copy-login-url() => { root.launch-copy-url(); } - close() => { root.settings-close(); } - reset-defaults() => { root.settings-reset(); } - } + browsing: root.launch-browsing; + browse-path: root.launch-browse-path; + browse-loading: root.launch-browse-loading; + browse-entries: root.launch-browse-entries; - // The launch screen replaces the whole window rather than overlaying it: - // there is no library to look at until an account is configured. - if !root.show-settings && root.show-launch: LaunchScreen { - width: 100%; - height: 100%; - signed-in: root.launch-signed-in; - account: root.launch-account; - library-root: root.launch-root; - server-url: root.launch-server; - busy: root.launch-busy; - status: root.launch-status; - error: root.launch-error; - login-url: root.launch-login-url; - can-remember: root.launch-can-remember; - format-labels: root.launch-format-labels; - format-checked: root.launch-format-checked; - - sign-in(server) => { root.launch-sign-in(server); } - sign-in-direct(server, user, pw) => { - root.launch-sign-in-direct(server, user, pw); + browse-into(name) => { root.launch-browse-into(name); } + browse-up() => { root.launch-browse-up(); } + browse-confirm() => { root.launch-browse-confirm(); } + browse-cancel() => { root.launch-browse-cancel(); } } - sign-out() => { root.launch-sign-out(); } - choose-folder() => { root.launch-choose-folder(); } - open-library() => { root.launch-open-library(); } - format-toggled(i, on) => { root.launch-format-toggled(i, on); } - copy-login-url() => { root.launch-copy-url(); } - browsing: root.launch-browsing; - browse-path: root.launch-browse-path; - browse-loading: root.launch-browse-loading; - browse-entries: root.launch-browse-entries; + // The library view sits between the launch screen and develop: a library + // has been opened but no image chosen yet. The collections sidebar and the + // grid are siblings here rather than the sidebar living inside the grid, + // because the drag that connects them has to be owned above both. + if !root.show-settings && !root.show-launch && root.show-library: Rectangle { + width: 100%; + height: 100%; + background: Theme.ground; - browse-into(name) => { root.launch-browse-into(name); } - browse-up() => { root.launch-browse-up(); } - browse-confirm() => { root.launch-browse-confirm(); } - browse-cancel() => { root.launch-browse-cancel(); } - } + HorizontalLayout { + // Always instantiated, width collapsed to zero when hidden — the + // same reasoning as the adjust panel below: an `if` here depends on + // the layout class, which derives from the window width, which the + // layout then influences. Slint flags that loop and it can panic. + Rectangle { + width: root.collections-visible ? 232px : 0px; + visible: root.collections-visible; + horizontal-stretch: 0; - // The library view sits between the launch screen and develop: a library - // has been opened but no image chosen yet. The collections sidebar and the - // grid are siblings here rather than the sidebar living inside the grid, - // because the drag that connects them has to be owned above both. - if !root.show-settings && !root.show-launch && root.show-library: Rectangle { - background: Theme.ground; + CollectionsPanel { + width: 100%; + height: 100%; + rows: root.collection-rows; + selected-id: root.collection-selected; + total-images: root.library-total; + error: root.collection-error; - HorizontalLayout { - // Always instantiated, width collapsed to zero when hidden — the - // same reasoning as the adjust panel below: an `if` here depends on - // the layout class, which derives from the window width, which the - // layout then influences. Slint flags that loop and it can panic. - Rectangle { - width: root.collections-visible ? 232px : 0px; - visible: root.collections-visible; - horizontal-stretch: 0; + select(id) => { root.collection-select(id); } + toggle(id) => { root.collection-toggle(id); } + new-collection() => { root.collection-new(); } + row-menu(id) => { root.collection-menu(id); } - CollectionsPanel { - width: 100%; - height: 100%; - rows: root.collection-rows; - selected-id: root.collection-selected; - total-images: root.library-total; - error: root.collection-error; + renaming-id: root.collection-renaming; + rename-start(id) => { root.collection-rename-start(id); } + rename-commit(id, name) => { + root.collection-rename-commit(id, name); + } + rename-cancel() => { root.collection-rename-cancel(); } + trash-count: root.trash-count; + trash-label: root.trash-label; + selected-count: root.library-selected-count; - select(id) => { root.collection-select(id); } - toggle(id) => { root.collection-toggle(id); } - new-collection() => { root.collection-new(); } - row-menu(id) => { root.collection-menu(id); } - - renaming-id: root.collection-renaming; - rename-start(id) => { root.collection-rename-start(id); } - rename-commit(id, name) => { - root.collection-rename-commit(id, name); + dropped-on(id) => { root.collection-dropped(id); } + dropped-on-trash() => { root.trash-dropped(); } + empty-trash() => { root.trash-empty(); } + restore-selected() => { root.trash-restore(); } + drag-over(id, over) => { root.collection-drag-over(id, over); } } - rename-cancel() => { root.collection-rename-cancel(); } - trash-count: root.trash-count; - trash-label: root.trash-label; + } + + LibraryGrid { + horizontal-stretch: 1; + cells: root.library-cells; + total: root.library-total; + scanning: root.library-scanning; + scan-status: root.library-status; + scan-error: root.library-error; + thumbs-done: root.library-thumbs-done; + thumbs-total: root.library-thumbs-total; + + offline: root.library-offline; + offline-reason: root.library-offline-reason; + offline-since: root.library-offline-since; + retry-connection() => { root.library-retry-connection(); } + local-only: root.library-local-only; + local-count: root.library-local-count; + toggle-local-only() => { root.library-toggle-local-only(); } + + collections-visible: root.collections-visible; + toggle-collections() => { root.toggle-collections(); } + + scope-pinned: root.library-scope-pinned; + pin-done: root.library-pin-done; + pin-total: root.library-pin-total; + toggle-pin-scope() => { root.library-toggle-pin-scope(); } + root-label: root.library-root-label; + timeline: root.library-timeline; + timeline-label: root.library-timeline-label; + window-label: root.library-window-label; + offset: root.library-offset; selected-count: root.library-selected-count; + scope-label: root.collection-scope-label; - dropped-on(id) => { root.collection-dropped(id); } - dropped-on-trash() => { root.trash-dropped(); } - empty-trash() => { root.trash-empty(); } - restore-selected() => { root.trash-restore(); } - drag-over(id, over) => { root.collection-drag-over(id, over); } + sweep-done: root.library-sweep-done; + sweep-total: root.library-sweep-total; + + current-bucket: root.library-current-bucket; + current-bucket-fraction: root.library-current-fraction; + timeline-anchored: root.library-timeline-anchored; + + scrub-fraction(f) => { root.library-scrub-fraction(f); } + timeline-pinch(r) => { root.library-timeline-pinch(r); } + timeline-pan(d) => { root.library-timeline-pan(d); } + timeline-zoom(d) => { root.library-timeline-zoom(d); } + syncing: root.library-syncing; + cell-size: root.library-cell-size; + zoom-cells(d) => { root.library-zoom-cells(d); } + scroll-to: root.library-scroll-to; + scroll-token: root.library-scroll-token; + sync-now() => { root.library-sync-now(); } + columns-changed(n) => { + root.library-columns = n; + // Which cell begins a row just changed, and month headings sit on + // row-leading cells — so they must be recomputed, not just moved. + root.library-columns-changed(); + } + scrolled(i) => { root.library-scrolled(i); } + capacity-changed(n) => { root.library-capacity(n); } + cell-clicked(i) => { root.library-cell-clicked(i); } + rescan() => { root.library-rescan(); } + change-library() => { root.library-change(); } + open-settings() => { root.settings-open(); } + + cell-pressed(i, ctrl, shift) => { + root.library-cell-pressed(i, ctrl, shift); + } + drag-image: root.library-drag-image; + drag-payload() => { return root.library-drag-payload(); } + drag-started(i) => { root.library-drag-started(i); } + drag-finished() => { root.library-drag-finished(); } + remove-from-collection() => { + root.library-remove-from-collection(); + } + + filter-min-rating: root.library-filter-min-rating; + filter-unjudged: root.library-filter-unjudged; + filter-flag: root.library-filter-flag; + rating-counts: root.library-rating-counts; + + cell-rated(i, n) => { root.library-cell-rated(i, n); } + cell-trashed(i) => { root.library-cell-trashed(i); } + trash-selection() => { root.library-trash-selection(); } + // Derived from the sidebar's own selection rather than + // mirrored in a second property: `-1` is already the sentinel + // for the trash row (see collections.slint), and two sources + // for one fact is how they come to disagree. + viewing-trash: root.collection-selected == -1; + judged(rating, flag) => { root.library-judged(rating, flag); } + // `F2` renames whatever the grid is scoped to. Answered from + // the sidebar's selection rather than a second property, for + // the same reason `viewing-trash` above is: one fact, one + // source. Ids at or below 0 are "all photographs" and the + // trash, neither of which has a name to change — Rust drops + // those rather than the binding trying to reason about it. + rename-scope() => { + root.collection-rename-start(root.collection-selected); + } + filter-min-rating-changed(n) => { + root.library-filter-min-rating-changed(n); + } + filter-unjudged-toggled(on) => { + root.library-filter-unjudged-changed(on); + } + filter-flag-changed(f) => { root.library-filter-flag-changed(f); } } } - - LibraryGrid { - horizontal-stretch: 1; - cells: root.library-cells; - total: root.library-total; - scanning: root.library-scanning; - scan-status: root.library-status; - scan-error: root.library-error; - thumbs-done: root.library-thumbs-done; - thumbs-total: root.library-thumbs-total; - - offline: root.library-offline; - offline-reason: root.library-offline-reason; - offline-since: root.library-offline-since; - retry-connection() => { root.library-retry-connection(); } - local-only: root.library-local-only; - local-count: root.library-local-count; - toggle-local-only() => { root.library-toggle-local-only(); } - - scope-pinned: root.library-scope-pinned; - pin-done: root.library-pin-done; - pin-total: root.library-pin-total; - toggle-pin-scope() => { root.library-toggle-pin-scope(); } - root-label: root.library-root-label; - timeline: root.library-timeline; - timeline-label: root.library-timeline-label; - window-label: root.library-window-label; - offset: root.library-offset; - selected-count: root.library-selected-count; - scope-label: root.collection-scope-label; - - sweep-done: root.library-sweep-done; - sweep-total: root.library-sweep-total; - - current-bucket: root.library-current-bucket; - current-bucket-fraction: root.library-current-fraction; - timeline-anchored: root.library-timeline-anchored; - - scrub-fraction(f) => { root.library-scrub-fraction(f); } - timeline-pinch(r) => { root.library-timeline-pinch(r); } - timeline-pan(d) => { root.library-timeline-pan(d); } - timeline-zoom(d) => { root.library-timeline-zoom(d); } - syncing: root.library-syncing; - cell-size: root.library-cell-size; - zoom-cells(d) => { root.library-zoom-cells(d); } - scroll-to: root.library-scroll-to; - scroll-token: root.library-scroll-token; - sync-now() => { root.library-sync-now(); } - columns-changed(n) => { - root.library-columns = n; - // Which cell begins a row just changed, and month headings sit on - // row-leading cells — so they must be recomputed, not just moved. - root.library-columns-changed(); } - scrolled(i) => { root.library-scrolled(i); } - capacity-changed(n) => { root.library-capacity(n); } - cell-clicked(i) => { root.library-cell-clicked(i); } - rescan() => { root.library-rescan(); } - change-library() => { root.library-change(); } + + if !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout { + width: 100%; + height: 100%; + + StatusBar { + adapter: root.adapter; + backend: root.backend; + layout-class: root.layout-class; + fps: root.fps; + filename: root.filename; + position: root.total > 0 ? root.index + 1 + " / " + root.total : ""; + // Only where a library was opened; command-line files have no + // grid to return to. + can-return-to-library: root.library-total > 0; + panel-visible: root.panel-visible; + back-to-library() => { root.back-to-library(); } open-settings() => { root.settings-open(); } - - cell-pressed(i, ctrl, shift) => { - root.library-cell-pressed(i, ctrl, shift); - } - drag-image: root.library-drag-image; - drag-payload() => { return root.library-drag-payload(); } - drag-started(i) => { root.library-drag-started(i); } - drag-finished() => { root.library-drag-finished(); } - remove-from-collection() => { - root.library-remove-from-collection(); - } - - filter-min-rating: root.library-filter-min-rating; - filter-unjudged: root.library-filter-unjudged; - filter-flag: root.library-filter-flag; - rating-counts: root.library-rating-counts; - - cell-rated(i, n) => { root.library-cell-rated(i, n); } - cell-trashed(i) => { root.library-cell-trashed(i); } - trash-selection() => { root.library-trash-selection(); } - // Derived from the sidebar's own selection rather than - // mirrored in a second property: `-1` is already the sentinel - // for the trash row (see collections.slint), and two sources - // for one fact is how they come to disagree. - viewing-trash: root.collection-selected == -1; - judged(rating, flag) => { root.library-judged(rating, flag); } - // `F2` renames whatever the grid is scoped to. Answered from - // the sidebar's selection rather than a second property, for - // the same reason `viewing-trash` above is: one fact, one - // source. Ids at or below 0 are "all photographs" and the - // trash, neither of which has a name to change — Rust drops - // those rather than the binding trying to reason about it. - rename-scope() => { - root.collection-rename-start(root.collection-selected); - } - filter-min-rating-changed(n) => { - root.library-filter-min-rating-changed(n); - } - filter-unjudged-toggled(on) => { - root.library-filter-unjudged-changed(on); - } - filter-flag-changed(f) => { root.library-filter-flag-changed(f); } + toggle-panel() => { root.toggle-panel(); } } - } - } - if !root.show-settings && !root.show-launch && !root.show-library: VerticalLayout { - StatusBar { - adapter: root.adapter; - backend: root.backend; - layout-class: root.layout-class; - fps: root.fps; - filename: root.filename; - position: root.total > 0 ? root.index + 1 + " / " + root.total : ""; - // Only where a library was opened; command-line files have no - // grid to return to. - can-return-to-library: root.library-total > 0; - back-to-library() => { root.back-to-library(); } - open-settings() => { root.settings-open(); } - } + HorizontalLayout { + // The canvas: compute output composited directly. No CPU + // round-trip anywhere in this path (ARCH §6.1). + canvas-area := Rectangle { + horizontal-stretch: 1; + background: Theme.ground; + clip: true; - HorizontalLayout { - // The canvas: compute output composited directly. No CPU - // round-trip anywhere in this path (ARCH §6.1). - canvas-area := Rectangle { - horizontal-stretch: 1; - background: Theme.ground; - clip: true; + canvas-image := Image { + width: 100%; + height: 100%; + source: root.canvas; + image-fit: contain; + // Past 1:1 there is no detail left to reconstruct, so + // smoothing only invents values between real pixels — and + // inspecting focus or noise is the whole reason to zoom in + // that far. Below 1:1 it stays smooth, where filtering is + // what keeps the image from aliasing. + // + // This matters even at moderate zoom on a HiDPI display: + // the canvas is rendered at *logical* size and Slint scales + // it up by the device pixel ratio, so the buffer is + // resampled on its way to the screen whatever the pipeline + // did. + image-rendering: root.magnified + ? ImageRendering.pixelated + : ImageRendering.smooth; + visible: root.total > 0 && root.load-error == ""; + } - canvas-image := Image { - width: 100%; - height: 100%; - source: root.canvas; - image-fit: contain; - // Past 1:1 there is no detail left to reconstruct, so - // smoothing only invents values between real pixels — and - // inspecting focus or noise is the whole reason to zoom in - // that far. Below 1:1 it stays smooth, where filtering is - // what keeps the image from aliasing. + // Where the photograph actually sits inside this box. // - // This matters even at moderate zoom on a HiDPI display: - // the canvas is rendered at *logical* size and Slint scales - // it up by the device pixel ratio, so the buffer is - // resampled on its way to the screen whatever the pipeline - // did. - image-rendering: root.magnified - ? ImageRendering.pixelated - : ImageRendering.smooth; - visible: root.total > 0 && root.load-error == ""; - } + // `image-fit: contain` letterboxes, and Slint does not report + // the fitted rect, so it is derived from the source's aspect. + // The overlay is placed against *this*, not against the whole + // area — otherwise the crop rect drifts off the picture on any + // window whose shape differs from the image's. + property src-w: root.canvas.width > 0 ? root.canvas.width : 1; + property src-h: root.canvas.height > 0 ? root.canvas.height : 1; + property box-w: self.width / 1px; + property box-h: self.height / 1px; + property fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h); + property shown-w: self.src-w * self.fit-scale * 1px; + property shown-h: self.src-h * self.fit-scale * 1px; + property shown-x: (self.width - self.shown-w) / 2; + property shown-y: (self.height - self.shown-h) / 2; - // Where the photograph actually sits inside this box. - // - // `image-fit: contain` letterboxes, and Slint does not report - // the fitted rect, so it is derived from the source's aspect. - // The overlay is placed against *this*, not against the whole - // area — otherwise the crop rect drifts off the picture on any - // window whose shape differs from the image's. - property src-w: root.canvas.width > 0 ? root.canvas.width : 1; - property src-h: root.canvas.height > 0 ? root.canvas.height : 1; - property box-w: self.width / 1px; - property box-h: self.height / 1px; - property fit-scale: min(self.box-w / self.src-w, self.box-h / self.src-h); - property shown-w: self.src-w * self.fit-scale * 1px; - property shown-h: self.src-h * self.fit-scale * 1px; - property shown-x: (self.width - self.shown-w) / 2; - property shown-y: (self.height - self.shown-h) / 2; - - // Empty and error states say what happened rather than - // showing a blank canvas. - if root.total == 0 || root.load-error != "": EmptyState { - headline: root.load-error != "" ? "Could not load image" : "No images"; - detail: root.load-error != "" ? root.load-error - : "Pass a folder or file on the command line."; - } - - // --- zoom and pan ------------------------------------------ - // - // Below the crop overlay in z-order so that, in crop mode, the - // handles take the drag instead. Scroll still zooms either way. - if root.total > 0 && root.load-error == "": TouchArea { - x: 0; y: 0; - width: 100%; - height: 100%; - // A pan is only meaningful once there is something outside - // the viewport to reach. - mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default; - enabled: !root.crop-mode; - - property last-x; - property last-y; - - pointer-event(ev) => { - if (ev.kind == PointerEventKind.down) { - self.last-x = self.mouse-x; - self.last-y = self.mouse-y; - } + // Empty and error states say what happened rather than + // showing a blank canvas. + if root.total == 0 || root.load-error != "": EmptyState { + headline: root.load-error != "" ? "Could not load image" : "No images"; + detail: root.load-error != "" ? root.load-error + : "Pass a folder or file on the command line."; } - moved => { - if (self.pressed && root.zoomed) { - // Fractions of the *visible* area, which is what - // the session's pan expects. Negated: dragging - // right moves the image right, so the window onto - // it moves left. - root.pan-by( - -(self.mouse-x - self.last-x) / max(parent.shown-w, 1px), - -(self.mouse-y - self.last-y) / max(parent.shown-h, 1px), + // --- zoom and pan ------------------------------------------ + // + // Below the crop overlay in z-order so that, in crop mode, the + // handles take the drag instead. Scroll still zooms either way. + + // TRACES: FR-UI-4 + // Pinch to zoom. The wheel gesture below is the desktop's + // answer and there is no wheel on a tablet, so without this + // the only way to magnify a photograph on the device the + // panel was sized for is a control that does not exist. + // + // `scale` is cumulative from 1.0 across the whole gesture, + // so the ratio since the last update is what maps onto a + // zoom step — the same reasoning as the timeline's pinch, + // and for the same reason: applying the running total on + // every update squares it and shoots straight to the limit. + // + // Anchored on the midpoint between the fingers, which is + // what makes a pinch feel like it is moving the picture + // rather than the viewport. + if root.total > 0 && root.load-error == "": ScaleRotateGestureHandler { + x: 0; y: 0; + width: 100%; + height: 100%; + enabled: !root.crop-mode; + + property last-scale: 1.0; + + started => { self.last-scale = 1.0; } + updated => { + root.zoom-at( + self.scale / max(0.01, self.last-scale), + (self.center.x - canvas-area.shown-x) + / max(canvas-area.shown-w, 1px), + (self.center.y - canvas-area.shown-y) + / max(canvas-area.shown-h, 1px), ); - self.last-x = self.mouse-x; - self.last-y = self.mouse-y; + self.last-scale = self.scale; } + ended => { self.last-scale = 1.0; } + cancelled => { self.last-scale = 1.0; } } - scroll-event(ev) => { - if (ev.delta-y == 0) { - return reject; + if root.total > 0 && root.load-error == "": TouchArea { + x: 0; y: 0; + width: 100%; + height: 100%; + // A pan is only meaningful once there is something outside + // the viewport to reach. + mouse-cursor: root.zoomed ? MouseCursor.grab : MouseCursor.default; + enabled: !root.crop-mode; + + property last-x; + property last-y; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.last-x = self.mouse-x; + self.last-y = self.mouse-y; + } } - // Anchored on the pointer, in fractions of the shown - // image, so whatever is under the cursor stays there. - root.zoom-at( - ev.delta-y > 0 ? 1.15 : 1.0 / 1.15, - (self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px), - (self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px), - ); - return accept; + + moved => { + if (self.pressed && root.zoomed) { + // Fractions of the *visible* area, which is what + // the session's pan expects. Negated: dragging + // right moves the image right, so the window onto + // it moves left. + root.pan-by( + -(self.mouse-x - self.last-x) / max(parent.shown-w, 1px), + -(self.mouse-y - self.last-y) / max(parent.shown-h, 1px), + ); + self.last-x = self.mouse-x; + self.last-y = self.mouse-y; + } + } + + scroll-event(ev) => { + if (ev.delta-y == 0) { + return reject; + } + // Anchored on the pointer, in fractions of the shown + // image, so whatever is under the cursor stays there. + root.zoom-at( + ev.delta-y > 0 ? 1.15 : 1.0 / 1.15, + (self.mouse-x - parent.shown-x) / max(parent.shown-w, 1px), + (self.mouse-y - parent.shown-y) / max(parent.shown-h, 1px), + ); + return accept; + } + + double-clicked => { root.zoom-reset(); } } - double-clicked => { root.zoom-reset(); } - } + // --- crop overlay ------------------------------------------ + // + // Four dimmed, desaturated panels around the crop, then the + // rect itself with handles. Panels rather than one shape with + // a hole: Slint has no cut-out, and four rectangles are exact. + if root.crop-mode && root.total > 0 && root.load-error == "": crop-overlay := Rectangle { + x: parent.shown-x; + y: parent.shown-y; + width: parent.shown-w; + height: parent.shown-h; - // --- crop overlay ------------------------------------------ - // - // Four dimmed, desaturated panels around the crop, then the - // rect itself with handles. Panels rather than one shape with - // a hole: Slint has no cut-out, and four rectangles are exact. - if root.crop-mode && root.total > 0 && root.load-error == "": crop-overlay := Rectangle { - x: parent.shown-x; - y: parent.shown-y; - width: parent.shown-w; - height: parent.shown-h; + property rx: root.crop-x * self.width; + property ry: root.crop-y * self.height; + property rw: root.crop-w * self.width; + property rh: root.crop-h * self.height; - property rx: root.crop-x * self.width; - property ry: root.crop-y * self.height; - property rw: root.crop-w * self.width; - property rh: root.crop-h * self.height; + // The surround: dimmed and drained of colour, so the crop + // reads as the photograph and everything else as context + // being discarded. Slint cannot desaturate a live image, + // so this is a heavy neutral wash over it — the dimming + // carries the separation and the neutrality kills the + // colour cue. + property veil: #20242aE0; - // The surround: dimmed and drained of colour, so the crop - // reads as the photograph and everything else as context - // being discarded. Slint cannot desaturate a live image, - // so this is a heavy neutral wash over it — the dimming - // carries the separation and the neutrality kills the - // colour cue. - property veil: #20242aE0; - - Rectangle { - x: 0; - y: 0; - width: parent.width; - height: crop-overlay.ry; - background: crop-overlay.veil; - } - Rectangle { - x: 0; - y: crop-overlay.ry + crop-overlay.rh; - width: parent.width; - height: parent.height - crop-overlay.ry - crop-overlay.rh; - background: crop-overlay.veil; - } - Rectangle { - x: 0; - y: crop-overlay.ry; - width: crop-overlay.rx; - height: crop-overlay.rh; - background: crop-overlay.veil; - } - Rectangle { - x: crop-overlay.rx + crop-overlay.rw; - y: crop-overlay.ry; - width: parent.width - crop-overlay.rx - crop-overlay.rw; - height: crop-overlay.rh; - background: crop-overlay.veil; - } - - // The rect, its thirds, and the handles. - Rectangle { - x: parent.rx; - y: parent.ry; - width: parent.rw; - height: parent.rh; - border-width: 1px; - border-color: #ffffffCC; - - // Rule of thirds, the reason a crop overlay is worth - // drawing at all rather than typing numbers. Rectangle { - x: parent.width / 3; - y: 0; width: 1px; height: parent.height; - background: #ffffff44; + x: 0; + y: 0; + width: parent.width; + height: crop-overlay.ry; + background: crop-overlay.veil; } Rectangle { - x: parent.width * 2 / 3; - y: 0; width: 1px; height: parent.height; - background: #ffffff44; + x: 0; + y: crop-overlay.ry + crop-overlay.rh; + width: parent.width; + height: parent.height - crop-overlay.ry - crop-overlay.rh; + background: crop-overlay.veil; } Rectangle { - x: 0; y: parent.height / 3; - width: parent.width; height: 1px; - background: #ffffff44; + x: 0; + y: crop-overlay.ry; + width: crop-overlay.rx; + height: crop-overlay.rh; + background: crop-overlay.veil; } Rectangle { - x: 0; y: parent.height * 2 / 3; - width: parent.width; height: 1px; - background: #ffffff44; + x: crop-overlay.rx + crop-overlay.rw; + y: crop-overlay.ry; + width: parent.width - crop-overlay.rx - crop-overlay.rw; + height: crop-overlay.rh; + background: crop-overlay.veil; } - // Drag the whole rect. - move-area := TouchArea { - width: 100%; - height: 100%; - mouse-cursor: MouseCursor.move; + // The rect, its thirds, and the handles. + Rectangle { + x: parent.rx; + y: parent.ry; + width: parent.rw; + height: parent.rh; + border-width: 1px; + border-color: #ffffffCC; - property start-x; - property start-y; - - pointer-event(ev) => { - if (ev.kind == PointerEventKind.down) { - self.start-x = root.crop-x; - self.start-y = root.crop-y; - } + // Rule of thirds, the reason a crop overlay is worth + // drawing at all rather than typing numbers. + Rectangle { + x: parent.width / 3; + y: 0; width: 1px; height: parent.height; + background: #ffffff44; + } + Rectangle { + x: parent.width * 2 / 3; + y: 0; width: 1px; height: parent.height; + background: #ffffff44; + } + Rectangle { + x: 0; y: parent.height / 3; + width: parent.width; height: 1px; + background: #ffffff44; + } + Rectangle { + x: 0; y: parent.height * 2 / 3; + width: parent.width; height: 1px; + background: #ffffff44; } - moved => { - if (self.pressed) { + // Drag the whole rect. + move-area := TouchArea { + width: 100%; + height: 100%; + mouse-cursor: MouseCursor.move; + + property start-x; + property start-y; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.start-x = root.crop-x; + self.start-y = root.crop-y; + } + } + + moved => { + if (self.pressed) { + root.crop-changed( + self.start-x + + (self.mouse-x - self.pressed-x) + / max(crop-overlay.width, 1px), + self.start-y + + (self.mouse-y - self.pressed-y) + / max(crop-overlay.height, 1px), + root.crop-w, + root.crop-h, + ); + } + } + } + } + + // Corner handles. Each drags one corner while the opposite + // stays put, which is the only behaviour that lets a crop + // be shaped rather than merely moved. + for corner in [ + { hx: 0.0, hy: 0.0 }, + { hx: 1.0, hy: 0.0 }, + { hx: 0.0, hy: 1.0 }, + { hx: 1.0, hy: 1.0 }, + ]: Rectangle { + property size: 18px; + x: parent.rx + corner.hx * parent.rw - self.size / 2; + y: parent.ry + corner.hy * parent.rh - self.size / 2; + width: self.size; + height: self.size; + + Rectangle { + width: 12px; + height: 12px; + background: #ffffff; + border-radius: 2px; + } + + TouchArea { + width: 100%; + height: 100%; + mouse-cursor: (corner.hx == corner.hy) + ? MouseCursor.nwse-resize + : MouseCursor.nesw-resize; + + property ox; + property oy; + property ow; + property oh; + + pointer-event(ev) => { + if (ev.kind == PointerEventKind.down) { + self.ox = root.crop-x; + self.oy = root.crop-y; + self.ow = root.crop-w; + self.oh = root.crop-h; + } + } + + // Movement as a fraction of the frame, live while + // the handle is held. + property dx: + (self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px); + property dy: + (self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px); + + moved => { + if (!self.pressed) { + return; + } + // Dragging a left/top handle moves the origin + // and shrinks the extent by the same amount; + // a right/bottom handle moves only the extent. + // Rust clamps the result, so an over-drag + // slides rather than inverting. root.crop-changed( - self.start-x - + (self.mouse-x - self.pressed-x) - / max(crop-overlay.width, 1px), - self.start-y - + (self.mouse-y - self.pressed-y) - / max(crop-overlay.height, 1px), - root.crop-w, - root.crop-h, + corner.hx == 0 ? self.ox + self.dx : self.ox, + corner.hy == 0 ? self.oy + self.dy : self.oy, + corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx, + corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy, ); } } } } - // Corner handles. Each drags one corner while the opposite - // stays put, which is the only behaviour that lets a crop - // be shaped rather than merely moved. - for corner in [ - { hx: 0.0, hy: 0.0 }, - { hx: 1.0, hy: 0.0 }, - { hx: 0.0, hy: 1.0 }, - { hx: 1.0, hy: 1.0 }, - ]: Rectangle { - property size: 18px; - x: parent.rx + corner.hx * parent.rw - self.size / 2; - y: parent.ry + corner.hy * parent.rh - self.size / 2; - width: self.size; - height: self.size; + // Arrow keys and space step through the folder. + // + // Focused on show rather than waiting for a click, exactly + // as the grid's judgement scope is: without it the first + // keystroke after opening an image goes nowhere, and — now + // that the shell answers the back gesture by bubbling — + // there would be no focus item for `Key.Back` to bubble + // *from*, so back would close the application from develop + // instead of returning to the grid. + FocusScope { + init => { self.focus(); } + + key-pressed(event) => { + if (event.text == Key.RightArrow || event.text == " ") { + root.next-image(); + return accept; + } + if (event.text == Key.LeftArrow) { + root.prev-image(); + return accept; + } + return reject; + } + } + + // Zoom controls, floating over the canvas. + // + // Zoom stays here rather than moving to the sidebar with the + // crop: it is a property of *looking*, it is driven by the + // scroll wheel on the image itself, and its readout belongs + // beside what it is reporting on. Crop moved to the panel + // because it is an edit, and edits live with the other edits. + // + // A crop exit is still reachable from here while cropping — + // the panel may be collapsed on a narrow window, and stranding + // the user in a mode with no visible way out is worse than one + // duplicated control. + if root.total > 0 && root.load-error == "": HorizontalLayout { + x: 12px; + y: parent.height - self.preferred-height - 12px; + spacing: 6px; + + if root.crop-mode: Button { + text: "Done"; + active: true; + clicked => { root.crop-mode-toggled(false); } + } + + // Zoom is a view state, so its readout doubles as the + // control that clears it. + Button { + text: root.zoomed + ? Math.round(root.zoom * 100) + "%" + : "Fit"; + active: root.zoomed; + enabled: root.zoomed; + clicked => { root.zoom-reset(); } + } + } + + // Report size changes so the render target can be resized to + // match. Width and height are tracked separately because + // Slint has no single "geometry changed" hook. + property px-w: Math.round(self.width / 1px); + property px-h: Math.round(self.height / 1px); + changed px-w => { root.canvas-resized(self.px-w, self.px-h); } + changed px-h => { root.canvas-resized(self.px-w, self.px-h); } + } + + // Always instantiated, width collapsed to zero when closed. A + // conditional `if` here creates a binding loop — the layout + // depends on `panel-visible`, which derives from the window + // width, which the layout then influences. Slint flags it, and + // it can panic at runtime. + Rectangle { + width: root.panel-visible ? 280px : 0px; + visible: root.panel-visible; + background: Theme.surface; + + VerticalLayout { + InfoPanel { + camera: root.camera; + exposure: root.exposure; + dimensions: root.dimensions; + } Rectangle { - width: 12px; - height: 12px; - background: #ffffff; - border-radius: 2px; + height: 1px; + background: Theme.rule; } - TouchArea { - width: 100%; - height: 100%; - mouse-cursor: (corner.hx == corner.hy) - ? MouseCursor.nwse-resize - : MouseCursor.nesw-resize; + // Framing above the colour work, matching how the edit is + // made rather than how it is applied: the frame is decided + // by eye first and the pipeline runs it last (see + // `dr_pipeline::framing` on the coordinate order). + GeometryPanel { + enabled: root.adjust-enabled; + crop-mode: root.crop-mode; + angle: root.straighten; + max-straighten: root.max-straighten; + flip-h: root.flip-h; + flip-v: root.flip-v; + modified: root.framing-modified; - property ox; - property oy; - property ow; - property oh; + crop-toggled(on) => { root.crop-mode-toggled(on); } + rotate(turns) => { root.rotate-quarters(turns); } + flip-h-toggled => { root.flip-h-toggled(); } + flip-v-toggled => { root.flip-v-toggled(); } + angle-changed(v) => { root.straighten-changed(v); } + angle-reset => { root.straighten-changed(0); } + reset => { root.framing-reset(); } + } - pointer-event(ev) => { - if (ev.kind == PointerEventKind.down) { - self.ox = root.crop-x; - self.oy = root.crop-y; - self.ow = root.crop-w; - self.oh = root.crop-h; - } + Rectangle { + height: 1px; + background: Theme.rule; + } + + AdjustPanel { + vertical-stretch: 1; + rows: root.adjust-rows; + enabled: root.adjust-enabled; + curve-samples: root.curve-samples; + param-changed(op, param, value) => { + root.param-changed(op, param, value); } - - // Movement as a fraction of the frame, live while - // the handle is held. - property dx: - (self.mouse-x - self.pressed-x) / max(crop-overlay.width, 1px); - property dy: - (self.mouse-y - self.pressed-y) / max(crop-overlay.height, 1px); - - moved => { - if (!self.pressed) { - return; - } - // Dragging a left/top handle moves the origin - // and shrinks the extent by the same amount; - // a right/bottom handle moves only the extent. - // Rust clamps the result, so an over-drag - // slides rather than inverting. - root.crop-changed( - corner.hx == 0 ? self.ox + self.dx : self.ox, - corner.hy == 0 ? self.oy + self.dy : self.oy, - corner.hx == 0 ? self.ow - self.dx : self.ow + self.dx, - corner.hy == 0 ? self.oh - self.dy : self.oh + self.dy, - ); + param-reset(op, param) => { + root.param-reset(op, param); } + curve-reset(op) => { root.curve-reset(op); } + // A section's reset and a curve's reset are the same + // action — every parameter of one operation back to + // its default — so they share the one callback rather + // than duplicating a handler that would have to be + // kept in step with it. + op-reset(op) => { root.curve-reset(op); } + reset-all => { root.reset-all(); } } } - } - - // Arrow keys and space step through the folder. - FocusScope { - key-pressed(event) => { - if (event.text == Key.RightArrow || event.text == " ") { - root.next-image(); - return accept; - } - if (event.text == Key.LeftArrow) { - root.prev-image(); - return accept; - } - return reject; - } - } - - // Zoom controls, floating over the canvas. - // - // Zoom stays here rather than moving to the sidebar with the - // crop: it is a property of *looking*, it is driven by the - // scroll wheel on the image itself, and its readout belongs - // beside what it is reporting on. Crop moved to the panel - // because it is an edit, and edits live with the other edits. - // - // A crop exit is still reachable from here while cropping — - // the panel may be collapsed on a narrow window, and stranding - // the user in a mode with no visible way out is worse than one - // duplicated control. - if root.total > 0 && root.load-error == "": HorizontalLayout { - x: 12px; - y: parent.height - self.preferred-height - 12px; - spacing: 6px; - - if root.crop-mode: Button { - text: "Done"; - active: true; - clicked => { root.crop-mode-toggled(false); } - } - - // Zoom is a view state, so its readout doubles as the - // control that clears it. - Button { - text: root.zoomed - ? Math.round(root.zoom * 100) + "%" - : "Fit"; - active: root.zoomed; - enabled: root.zoomed; - clicked => { root.zoom-reset(); } - } - } - - // Report size changes so the render target can be resized to - // match. Width and height are tracked separately because - // Slint has no single "geometry changed" hook. - property px-w: Math.round(self.width / 1px); - property px-h: Math.round(self.height / 1px); - changed px-w => { root.canvas-resized(self.px-w, self.px-h); } - changed px-h => { root.canvas-resized(self.px-w, self.px-h); } - } - - // Always instantiated, width collapsed to zero in compact mode. - // A conditional `if` here creates a binding loop — the layout - // depends on `expanded`, which derives from the window width, - // which the layout then influences. Slint flags it, and it can - // panic at runtime. - Rectangle { - width: root.expanded ? 280px : 0px; - visible: root.expanded; - background: Theme.surface; - - VerticalLayout { - InfoPanel { - camera: root.camera; - exposure: root.exposure; - dimensions: root.dimensions; - } Rectangle { - height: 1px; + width: 1px; background: Theme.rule; } - - // Framing above the colour work, matching how the edit is - // made rather than how it is applied: the frame is decided - // by eye first and the pipeline runs it last (see - // `dr_pipeline::framing` on the coordinate order). - GeometryPanel { - enabled: root.adjust-enabled; - crop-mode: root.crop-mode; - angle: root.straighten; - max-straighten: root.max-straighten; - flip-h: root.flip-h; - flip-v: root.flip-v; - modified: root.framing-modified; - - crop-toggled(on) => { root.crop-mode-toggled(on); } - rotate(turns) => { root.rotate-quarters(turns); } - flip-h-toggled => { root.flip-h-toggled(); } - flip-v-toggled => { root.flip-v-toggled(); } - angle-changed(v) => { root.straighten-changed(v); } - angle-reset => { root.straighten-changed(0); } - reset => { root.framing-reset(); } - } - - Rectangle { - height: 1px; - background: Theme.rule; - } - - AdjustPanel { - vertical-stretch: 1; - rows: root.adjust-rows; - enabled: root.adjust-enabled; - curve-samples: root.curve-samples; - param-changed(op, param, value) => { - root.param-changed(op, param, value); - } - param-reset(op, param) => { - root.param-reset(op, param); - } - curve-reset(op) => { root.curve-reset(op); } - // A section's reset and a curve's reset are the same - // action — every parameter of one operation back to - // its default — so they share the one callback rather - // than duplicating a handler that would have to be - // kept in step with it. - op-reset(op) => { root.curve-reset(op); } - reset-all => { root.reset-all(); } - } - } - - Rectangle { - width: 1px; - background: Theme.rule; } } } diff --git a/ui/dr-ui/ui/launch.slint b/ui/dr-ui/ui/launch.slint index 9c57019..40e4771 100644 --- a/ui/dr-ui/ui/launch.slint +++ b/ui/dr-ui/ui/launch.slint @@ -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; diff --git a/ui/dr-ui/ui/library.slint b/ui/dr-ui/ui/library.slint index dd2ac31..186d42f 100644 --- a/ui/dr-ui/ui/library.slint +++ b/ui/dr-ui/ui/library.slint @@ -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 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 diff --git a/ui/dr-ui/ui/settings.slint b/ui/dr-ui/ui/settings.slint index 294a717..6ab15e8 100644 --- a/ui/dr-ui/ui/settings.slint +++ b/ui/dr-ui/ui/settings.slint @@ -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 ---------------------------------------------------- //