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 ---------------------------------------------------- //