diff --git a/docs/dev/traceability.md b/docs/dev/traceability.md index 907ecff..a3a6864 100644 --- a/docs/dev/traceability.md +++ b/docs/dev/traceability.md @@ -9,7 +9,7 @@ Denominators are parsed from [`requirements.md`](requirements.md) at run time, n | Metric | Value | |---|---| -| Source files scanned | 423 | +| Source files scanned | 432 | | TRACES tags found | 1571 | | Requirements defined | 184 | | Requirements deferred (post-v1) | 24 | @@ -38,17 +38,17 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-CAT-10 | [`core/dr-ingest/src/layout.rs:1`](../../core/dr-ingest/src/layout.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:733`](../../core/dr-ingest/src/lib.rs#L733), [`core/dr-types/src/settings.rs:377`](../../core/dr-types/src/settings.rs#L377), [`platform/dr-plat/src/storage.rs:287`](../../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:586`](../../platform/dr-plat/src/storage.rs#L586), [`platform/dr-plat/src/volumes.rs:1`](../../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../../platform/dr-plat/src/volumes.rs#L62), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:336`](../../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1790`](../../ui/dr-ui/src/lib.rs#L1790), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5), [`ui/dr-ui/ui/library.slint:1033`](../../ui/dr-ui/ui/library.slint#L1033), [`ui/dr-ui/ui/library.slint:1254`](../../ui/dr-ui/ui/library.slint#L1254), [`ui/dr-ui/ui/library.slint:979`](../../ui/dr-ui/ui/library.slint#L979) | | FR-CAT-11 | [`core/dr-catalog/src/dedup.rs:1`](../../core/dr-catalog/src/dedup.rs#L1), [`core/dr-ingest/src/lib.rs:1`](../../core/dr-ingest/src/lib.rs#L1), [`core/dr-ingest/src/lib.rs:392`](../../core/dr-ingest/src/lib.rs#L392), [`core/dr-sync/src/upload.rs:40`](../../core/dr-sync/src/upload.rs#L40), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1790`](../../ui/dr-ui/src/lib.rs#L1790), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/thumbnails_gen.rs:518`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L518), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | | FR-CAT-12 | [`core/dr-pipeline/src/sidecar.rs:137`](../../core/dr-pipeline/src/sidecar.rs#L137) | -| FR-CAT-13 | [`core/dr-catalog/src/rating.rs:278`](../../core/dr-catalog/src/rating.rs#L278), [`core/dr-catalog/src/schema.rs:836`](../../core/dr-catalog/src/schema.rs#L836), [`core/dr-sync/src/scan.rs:40`](../../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library/scan.rs:335`](../../ui/dr-ui/src/library/scan.rs#L335), [`ui/dr-ui/src/library/xmp.rs:132`](../../ui/dr-ui/src/library/xmp.rs#L132), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui.rs:1322`](../../ui/dr-ui/src/library_ui.rs#L1322), [`ui/dr-ui/src/library_ui.rs:301`](../../ui/dr-ui/src/library_ui.rs#L301), [`ui/dr-ui/src/library_ui.rs:3427`](../../ui/dr-ui/src/library_ui.rs#L3427), [`ui/dr-ui/src/library_ui.rs:3483`](../../ui/dr-ui/src/library_ui.rs#L3483), [`ui/dr-ui/src/library_ui.rs:3581`](../../ui/dr-ui/src/library_ui.rs#L3581), [`ui/dr-ui/src/library_ui.rs:392`](../../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/library_ui.rs:5766`](../../ui/dr-ui/src/library_ui.rs#L5766), [`ui/dr-ui/src/settings_ui.rs:558`](../../ui/dr-ui/src/settings_ui.rs#L558), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:939`](../../ui/dr-ui/ui/app.slint#L939), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | -| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1210`](../../core/dr-catalog/src/schema.rs#L1210), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:522`](../../core/dr-sync-nextcloud/src/lib.rs#L522), [`core/dr-sync/src/lib.rs:135`](../../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:618`](../../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393), [`core/dr-types/src/place.rs:91`](../../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui/trash.rs:78`](../../ui/dr-ui/src/collections_ui/trash.rs#L78), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:772`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L772), [`ui/dr-ui/src/library/cells.rs:158`](../../ui/dr-ui/src/library/cells.rs#L158), [`ui/dr-ui/src/library/cells.rs:192`](../../ui/dr-ui/src/library/cells.rs#L192), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/filters.rs:29`](../../ui/dr-ui/src/library/filters.rs#L29), [`ui/dr-ui/src/library/filters.rs:59`](../../ui/dr-ui/src/library/filters.rs#L59), [`ui/dr-ui/src/library_ui.rs:238`](../../ui/dr-ui/src/library_ui.rs#L238), [`ui/dr-ui/src/library_ui.rs:914`](../../ui/dr-ui/src/library_ui.rs#L914), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:745`](../../ui/dr-ui/ui/collections.slint#L745) | +| FR-CAT-13 | [`core/dr-catalog/src/rating.rs:278`](../../core/dr-catalog/src/rating.rs#L278), [`core/dr-catalog/src/schema.rs:836`](../../core/dr-catalog/src/schema.rs#L836), [`core/dr-sync/src/scan.rs:40`](../../core/dr-sync/src/scan.rs#L40), [`core/dr-sync/src/scan.rs:944`](../../core/dr-sync/src/scan.rs#L944), [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`core/dr-xmp/src/lib.rs:1`](../../core/dr-xmp/src/lib.rs#L1), [`core/dr-xmp/src/lib.rs:331`](../../core/dr-xmp/src/lib.rs#L331), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`core/dr-xmp/src/lib.rs:535`](../../core/dr-xmp/src/lib.rs#L535), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`core/dr-xmp/src/read.rs:1`](../../core/dr-xmp/src/read.rs#L1), [`core/dr-xmp/src/write.rs:1`](../../core/dr-xmp/src/write.rs#L1), [`core/dr-xmp/tests/round_trip.rs:1`](../../core/dr-xmp/tests/round_trip.rs#L1), [`ui/dr-ui/src/library/scan.rs:335`](../../ui/dr-ui/src/library/scan.rs#L335), [`ui/dr-ui/src/library/xmp.rs:132`](../../ui/dr-ui/src/library/xmp.rs#L132), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui/controller.rs:296`](../../ui/dr-ui/src/library_ui/controller.rs#L296), [`ui/dr-ui/src/library_ui/controller.rs:387`](../../ui/dr-ui/src/library_ui/controller.rs#L387), [`ui/dr-ui/src/library_ui/controller.rs:532`](../../ui/dr-ui/src/library_ui/controller.rs#L532), [`ui/dr-ui/src/library_ui/grid.rs:156`](../../ui/dr-ui/src/library_ui/grid.rs#L156), [`ui/dr-ui/src/library_ui/open.rs:423`](../../ui/dr-ui/src/library_ui/open.rs#L423), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:503`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L503), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:559`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L559), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:657`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L657), [`ui/dr-ui/src/settings_ui.rs:558`](../../ui/dr-ui/src/settings_ui.rs#L558), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:250`](../../ui/dr-ui/src/xmp_sync.rs#L250), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287), [`ui/dr-ui/src/xmp_sync.rs:324`](../../ui/dr-ui/src/xmp_sync.rs#L324), [`ui/dr-ui/ui/app.slint:939`](../../ui/dr-ui/ui/app.slint#L939), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | +| FR-CAT-15 | [`core/dr-catalog/src/schema.rs:1210`](../../core/dr-catalog/src/schema.rs#L1210), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:522`](../../core/dr-sync-nextcloud/src/lib.rs#L522), [`core/dr-sync/src/lib.rs:135`](../../core/dr-sync/src/lib.rs#L135), [`core/dr-sync/src/scan.rs:618`](../../core/dr-sync/src/scan.rs#L618), [`core/dr-sync/src/scan.rs:93`](../../core/dr-sync/src/scan.rs#L93), [`core/dr-thumbs/src/lib.rs:393`](../../core/dr-thumbs/src/lib.rs#L393), [`core/dr-types/src/place.rs:91`](../../core/dr-types/src/place.rs#L91), [`ui/dr-ui/src/collections_ui/trash.rs:78`](../../ui/dr-ui/src/collections_ui/trash.rs#L78), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:772`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L772), [`ui/dr-ui/src/library/cells.rs:158`](../../ui/dr-ui/src/library/cells.rs#L158), [`ui/dr-ui/src/library/cells.rs:192`](../../ui/dr-ui/src/library/cells.rs#L192), [`ui/dr-ui/src/library/filters.rs:12`](../../ui/dr-ui/src/library/filters.rs#L12), [`ui/dr-ui/src/library/filters.rs:29`](../../ui/dr-ui/src/library/filters.rs#L29), [`ui/dr-ui/src/library/filters.rs:59`](../../ui/dr-ui/src/library/filters.rs#L59), [`ui/dr-ui/src/library_ui/controller.rs:232`](../../ui/dr-ui/src/library_ui/controller.rs#L232), [`ui/dr-ui/src/library_ui/controller.rs:909`](../../ui/dr-ui/src/library_ui/controller.rs#L909), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1), [`ui/dr-ui/ui/collections.slint:745`](../../ui/dr-ui/ui/collections.slint#L745) | | FR-CAT-1a | [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:56`](../../core/dr-types/src/lib.rs#L56), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:46`](../../platform/dr-plat/src/storage.rs#L46) | | 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) | -| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:105`](../../core/dr-sync/src/scan.rs#L105), [`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), [`ui/dr-ui/src/import.rs:463`](../../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui.rs:177`](../../ui/dr-ui/src/library_ui.rs#L177), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/src/library_ui.rs:6053`](../../ui/dr-ui/src/library_ui.rs#L6053), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/repairs.rs:687`](../../ui/dr-ui/src/repairs.rs#L687), [`ui/dr-ui/ui/app.slint:429`](../../ui/dr-ui/ui/app.slint#L429), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | -| 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), [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library/filters.rs:39`](../../ui/dr-ui/src/library/filters.rs#L39), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.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/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:1682`](../../core/dr-catalog/src/schema.rs#L1682), [`core/dr-decode/src/lib.rs:301`](../../core/dr-decode/src/lib.rs#L301), [`core/dr-decode/src/lib.rs:307`](../../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/lib.rs:317`](../../core/dr-decode/src/lib.rs#L317), [`core/dr-decode/src/lib.rs:444`](../../core/dr-decode/src/lib.rs#L444), [`core/dr-export/src/dng.rs:308`](../../core/dr-export/src/dng.rs#L308), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`ui/dr-ui/src/collections_ui/controller.rs:16`](../../ui/dr-ui/src/collections_ui/controller.rs#L16), [`ui/dr-ui/src/collections_ui/controller.rs:204`](../../ui/dr-ui/src/collections_ui/controller.rs#L204), [`ui/dr-ui/src/collections_ui/controller.rs:413`](../../ui/dr-ui/src/collections_ui/controller.rs#L413), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:143`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L143), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:249`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L249), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:308`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L308), [`ui/dr-ui/src/library/cells.rs:206`](../../ui/dr-ui/src/library/cells.rs#L206), [`ui/dr-ui/src/library_ui.rs:789`](../../ui/dr-ui/src/library_ui.rs#L789), [`ui/dr-ui/src/library_ui.rs:8226`](../../ui/dr-ui/src/library_ui.rs#L8226), [`ui/dr-ui/src/library_ui.rs:8237`](../../ui/dr-ui/src/library_ui.rs#L8237), [`ui/dr-ui/src/library_ui.rs:8250`](../../ui/dr-ui/src/library_ui.rs#L8250), [`ui/dr-ui/src/library_ui.rs:8265`](../../ui/dr-ui/src/library_ui.rs#L8265), [`ui/dr-ui/src/library_ui.rs:8274`](../../ui/dr-ui/src/library_ui.rs#L8274), [`ui/dr-ui/ui/app.slint:350`](../../ui/dr-ui/ui/app.slint#L350), [`ui/dr-ui/ui/app.slint:616`](../../ui/dr-ui/ui/app.slint#L616), [`ui/dr-ui/ui/library.slint:1360`](../../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246) | -| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.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-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/spans.rs:253`](../../ui/dr-ui/src/library/spans.rs#L253), [`ui/dr-ui/src/library_ui.rs:396`](../../ui/dr-ui/src/library_ui.rs#L396), [`ui/dr-ui/src/library_ui.rs:505`](../../ui/dr-ui/src/library_ui.rs#L505), [`ui/dr-ui/src/library_ui.rs:6822`](../../ui/dr-ui/src/library_ui.rs#L6822), [`ui/dr-ui/src/library_ui.rs:6875`](../../ui/dr-ui/src/library_ui.rs#L6875), [`ui/dr-ui/src/library_ui.rs:8366`](../../ui/dr-ui/src/library_ui.rs#L8366), [`ui/dr-ui/ui/app.slint:362`](../../ui/dr-ui/ui/app.slint#L362), [`ui/dr-ui/ui/app.slint:616`](../../ui/dr-ui/ui/app.slint#L616), [`ui/dr-ui/ui/app.slint:935`](../../ui/dr-ui/ui/app.slint#L935), [`ui/dr-ui/ui/library.slint:111`](../../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1552`](../../ui/dr-ui/ui/library.slint#L1552), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246), [`ui/dr-ui/ui/settings.slint:191`](../../ui/dr-ui/ui/settings.slint#L191) | -| 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/controller.rs:258`](../../ui/dr-ui/src/collections_ui/controller.rs#L258), [`ui/dr-ui/src/collections_ui/controller.rs:268`](../../ui/dr-ui/src/collections_ui/controller.rs#L268), [`ui/dr-ui/src/collections_ui/controller.rs:291`](../../ui/dr-ui/src/collections_ui/controller.rs#L291), [`ui/dr-ui/src/collections_ui/controller.rs:312`](../../ui/dr-ui/src/collections_ui/controller.rs#L312), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/drag.rs:183`](../../ui/dr-ui/src/collections_ui/drag.rs#L183), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/collections_ui/press.rs:257`](../../ui/dr-ui/src/collections_ui/press.rs#L257), [`ui/dr-ui/src/collections_ui/press.rs:489`](../../ui/dr-ui/src/collections_ui/press.rs#L489), [`ui/dr-ui/src/collections_ui/press.rs:53`](../../ui/dr-ui/src/collections_ui/press.rs#L53), [`ui/dr-ui/src/collections_ui/rename_menu.rs:171`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L171), [`ui/dr-ui/src/collections_ui/rename_menu.rs:21`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L21), [`ui/dr-ui/src/collections_ui/tree_sync.rs:240`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L240), [`ui/dr-ui/src/collections_ui/tree_sync.rs:311`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L311), [`ui/dr-ui/src/collections_ui/tree_sync.rs:333`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L333), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:116`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L116), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:162`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L162), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:422`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L422), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:495`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L495), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:555`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L555), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:319`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L319), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:365`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L365), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:475`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L475), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:531`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L531), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:139`](../../ui/dr-ui/src/library/spans.rs#L139), [`ui/dr-ui/src/library/spans.rs:58`](../../ui/dr-ui/src/library/spans.rs#L58), [`ui/dr-ui/src/library/spans.rs:84`](../../ui/dr-ui/src/library/spans.rs#L84), [`ui/dr-ui/src/library_ui.rs:2774`](../../ui/dr-ui/src/library_ui.rs#L2774), [`ui/dr-ui/src/library_ui.rs:4369`](../../ui/dr-ui/src/library_ui.rs#L4369), [`ui/dr-ui/src/library_ui.rs:5128`](../../ui/dr-ui/src/library_ui.rs#L5128), [`ui/dr-ui/ui/app.slint:3252`](../../ui/dr-ui/ui/app.slint#L3252), [`ui/dr-ui/ui/app.slint:353`](../../ui/dr-ui/ui/app.slint#L353), [`ui/dr-ui/ui/app.slint:611`](../../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:640`](../../ui/dr-ui/ui/app.slint#L640), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:587`](../../ui/dr-ui/ui/collections.slint#L587), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:2942`](../../ui/dr-ui/ui/library.slint#L2942), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998) | -| FR-CAT-8 | [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/ops/curve.rs:137`](../../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1400`](../../core/dr-pipeline/src/sidecar.rs#L1400), [`core/dr-pipeline/src/sidecar.rs:2232`](../../core/dr-pipeline/src/sidecar.rs#L2232), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`ui/dr-ui/src/develop/mask_ops.rs:1076`](../../ui/dr-ui/src/develop/mask_ops.rs#L1076), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/mask_ops.rs:178`](../../ui/dr-ui/src/develop/mask_ops.rs#L178), [`ui/dr-ui/src/develop/masks.rs:740`](../../ui/dr-ui/src/develop/masks.rs#L740), [`ui/dr-ui/src/develop/session.rs:561`](../../ui/dr-ui/src/develop/session.rs#L561), [`ui/dr-ui/src/develop/session.rs:590`](../../ui/dr-ui/src/develop/session.rs#L590), [`ui/dr-ui/src/export.rs:812`](../../ui/dr-ui/src/export.rs#L812), [`ui/dr-ui/src/lib.rs:1183`](../../ui/dr-ui/src/lib.rs#L1183), [`ui/dr-ui/src/lib.rs:1243`](../../ui/dr-ui/src/lib.rs#L1243), [`ui/dr-ui/src/lib.rs:2691`](../../ui/dr-ui/src/lib.rs#L2691), [`ui/dr-ui/src/lib.rs:2838`](../../ui/dr-ui/src/lib.rs#L2838), [`ui/dr-ui/src/lib.rs:758`](../../ui/dr-ui/src/lib.rs#L758), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:373`](../../ui/dr-ui/src/library/sidecar.rs#L373), [`ui/dr-ui/src/library/sidecar.rs:63`](../../ui/dr-ui/src/library/sidecar.rs#L63), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui.rs:1339`](../../ui/dr-ui/src/library_ui.rs#L1339), [`ui/dr-ui/src/library_ui.rs:6389`](../../ui/dr-ui/src/library_ui.rs#L6389), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | -| 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:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop/render.rs:584`](../../ui/dr-ui/src/develop/render.rs#L584), [`ui/dr-ui/src/develop/render.rs:627`](../../ui/dr-ui/src/develop/render.rs#L627), [`ui/dr-ui/src/library/filters.rs:84`](../../ui/dr-ui/src/library/filters.rs#L84), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:605`](../../ui/dr-ui/src/library/scan.rs#L605), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:357`](../../ui/dr-ui/src/library/sidecar.rs#L357), [`ui/dr-ui/src/library/sidecar.rs:373`](../../ui/dr-ui/src/library/sidecar.rs#L373), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/spans.rs:361`](../../ui/dr-ui/src/library/spans.rs#L361), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library/thumbnails_gen.rs:74`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L74), [`ui/dr-ui/src/library_ui.rs:1406`](../../ui/dr-ui/src/library_ui.rs#L1406), [`ui/dr-ui/src/library_ui.rs:1997`](../../ui/dr-ui/src/library_ui.rs#L1997), [`ui/dr-ui/src/library_ui.rs:2041`](../../ui/dr-ui/src/library_ui.rs#L2041), [`ui/dr-ui/src/library_ui.rs:2057`](../../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:2151`](../../ui/dr-ui/src/library_ui.rs#L2151), [`ui/dr-ui/src/library_ui.rs:284`](../../ui/dr-ui/src/library_ui.rs#L284), [`ui/dr-ui/src/library_ui.rs:2993`](../../ui/dr-ui/src/library_ui.rs#L2993), [`ui/dr-ui/src/library_ui.rs:320`](../../ui/dr-ui/src/library_ui.rs#L320), [`ui/dr-ui/src/library_ui.rs:328`](../../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:3616`](../../ui/dr-ui/src/library_ui.rs#L3616), [`ui/dr-ui/src/library_ui.rs:3838`](../../ui/dr-ui/src/library_ui.rs#L3838), [`ui/dr-ui/src/library_ui.rs:4116`](../../ui/dr-ui/src/library_ui.rs#L4116), [`ui/dr-ui/src/library_ui.rs:4232`](../../ui/dr-ui/src/library_ui.rs#L4232), [`ui/dr-ui/src/library_ui.rs:4420`](../../ui/dr-ui/src/library_ui.rs#L4420), [`ui/dr-ui/src/library_ui.rs:4542`](../../ui/dr-ui/src/library_ui.rs#L4542), [`ui/dr-ui/src/library_ui.rs:573`](../../ui/dr-ui/src/library_ui.rs#L573), [`ui/dr-ui/src/library_ui.rs:662`](../../ui/dr-ui/src/library_ui.rs#L662), [`ui/dr-ui/src/library_ui.rs:6928`](../../ui/dr-ui/src/library_ui.rs#L6928), [`ui/dr-ui/src/library_ui.rs:7010`](../../ui/dr-ui/src/library_ui.rs#L7010), [`ui/dr-ui/src/presets.rs:486`](../../ui/dr-ui/src/presets.rs#L486), [`ui/dr-ui/src/presets.rs:498`](../../ui/dr-ui/src/presets.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | +| FR-CAT-3 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/walk.rs:66`](../../core/dr-catalog/src/walk.rs#L66), [`core/dr-sync/src/scan.rs:105`](../../core/dr-sync/src/scan.rs#L105), [`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), [`ui/dr-ui/src/import.rs:463`](../../ui/dr-ui/src/import.rs#L463), [`ui/dr-ui/src/import.rs:488`](../../ui/dr-ui/src/import.rs#L488), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui/controller.rs:171`](../../ui/dr-ui/src/library_ui/controller.rs#L171), [`ui/dr-ui/src/library_ui/grid.rs:443`](../../ui/dr-ui/src/library_ui/grid.rs#L443), [`ui/dr-ui/src/library_ui/sync.rs:363`](../../ui/dr-ui/src/library_ui/sync.rs#L363), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/repairs.rs:687`](../../ui/dr-ui/src/repairs.rs#L687), [`ui/dr-ui/ui/app.slint:429`](../../ui/dr-ui/ui/app.slint#L429), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | +| 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), [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library/filters.rs:39`](../../ui/dr-ui/src/library/filters.rs#L39), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui/mod.rs:1`](../../ui/dr-ui/src/library_ui/mod.rs#L1) | +| FR-CAT-5 | [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:1682`](../../core/dr-catalog/src/schema.rs#L1682), [`core/dr-decode/src/lib.rs:301`](../../core/dr-decode/src/lib.rs#L301), [`core/dr-decode/src/lib.rs:307`](../../core/dr-decode/src/lib.rs#L307), [`core/dr-decode/src/lib.rs:317`](../../core/dr-decode/src/lib.rs#L317), [`core/dr-decode/src/lib.rs:444`](../../core/dr-decode/src/lib.rs#L444), [`core/dr-export/src/dng.rs:308`](../../core/dr-export/src/dng.rs#L308), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`ui/dr-ui/src/collections_ui/controller.rs:16`](../../ui/dr-ui/src/collections_ui/controller.rs#L16), [`ui/dr-ui/src/collections_ui/controller.rs:204`](../../ui/dr-ui/src/collections_ui/controller.rs#L204), [`ui/dr-ui/src/collections_ui/controller.rs:413`](../../ui/dr-ui/src/collections_ui/controller.rs#L413), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:143`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L143), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:249`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L249), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:308`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L308), [`ui/dr-ui/src/library/cells.rs:206`](../../ui/dr-ui/src/library/cells.rs#L206), [`ui/dr-ui/src/library_ui/controller.rs:784`](../../ui/dr-ui/src/library_ui/controller.rs#L784), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1011`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1011), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:1020`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L1020), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:972`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L972), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:983`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L983), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:996`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L996), [`ui/dr-ui/ui/app.slint:350`](../../ui/dr-ui/ui/app.slint#L350), [`ui/dr-ui/ui/app.slint:616`](../../ui/dr-ui/ui/app.slint#L616), [`ui/dr-ui/ui/library.slint:1360`](../../ui/dr-ui/ui/library.slint#L1360), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1437`](../../ui/dr-ui/ui/library.slint#L1437), [`ui/dr-ui/ui/library.slint:19`](../../ui/dr-ui/ui/library.slint#L19), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246) | +| FR-CAT-6 | [`core/dr-catalog/src/collections.rs:1`](../../core/dr-catalog/src/collections.rs#L1), [`core/dr-catalog/src/keywords.rs:1`](../../core/dr-catalog/src/keywords.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-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`core/dr-types/src/selector.rs:1`](../../core/dr-types/src/selector.rs#L1), [`core/dr-types/src/settings.rs:64`](../../core/dr-types/src/settings.rs#L64), [`core/dr-types/src/time.rs:67`](../../core/dr-types/src/time.rs#L67), [`core/dr-types/src/time.rs:90`](../../core/dr-types/src/time.rs#L90), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/spans.rs:253`](../../ui/dr-ui/src/library/spans.rs#L253), [`ui/dr-ui/src/library_ui/controller.rs:391`](../../ui/dr-ui/src/library_ui/controller.rs#L391), [`ui/dr-ui/src/library_ui/controller.rs:500`](../../ui/dr-ui/src/library_ui/controller.rs#L500), [`ui/dr-ui/src/library_ui/filter_bar.rs:274`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L274), [`ui/dr-ui/src/library_ui/filter_bar.rs:327`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L327), [`ui/dr-ui/src/library_ui/filter_bar.rs:678`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L678), [`ui/dr-ui/ui/app.slint:362`](../../ui/dr-ui/ui/app.slint#L362), [`ui/dr-ui/ui/app.slint:616`](../../ui/dr-ui/ui/app.slint#L616), [`ui/dr-ui/ui/app.slint:935`](../../ui/dr-ui/ui/app.slint#L935), [`ui/dr-ui/ui/library.slint:111`](../../ui/dr-ui/ui/library.slint#L111), [`ui/dr-ui/ui/library.slint:1552`](../../ui/dr-ui/ui/library.slint#L1552), [`ui/dr-ui/ui/library.slint:4246`](../../ui/dr-ui/ui/library.slint#L4246), [`ui/dr-ui/ui/settings.slint:191`](../../ui/dr-ui/ui/settings.slint#L191) | +| 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/controller.rs:258`](../../ui/dr-ui/src/collections_ui/controller.rs#L258), [`ui/dr-ui/src/collections_ui/controller.rs:268`](../../ui/dr-ui/src/collections_ui/controller.rs#L268), [`ui/dr-ui/src/collections_ui/controller.rs:291`](../../ui/dr-ui/src/collections_ui/controller.rs#L291), [`ui/dr-ui/src/collections_ui/controller.rs:312`](../../ui/dr-ui/src/collections_ui/controller.rs#L312), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/drag.rs:183`](../../ui/dr-ui/src/collections_ui/drag.rs#L183), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/collections_ui/press.rs:257`](../../ui/dr-ui/src/collections_ui/press.rs#L257), [`ui/dr-ui/src/collections_ui/press.rs:489`](../../ui/dr-ui/src/collections_ui/press.rs#L489), [`ui/dr-ui/src/collections_ui/press.rs:53`](../../ui/dr-ui/src/collections_ui/press.rs#L53), [`ui/dr-ui/src/collections_ui/rename_menu.rs:171`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L171), [`ui/dr-ui/src/collections_ui/rename_menu.rs:21`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L21), [`ui/dr-ui/src/collections_ui/tree_sync.rs:240`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L240), [`ui/dr-ui/src/collections_ui/tree_sync.rs:311`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L311), [`ui/dr-ui/src/collections_ui/tree_sync.rs:333`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L333), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:116`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L116), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:162`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L162), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:422`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L422), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:495`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L495), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:555`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L555), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:319`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L319), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:365`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L365), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:475`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L475), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:531`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L531), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/spans.rs:139`](../../ui/dr-ui/src/library/spans.rs#L139), [`ui/dr-ui/src/library/spans.rs:58`](../../ui/dr-ui/src/library/spans.rs#L58), [`ui/dr-ui/src/library/spans.rs:84`](../../ui/dr-ui/src/library/spans.rs#L84), [`ui/dr-ui/src/library_ui/sync.rs:217`](../../ui/dr-ui/src/library_ui/sync.rs#L217), [`ui/dr-ui/src/library_ui/timeline.rs:492`](../../ui/dr-ui/src/library_ui/timeline.rs#L492), [`ui/dr-ui/src/library_ui/window.rs:271`](../../ui/dr-ui/src/library_ui/window.rs#L271), [`ui/dr-ui/ui/app.slint:3252`](../../ui/dr-ui/ui/app.slint#L3252), [`ui/dr-ui/ui/app.slint:353`](../../ui/dr-ui/ui/app.slint#L353), [`ui/dr-ui/ui/app.slint:611`](../../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:640`](../../ui/dr-ui/ui/app.slint#L640), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:587`](../../ui/dr-ui/ui/collections.slint#L587), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:2942`](../../ui/dr-ui/ui/library.slint#L2942), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998) | +| FR-CAT-8 | [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-pipeline/src/coverage.rs:1`](../../core/dr-pipeline/src/coverage.rs#L1), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/ops/curve.rs:137`](../../core/dr-pipeline/src/ops/curve.rs#L137), [`core/dr-pipeline/src/ops/curve.rs:656`](../../core/dr-pipeline/src/ops/curve.rs#L656), [`core/dr-pipeline/src/sidecar.rs:1400`](../../core/dr-pipeline/src/sidecar.rs#L1400), [`core/dr-pipeline/src/sidecar.rs:2232`](../../core/dr-pipeline/src/sidecar.rs#L2232), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`core/dr-pipeline/tests/tone_curve.rs:34`](../../core/dr-pipeline/tests/tone_curve.rs#L34), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`ui/dr-ui/src/develop/mask_ops.rs:1076`](../../ui/dr-ui/src/develop/mask_ops.rs#L1076), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/mask_ops.rs:178`](../../ui/dr-ui/src/develop/mask_ops.rs#L178), [`ui/dr-ui/src/develop/masks.rs:740`](../../ui/dr-ui/src/develop/masks.rs#L740), [`ui/dr-ui/src/develop/session.rs:561`](../../ui/dr-ui/src/develop/session.rs#L561), [`ui/dr-ui/src/develop/session.rs:590`](../../ui/dr-ui/src/develop/session.rs#L590), [`ui/dr-ui/src/export.rs:812`](../../ui/dr-ui/src/export.rs#L812), [`ui/dr-ui/src/lib.rs:1183`](../../ui/dr-ui/src/lib.rs#L1183), [`ui/dr-ui/src/lib.rs:1243`](../../ui/dr-ui/src/lib.rs#L1243), [`ui/dr-ui/src/lib.rs:2691`](../../ui/dr-ui/src/lib.rs#L2691), [`ui/dr-ui/src/lib.rs:2838`](../../ui/dr-ui/src/lib.rs#L2838), [`ui/dr-ui/src/lib.rs:758`](../../ui/dr-ui/src/lib.rs#L758), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:373`](../../ui/dr-ui/src/library/sidecar.rs#L373), [`ui/dr-ui/src/library/sidecar.rs:63`](../../ui/dr-ui/src/library/sidecar.rs#L63), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui/grid.rs:667`](../../ui/dr-ui/src/library_ui/grid.rs#L667), [`ui/dr-ui/src/library_ui/open.rs:440`](../../ui/dr-ui/src/library_ui/open.rs#L440), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | +| 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:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:162`](../../core/dr-catalog/src/walk.rs#L162), [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-catalog/src/walk.rs:728`](../../core/dr-catalog/src/walk.rs#L728), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`ui/dr-ui/src/develop/render.rs:584`](../../ui/dr-ui/src/develop/render.rs#L584), [`ui/dr-ui/src/develop/render.rs:627`](../../ui/dr-ui/src/develop/render.rs#L627), [`ui/dr-ui/src/library/filters.rs:84`](../../ui/dr-ui/src/library/filters.rs#L84), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:605`](../../ui/dr-ui/src/library/scan.rs#L605), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:357`](../../ui/dr-ui/src/library/sidecar.rs#L357), [`ui/dr-ui/src/library/sidecar.rs:373`](../../ui/dr-ui/src/library/sidecar.rs#L373), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/spans.rs:361`](../../ui/dr-ui/src/library/spans.rs#L361), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library/thumbnails_gen.rs:74`](../../ui/dr-ui/src/library/thumbnails_gen.rs#L74), [`ui/dr-ui/src/library_ui/controller.rs:279`](../../ui/dr-ui/src/library_ui/controller.rs#L279), [`ui/dr-ui/src/library_ui/controller.rs:315`](../../ui/dr-ui/src/library_ui/controller.rs#L315), [`ui/dr-ui/src/library_ui/controller.rs:323`](../../ui/dr-ui/src/library_ui/controller.rs#L323), [`ui/dr-ui/src/library_ui/controller.rs:568`](../../ui/dr-ui/src/library_ui/controller.rs#L568), [`ui/dr-ui/src/library_ui/controller.rs:657`](../../ui/dr-ui/src/library_ui/controller.rs#L657), [`ui/dr-ui/src/library_ui/filter_bar.rs:380`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L380), [`ui/dr-ui/src/library_ui/filter_bar.rs:462`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L462), [`ui/dr-ui/src/library_ui/offline.rs:538`](../../ui/dr-ui/src/library_ui/offline.rs#L538), [`ui/dr-ui/src/library_ui/offline.rs:582`](../../ui/dr-ui/src/library_ui/offline.rs#L582), [`ui/dr-ui/src/library_ui/offline.rs:598`](../../ui/dr-ui/src/library_ui/offline.rs#L598), [`ui/dr-ui/src/library_ui/open.rs:507`](../../ui/dr-ui/src/library_ui/open.rs#L507), [`ui/dr-ui/src/library_ui/open.rs:583`](../../ui/dr-ui/src/library_ui/open.rs#L583), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:692`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L692), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:69`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L69), [`ui/dr-ui/src/library_ui/sync.rs:268`](../../ui/dr-ui/src/library_ui/sync.rs#L268), [`ui/dr-ui/src/library_ui/sync.rs:390`](../../ui/dr-ui/src/library_ui/sync.rs#L390), [`ui/dr-ui/src/library_ui/sync.rs:80`](../../ui/dr-ui/src/library_ui/sync.rs#L80), [`ui/dr-ui/src/library_ui/window.rs:545`](../../ui/dr-ui/src/library_ui/window.rs#L545), [`ui/dr-ui/src/library_ui/window.rs:823`](../../ui/dr-ui/src/library_ui/window.rs#L823), [`ui/dr-ui/src/presets.rs:486`](../../ui/dr-ui/src/presets.rs#L486), [`ui/dr-ui/src/presets.rs:498`](../../ui/dr-ui/src/presets.rs#L498), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-CULL-1 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | | FR-CULL-10 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:603`](../../core/dr-catalog/src/schema.rs#L603), [`core/dr-catalog/src/schema.rs:781`](../../core/dr-catalog/src/schema.rs#L781), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/develop/segmentation.rs:104`](../../ui/dr-ui/src/develop/segmentation.rs#L104), [`ui/dr-ui/src/develop/segmentation.rs:170`](../../ui/dr-ui/src/develop/segmentation.rs#L170), [`ui/dr-ui/src/develop/segmentation.rs:590`](../../ui/dr-ui/src/develop/segmentation.rs#L590), [`ui/dr-ui/src/develop/segmentation.rs:95`](../../ui/dr-ui/src/develop/segmentation.rs#L95), [`ui/dr-ui/src/develop/session.rs:23`](../../ui/dr-ui/src/develop/session.rs#L23), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1434`](../../ui/dr-ui/src/identity_ui.rs#L1434), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/lib.rs:2810`](../../ui/dr-ui/src/lib.rs#L2810), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | | FR-CULL-11 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`ui/dr-ui/src/identity.rs:1`](../../ui/dr-ui/src/identity.rs#L1), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/library/filters.rs:105`](../../ui/dr-ui/src/library/filters.rs#L105), [`ui/dr-ui/src/library/filters.rs:155`](../../ui/dr-ui/src/library/filters.rs#L155), [`ui/dr-ui/ui/identity.slint:1`](../../ui/dr-ui/ui/identity.slint#L1) | @@ -58,7 +58,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-CULL-3 | [`core/dr-gpu/src/focus.rs:154`](../../core/dr-gpu/src/focus.rs#L154), [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/raw_histogram.rs:129`](../../core/dr-gpu/src/raw_histogram.rs#L129), [`core/dr-gpu/src/raw_histogram.rs:1`](../../core/dr-gpu/src/raw_histogram.rs#L1), [`core/dr-gpu/src/raw_histogram.rs:272`](../../core/dr-gpu/src/raw_histogram.rs#L272), [`core/dr-gpu/src/raw_histogram.rs:407`](../../core/dr-gpu/src/raw_histogram.rs#L407), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`core/dr-gpu/src/shaders/raw_histogram.wgsl:1`](../../core/dr-gpu/src/shaders/raw_histogram.wgsl#L1), [`ui/dr-ui/src/develop/render.rs:1203`](../../ui/dr-ui/src/develop/render.rs#L1203), [`ui/dr-ui/src/develop/render.rs:1267`](../../ui/dr-ui/src/develop/render.rs#L1267), [`ui/dr-ui/src/develop/render.rs:1290`](../../ui/dr-ui/src/develop/render.rs#L1290), [`ui/dr-ui/src/develop/render.rs:256`](../../ui/dr-ui/src/develop/render.rs#L256), [`ui/dr-ui/src/develop/render.rs:269`](../../ui/dr-ui/src/develop/render.rs#L269), [`ui/dr-ui/src/develop/render.rs:314`](../../ui/dr-ui/src/develop/render.rs#L314), [`ui/dr-ui/src/develop/render.rs:325`](../../ui/dr-ui/src/develop/render.rs#L325), [`ui/dr-ui/src/develop/render.rs:331`](../../ui/dr-ui/src/develop/render.rs#L331), [`ui/dr-ui/src/develop/render.rs:348`](../../ui/dr-ui/src/develop/render.rs#L348), [`ui/dr-ui/src/develop/session.rs:118`](../../ui/dr-ui/src/develop/session.rs#L118), [`ui/dr-ui/src/develop/session.rs:138`](../../ui/dr-ui/src/develop/session.rs#L138), [`ui/dr-ui/src/develop/session.rs:144`](../../ui/dr-ui/src/develop/session.rs#L144), [`ui/dr-ui/src/develop_ui.rs:1199`](../../ui/dr-ui/src/develop_ui.rs#L1199), [`ui/dr-ui/src/histogram.rs:1`](../../ui/dr-ui/src/histogram.rs#L1), [`ui/dr-ui/src/histogram.rs:208`](../../ui/dr-ui/src/histogram.rs#L208), [`ui/dr-ui/src/histogram.rs:228`](../../ui/dr-ui/src/histogram.rs#L228), [`ui/dr-ui/src/histogram.rs:272`](../../ui/dr-ui/src/histogram.rs#L272), [`ui/dr-ui/src/histogram.rs:544`](../../ui/dr-ui/src/histogram.rs#L544), [`ui/dr-ui/src/histogram.rs:565`](../../ui/dr-ui/src/histogram.rs#L565), [`ui/dr-ui/src/histogram.rs:593`](../../ui/dr-ui/src/histogram.rs#L593), [`ui/dr-ui/src/histogram.rs:621`](../../ui/dr-ui/src/histogram.rs#L621), [`ui/dr-ui/src/histogram.rs:658`](../../ui/dr-ui/src/histogram.rs#L658), [`ui/dr-ui/src/lib.rs:1272`](../../ui/dr-ui/src/lib.rs#L1272), [`ui/dr-ui/src/lib.rs:2211`](../../ui/dr-ui/src/lib.rs#L2211), [`ui/dr-ui/src/lib.rs:2324`](../../ui/dr-ui/src/lib.rs#L2324), [`ui/dr-ui/src/lib.rs:2365`](../../ui/dr-ui/src/lib.rs#L2365), [`ui/dr-ui/src/lib.rs:443`](../../ui/dr-ui/src/lib.rs#L443), [`ui/dr-ui/src/peaking.rs:1`](../../ui/dr-ui/src/peaking.rs#L1), [`ui/dr-ui/ui/app.slint:129`](../../ui/dr-ui/ui/app.slint#L129), [`ui/dr-ui/ui/app.slint:2067`](../../ui/dr-ui/ui/app.slint#L2067), [`ui/dr-ui/ui/app.slint:3074`](../../ui/dr-ui/ui/app.slint#L3074), [`ui/dr-ui/ui/peaking.slint:1`](../../ui/dr-ui/ui/peaking.slint#L1), [`ui/dr-ui/ui/peaking.slint:25`](../../ui/dr-ui/ui/peaking.slint#L25), [`ui/dr-ui/ui/peaking.slint:56`](../../ui/dr-ui/ui/peaking.slint#L56), [`ui/dr-ui/ui/peaking.slint:91`](../../ui/dr-ui/ui/peaking.slint#L91) | | FR-CULL-4 | [`core/dr-catalog/src/rating.rs:1`](../../core/dr-catalog/src/rating.rs#L1), [`core/dr-pipeline/src/sidecar.rs:171`](../../core/dr-pipeline/src/sidecar.rs#L171), [`core/dr-xmp/src/lib.rs:442`](../../core/dr-xmp/src/lib.rs#L442), [`ui/dr-ui/src/library/filters.rs:64`](../../ui/dr-ui/src/library/filters.rs#L64), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11) | | FR-CULL-5 | [`core/dr-catalog/src/bursts.rs:1`](../../core/dr-catalog/src/bursts.rs#L1), [`core/dr-catalog/src/schema.rs:646`](../../core/dr-catalog/src/schema.rs#L646), [`ui/dr-ui/src/bursts.rs:1`](../../ui/dr-ui/src/bursts.rs#L1), [`ui/dr-ui/src/library/cells.rs:19`](../../ui/dr-ui/src/library/cells.rs#L19), [`ui/dr-ui/src/library/cells.rs:769`](../../ui/dr-ui/src/library/cells.rs#L769) | -| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:714`](../../core/dr-catalog/src/schema.rs#L714), [`core/dr-catalog/src/schema.rs:970`](../../core/dr-catalog/src/schema.rs#L970), [`core/dr-face/src/align.rs:296`](../../core/dr-face/src/align.rs#L296), [`core/dr-types/src/settings.rs:176`](../../core/dr-types/src/settings.rs#L176), [`ui/dr-ui/examples/face_detectors.rs:1`](../../ui/dr-ui/examples/face_detectors.rs#L1), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/derived_sync.rs:124`](../../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:446`](../../ui/dr-ui/src/faces.rs#L446), [`ui/dr-ui/src/faces.rs:462`](../../ui/dr-ui/src/faces.rs#L462), [`ui/dr-ui/src/faces.rs:584`](../../ui/dr-ui/src/faces.rs#L584), [`ui/dr-ui/src/identity_ui.rs:1434`](../../ui/dr-ui/src/identity_ui.rs#L1434), [`ui/dr-ui/src/identity_ui.rs:617`](../../ui/dr-ui/src/identity_ui.rs#L617), [`ui/dr-ui/src/lib.rs:1739`](../../ui/dr-ui/src/lib.rs#L1739), [`ui/dr-ui/src/lib.rs:3098`](../../ui/dr-ui/src/lib.rs#L3098), [`ui/dr-ui/src/library/sweep.rs:241`](../../ui/dr-ui/src/library/sweep.rs#L241), [`ui/dr-ui/src/library/sweep.rs:506`](../../ui/dr-ui/src/library/sweep.rs#L506), [`ui/dr-ui/src/library_ui.rs:406`](../../ui/dr-ui/src/library_ui.rs#L406), [`ui/dr-ui/src/library_ui.rs:493`](../../ui/dr-ui/src/library_ui.rs#L493), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/settings_ui.rs:210`](../../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:571`](../../ui/dr-ui/src/settings_ui.rs#L571), [`ui/dr-ui/ui/app.slint:985`](../../ui/dr-ui/ui/app.slint#L985), [`ui/dr-ui/ui/settings.slint:100`](../../ui/dr-ui/ui/settings.slint#L100), [`ui/dr-ui/ui/settings.slint:467`](../../ui/dr-ui/ui/settings.slint#L467), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:82`](../../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | +| FR-CULL-8 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:714`](../../core/dr-catalog/src/schema.rs#L714), [`core/dr-catalog/src/schema.rs:970`](../../core/dr-catalog/src/schema.rs#L970), [`core/dr-face/src/align.rs:296`](../../core/dr-face/src/align.rs#L296), [`core/dr-types/src/settings.rs:176`](../../core/dr-types/src/settings.rs#L176), [`ui/dr-ui/examples/face_detectors.rs:1`](../../ui/dr-ui/examples/face_detectors.rs#L1), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/derived_sync.rs:124`](../../ui/dr-ui/src/derived_sync.rs#L124), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:446`](../../ui/dr-ui/src/faces.rs#L446), [`ui/dr-ui/src/faces.rs:462`](../../ui/dr-ui/src/faces.rs#L462), [`ui/dr-ui/src/faces.rs:584`](../../ui/dr-ui/src/faces.rs#L584), [`ui/dr-ui/src/identity_ui.rs:1434`](../../ui/dr-ui/src/identity_ui.rs#L1434), [`ui/dr-ui/src/identity_ui.rs:617`](../../ui/dr-ui/src/identity_ui.rs#L617), [`ui/dr-ui/src/lib.rs:1739`](../../ui/dr-ui/src/lib.rs#L1739), [`ui/dr-ui/src/lib.rs:3098`](../../ui/dr-ui/src/lib.rs#L3098), [`ui/dr-ui/src/library/sweep.rs:241`](../../ui/dr-ui/src/library/sweep.rs#L241), [`ui/dr-ui/src/library/sweep.rs:506`](../../ui/dr-ui/src/library/sweep.rs#L506), [`ui/dr-ui/src/library_ui/controller.rs:401`](../../ui/dr-ui/src/library_ui/controller.rs#L401), [`ui/dr-ui/src/library_ui/controller.rs:488`](../../ui/dr-ui/src/library_ui/controller.rs#L488), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1), [`ui/dr-ui/src/settings_ui.rs:210`](../../ui/dr-ui/src/settings_ui.rs#L210), [`ui/dr-ui/src/settings_ui.rs:571`](../../ui/dr-ui/src/settings_ui.rs#L571), [`ui/dr-ui/ui/app.slint:985`](../../ui/dr-ui/ui/app.slint#L985), [`ui/dr-ui/ui/settings.slint:100`](../../ui/dr-ui/ui/settings.slint#L100), [`ui/dr-ui/ui/settings.slint:467`](../../ui/dr-ui/ui/settings.slint#L467), [`ui/dr-ui/ui/settings.slint:529`](../../ui/dr-ui/ui/settings.slint#L529), [`ui/dr-ui/ui/settings.slint:82`](../../ui/dr-ui/ui/settings.slint#L82), [`ui/dr-ui/ui/settings.slint:94`](../../ui/dr-ui/ui/settings.slint#L94) | | FR-CULL-8a | [`core/dr-catalog/src/faces.rs:124`](../../core/dr-catalog/src/faces.rs#L124), [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/classify.rs:1`](../../core/dr-face/src/classify.rs#L1), [`core/dr-face/src/eyes.rs:1`](../../core/dr-face/src/eyes.rs#L1), [`core/dr-face/src/landmarks.rs:1`](../../core/dr-face/src/landmarks.rs#L1), [`core/dr-types/src/place.rs:129`](../../core/dr-types/src/place.rs#L129), [`ui/dr-ui/src/faces.rs:166`](../../ui/dr-ui/src/faces.rs#L166), [`ui/dr-ui/src/faces.rs:1821`](../../ui/dr-ui/src/faces.rs#L1821), [`ui/dr-ui/src/faces.rs:308`](../../ui/dr-ui/src/faces.rs#L308), [`ui/dr-ui/src/identity.rs:104`](../../ui/dr-ui/src/identity.rs#L104), [`ui/dr-ui/src/library/filters.rs:131`](../../ui/dr-ui/src/library/filters.rs#L131), [`ui/dr-ui/src/library/filters.rs:683`](../../ui/dr-ui/src/library/filters.rs#L683), [`ui/dr-ui/src/library/filters.rs:726`](../../ui/dr-ui/src/library/filters.rs#L726), [`ui/dr-ui/src/library/filters.rs:764`](../../ui/dr-ui/src/library/filters.rs#L764), [`ui/dr-ui/src/repairs.rs:571`](../../ui/dr-ui/src/repairs.rs#L571), [`ui/dr-ui/ui/app.slint:748`](../../ui/dr-ui/ui/app.slint#L748), [`ui/dr-ui/ui/identity.slint:273`](../../ui/dr-ui/ui/identity.slint#L273), [`ui/dr-ui/ui/identity.slint:63`](../../ui/dr-ui/ui/identity.slint#L63), [`ui/dr-ui/ui/library.slint:1512`](../../ui/dr-ui/ui/library.slint#L1512) | | FR-CULL-9 | [`core/dr-catalog/src/faces.rs:1`](../../core/dr-catalog/src/faces.rs#L1), [`core/dr-catalog/src/schema.rs:1011`](../../core/dr-catalog/src/schema.rs#L1011), [`core/dr-catalog/src/schema.rs:781`](../../core/dr-catalog/src/schema.rs#L781), [`core/dr-face/src/assign.rs:1`](../../core/dr-face/src/assign.rs#L1), [`core/dr-face/src/neighbours.rs:1`](../../core/dr-face/src/neighbours.rs#L1), [`core/dr-types/src/settings.rs:129`](../../core/dr-types/src/settings.rs#L129), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/faces.rs:584`](../../ui/dr-ui/src/faces.rs#L584), [`ui/dr-ui/src/identity_ui.rs:1`](../../ui/dr-ui/src/identity_ui.rs#L1), [`ui/dr-ui/src/repairs.rs:517`](../../ui/dr-ui/src/repairs.rs#L517), [`ui/dr-ui/ui/identity.slint:347`](../../ui/dr-ui/ui/identity.slint#L347) | | FR-DEV-1 | [`core/dr-pipeline/src/graph.rs:1`](../../core/dr-pipeline/src/graph.rs#L1), [`core/dr-pipeline/src/sidecar.rs:1`](../../core/dr-pipeline/src/sidecar.rs#L1) | @@ -82,7 +82,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-DEV-3i | [`core/dr-pipeline/src/mask.rs:558`](../../core/dr-pipeline/src/mask.rs#L558), [`core/dr-segment/src/lib.rs:1`](../../core/dr-segment/src/lib.rs#L1), [`ui/dr-ui/src/segmentation.rs:1`](../../ui/dr-ui/src/segmentation.rs#L1) | | FR-DEV-4 | [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:368`](../../core/dr-gpu/src/lib.rs#L368), [`ui/dr-ui/ui/crop.slint:1`](../../ui/dr-ui/ui/crop.slint#L1) | | FR-DEV-5 | [`core/dr-pipeline/src/graph.rs:1440`](../../core/dr-pipeline/src/graph.rs#L1440), [`core/dr-pipeline/src/graph.rs:587`](../../core/dr-pipeline/src/graph.rs#L587), [`core/dr-pipeline/src/graph.rs:640`](../../core/dr-pipeline/src/graph.rs#L640), [`core/dr-pipeline/src/history.rs:102`](../../core/dr-pipeline/src/history.rs#L102), [`core/dr-pipeline/src/history.rs:110`](../../core/dr-pipeline/src/history.rs#L110), [`core/dr-pipeline/src/history.rs:127`](../../core/dr-pipeline/src/history.rs#L127), [`core/dr-pipeline/src/history.rs:184`](../../core/dr-pipeline/src/history.rs#L184), [`core/dr-pipeline/src/history.rs:1`](../../core/dr-pipeline/src/history.rs#L1), [`core/dr-pipeline/src/history.rs:214`](../../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:234`](../../core/dr-pipeline/src/history.rs#L234), [`core/dr-pipeline/src/history.rs:293`](../../core/dr-pipeline/src/history.rs#L293), [`core/dr-pipeline/src/history.rs:479`](../../core/dr-pipeline/src/history.rs#L479), [`core/dr-pipeline/src/history.rs:489`](../../core/dr-pipeline/src/history.rs#L489), [`core/dr-pipeline/src/history.rs:499`](../../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../../core/dr-pipeline/src/history.rs#L526), [`core/dr-pipeline/src/history.rs:86`](../../core/dr-pipeline/src/history.rs#L86), [`core/dr-pipeline/src/sidecar.rs:1045`](../../core/dr-pipeline/src/sidecar.rs#L1045), [`core/dr-pipeline/src/sidecar.rs:144`](../../core/dr-pipeline/src/sidecar.rs#L144), [`core/dr-pipeline/src/sidecar.rs:2472`](../../core/dr-pipeline/src/sidecar.rs#L2472), [`core/dr-pipeline/src/sidecar.rs:2508`](../../core/dr-pipeline/src/sidecar.rs#L2508), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:303`](../../core/dr-pipeline/src/sidecar.rs#L303), [`core/dr-pipeline/src/sidecar.rs:694`](../../core/dr-pipeline/src/sidecar.rs#L694), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:857`](../../core/dr-pipeline/src/sidecar.rs#L857), [`core/dr-pipeline/src/state.rs:1`](../../core/dr-pipeline/src/state.rs#L1), [`core/dr-pipeline/src/state.rs:75`](../../core/dr-pipeline/src/state.rs#L75), [`ui/dr-ui/src/develop/framing.rs:720`](../../ui/dr-ui/src/develop/framing.rs#L720), [`ui/dr-ui/src/develop/history.rs:11`](../../ui/dr-ui/src/develop/history.rs#L11), [`ui/dr-ui/src/develop/history.rs:152`](../../ui/dr-ui/src/develop/history.rs#L152), [`ui/dr-ui/src/develop/history.rs:165`](../../ui/dr-ui/src/develop/history.rs#L165), [`ui/dr-ui/src/develop/history.rs:178`](../../ui/dr-ui/src/develop/history.rs#L178), [`ui/dr-ui/src/develop/history.rs:190`](../../ui/dr-ui/src/develop/history.rs#L190), [`ui/dr-ui/src/develop/history.rs:206`](../../ui/dr-ui/src/develop/history.rs#L206), [`ui/dr-ui/src/develop/history.rs:238`](../../ui/dr-ui/src/develop/history.rs#L238), [`ui/dr-ui/src/develop/history.rs:242`](../../ui/dr-ui/src/develop/history.rs#L242), [`ui/dr-ui/src/develop/history.rs:261`](../../ui/dr-ui/src/develop/history.rs#L261), [`ui/dr-ui/src/develop/history.rs:277`](../../ui/dr-ui/src/develop/history.rs#L277), [`ui/dr-ui/src/develop/history.rs:299`](../../ui/dr-ui/src/develop/history.rs#L299), [`ui/dr-ui/src/develop/history.rs:29`](../../ui/dr-ui/src/develop/history.rs#L29), [`ui/dr-ui/src/develop/history.rs:359`](../../ui/dr-ui/src/develop/history.rs#L359), [`ui/dr-ui/src/develop/history.rs:68`](../../ui/dr-ui/src/develop/history.rs#L68), [`ui/dr-ui/src/develop/history.rs:82`](../../ui/dr-ui/src/develop/history.rs#L82), [`ui/dr-ui/src/develop/history.rs:93`](../../ui/dr-ui/src/develop/history.rs#L93), [`ui/dr-ui/src/develop/render.rs:671`](../../ui/dr-ui/src/develop/render.rs#L671), [`ui/dr-ui/src/develop/render.rs:847`](../../ui/dr-ui/src/develop/render.rs#L847), [`ui/dr-ui/src/develop/render.rs:883`](../../ui/dr-ui/src/develop/render.rs#L883), [`ui/dr-ui/src/develop/session.rs:80`](../../ui/dr-ui/src/develop/session.rs#L80), [`ui/dr-ui/src/develop/session.rs:89`](../../ui/dr-ui/src/develop/session.rs#L89), [`ui/dr-ui/src/develop/white_balance.rs:11`](../../ui/dr-ui/src/develop/white_balance.rs#L11), [`ui/dr-ui/src/develop/white_balance.rs:162`](../../ui/dr-ui/src/develop/white_balance.rs#L162), [`ui/dr-ui/src/develop_ui.rs:657`](../../ui/dr-ui/src/develop_ui.rs#L657), [`ui/dr-ui/src/develop_ui.rs:677`](../../ui/dr-ui/src/develop_ui.rs#L677), [`ui/dr-ui/src/labels.rs:12`](../../ui/dr-ui/src/labels.rs#L12), [`ui/dr-ui/src/labels.rs:278`](../../ui/dr-ui/src/labels.rs#L278), [`ui/dr-ui/src/labels.rs:31`](../../ui/dr-ui/src/labels.rs#L31), [`ui/dr-ui/src/lib.rs:2123`](../../ui/dr-ui/src/lib.rs#L2123), [`ui/dr-ui/src/lib.rs:2128`](../../ui/dr-ui/src/lib.rs#L2128), [`ui/dr-ui/src/lib.rs:2160`](../../ui/dr-ui/src/lib.rs#L2160), [`ui/dr-ui/src/lib.rs:2169`](../../ui/dr-ui/src/lib.rs#L2169), [`ui/dr-ui/src/lib.rs:2181`](../../ui/dr-ui/src/lib.rs#L2181), [`ui/dr-ui/src/library/sidecar.rs:343`](../../ui/dr-ui/src/library/sidecar.rs#L343), [`ui/dr-ui/src/library/sidecar.rs:58`](../../ui/dr-ui/src/library/sidecar.rs#L58), [`ui/dr-ui/src/presets.rs:1678`](../../ui/dr-ui/src/presets.rs#L1678), [`ui/dr-ui/src/presets.rs:373`](../../ui/dr-ui/src/presets.rs#L373), [`ui/dr-ui/src/presets.rs:437`](../../ui/dr-ui/src/presets.rs#L437), [`ui/dr-ui/src/presets.rs:557`](../../ui/dr-ui/src/presets.rs#L557), [`ui/dr-ui/ui/history.slint:191`](../../ui/dr-ui/ui/history.slint#L191), [`ui/dr-ui/ui/history.slint:1`](../../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:218`](../../ui/dr-ui/ui/history.slint#L218), [`ui/dr-ui/ui/history.slint:303`](../../ui/dr-ui/ui/history.slint#L303), [`ui/dr-ui/ui/history.slint:43`](../../ui/dr-ui/ui/history.slint#L43), [`ui/dr-ui/ui/history.slint:53`](../../ui/dr-ui/ui/history.slint#L53) | -| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:390`](../../core/dr-pipeline/src/preset.rs#L390), [`core/dr-pipeline/src/preset.rs:423`](../../core/dr-pipeline/src/preset.rs#L423), [`core/dr-pipeline/src/starter.rs:1`](../../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:443`](../../core/dr-types/src/settings.rs#L443), [`ui/dr-ui/src/develop/session.rs:555`](../../ui/dr-ui/src/develop/session.rs#L555), [`ui/dr-ui/src/develop/session.rs:576`](../../ui/dr-ui/src/develop/session.rs#L576), [`ui/dr-ui/src/develop_ui.rs:167`](../../ui/dr-ui/src/develop_ui.rs#L167), [`ui/dr-ui/src/develop_ui.rs:196`](../../ui/dr-ui/src/develop_ui.rs#L196), [`ui/dr-ui/src/lib.rs:1243`](../../ui/dr-ui/src/lib.rs#L1243), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:39`](../../ui/dr-ui/src/library/sidecar.rs#L39), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui.rs:3255`](../../ui/dr-ui/src/library_ui.rs#L3255), [`ui/dr-ui/src/library_ui.rs:3838`](../../ui/dr-ui/src/library_ui.rs#L3838), [`ui/dr-ui/src/library_ui.rs:630`](../../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/preset_store.rs:1`](../../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:707`](../../ui/dr-ui/src/presets.rs#L707), [`ui/dr-ui/src/presets.rs:741`](../../ui/dr-ui/src/presets.rs#L741), [`ui/dr-ui/ui/adjust.slint:853`](../../ui/dr-ui/ui/adjust.slint#L853), [`ui/dr-ui/ui/adjust.slint:878`](../../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/adjust.slint:889`](../../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:932`](../../ui/dr-ui/ui/adjust.slint#L932), [`ui/dr-ui/ui/app.slint:1745`](../../ui/dr-ui/ui/app.slint#L1745), [`ui/dr-ui/ui/app.slint:2577`](../../ui/dr-ui/ui/app.slint#L2577), [`ui/dr-ui/ui/app.slint:3212`](../../ui/dr-ui/ui/app.slint#L3212), [`ui/dr-ui/ui/app.slint:806`](../../ui/dr-ui/ui/app.slint#L806), [`ui/dr-ui/ui/app.slint:817`](../../ui/dr-ui/ui/app.slint#L817), [`ui/dr-ui/ui/library.slint:1579`](../../ui/dr-ui/ui/library.slint#L1579), [`ui/dr-ui/ui/library.slint:1585`](../../ui/dr-ui/ui/library.slint#L1585), [`ui/dr-ui/ui/library.slint:4262`](../../ui/dr-ui/ui/library.slint#L4262), [`ui/dr-ui/ui/library.slint:4286`](../../ui/dr-ui/ui/library.slint#L4286), [`ui/dr-ui/ui/presets.slint:105`](../../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:119`](../../ui/dr-ui/ui/settings.slint#L119), [`ui/dr-ui/ui/settings.slint:711`](../../ui/dr-ui/ui/settings.slint#L711) | +| FR-DEV-6 | [`core/dr-pipeline/src/preset.rs:1`](../../core/dr-pipeline/src/preset.rs#L1), [`core/dr-pipeline/src/preset.rs:390`](../../core/dr-pipeline/src/preset.rs#L390), [`core/dr-pipeline/src/preset.rs:423`](../../core/dr-pipeline/src/preset.rs#L423), [`core/dr-pipeline/src/starter.rs:1`](../../core/dr-pipeline/src/starter.rs#L1), [`core/dr-preset-xmp/src/lib.rs:1`](../../core/dr-preset-xmp/src/lib.rs#L1), [`core/dr-types/src/settings.rs:443`](../../core/dr-types/src/settings.rs#L443), [`ui/dr-ui/src/develop/session.rs:555`](../../ui/dr-ui/src/develop/session.rs#L555), [`ui/dr-ui/src/develop/session.rs:576`](../../ui/dr-ui/src/develop/session.rs#L576), [`ui/dr-ui/src/develop_ui.rs:167`](../../ui/dr-ui/src/develop_ui.rs#L167), [`ui/dr-ui/src/develop_ui.rs:196`](../../ui/dr-ui/src/develop_ui.rs#L196), [`ui/dr-ui/src/lib.rs:1243`](../../ui/dr-ui/src/lib.rs#L1243), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:39`](../../ui/dr-ui/src/library/sidecar.rs#L39), [`ui/dr-ui/src/library/thumbnails_fetch.rs:413`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L413), [`ui/dr-ui/src/library_ui/controller.rs:625`](../../ui/dr-ui/src/library_ui/controller.rs#L625), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:331`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L331), [`ui/dr-ui/src/library_ui/window.rs:545`](../../ui/dr-ui/src/library_ui/window.rs#L545), [`ui/dr-ui/src/preset_store.rs:1`](../../ui/dr-ui/src/preset_store.rs#L1), [`ui/dr-ui/src/presets.rs:1`](../../ui/dr-ui/src/presets.rs#L1), [`ui/dr-ui/src/presets.rs:707`](../../ui/dr-ui/src/presets.rs#L707), [`ui/dr-ui/src/presets.rs:741`](../../ui/dr-ui/src/presets.rs#L741), [`ui/dr-ui/ui/adjust.slint:853`](../../ui/dr-ui/ui/adjust.slint#L853), [`ui/dr-ui/ui/adjust.slint:878`](../../ui/dr-ui/ui/adjust.slint#L878), [`ui/dr-ui/ui/adjust.slint:889`](../../ui/dr-ui/ui/adjust.slint#L889), [`ui/dr-ui/ui/adjust.slint:932`](../../ui/dr-ui/ui/adjust.slint#L932), [`ui/dr-ui/ui/app.slint:1745`](../../ui/dr-ui/ui/app.slint#L1745), [`ui/dr-ui/ui/app.slint:2577`](../../ui/dr-ui/ui/app.slint#L2577), [`ui/dr-ui/ui/app.slint:3212`](../../ui/dr-ui/ui/app.slint#L3212), [`ui/dr-ui/ui/app.slint:806`](../../ui/dr-ui/ui/app.slint#L806), [`ui/dr-ui/ui/app.slint:817`](../../ui/dr-ui/ui/app.slint#L817), [`ui/dr-ui/ui/library.slint:1579`](../../ui/dr-ui/ui/library.slint#L1579), [`ui/dr-ui/ui/library.slint:1585`](../../ui/dr-ui/ui/library.slint#L1585), [`ui/dr-ui/ui/library.slint:4262`](../../ui/dr-ui/ui/library.slint#L4262), [`ui/dr-ui/ui/library.slint:4286`](../../ui/dr-ui/ui/library.slint#L4286), [`ui/dr-ui/ui/presets.slint:105`](../../ui/dr-ui/ui/presets.slint#L105), [`ui/dr-ui/ui/presets.slint:113`](../../ui/dr-ui/ui/presets.slint#L113), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/presets.slint:276`](../../ui/dr-ui/ui/presets.slint#L276), [`ui/dr-ui/ui/presets.slint:297`](../../ui/dr-ui/ui/presets.slint#L297), [`ui/dr-ui/ui/presets.slint:5`](../../ui/dr-ui/ui/presets.slint#L5), [`ui/dr-ui/ui/presets.slint:60`](../../ui/dr-ui/ui/presets.slint#L60), [`ui/dr-ui/ui/settings.slint:119`](../../ui/dr-ui/ui/settings.slint#L119), [`ui/dr-ui/ui/settings.slint:711`](../../ui/dr-ui/ui/settings.slint#L711) | | FR-DEV-7 | [`core/dr-pipeline/src/history.rs:214`](../../core/dr-pipeline/src/history.rs#L214), [`core/dr-pipeline/src/history.rs:499`](../../core/dr-pipeline/src/history.rs#L499), [`core/dr-pipeline/src/history.rs:526`](../../core/dr-pipeline/src/history.rs#L526), [`ui/dr-ui/src/develop/history.rs:107`](../../ui/dr-ui/src/develop/history.rs#L107), [`ui/dr-ui/src/develop/history.rs:124`](../../ui/dr-ui/src/develop/history.rs#L124), [`ui/dr-ui/src/develop/history.rs:206`](../../ui/dr-ui/src/develop/history.rs#L206), [`ui/dr-ui/src/develop/history.rs:238`](../../ui/dr-ui/src/develop/history.rs#L238), [`ui/dr-ui/src/develop/history.rs:359`](../../ui/dr-ui/src/develop/history.rs#L359), [`ui/dr-ui/src/develop/render.rs:453`](../../ui/dr-ui/src/develop/render.rs#L453), [`ui/dr-ui/src/develop/render.rs:494`](../../ui/dr-ui/src/develop/render.rs#L494), [`ui/dr-ui/src/develop/render.rs:883`](../../ui/dr-ui/src/develop/render.rs#L883), [`ui/dr-ui/src/develop/session.rs:105`](../../ui/dr-ui/src/develop/session.rs#L105), [`ui/dr-ui/src/develop_ui.rs:598`](../../ui/dr-ui/src/develop_ui.rs#L598), [`ui/dr-ui/src/develop_ui.rs:657`](../../ui/dr-ui/src/develop_ui.rs#L657), [`ui/dr-ui/src/develop_ui.rs:729`](../../ui/dr-ui/src/develop_ui.rs#L729), [`ui/dr-ui/src/lib.rs:2169`](../../ui/dr-ui/src/lib.rs#L2169), [`ui/dr-ui/src/lib.rs:2241`](../../ui/dr-ui/src/lib.rs#L2241), [`ui/dr-ui/src/lib.rs:2255`](../../ui/dr-ui/src/lib.rs#L2255), [`ui/dr-ui/src/lib.rs:412`](../../ui/dr-ui/src/lib.rs#L412), [`ui/dr-ui/ui/app.slint:170`](../../ui/dr-ui/ui/app.slint#L170), [`ui/dr-ui/ui/app.slint:2484`](../../ui/dr-ui/ui/app.slint#L2484), [`ui/dr-ui/ui/app.slint:2499`](../../ui/dr-ui/ui/app.slint#L2499), [`ui/dr-ui/ui/app.slint:2819`](../../ui/dr-ui/ui/app.slint#L2819), [`ui/dr-ui/ui/history.slint:102`](../../ui/dr-ui/ui/history.slint#L102), [`ui/dr-ui/ui/history.slint:1`](../../ui/dr-ui/ui/history.slint#L1), [`ui/dr-ui/ui/history.slint:226`](../../ui/dr-ui/ui/history.slint#L226), [`ui/dr-ui/ui/history.slint:53`](../../ui/dr-ui/ui/history.slint#L53), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | | FR-DEV-8 | [`core/dr-gpu/src/detail.rs:405`](../../core/dr-gpu/src/detail.rs#L405), [`core/dr-gpu/src/detail.rs:623`](../../core/dr-gpu/src/detail.rs#L623), [`core/dr-gpu/tests/detail_instances.rs:1`](../../core/dr-gpu/tests/detail_instances.rs#L1), [`core/dr-gpu/tests/spot_removal.rs:1`](../../core/dr-gpu/tests/spot_removal.rs#L1), [`core/dr-pipeline/src/detail.rs:410`](../../core/dr-pipeline/src/detail.rs#L410), [`core/dr-pipeline/src/detail.rs:434`](../../core/dr-pipeline/src/detail.rs#L434), [`core/dr-pipeline/src/detail.rs:469`](../../core/dr-pipeline/src/detail.rs#L469), [`core/dr-pipeline/src/detail.rs:555`](../../core/dr-pipeline/src/detail.rs#L555), [`core/dr-pipeline/src/graph.rs:1092`](../../core/dr-pipeline/src/graph.rs#L1092), [`core/dr-pipeline/src/graph.rs:115`](../../core/dr-pipeline/src/graph.rs#L115), [`core/dr-pipeline/src/graph.rs:247`](../../core/dr-pipeline/src/graph.rs#L247), [`core/dr-pipeline/src/operation.rs:330`](../../core/dr-pipeline/src/operation.rs#L330), [`core/dr-pipeline/src/operation.rs:717`](../../core/dr-pipeline/src/operation.rs#L717), [`core/dr-pipeline/src/sidecar.rs:1071`](../../core/dr-pipeline/src/sidecar.rs#L1071), [`core/dr-pipeline/src/sidecar.rs:1125`](../../core/dr-pipeline/src/sidecar.rs#L1125), [`core/dr-pipeline/src/sidecar.rs:1155`](../../core/dr-pipeline/src/sidecar.rs#L1155), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:888`](../../core/dr-pipeline/src/sidecar.rs#L888), [`core/dr-pipeline/src/spot.rs:115`](../../core/dr-pipeline/src/spot.rs#L115), [`core/dr-pipeline/src/spot.rs:151`](../../core/dr-pipeline/src/spot.rs#L151), [`core/dr-pipeline/src/spot.rs:1`](../../core/dr-pipeline/src/spot.rs#L1), [`core/dr-pipeline/src/spot.rs:207`](../../core/dr-pipeline/src/spot.rs#L207), [`core/dr-pipeline/src/spot.rs:387`](../../core/dr-pipeline/src/spot.rs#L387), [`core/dr-pipeline/src/spot.rs:472`](../../core/dr-pipeline/src/spot.rs#L472), [`core/dr-pipeline/src/spot.rs:582`](../../core/dr-pipeline/src/spot.rs#L582), [`core/dr-pipeline/src/spot.rs:673`](../../core/dr-pipeline/src/spot.rs#L673), [`core/dr-pipeline/src/state.rs:103`](../../core/dr-pipeline/src/state.rs#L103), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-pipeline/tests/spots.rs:1`](../../core/dr-pipeline/tests/spots.rs#L1), [`ui/dr-ui/src/develop/repairs.rs:114`](../../ui/dr-ui/src/develop/repairs.rs#L114), [`ui/dr-ui/src/develop/repairs.rs:11`](../../ui/dr-ui/src/develop/repairs.rs#L11), [`ui/dr-ui/src/develop/repairs.rs:197`](../../ui/dr-ui/src/develop/repairs.rs#L197), [`ui/dr-ui/src/develop/repairs.rs:211`](../../ui/dr-ui/src/develop/repairs.rs#L211), [`ui/dr-ui/src/develop/repairs.rs:49`](../../ui/dr-ui/src/develop/repairs.rs#L49), [`ui/dr-ui/src/develop/session.rs:212`](../../ui/dr-ui/src/develop/session.rs#L212), [`ui/dr-ui/src/develop_ui.rs:884`](../../ui/dr-ui/src/develop_ui.rs#L884), [`ui/dr-ui/src/labels.rs:80`](../../ui/dr-ui/src/labels.rs#L80), [`ui/dr-ui/src/lib.rs:2199`](../../ui/dr-ui/src/lib.rs#L2199), [`ui/dr-ui/src/lib.rs:454`](../../ui/dr-ui/src/lib.rs#L454), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/src/spots_ui.rs:1`](../../ui/dr-ui/src/spots_ui.rs#L1), [`ui/dr-ui/src/spots_ui.rs:265`](../../ui/dr-ui/src/spots_ui.rs#L265), [`ui/dr-ui/ui/app.slint:2340`](../../ui/dr-ui/ui/app.slint#L2340), [`ui/dr-ui/ui/app.slint:2542`](../../ui/dr-ui/ui/app.slint#L2542), [`ui/dr-ui/ui/app.slint:3118`](../../ui/dr-ui/ui/app.slint#L3118), [`ui/dr-ui/ui/icons.slint:366`](../../ui/dr-ui/ui/icons.slint#L366), [`ui/dr-ui/ui/session.slint:102`](../../ui/dr-ui/ui/session.slint#L102), [`ui/dr-ui/ui/session.slint:52`](../../ui/dr-ui/ui/session.slint#L52), [`ui/dr-ui/ui/spots.slint:191`](../../ui/dr-ui/ui/spots.slint#L191), [`ui/dr-ui/ui/spots.slint:49`](../../ui/dr-ui/ui/spots.slint#L49), [`ui/dr-ui/ui/spots.slint:6`](../../ui/dr-ui/ui/spots.slint#L6), [`ui/dr-ui/ui/spots.slint:91`](../../ui/dr-ui/ui/spots.slint#L91) | | FR-DSP-1 | [`core/dr-gpu/src/adjust.rs:2366`](../../core/dr-gpu/src/adjust.rs#L2366), [`core/dr-gpu/src/adjust.rs:2443`](../../core/dr-gpu/src/adjust.rs#L2443), [`core/dr-gpu/src/adjust.rs:2528`](../../core/dr-gpu/src/adjust.rs#L2528), [`core/dr-gpu/src/adjust.rs:54`](../../core/dr-gpu/src/adjust.rs#L54), [`core/dr-gpu/src/adjust.rs:798`](../../core/dr-gpu/src/adjust.rs#L798), [`core/dr-gpu/src/lib.rs:174`](../../core/dr-gpu/src/lib.rs#L174), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-gpu/src/lib.rs:72`](../../core/dr-gpu/src/lib.rs#L72), [`core/dr-gpu/src/lib.rs:95`](../../core/dr-gpu/src/lib.rs#L95), [`core/dr-gpu/tests/capture_sharpen.rs:200`](../../core/dr-gpu/tests/capture_sharpen.rs#L200), [`core/dr-gpu/tests/detail_stage.rs:328`](../../core/dr-gpu/tests/detail_stage.rs#L328), [`core/dr-gpu/tests/local_contrast.rs:264`](../../core/dr-gpu/tests/local_contrast.rs#L264), [`core/dr-gpu/tests/noise_reduction.rs:378`](../../core/dr-gpu/tests/noise_reduction.rs#L378), [`core/dr-pipeline/src/detail.rs:136`](../../core/dr-pipeline/src/detail.rs#L136), [`core/dr-pipeline/src/detail.rs:524`](../../core/dr-pipeline/src/detail.rs#L524), [`core/dr-pipeline/src/graph.rs:922`](../../core/dr-pipeline/src/graph.rs#L922), [`core/dr-pipeline/src/graph.rs:952`](../../core/dr-pipeline/src/graph.rs#L952), [`core/dr-pipeline/src/ops/capture_sharpen.rs:1`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L1), [`core/dr-pipeline/src/ops/capture_sharpen.rs:659`](../../core/dr-pipeline/src/ops/capture_sharpen.rs#L659), [`core/dr-pipeline/src/ops/dehaze.rs:1`](../../core/dr-pipeline/src/ops/dehaze.rs#L1), [`core/dr-pipeline/src/ops/dehaze.rs:592`](../../core/dr-pipeline/src/ops/dehaze.rs#L592), [`core/dr-pipeline/src/ops/local_contrast.rs:1`](../../core/dr-pipeline/src/ops/local_contrast.rs#L1), [`core/dr-pipeline/src/ops/local_contrast.rs:971`](../../core/dr-pipeline/src/ops/local_contrast.rs#L971), [`core/dr-pipeline/src/ops/noise_reduction.rs:700`](../../core/dr-pipeline/src/ops/noise_reduction.rs#L700), [`core/dr-pipeline/src/spot.rs:673`](../../core/dr-pipeline/src/spot.rs#L673), [`ui/dr-ui/src/develop/framing.rs:444`](../../ui/dr-ui/src/develop/framing.rs#L444), [`ui/dr-ui/src/develop/mod.rs:55`](../../ui/dr-ui/src/develop/mod.rs#L55), [`ui/dr-ui/src/develop/render.rs:140`](../../ui/dr-ui/src/develop/render.rs#L140), [`ui/dr-ui/src/develop/render.rs:940`](../../ui/dr-ui/src/develop/render.rs#L940), [`ui/dr-ui/src/develop/render.rs:974`](../../ui/dr-ui/src/develop/render.rs#L974), [`ui/dr-ui/src/lib.rs:101`](../../ui/dr-ui/src/lib.rs#L101), [`ui/dr-ui/src/lib.rs:1027`](../../ui/dr-ui/src/lib.rs#L1027), [`ui/dr-ui/src/lib.rs:1086`](../../ui/dr-ui/src/lib.rs#L1086) | @@ -97,7 +97,7 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-EXP-4 | [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/sharpen.rs:1`](../../core/dr-export/src/sharpen.rs#L1), [`core/dr-export/src/size.rs:1`](../../core/dr-export/src/size.rs#L1), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1) | | FR-EXP-5 | [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1) | | FR-EXP-6 | [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-export/src/name.rs:1`](../../core/dr-export/src/name.rs#L1), [`core/dr-types/src/settings.rs:1`](../../core/dr-types/src/settings.rs#L1), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:608`](../../ui/dr-ui/src/lib.rs#L608), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:49`](../../ui/dr-ui/src/settings_ui.rs#L49), [`ui/dr-ui/src/settings_ui.rs:847`](../../ui/dr-ui/src/settings_ui.rs#L847) | -| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/develop_ui.rs:309`](../../ui/dr-ui/src/develop_ui.rs#L309), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:269`](../../ui/dr-ui/src/lib.rs#L269), [`ui/dr-ui/src/lib.rs:608`](../../ui/dr-ui/src/lib.rs#L608), [`ui/dr-ui/src/lib.rs:654`](../../ui/dr-ui/src/lib.rs#L654), [`ui/dr-ui/src/lib.rs:681`](../../ui/dr-ui/src/lib.rs#L681), [`ui/dr-ui/src/library_ui.rs:4249`](../../ui/dr-ui/src/library_ui.rs#L4249), [`ui/dr-ui/src/library_ui.rs:721`](../../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/library_ui.rs:8208`](../../ui/dr-ui/src/library_ui.rs#L8208), [`ui/dr-ui/src/library_ui.rs:821`](../../ui/dr-ui/src/library_ui.rs#L821), [`ui/dr-ui/src/library_ui.rs:8285`](../../ui/dr-ui/src/library_ui.rs#L8285), [`ui/dr-ui/src/library_ui.rs:8297`](../../ui/dr-ui/src/library_ui.rs#L8297), [`ui/dr-ui/src/library_ui.rs:878`](../../ui/dr-ui/src/library_ui.rs#L878), [`ui/dr-ui/ui/app.slint:1178`](../../ui/dr-ui/ui/app.slint#L1178), [`ui/dr-ui/ui/app.slint:1755`](../../ui/dr-ui/ui/app.slint#L1755), [`ui/dr-ui/ui/library.slint:1588`](../../ui/dr-ui/ui/library.slint#L1588), [`ui/dr-ui/ui/library.slint:4332`](../../ui/dr-ui/ui/library.slint#L4332) | +| FR-EXP-7 | [`ui/dr-ui/src/activity.rs:83`](../../ui/dr-ui/src/activity.rs#L83), [`ui/dr-ui/src/develop_ui.rs:309`](../../ui/dr-ui/src/develop_ui.rs#L309), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:269`](../../ui/dr-ui/src/lib.rs#L269), [`ui/dr-ui/src/lib.rs:608`](../../ui/dr-ui/src/lib.rs#L608), [`ui/dr-ui/src/lib.rs:654`](../../ui/dr-ui/src/lib.rs#L654), [`ui/dr-ui/src/lib.rs:681`](../../ui/dr-ui/src/lib.rs#L681), [`ui/dr-ui/src/library_ui/controller.rs:1004`](../../ui/dr-ui/src/library_ui/controller.rs#L1004), [`ui/dr-ui/src/library_ui/controller.rs:1016`](../../ui/dr-ui/src/library_ui/controller.rs#L1016), [`ui/dr-ui/src/library_ui/controller.rs:1028`](../../ui/dr-ui/src/library_ui/controller.rs#L1028), [`ui/dr-ui/src/library_ui/controller.rs:716`](../../ui/dr-ui/src/library_ui/controller.rs#L716), [`ui/dr-ui/src/library_ui/controller.rs:816`](../../ui/dr-ui/src/library_ui/controller.rs#L816), [`ui/dr-ui/src/library_ui/controller.rs:873`](../../ui/dr-ui/src/library_ui/controller.rs#L873), [`ui/dr-ui/src/library_ui/sync.rs:97`](../../ui/dr-ui/src/library_ui/sync.rs#L97), [`ui/dr-ui/ui/app.slint:1178`](../../ui/dr-ui/ui/app.slint#L1178), [`ui/dr-ui/ui/app.slint:1755`](../../ui/dr-ui/ui/app.slint#L1755), [`ui/dr-ui/ui/library.slint:1588`](../../ui/dr-ui/ui/library.slint#L1588), [`ui/dr-ui/ui/library.slint:4332`](../../ui/dr-ui/ui/library.slint#L4332) | | FR-EXP-8 | [`core/dr-decode/src/lib.rs:366`](../../core/dr-decode/src/lib.rs#L366), [`core/dr-decode/src/lib.rs:390`](../../core/dr-decode/src/lib.rs#L390), [`core/dr-decode/src/lib.rs:404`](../../core/dr-decode/src/lib.rs#L404), [`core/dr-decode/src/lib.rs:71`](../../core/dr-decode/src/lib.rs#L71), [`core/dr-decode/src/lib.rs:79`](../../core/dr-decode/src/lib.rs#L79), [`core/dr-decode/src/lib.rs:82`](../../core/dr-decode/src/lib.rs#L82), [`core/dr-decode/src/locate.rs:1233`](../../core/dr-decode/src/locate.rs#L1233), [`core/dr-decode/src/locate.rs:1292`](../../core/dr-decode/src/locate.rs#L1292), [`core/dr-decode/src/locate.rs:359`](../../core/dr-decode/src/locate.rs#L359), [`core/dr-decode/src/locate.rs:556`](../../core/dr-decode/src/locate.rs#L556), [`core/dr-decode/src/locate.rs:640`](../../core/dr-decode/src/locate.rs#L640), [`core/dr-decode/src/locate.rs:653`](../../core/dr-decode/src/locate.rs#L653), [`core/dr-decode/src/locate.rs:736`](../../core/dr-decode/src/locate.rs#L736), [`core/dr-export/examples/export.rs:99`](../../core/dr-export/examples/export.rs#L99), [`core/dr-export/src/encode.rs:117`](../../core/dr-export/src/encode.rs#L117), [`core/dr-export/src/encode.rs:161`](../../core/dr-export/src/encode.rs#L161), [`core/dr-export/src/encode.rs:1`](../../core/dr-export/src/encode.rs#L1), [`core/dr-export/src/encode.rs:206`](../../core/dr-export/src/encode.rs#L206), [`core/dr-export/src/encode.rs:235`](../../core/dr-export/src/encode.rs#L235), [`core/dr-export/src/encode.rs:311`](../../core/dr-export/src/encode.rs#L311), [`core/dr-export/src/encode.rs:325`](../../core/dr-export/src/encode.rs#L325), [`core/dr-export/src/encode.rs:408`](../../core/dr-export/src/encode.rs#L408), [`core/dr-export/src/encode.rs:456`](../../core/dr-export/src/encode.rs#L456), [`core/dr-export/src/encode.rs:70`](../../core/dr-export/src/encode.rs#L70), [`core/dr-export/src/encode.rs:795`](../../core/dr-export/src/encode.rs#L795), [`core/dr-export/src/encode.rs:809`](../../core/dr-export/src/encode.rs#L809), [`core/dr-export/src/encode.rs:850`](../../core/dr-export/src/encode.rs#L850), [`core/dr-export/src/encode.rs:898`](../../core/dr-export/src/encode.rs#L898), [`core/dr-export/src/exif.rs:1`](../../core/dr-export/src/exif.rs#L1), [`core/dr-export/src/lib.rs:140`](../../core/dr-export/src/lib.rs#L140), [`core/dr-export/src/metadata.rs:1`](../../core/dr-export/src/metadata.rs#L1), [`core/dr-export/src/metadata.rs:41`](../../core/dr-export/src/metadata.rs#L41), [`core/dr-export/src/metadata.rs:74`](../../core/dr-export/src/metadata.rs#L74), [`core/dr-types/src/lib.rs:655`](../../core/dr-types/src/lib.rs#L655), [`core/dr-types/src/settings.rs:671`](../../core/dr-types/src/settings.rs#L671), [`ui/dr-ui/src/develop/session.rs:37`](../../ui/dr-ui/src/develop/session.rs#L37), [`ui/dr-ui/src/develop/session.rs:412`](../../ui/dr-ui/src/develop/session.rs#L412), [`ui/dr-ui/src/develop/session.rs:524`](../../ui/dr-ui/src/develop/session.rs#L524), [`ui/dr-ui/src/export.rs:474`](../../ui/dr-ui/src/export.rs#L474), [`ui/dr-ui/src/export.rs:641`](../../ui/dr-ui/src/export.rs#L641), [`ui/dr-ui/src/export.rs:687`](../../ui/dr-ui/src/export.rs#L687), [`ui/dr-ui/src/export.rs:715`](../../ui/dr-ui/src/export.rs#L715), [`ui/dr-ui/src/export.rs:839`](../../ui/dr-ui/src/export.rs#L839), [`ui/dr-ui/src/export.rs:857`](../../ui/dr-ui/src/export.rs#L857), [`ui/dr-ui/src/lib.rs:269`](../../ui/dr-ui/src/lib.rs#L269), [`ui/dr-ui/src/lib.rs:292`](../../ui/dr-ui/src/lib.rs#L292), [`ui/dr-ui/src/lib.rs:640`](../../ui/dr-ui/src/lib.rs#L640), [`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:546`](../../core/dr-decode/src/lib.rs#L546), [`core/dr-export/src/lib.rs:132`](../../core/dr-export/src/lib.rs#L132), [`core/dr-export/src/lib.rs:1`](../../core/dr-export/src/lib.rs#L1), [`core/dr-gpu/src/adjust.rs:1134`](../../core/dr-gpu/src/adjust.rs#L1134), [`ui/dr-ui/examples/face_native.rs:1`](../../ui/dr-ui/examples/face_native.rs#L1), [`ui/dr-ui/src/develop/mask_ops.rs:1130`](../../ui/dr-ui/src/develop/mask_ops.rs#L1130), [`ui/dr-ui/src/develop/render.rs:546`](../../ui/dr-ui/src/develop/render.rs#L546), [`ui/dr-ui/src/develop/render.rs:584`](../../ui/dr-ui/src/develop/render.rs#L584), [`ui/dr-ui/src/lib.rs:608`](../../ui/dr-ui/src/lib.rs#L608), [`ui/dr-ui/src/library/sweep.rs:506`](../../ui/dr-ui/src/library/sweep.rs#L506), [`ui/dr-ui/tests/export_ignores_the_viewport.rs:1`](../../ui/dr-ui/tests/export_ignores_the_viewport.rs#L1) | | FR-MRG-1 | [`core/dr-pano/src/align.rs:1`](../../core/dr-pano/src/align.rs#L1), [`core/dr-pano/src/lib.rs:1`](../../core/dr-pano/src/lib.rs#L1), [`ui/dr-ui/src/lib.rs:1820`](../../ui/dr-ui/src/lib.rs#L1820), [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:1760`](../../ui/dr-ui/ui/app.slint#L1760), [`ui/dr-ui/ui/app.slint:869`](../../ui/dr-ui/ui/app.slint#L869), [`ui/dr-ui/ui/library.slint:1596`](../../ui/dr-ui/ui/library.slint#L1596), [`ui/dr-ui/ui/library.slint:4277`](../../ui/dr-ui/ui/library.slint#L4277), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | @@ -111,24 +111,24 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-MRG-7 | [`ui/dr-ui/src/merge.rs:1`](../../ui/dr-ui/src/merge.rs#L1), [`ui/dr-ui/src/merge_ui.rs:1`](../../ui/dr-ui/src/merge_ui.rs#L1), [`ui/dr-ui/ui/app.slint:869`](../../ui/dr-ui/ui/app.slint#L869), [`ui/dr-ui/ui/merge.slint:1`](../../ui/dr-ui/ui/merge.slint#L1) | | FR-MRG-8 | [`core/dr-pano/src/xfeat.rs:1`](../../core/dr-pano/src/xfeat.rs#L1), [`core/dr-segment/examples/onnx_probe.rs:1`](../../core/dr-segment/examples/onnx_probe.rs#L1) | | 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/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`ui/dr-ui/src/launch.rs:316`](../../ui/dr-ui/src/launch.rs#L316), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61), [`ui/dr-ui/src/launch_ui.rs:446`](../../ui/dr-ui/src/launch_ui.rs#L446) | -| FR-NC-10 | [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:681`](../../ui/dr-ui/src/lib.rs#L681), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library_ui.rs:2057`](../../ui/dr-ui/src/library_ui.rs#L2057), [`ui/dr-ui/src/library_ui.rs:4249`](../../ui/dr-ui/src/library_ui.rs#L4249), [`ui/dr-ui/src/library_ui.rs:662`](../../ui/dr-ui/src/library_ui.rs#L662), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | +| FR-NC-10 | [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/export.rs:1`](../../ui/dr-ui/src/export.rs#L1), [`ui/dr-ui/src/lib.rs:681`](../../ui/dr-ui/src/lib.rs#L681), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69), [`ui/dr-ui/src/library/sidecar.rs:100`](../../ui/dr-ui/src/library/sidecar.rs#L100), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/thumbnails_fetch.rs:449`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L449), [`ui/dr-ui/src/library_ui/controller.rs:657`](../../ui/dr-ui/src/library_ui/controller.rs#L657), [`ui/dr-ui/src/library_ui/offline.rs:598`](../../ui/dr-ui/src/library_ui/offline.rs#L598), [`ui/dr-ui/src/library_ui/sync.rs:97`](../../ui/dr-ui/src/library_ui/sync.rs#L97), [`ui/dr-ui/src/sidecar_cache.rs:1`](../../ui/dr-ui/src/sidecar_cache.rs#L1) | | FR-NC-12 | [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:1049`](../../core/dr-sync-nextcloud/src/lib.rs#L1049), [`core/dr-sync-nextcloud/src/lib.rs:40`](../../core/dr-sync-nextcloud/src/lib.rs#L40), [`core/dr-sync-nextcloud/src/provider.rs:1`](../../core/dr-sync-nextcloud/src/provider.rs#L1), [`core/dr-sync/src/account.rs:1`](../../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:87`](../../core/dr-sync/src/account.rs#L87), [`core/dr-sync/src/lib.rs:218`](../../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/lib.rs:51`](../../core/dr-sync/src/lib.rs#L51), [`core/dr-sync/src/provider.rs:106`](../../core/dr-sync/src/provider.rs#L106), [`core/dr-sync/src/provider.rs:1`](../../core/dr-sync/src/provider.rs#L1), [`core/dr-sync/src/provider.rs:53`](../../core/dr-sync/src/provider.rs#L53), [`core/dr-sync/src/reachability.rs:1`](../../core/dr-sync/src/reachability.rs#L1), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | | FR-NC-13 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync/src/provider.rs:106`](../../core/dr-sync/src/provider.rs#L106), [`ui/dr-ui/src/launch_ui.rs:345`](../../ui/dr-ui/src/launch_ui.rs#L345), [`ui/dr-ui/src/remote.rs:1`](../../ui/dr-ui/src/remote.rs#L1) | | FR-NC-2 | [`core/dr-sync/src/account.rs:1`](../../core/dr-sync/src/account.rs#L1), [`core/dr-sync/src/account.rs:318`](../../core/dr-sync/src/account.rs#L318), [`core/dr-sync/src/account.rs:375`](../../core/dr-sync/src/account.rs#L375), [`core/dr-sync/src/account.rs:59`](../../core/dr-sync/src/account.rs#L59), [`platform/dr-plat/src/secrets.rs:82`](../../platform/dr-plat/src/secrets.rs#L82) | -| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/src/library_ui.rs:6053`](../../ui/dr-ui/src/library_ui.rs#L6053), [`ui/dr-ui/ui/app.slint:429`](../../ui/dr-ui/ui/app.slint#L429), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | +| FR-NC-3 | [`core/dr-decode/src/locate.rs:1`](../../core/dr-decode/src/locate.rs#L1), [`core/dr-decode/src/preview.rs:148`](../../core/dr-decode/src/preview.rs#L148), [`core/dr-sync/src/capability.rs:85`](../../core/dr-sync/src/capability.rs#L85), [`core/dr-thumbs/src/lib.rs:1`](../../core/dr-thumbs/src/lib.rs#L1), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui/grid.rs:443`](../../ui/dr-ui/src/library_ui/grid.rs#L443), [`ui/dr-ui/src/library_ui/mod.rs:1`](../../ui/dr-ui/src/library_ui/mod.rs#L1), [`ui/dr-ui/src/library_ui/sync.rs:363`](../../ui/dr-ui/src/library_ui/sync.rs#L363), [`ui/dr-ui/ui/app.slint:429`](../../ui/dr-ui/ui/app.slint#L429), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435), [`ui/dr-ui/ui/settings.slint:73`](../../ui/dr-ui/ui/settings.slint#L73) | | FR-NC-4 | [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-nextcloud/src/propfind.rs:103`](../../core/dr-sync-nextcloud/src/propfind.rs#L103), [`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:218`](../../core/dr-sync/src/lib.rs#L218), [`core/dr-sync/src/scan.rs:129`](../../core/dr-sync/src/scan.rs#L129), [`ui/dr-ui/src/launch.rs:61`](../../ui/dr-ui/src/launch.rs#L61) | | FR-NC-5 | [`core/dr-sync-nextcloud/src/propfind.rs:51`](../../core/dr-sync-nextcloud/src/propfind.rs#L51), [`ui/dr-ui/src/import.rs:488`](../../ui/dr-ui/src/import.rs#L488) | | FR-NC-6 | [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1) | -| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/schema.rs:1637`](../../core/dr-catalog/src/schema.rs#L1637), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`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/collections_ui/rename_menu.rs:61`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L61), [`ui/dr-ui/src/collections_ui/tree_sync.rs:107`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L107), [`ui/dr-ui/src/collections_ui/tree_sync.rs:36`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L36), [`ui/dr-ui/src/collections_ui/tree_sync.rs:377`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L377), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:512`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L512), [`ui/dr-ui/src/lib.rs:1572`](../../ui/dr-ui/src/lib.rs#L1572), [`ui/dr-ui/src/lib.rs:2721`](../../ui/dr-ui/src/lib.rs#L2721), [`ui/dr-ui/src/lib.rs:2792`](../../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:3010`](../../ui/dr-ui/src/lib.rs#L3010), [`ui/dr-ui/src/lib.rs:3011`](../../ui/dr-ui/src/lib.rs#L3011), [`ui/dr-ui/src/lib.rs:3050`](../../ui/dr-ui/src/lib.rs#L3050), [`ui/dr-ui/src/library/thumbnails_fetch.rs:118`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L118), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:574`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L574), [`ui/dr-ui/src/library/thumbnails_fetch.rs:711`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L711), [`ui/dr-ui/src/library/thumbnails_fetch.rs:726`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L726), [`ui/dr-ui/src/library/thumbnails_fetch.rs:95`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L95), [`ui/dr-ui/src/library_ui.rs:1482`](../../ui/dr-ui/src/library_ui.rs#L1482), [`ui/dr-ui/src/library_ui.rs:1555`](../../ui/dr-ui/src/library_ui.rs#L1555), [`ui/dr-ui/src/library_ui.rs:1656`](../../ui/dr-ui/src/library_ui.rs#L1656), [`ui/dr-ui/src/library_ui.rs:1711`](../../ui/dr-ui/src/library_ui.rs#L1711), [`ui/dr-ui/src/library_ui.rs:1846`](../../ui/dr-ui/src/library_ui.rs#L1846), [`ui/dr-ui/src/library_ui.rs:1964`](../../ui/dr-ui/src/library_ui.rs#L1964), [`ui/dr-ui/src/library_ui.rs:2731`](../../ui/dr-ui/src/library_ui.rs#L2731), [`ui/dr-ui/src/library_ui.rs:344`](../../ui/dr-ui/src/library_ui.rs#L344), [`ui/dr-ui/src/library_ui.rs:355`](../../ui/dr-ui/src/library_ui.rs#L355), [`ui/dr-ui/src/library_ui.rs:370`](../../ui/dr-ui/src/library_ui.rs#L370), [`ui/dr-ui/src/library_ui.rs:379`](../../ui/dr-ui/src/library_ui.rs#L379), [`ui/dr-ui/src/library_ui.rs:387`](../../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:517`](../../ui/dr-ui/src/library_ui.rs#L517), [`ui/dr-ui/src/library_ui.rs:527`](../../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:542`](../../ui/dr-ui/src/library_ui.rs#L542), [`ui/dr-ui/src/library_ui.rs:589`](../../ui/dr-ui/src/library_ui.rs#L589), [`ui/dr-ui/src/library_ui.rs:604`](../../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:678`](../../ui/dr-ui/src/library_ui.rs#L678), [`ui/dr-ui/src/library_ui.rs:6945`](../../ui/dr-ui/src/library_ui.rs#L6945), [`ui/dr-ui/src/library_ui.rs:6963`](../../ui/dr-ui/src/library_ui.rs#L6963), [`ui/dr-ui/src/library_ui.rs:709`](../../ui/dr-ui/src/library_ui.rs#L709), [`ui/dr-ui/src/library_ui.rs:721`](../../ui/dr-ui/src/library_ui.rs#L721), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:542`](../../ui/dr-ui/src/settings_ui.rs#L542), [`ui/dr-ui/ui/app.slint:1649`](../../ui/dr-ui/ui/app.slint#L1649), [`ui/dr-ui/ui/app.slint:3202`](../../ui/dr-ui/ui/app.slint#L3202), [`ui/dr-ui/ui/app.slint:582`](../../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/app.slint:590`](../../ui/dr-ui/ui/app.slint#L590), [`ui/dr-ui/ui/app.slint:596`](../../ui/dr-ui/ui/app.slint#L596), [`ui/dr-ui/ui/collections.slint:1037`](../../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | -| FR-NC-6b | [`ui/dr-ui/src/library_ui.rs:1711`](../../ui/dr-ui/src/library_ui.rs#L1711), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | -| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui/tree_sync.rs:107`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L107), [`ui/dr-ui/src/collections_ui/tree_sync.rs:36`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L36), [`ui/dr-ui/src/collections_ui/tree_sync.rs:377`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L377), [`ui/dr-ui/src/derived_sync.rs:1042`](../../ui/dr-ui/src/derived_sync.rs#L1042), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/library/sidecar.rs:531`](../../ui/dr-ui/src/library/sidecar.rs#L531), [`ui/dr-ui/src/library/sweep.rs:677`](../../ui/dr-ui/src/library/sweep.rs#L677), [`ui/dr-ui/src/library/thumbnails_fetch.rs:215`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L215), [`ui/dr-ui/src/library/thumbnails_fetch.rs:305`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L305), [`ui/dr-ui/src/library_ui.rs:1482`](../../ui/dr-ui/src/library_ui.rs#L1482), [`ui/dr-ui/src/library_ui.rs:1555`](../../ui/dr-ui/src/library_ui.rs#L1555), [`ui/dr-ui/src/library_ui.rs:1754`](../../ui/dr-ui/src/library_ui.rs#L1754), [`ui/dr-ui/src/remote.rs:40`](../../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/src/repairs.rs:1140`](../../ui/dr-ui/src/repairs.rs#L1140), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262) | +| FR-NC-6a | [`core/dr-catalog/src/cache.rs:1`](../../core/dr-catalog/src/cache.rs#L1), [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-catalog/src/schema.rs:1182`](../../core/dr-catalog/src/schema.rs#L1182), [`core/dr-catalog/src/schema.rs:1637`](../../core/dr-catalog/src/schema.rs#L1637), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`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/collections_ui/rename_menu.rs:61`](../../ui/dr-ui/src/collections_ui/rename_menu.rs#L61), [`ui/dr-ui/src/collections_ui/tree_sync.rs:107`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L107), [`ui/dr-ui/src/collections_ui/tree_sync.rs:36`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L36), [`ui/dr-ui/src/collections_ui/tree_sync.rs:377`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L377), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:512`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L512), [`ui/dr-ui/src/lib.rs:1572`](../../ui/dr-ui/src/lib.rs#L1572), [`ui/dr-ui/src/lib.rs:2721`](../../ui/dr-ui/src/lib.rs#L2721), [`ui/dr-ui/src/lib.rs:2792`](../../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:3010`](../../ui/dr-ui/src/lib.rs#L3010), [`ui/dr-ui/src/lib.rs:3011`](../../ui/dr-ui/src/lib.rs#L3011), [`ui/dr-ui/src/lib.rs:3050`](../../ui/dr-ui/src/lib.rs#L3050), [`ui/dr-ui/src/library/thumbnails_fetch.rs:118`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L118), [`ui/dr-ui/src/library/thumbnails_fetch.rs:393`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L393), [`ui/dr-ui/src/library/thumbnails_fetch.rs:574`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L574), [`ui/dr-ui/src/library/thumbnails_fetch.rs:711`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L711), [`ui/dr-ui/src/library/thumbnails_fetch.rs:726`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L726), [`ui/dr-ui/src/library/thumbnails_fetch.rs:95`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L95), [`ui/dr-ui/src/library_ui/controller.rs:339`](../../ui/dr-ui/src/library_ui/controller.rs#L339), [`ui/dr-ui/src/library_ui/controller.rs:350`](../../ui/dr-ui/src/library_ui/controller.rs#L350), [`ui/dr-ui/src/library_ui/controller.rs:365`](../../ui/dr-ui/src/library_ui/controller.rs#L365), [`ui/dr-ui/src/library_ui/controller.rs:374`](../../ui/dr-ui/src/library_ui/controller.rs#L374), [`ui/dr-ui/src/library_ui/controller.rs:382`](../../ui/dr-ui/src/library_ui/controller.rs#L382), [`ui/dr-ui/src/library_ui/controller.rs:512`](../../ui/dr-ui/src/library_ui/controller.rs#L512), [`ui/dr-ui/src/library_ui/controller.rs:522`](../../ui/dr-ui/src/library_ui/controller.rs#L522), [`ui/dr-ui/src/library_ui/controller.rs:537`](../../ui/dr-ui/src/library_ui/controller.rs#L537), [`ui/dr-ui/src/library_ui/controller.rs:584`](../../ui/dr-ui/src/library_ui/controller.rs#L584), [`ui/dr-ui/src/library_ui/controller.rs:599`](../../ui/dr-ui/src/library_ui/controller.rs#L599), [`ui/dr-ui/src/library_ui/controller.rs:673`](../../ui/dr-ui/src/library_ui/controller.rs#L673), [`ui/dr-ui/src/library_ui/controller.rs:704`](../../ui/dr-ui/src/library_ui/controller.rs#L704), [`ui/dr-ui/src/library_ui/controller.rs:716`](../../ui/dr-ui/src/library_ui/controller.rs#L716), [`ui/dr-ui/src/library_ui/filter_bar.rs:397`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L397), [`ui/dr-ui/src/library_ui/filter_bar.rs:415`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L415), [`ui/dr-ui/src/library_ui/offline.rs:194`](../../ui/dr-ui/src/library_ui/offline.rs#L194), [`ui/dr-ui/src/library_ui/offline.rs:20`](../../ui/dr-ui/src/library_ui/offline.rs#L20), [`ui/dr-ui/src/library_ui/offline.rs:249`](../../ui/dr-ui/src/library_ui/offline.rs#L249), [`ui/dr-ui/src/library_ui/offline.rs:387`](../../ui/dr-ui/src/library_ui/offline.rs#L387), [`ui/dr-ui/src/library_ui/offline.rs:505`](../../ui/dr-ui/src/library_ui/offline.rs#L505), [`ui/dr-ui/src/library_ui/offline.rs:93`](../../ui/dr-ui/src/library_ui/offline.rs#L93), [`ui/dr-ui/src/library_ui/window.rs:228`](../../ui/dr-ui/src/library_ui/window.rs#L228), [`ui/dr-ui/src/settings_store.rs:1`](../../ui/dr-ui/src/settings_store.rs#L1), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:1`](../../ui/dr-ui/src/settings_ui.rs#L1), [`ui/dr-ui/src/settings_ui.rs:542`](../../ui/dr-ui/src/settings_ui.rs#L542), [`ui/dr-ui/ui/app.slint:1649`](../../ui/dr-ui/ui/app.slint#L1649), [`ui/dr-ui/ui/app.slint:3202`](../../ui/dr-ui/ui/app.slint#L3202), [`ui/dr-ui/ui/app.slint:582`](../../ui/dr-ui/ui/app.slint#L582), [`ui/dr-ui/ui/app.slint:590`](../../ui/dr-ui/ui/app.slint#L590), [`ui/dr-ui/ui/app.slint:596`](../../ui/dr-ui/ui/app.slint#L596), [`ui/dr-ui/ui/collections.slint:1037`](../../ui/dr-ui/ui/collections.slint#L1037), [`ui/dr-ui/ui/collections.slint:1048`](../../ui/dr-ui/ui/collections.slint#L1048), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:424`](../../ui/dr-ui/ui/collections.slint#L424), [`ui/dr-ui/ui/collections.slint:433`](../../ui/dr-ui/ui/collections.slint#L433), [`ui/dr-ui/ui/collections.slint:436`](../../ui/dr-ui/ui/collections.slint#L436), [`ui/dr-ui/ui/collections.slint:482`](../../ui/dr-ui/ui/collections.slint#L482), [`ui/dr-ui/ui/collections.slint:52`](../../ui/dr-ui/ui/collections.slint#L52), [`ui/dr-ui/ui/collections.slint:720`](../../ui/dr-ui/ui/collections.slint#L720), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/collections.slint:91`](../../ui/dr-ui/ui/collections.slint#L91), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | +| FR-NC-6b | [`ui/dr-ui/src/library_ui/offline.rs:249`](../../ui/dr-ui/src/library_ui/offline.rs#L249), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | +| FR-NC-6c | [`core/dr-catalog/src/cache.rs:225`](../../core/dr-catalog/src/cache.rs#L225), [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:197`](../../core/dr-sync-folder/src/lib.rs#L197), [`core/dr-sync-folder/src/lib.rs:73`](../../core/dr-sync-folder/src/lib.rs#L73), [`core/dr-sync-folder/src/lib.rs:818`](../../core/dr-sync-folder/src/lib.rs#L818), [`core/dr-sync-folder/src/lib.rs:857`](../../core/dr-sync-folder/src/lib.rs#L857), [`core/dr-sync-folder/src/vfs.rs:1`](../../core/dr-sync-folder/src/vfs.rs#L1), [`core/dr-sync-nextcloud/src/desktop_client.rs:172`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L172), [`core/dr-sync-nextcloud/src/desktop_client.rs:35`](../../core/dr-sync-nextcloud/src/desktop_client.rs#L35), [`core/dr-sync/src/capability.rs:41`](../../core/dr-sync/src/capability.rs#L41), [`core/dr-sync/src/error.rs:39`](../../core/dr-sync/src/error.rs#L39), [`core/dr-sync/src/lib.rs:158`](../../core/dr-sync/src/lib.rs#L158), [`core/dr-sync/src/types.rs:101`](../../core/dr-sync/src/types.rs#L101), [`core/dr-types/src/lib.rs:122`](../../core/dr-types/src/lib.rs#L122), [`core/dr-types/src/lib.rs:204`](../../core/dr-types/src/lib.rs#L204), [`ui/dr-ui/src/activity.rs:1`](../../ui/dr-ui/src/activity.rs#L1), [`ui/dr-ui/src/collections_ui/tree_sync.rs:107`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L107), [`ui/dr-ui/src/collections_ui/tree_sync.rs:36`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L36), [`ui/dr-ui/src/collections_ui/tree_sync.rs:377`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L377), [`ui/dr-ui/src/derived_sync.rs:1042`](../../ui/dr-ui/src/derived_sync.rs#L1042), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/library/sidecar.rs:531`](../../ui/dr-ui/src/library/sidecar.rs#L531), [`ui/dr-ui/src/library/sweep.rs:677`](../../ui/dr-ui/src/library/sweep.rs#L677), [`ui/dr-ui/src/library/thumbnails_fetch.rs:215`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L215), [`ui/dr-ui/src/library/thumbnails_fetch.rs:305`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L305), [`ui/dr-ui/src/library_ui/offline.rs:20`](../../ui/dr-ui/src/library_ui/offline.rs#L20), [`ui/dr-ui/src/library_ui/offline.rs:292`](../../ui/dr-ui/src/library_ui/offline.rs#L292), [`ui/dr-ui/src/library_ui/offline.rs:93`](../../ui/dr-ui/src/library_ui/offline.rs#L93), [`ui/dr-ui/src/remote.rs:40`](../../ui/dr-ui/src/remote.rs#L40), [`ui/dr-ui/src/repairs.rs:1140`](../../ui/dr-ui/src/repairs.rs#L1140), [`ui/dr-ui/ui/collections.slint:272`](../../ui/dr-ui/ui/collections.slint#L272), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/icons.slint:262`](../../ui/dr-ui/ui/icons.slint#L262) | | FR-NC-6d | [`core/dr-sync-folder/src/borrow.rs:1`](../../core/dr-sync-folder/src/borrow.rs#L1), [`core/dr-sync-folder/src/lib.rs:1`](../../core/dr-sync-folder/src/lib.rs#L1), [`core/dr-sync-folder/src/vfs.rs:1`](../../core/dr-sync-folder/src/vfs.rs#L1) | -| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:120`](../../core/dr-sync-nextcloud/src/lib.rs#L120), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui.rs:4515`](../../ui/dr-ui/src/library_ui.rs#L4515), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435) | +| FR-NC-7 | [`core/dr-catalog/src/face_shard.rs:1`](../../core/dr-catalog/src/face_shard.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:120`](../../core/dr-sync-nextcloud/src/lib.rs#L120), [`ui/dr-ui/src/derived_sync.rs:1`](../../ui/dr-ui/src/derived_sync.rs#L1), [`ui/dr-ui/src/library/sweep.rs:557`](../../ui/dr-ui/src/library/sweep.rs#L557), [`ui/dr-ui/src/library_ui/sync.rs:363`](../../ui/dr-ui/src/library_ui/sync.rs#L363), [`ui/dr-ui/ui/settings.slint:435`](../../ui/dr-ui/ui/settings.slint#L435) | | FR-NC-7a | [`core/dr-ingest/src/layout.rs:1`](../../core/dr-ingest/src/layout.rs#L1), [`core/dr-sync/src/upload.rs:1`](../../core/dr-sync/src/upload.rs#L1), [`core/dr-sync/src/upload.rs:40`](../../core/dr-sync/src/upload.rs#L40), [`core/dr-types/src/settings.rs:377`](../../core/dr-types/src/settings.rs#L377), [`ui/dr-ui/src/import.rs:1`](../../ui/dr-ui/src/import.rs#L1), [`ui/dr-ui/src/import.rs:97`](../../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1790`](../../ui/dr-ui/src/lib.rs#L1790), [`ui/dr-ui/ui/import.slint:5`](../../ui/dr-ui/ui/import.slint#L5) | | FR-NC-7b | [`core/dr-ingest/src/lib.rs:733`](../../core/dr-ingest/src/lib.rs#L733), [`core/dr-sync/src/upload.rs:1`](../../core/dr-sync/src/upload.rs#L1), [`ui/dr-ui/src/import.rs:122`](../../ui/dr-ui/src/import.rs#L122), [`ui/dr-ui/src/import.rs:336`](../../ui/dr-ui/src/import.rs#L336), [`ui/dr-ui/src/import.rs:584`](../../ui/dr-ui/src/import.rs#L584), [`ui/dr-ui/src/import.rs:97`](../../ui/dr-ui/src/import.rs#L97), [`ui/dr-ui/src/import_ui.rs:1`](../../ui/dr-ui/src/import_ui.rs#L1), [`ui/dr-ui/src/lib.rs:1790`](../../ui/dr-ui/src/lib.rs#L1790) | -| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-pipeline/src/sidecar.rs:137`](../../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2691`](../../ui/dr-ui/src/lib.rs#L2691), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:271`](../../ui/dr-ui/src/library/sidecar.rs#L271), [`ui/dr-ui/src/library/sidecar.rs:573`](../../ui/dr-ui/src/library/sidecar.rs#L573), [`ui/dr-ui/src/library/thumbnails_fetch.rs:504`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L504), [`ui/dr-ui/src/library_ui.rs:630`](../../ui/dr-ui/src/library_ui.rs#L630), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336) | -| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:192`](../../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:2757`](../../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:493`](../../core/dr-pipeline/src/sidecar.rs#L493), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/spot.rs:245`](../../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:139`](../../core/dr-sync-nextcloud/src/lib.rs#L139), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:1070`](../../ui/dr-ui/src/derived_sync.rs#L1070), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/derived_sync.rs:675`](../../ui/dr-ui/src/derived_sync.rs#L675), [`ui/dr-ui/src/derived_sync.rs:821`](../../ui/dr-ui/src/derived_sync.rs#L821), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:271`](../../ui/dr-ui/src/library/sidecar.rs#L271), [`ui/dr-ui/src/library/sidecar.rs:343`](../../ui/dr-ui/src/library/sidecar.rs#L343), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/sidecar.rs:573`](../../ui/dr-ui/src/library/sidecar.rs#L573), [`ui/dr-ui/src/library/sidecar.rs:593`](../../ui/dr-ui/src/library/sidecar.rs#L593), [`ui/dr-ui/src/library/thumbnails_fetch.rs:504`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L504), [`ui/dr-ui/src/library_ui.rs:1339`](../../ui/dr-ui/src/library_ui.rs#L1339), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287) | -| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-sync/src/scan.rs:545`](../../core/dr-sync/src/scan.rs#L545), [`core/dr-sync/src/scan.rs:574`](../../core/dr-sync/src/scan.rs#L574), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:605`](../../ui/dr-ui/src/library/scan.rs#L605), [`ui/dr-ui/src/library_ui.rs:1310`](../../ui/dr-ui/src/library_ui.rs#L1310), [`ui/dr-ui/src/library_ui.rs:1406`](../../ui/dr-ui/src/library_ui.rs#L1406), [`ui/dr-ui/src/library_ui.rs:2005`](../../ui/dr-ui/src/library_ui.rs#L2005), [`ui/dr-ui/src/library_ui.rs:328`](../../ui/dr-ui/src/library_ui.rs#L328), [`ui/dr-ui/src/library_ui.rs:573`](../../ui/dr-ui/src/library_ui.rs#L573) | +| FR-NC-8 | [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-pipeline/src/sidecar.rs:137`](../../core/dr-pipeline/src/sidecar.rs#L137), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/sidecar.rs:96`](../../core/dr-pipeline/src/sidecar.rs#L96), [`ui/dr-ui/src/lib.rs:2691`](../../ui/dr-ui/src/lib.rs#L2691), [`ui/dr-ui/src/library/sidecar.rs:11`](../../ui/dr-ui/src/library/sidecar.rs#L11), [`ui/dr-ui/src/library/sidecar.rs:271`](../../ui/dr-ui/src/library/sidecar.rs#L271), [`ui/dr-ui/src/library/sidecar.rs:573`](../../ui/dr-ui/src/library/sidecar.rs#L573), [`ui/dr-ui/src/library/thumbnails_fetch.rs:504`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L504), [`ui/dr-ui/src/library_ui/controller.rs:625`](../../ui/dr-ui/src/library_ui/controller.rs#L625), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336) | +| FR-NC-9 | [`core/dr-catalog/src/merge.rs:1`](../../core/dr-catalog/src/merge.rs#L1), [`core/dr-catalog/src/rating.rs:204`](../../core/dr-catalog/src/rating.rs#L204), [`core/dr-catalog/src/rating.rs:66`](../../core/dr-catalog/src/rating.rs#L66), [`core/dr-catalog/src/rating.rs:929`](../../core/dr-catalog/src/rating.rs#L929), [`core/dr-catalog/src/schema.rs:1125`](../../core/dr-catalog/src/schema.rs#L1125), [`core/dr-catalog/src/schema.rs:327`](../../core/dr-catalog/src/schema.rs#L327), [`core/dr-catalog/src/schema.rs:740`](../../core/dr-catalog/src/schema.rs#L740), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-pipeline/src/sidecar.rs:192`](../../core/dr-pipeline/src/sidecar.rs#L192), [`core/dr-pipeline/src/sidecar.rs:219`](../../core/dr-pipeline/src/sidecar.rs#L219), [`core/dr-pipeline/src/sidecar.rs:2520`](../../core/dr-pipeline/src/sidecar.rs#L2520), [`core/dr-pipeline/src/sidecar.rs:2757`](../../core/dr-pipeline/src/sidecar.rs#L2757), [`core/dr-pipeline/src/sidecar.rs:2991`](../../core/dr-pipeline/src/sidecar.rs#L2991), [`core/dr-pipeline/src/sidecar.rs:395`](../../core/dr-pipeline/src/sidecar.rs#L395), [`core/dr-pipeline/src/sidecar.rs:493`](../../core/dr-pipeline/src/sidecar.rs#L493), [`core/dr-pipeline/src/sidecar.rs:660`](../../core/dr-pipeline/src/sidecar.rs#L660), [`core/dr-pipeline/src/sidecar.rs:710`](../../core/dr-pipeline/src/sidecar.rs#L710), [`core/dr-pipeline/src/sidecar.rs:735`](../../core/dr-pipeline/src/sidecar.rs#L735), [`core/dr-pipeline/src/spot.rs:245`](../../core/dr-pipeline/src/spot.rs#L245), [`core/dr-pipeline/tests/mask_sidecar.rs:1075`](../../core/dr-pipeline/tests/mask_sidecar.rs#L1075), [`core/dr-pipeline/tests/spot_sidecar.rs:1`](../../core/dr-pipeline/tests/spot_sidecar.rs#L1), [`core/dr-sync-nextcloud/src/lib.rs:139`](../../core/dr-sync-nextcloud/src/lib.rs#L139), [`core/dr-sync/src/scan.rs:31`](../../core/dr-sync/src/scan.rs#L31), [`core/dr-sync/src/scan.rs:56`](../../core/dr-sync/src/scan.rs#L56), [`core/dr-sync/src/scan.rs:926`](../../core/dr-sync/src/scan.rs#L926), [`core/dr-xmp/src/lib.rs:733`](../../core/dr-xmp/src/lib.rs#L733), [`core/dr-xmp/src/lib.rs:746`](../../core/dr-xmp/src/lib.rs#L746), [`ui/dr-ui/src/derived_sync.rs:1070`](../../ui/dr-ui/src/derived_sync.rs#L1070), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:642`](../../ui/dr-ui/src/derived_sync.rs#L642), [`ui/dr-ui/src/derived_sync.rs:675`](../../ui/dr-ui/src/derived_sync.rs#L675), [`ui/dr-ui/src/derived_sync.rs:821`](../../ui/dr-ui/src/derived_sync.rs#L821), [`ui/dr-ui/src/library/scan.rs:226`](../../ui/dr-ui/src/library/scan.rs#L226), [`ui/dr-ui/src/library/scan.rs:261`](../../ui/dr-ui/src/library/scan.rs#L261), [`ui/dr-ui/src/library/scan.rs:34`](../../ui/dr-ui/src/library/scan.rs#L34), [`ui/dr-ui/src/library/sidecar.rs:271`](../../ui/dr-ui/src/library/sidecar.rs#L271), [`ui/dr-ui/src/library/sidecar.rs:343`](../../ui/dr-ui/src/library/sidecar.rs#L343), [`ui/dr-ui/src/library/sidecar.rs:427`](../../ui/dr-ui/src/library/sidecar.rs#L427), [`ui/dr-ui/src/library/sidecar.rs:573`](../../ui/dr-ui/src/library/sidecar.rs#L573), [`ui/dr-ui/src/library/sidecar.rs:593`](../../ui/dr-ui/src/library/sidecar.rs#L593), [`ui/dr-ui/src/library/thumbnails_fetch.rs:504`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L504), [`ui/dr-ui/src/library_ui/open.rs:440`](../../ui/dr-ui/src/library_ui/open.rs#L440), [`ui/dr-ui/src/presets.rs:288`](../../ui/dr-ui/src/presets.rs#L288), [`ui/dr-ui/src/presets.rs:336`](../../ui/dr-ui/src/presets.rs#L336), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/src/xmp_sync.rs:287`](../../ui/dr-ui/src/xmp_sync.rs#L287) | +| FR-PLAT-AND-2 | [`core/dr-catalog/src/walk.rs:1022`](../../core/dr-catalog/src/walk.rs#L1022), [`core/dr-catalog/src/walk.rs:435`](../../core/dr-catalog/src/walk.rs#L435), [`core/dr-sync-folder/src/lib.rs:242`](../../core/dr-sync-folder/src/lib.rs#L242), [`core/dr-sync-folder/src/tests.rs:51`](../../core/dr-sync-folder/src/tests.rs#L51), [`core/dr-sync/src/error.rs:113`](../../core/dr-sync/src/error.rs#L113), [`core/dr-sync/src/error.rs:195`](../../core/dr-sync/src/error.rs#L195), [`core/dr-sync/src/scan.rs:211`](../../core/dr-sync/src/scan.rs#L211), [`core/dr-sync/src/scan.rs:519`](../../core/dr-sync/src/scan.rs#L519), [`core/dr-sync/src/scan.rs:545`](../../core/dr-sync/src/scan.rs#L545), [`core/dr-sync/src/scan.rs:574`](../../core/dr-sync/src/scan.rs#L574), [`ui/dr-ui/src/library/scan.rs:126`](../../ui/dr-ui/src/library/scan.rs#L126), [`ui/dr-ui/src/library/scan.rs:179`](../../ui/dr-ui/src/library/scan.rs#L179), [`ui/dr-ui/src/library/scan.rs:210`](../../ui/dr-ui/src/library/scan.rs#L210), [`ui/dr-ui/src/library/scan.rs:605`](../../ui/dr-ui/src/library/scan.rs#L605), [`ui/dr-ui/src/library_ui/controller.rs:323`](../../ui/dr-ui/src/library_ui/controller.rs#L323), [`ui/dr-ui/src/library_ui/controller.rs:568`](../../ui/dr-ui/src/library_ui/controller.rs#L568), [`ui/dr-ui/src/library_ui/offline.rs:546`](../../ui/dr-ui/src/library_ui/offline.rs#L546), [`ui/dr-ui/src/library_ui/open.rs:411`](../../ui/dr-ui/src/library_ui/open.rs#L411), [`ui/dr-ui/src/library_ui/open.rs:507`](../../ui/dr-ui/src/library_ui/open.rs#L507) | | FR-PLAT-AND-3 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`core/dr-catalog/src/runner.rs:1`](../../core/dr-catalog/src/runner.rs#L1) | | FR-PLAT-AND-4 | [`core/dr-catalog/src/runner.rs:1`](../../core/dr-catalog/src/runner.rs#L1) | | FR-PLAT-AND-5 | [`apps/darkroom-android/src/lib.rs:166`](../../apps/darkroom-android/src/lib.rs#L166), [`core/dr-gpu/src/adjust.rs:1057`](../../core/dr-gpu/src/adjust.rs#L1057), [`core/dr-gpu/src/detail.rs:161`](../../core/dr-gpu/src/detail.rs#L161), [`core/dr-gpu/src/detail.rs:547`](../../core/dr-gpu/src/detail.rs#L547), [`core/dr-gpu/tests/detail_stage.rs:417`](../../core/dr-gpu/tests/detail_stage.rs#L417), [`ui/dr-ui/src/develop/render.rs:519`](../../ui/dr-ui/src/develop/render.rs#L519), [`ui/dr-ui/src/identity_ui.rs:125`](../../ui/dr-ui/src/identity_ui.rs#L125), [`ui/dr-ui/src/lib.rs:1217`](../../ui/dr-ui/src/lib.rs#L1217), [`ui/dr-ui/src/lib.rs:1599`](../../ui/dr-ui/src/lib.rs#L1599), [`ui/dr-ui/src/memory.rs:163`](../../ui/dr-ui/src/memory.rs#L163), [`ui/dr-ui/src/memory.rs:1`](../../ui/dr-ui/src/memory.rs#L1) | @@ -143,13 +143,13 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | FR-RAW-4 | [`core/dr-decode/src/error.rs:1`](../../core/dr-decode/src/error.rs#L1), [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30), [`ui/dr-ui/src/lib.rs:269`](../../ui/dr-ui/src/lib.rs#L269) | | FR-RAW-5 | [`core/dr-decode/src/lib.rs:183`](../../core/dr-decode/src/lib.rs#L183), [`core/dr-gpu/src/demosaic.rs:34`](../../core/dr-gpu/src/demosaic.rs#L34), [`core/dr-gpu/src/demosaic.rs:734`](../../core/dr-gpu/src/demosaic.rs#L734), [`core/dr-gpu/src/demosaic.rs:813`](../../core/dr-gpu/src/demosaic.rs#L813), [`core/dr-gpu/src/demosaic.rs:937`](../../core/dr-gpu/src/demosaic.rs#L937) | | FR-UI-1 | [`core/dr-types/src/settings.rs:1377`](../../core/dr-types/src/settings.rs#L1377), [`core/dr-types/src/settings.rs:492`](../../core/dr-types/src/settings.rs#L492), [`core/dr-types/src/settings.rs:512`](../../core/dr-types/src/settings.rs#L512), [`ui/dr-ui/src/develop_ui.rs:204`](../../ui/dr-ui/src/develop_ui.rs#L204), [`ui/dr-ui/src/lib.rs:109`](../../ui/dr-ui/src/lib.rs#L109), [`ui/dr-ui/src/lib.rs:124`](../../ui/dr-ui/src/lib.rs#L124), [`ui/dr-ui/src/lib.rs:146`](../../ui/dr-ui/src/lib.rs#L146), [`ui/dr-ui/src/lib.rs:3201`](../../ui/dr-ui/src/lib.rs#L3201), [`ui/dr-ui/src/lib.rs:3220`](../../ui/dr-ui/src/lib.rs#L3220), [`ui/dr-ui/src/lib.rs:3247`](../../ui/dr-ui/src/lib.rs#L3247), [`ui/dr-ui/src/lib.rs:3298`](../../ui/dr-ui/src/lib.rs#L3298), [`ui/dr-ui/src/lib.rs:3331`](../../ui/dr-ui/src/lib.rs#L3331), [`ui/dr-ui/src/masks_ui.rs:1537`](../../ui/dr-ui/src/masks_ui.rs#L1537), [`ui/dr-ui/src/settings_ui.rs:179`](../../ui/dr-ui/src/settings_ui.rs#L179), [`ui/dr-ui/ui/adjust.slint:974`](../../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:1049`](../../ui/dr-ui/ui/app.slint#L1049), [`ui/dr-ui/ui/app.slint:2881`](../../ui/dr-ui/ui/app.slint#L2881), [`ui/dr-ui/ui/app.slint:945`](../../ui/dr-ui/ui/app.slint#L945), [`ui/dr-ui/ui/identity.slint:246`](../../ui/dr-ui/ui/identity.slint#L246), [`ui/dr-ui/ui/library.slint:1076`](../../ui/dr-ui/ui/library.slint#L1076), [`ui/dr-ui/ui/settings.slint:122`](../../ui/dr-ui/ui/settings.slint#L122), [`ui/dr-ui/ui/settings.slint:681`](../../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:156`](../../ui/dr-ui/ui/toolrail.slint#L156), [`ui/dr-ui/ui/toolrail.slint:89`](../../ui/dr-ui/ui/toolrail.slint#L89) | -| FR-UI-2 | [`ui/dr-ui/src/collections_ui/controller.rs:45`](../../ui/dr-ui/src/collections_ui/controller.rs#L45), [`ui/dr-ui/src/collections_ui/controller.rs:83`](../../ui/dr-ui/src/collections_ui/controller.rs#L83), [`ui/dr-ui/src/collections_ui/drag.rs:155`](../../ui/dr-ui/src/collections_ui/drag.rs#L155), [`ui/dr-ui/src/collections_ui/drag.rs:165`](../../ui/dr-ui/src/collections_ui/drag.rs#L165), [`ui/dr-ui/src/collections_ui/press.rs:452`](../../ui/dr-ui/src/collections_ui/press.rs#L452), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:116`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L116), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:133`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L133), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:395`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L395), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:109`](../../ui/dr-ui/src/lib.rs#L109), [`ui/dr-ui/src/library_ui.rs:6822`](../../ui/dr-ui/src/library_ui.rs#L6822), [`ui/dr-ui/src/library_ui.rs:8316`](../../ui/dr-ui/src/library_ui.rs#L8316), [`ui/dr-ui/ui/app.slint:1033`](../../ui/dr-ui/ui/app.slint#L1033), [`ui/dr-ui/ui/app.slint:110`](../../ui/dr-ui/ui/app.slint#L110), [`ui/dr-ui/ui/app.slint:635`](../../ui/dr-ui/ui/app.slint#L635), [`ui/dr-ui/ui/app.slint:649`](../../ui/dr-ui/ui/app.slint#L649), [`ui/dr-ui/ui/controls.slint:626`](../../ui/dr-ui/ui/controls.slint#L626), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:136`](../../ui/dr-ui/ui/settings.slint#L136) | -| FR-UI-3 | [`ui/dr-ui/src/collections_ui/controller.rs:138`](../../ui/dr-ui/src/collections_ui/controller.rs#L138), [`ui/dr-ui/src/collections_ui/controller.rs:343`](../../ui/dr-ui/src/collections_ui/controller.rs#L343), [`ui/dr-ui/src/collections_ui/controller.rs:356`](../../ui/dr-ui/src/collections_ui/controller.rs#L356), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/develop/masks.rs:360`](../../ui/dr-ui/src/develop/masks.rs#L360), [`ui/dr-ui/src/develop/repairs.rs:49`](../../ui/dr-ui/src/develop/repairs.rs#L49), [`ui/dr-ui/src/library_ui.rs:5778`](../../ui/dr-ui/src/library_ui.rs#L5778), [`ui/dr-ui/src/masks_ui.rs:1629`](../../ui/dr-ui/src/masks_ui.rs#L1629), [`ui/dr-ui/src/masks_ui.rs:1651`](../../ui/dr-ui/src/masks_ui.rs#L1651), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2424`](../../ui/dr-ui/ui/app.slint#L2424), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/masks.slint:1216`](../../ui/dr-ui/ui/masks.slint#L1216), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | -| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui/controller.rs:138`](../../ui/dr-ui/src/collections_ui/controller.rs#L138), [`ui/dr-ui/src/collections_ui/controller.rs:343`](../../ui/dr-ui/src/collections_ui/controller.rs#L343), [`ui/dr-ui/src/collections_ui/controller.rs:356`](../../ui/dr-ui/src/collections_ui/controller.rs#L356), [`ui/dr-ui/src/collections_ui/controller.rs:45`](../../ui/dr-ui/src/collections_ui/controller.rs#L45), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/controller.rs:55`](../../ui/dr-ui/src/collections_ui/controller.rs#L55), [`ui/dr-ui/src/collections_ui/controller.rs:83`](../../ui/dr-ui/src/collections_ui/controller.rs#L83), [`ui/dr-ui/src/collections_ui/drag.rs:155`](../../ui/dr-ui/src/collections_ui/drag.rs#L155), [`ui/dr-ui/src/collections_ui/drag.rs:165`](../../ui/dr-ui/src/collections_ui/drag.rs#L165), [`ui/dr-ui/src/collections_ui/press.rs:257`](../../ui/dr-ui/src/collections_ui/press.rs#L257), [`ui/dr-ui/src/collections_ui/press.rs:277`](../../ui/dr-ui/src/collections_ui/press.rs#L277), [`ui/dr-ui/src/collections_ui/press.rs:452`](../../ui/dr-ui/src/collections_ui/press.rs#L452), [`ui/dr-ui/src/collections_ui/press.rs:489`](../../ui/dr-ui/src/collections_ui/press.rs#L489), [`ui/dr-ui/src/collections_ui/press.rs:53`](../../ui/dr-ui/src/collections_ui/press.rs#L53), [`ui/dr-ui/src/collections_ui/tree_sync.rs:333`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L333), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:116`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L116), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:133`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L133), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:249`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L249), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:395`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L395), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:422`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L422), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:546`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L546), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:319`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L319), [`ui/dr-ui/src/develop/framing.rs:444`](../../ui/dr-ui/src/develop/framing.rs#L444), [`ui/dr-ui/src/develop/framing.rs:469`](../../ui/dr-ui/src/develop/framing.rs#L469), [`ui/dr-ui/src/develop/framing.rs:480`](../../ui/dr-ui/src/develop/framing.rs#L480), [`ui/dr-ui/src/develop/framing.rs:494`](../../ui/dr-ui/src/develop/framing.rs#L494), [`ui/dr-ui/src/develop/framing.rs:668`](../../ui/dr-ui/src/develop/framing.rs#L668), [`ui/dr-ui/src/develop/framing.rs:699`](../../ui/dr-ui/src/develop/framing.rs#L699), [`ui/dr-ui/src/develop/framing.rs:720`](../../ui/dr-ui/src/develop/framing.rs#L720), [`ui/dr-ui/src/develop_ui.rs:804`](../../ui/dr-ui/src/develop_ui.rs#L804), [`ui/dr-ui/src/gesture_book.rs:3`](../../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1284`](../../ui/dr-ui/src/lib.rs#L1284), [`ui/dr-ui/src/lib.rs:1572`](../../ui/dr-ui/src/lib.rs#L1572), [`ui/dr-ui/src/lib.rs:2573`](../../ui/dr-ui/src/lib.rs#L2573), [`ui/dr-ui/src/lib.rs:2792`](../../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:2852`](../../ui/dr-ui/src/lib.rs#L2852), [`ui/dr-ui/src/lib.rs:3011`](../../ui/dr-ui/src/lib.rs#L3011), [`ui/dr-ui/src/lib.rs:3451`](../../ui/dr-ui/src/lib.rs#L3451), [`ui/dr-ui/src/lib.rs:461`](../../ui/dr-ui/src/lib.rs#L461), [`ui/dr-ui/src/lib.rs:490`](../../ui/dr-ui/src/lib.rs#L490), [`ui/dr-ui/src/library/thumbnails_fetch.rs:711`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L711), [`ui/dr-ui/src/library/thumbnails_fetch.rs:726`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L726), [`ui/dr-ui/src/library_ui.rs:387`](../../ui/dr-ui/src/library_ui.rs#L387), [`ui/dr-ui/src/library_ui.rs:527`](../../ui/dr-ui/src/library_ui.rs#L527), [`ui/dr-ui/src/library_ui.rs:5778`](../../ui/dr-ui/src/library_ui.rs#L5778), [`ui/dr-ui/src/library_ui.rs:5790`](../../ui/dr-ui/src/library_ui.rs#L5790), [`ui/dr-ui/src/library_ui.rs:5860`](../../ui/dr-ui/src/library_ui.rs#L5860), [`ui/dr-ui/src/library_ui.rs:5992`](../../ui/dr-ui/src/library_ui.rs#L5992), [`ui/dr-ui/src/library_ui.rs:6020`](../../ui/dr-ui/src/library_ui.rs#L6020), [`ui/dr-ui/src/library_ui.rs:604`](../../ui/dr-ui/src/library_ui.rs#L604), [`ui/dr-ui/src/library_ui.rs:6963`](../../ui/dr-ui/src/library_ui.rs#L6963), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:542`](../../ui/dr-ui/src/settings_ui.rs#L542), [`ui/dr-ui/ui/app.slint:210`](../../ui/dr-ui/ui/app.slint#L210), [`ui/dr-ui/ui/app.slint:2119`](../../ui/dr-ui/ui/app.slint#L2119), [`ui/dr-ui/ui/app.slint:2241`](../../ui/dr-ui/ui/app.slint#L2241), [`ui/dr-ui/ui/app.slint:2648`](../../ui/dr-ui/ui/app.slint#L2648), [`ui/dr-ui/ui/app.slint:2781`](../../ui/dr-ui/ui/app.slint#L2781), [`ui/dr-ui/ui/app.slint:3252`](../../ui/dr-ui/ui/app.slint#L3252), [`ui/dr-ui/ui/app.slint:611`](../../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:640`](../../ui/dr-ui/ui/app.slint#L640), [`ui/dr-ui/ui/app.slint:649`](../../ui/dr-ui/ui/app.slint#L649), [`ui/dr-ui/ui/app.slint:753`](../../ui/dr-ui/ui/app.slint#L753), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | +| FR-UI-2 | [`ui/dr-ui/src/collections_ui/controller.rs:45`](../../ui/dr-ui/src/collections_ui/controller.rs#L45), [`ui/dr-ui/src/collections_ui/controller.rs:83`](../../ui/dr-ui/src/collections_ui/controller.rs#L83), [`ui/dr-ui/src/collections_ui/drag.rs:155`](../../ui/dr-ui/src/collections_ui/drag.rs#L155), [`ui/dr-ui/src/collections_ui/drag.rs:165`](../../ui/dr-ui/src/collections_ui/drag.rs#L165), [`ui/dr-ui/src/collections_ui/press.rs:452`](../../ui/dr-ui/src/collections_ui/press.rs#L452), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:116`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L116), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:133`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L133), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:395`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L395), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:109`](../../ui/dr-ui/src/lib.rs#L109), [`ui/dr-ui/src/library_ui/filter_bar.rs:274`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L274), [`ui/dr-ui/src/library_ui/window.rs:865`](../../ui/dr-ui/src/library_ui/window.rs#L865), [`ui/dr-ui/ui/app.slint:1033`](../../ui/dr-ui/ui/app.slint#L1033), [`ui/dr-ui/ui/app.slint:110`](../../ui/dr-ui/ui/app.slint#L110), [`ui/dr-ui/ui/app.slint:635`](../../ui/dr-ui/ui/app.slint#L635), [`ui/dr-ui/ui/app.slint:649`](../../ui/dr-ui/ui/app.slint#L649), [`ui/dr-ui/ui/controls.slint:626`](../../ui/dr-ui/ui/controls.slint#L626), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/presets.slint:19`](../../ui/dr-ui/ui/presets.slint#L19), [`ui/dr-ui/ui/settings.slint:136`](../../ui/dr-ui/ui/settings.slint#L136) | +| FR-UI-3 | [`ui/dr-ui/src/collections_ui/controller.rs:138`](../../ui/dr-ui/src/collections_ui/controller.rs#L138), [`ui/dr-ui/src/collections_ui/controller.rs:343`](../../ui/dr-ui/src/collections_ui/controller.rs#L343), [`ui/dr-ui/src/collections_ui/controller.rs:356`](../../ui/dr-ui/src/collections_ui/controller.rs#L356), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/develop/masks.rs:360`](../../ui/dr-ui/src/develop/masks.rs#L360), [`ui/dr-ui/src/develop/repairs.rs:49`](../../ui/dr-ui/src/develop/repairs.rs#L49), [`ui/dr-ui/src/library_ui/grid.rs:168`](../../ui/dr-ui/src/library_ui/grid.rs#L168), [`ui/dr-ui/src/masks_ui.rs:1629`](../../ui/dr-ui/src/masks_ui.rs#L1629), [`ui/dr-ui/src/masks_ui.rs:1651`](../../ui/dr-ui/src/masks_ui.rs#L1651), [`ui/dr-ui/src/masks_ui.rs:339`](../../ui/dr-ui/src/masks_ui.rs#L339), [`ui/dr-ui/src/spots_ui.rs:19`](../../ui/dr-ui/src/spots_ui.rs#L19), [`ui/dr-ui/ui/app.slint:2424`](../../ui/dr-ui/ui/app.slint#L2424), [`ui/dr-ui/ui/collections.slint:1127`](../../ui/dr-ui/ui/collections.slint#L1127), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/collections.slint:877`](../../ui/dr-ui/ui/collections.slint#L877), [`ui/dr-ui/ui/masks.slint:1216`](../../ui/dr-ui/ui/masks.slint#L1216), [`ui/dr-ui/ui/widgets.slint:71`](../../ui/dr-ui/ui/widgets.slint#L71) | +| FR-UI-4 | [`tools/traceability/src/gestures.rs:1`](../../tools/traceability/src/gestures.rs#L1), [`ui/dr-ui/src/collections_ui/controller.rs:138`](../../ui/dr-ui/src/collections_ui/controller.rs#L138), [`ui/dr-ui/src/collections_ui/controller.rs:343`](../../ui/dr-ui/src/collections_ui/controller.rs#L343), [`ui/dr-ui/src/collections_ui/controller.rs:356`](../../ui/dr-ui/src/collections_ui/controller.rs#L356), [`ui/dr-ui/src/collections_ui/controller.rs:45`](../../ui/dr-ui/src/collections_ui/controller.rs#L45), [`ui/dr-ui/src/collections_ui/controller.rs:46`](../../ui/dr-ui/src/collections_ui/controller.rs#L46), [`ui/dr-ui/src/collections_ui/controller.rs:55`](../../ui/dr-ui/src/collections_ui/controller.rs#L55), [`ui/dr-ui/src/collections_ui/controller.rs:83`](../../ui/dr-ui/src/collections_ui/controller.rs#L83), [`ui/dr-ui/src/collections_ui/drag.rs:155`](../../ui/dr-ui/src/collections_ui/drag.rs#L155), [`ui/dr-ui/src/collections_ui/drag.rs:165`](../../ui/dr-ui/src/collections_ui/drag.rs#L165), [`ui/dr-ui/src/collections_ui/press.rs:257`](../../ui/dr-ui/src/collections_ui/press.rs#L257), [`ui/dr-ui/src/collections_ui/press.rs:277`](../../ui/dr-ui/src/collections_ui/press.rs#L277), [`ui/dr-ui/src/collections_ui/press.rs:452`](../../ui/dr-ui/src/collections_ui/press.rs#L452), [`ui/dr-ui/src/collections_ui/press.rs:489`](../../ui/dr-ui/src/collections_ui/press.rs#L489), [`ui/dr-ui/src/collections_ui/press.rs:53`](../../ui/dr-ui/src/collections_ui/press.rs#L53), [`ui/dr-ui/src/collections_ui/tree_sync.rs:333`](../../ui/dr-ui/src/collections_ui/tree_sync.rs#L333), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:116`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L116), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:133`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L133), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:249`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L249), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:395`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L395), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:422`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L422), [`ui/dr-ui/src/collections_ui/wiring_grid.rs:546`](../../ui/dr-ui/src/collections_ui/wiring_grid.rs#L546), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:265`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L265), [`ui/dr-ui/src/collections_ui/wiring_tree.rs:319`](../../ui/dr-ui/src/collections_ui/wiring_tree.rs#L319), [`ui/dr-ui/src/develop/framing.rs:444`](../../ui/dr-ui/src/develop/framing.rs#L444), [`ui/dr-ui/src/develop/framing.rs:469`](../../ui/dr-ui/src/develop/framing.rs#L469), [`ui/dr-ui/src/develop/framing.rs:480`](../../ui/dr-ui/src/develop/framing.rs#L480), [`ui/dr-ui/src/develop/framing.rs:494`](../../ui/dr-ui/src/develop/framing.rs#L494), [`ui/dr-ui/src/develop/framing.rs:668`](../../ui/dr-ui/src/develop/framing.rs#L668), [`ui/dr-ui/src/develop/framing.rs:699`](../../ui/dr-ui/src/develop/framing.rs#L699), [`ui/dr-ui/src/develop/framing.rs:720`](../../ui/dr-ui/src/develop/framing.rs#L720), [`ui/dr-ui/src/develop_ui.rs:804`](../../ui/dr-ui/src/develop_ui.rs#L804), [`ui/dr-ui/src/gesture_book.rs:3`](../../ui/dr-ui/src/gesture_book.rs#L3), [`ui/dr-ui/src/gestures.rs:1`](../../ui/dr-ui/src/gestures.rs#L1), [`ui/dr-ui/src/lib.rs:1284`](../../ui/dr-ui/src/lib.rs#L1284), [`ui/dr-ui/src/lib.rs:1572`](../../ui/dr-ui/src/lib.rs#L1572), [`ui/dr-ui/src/lib.rs:2573`](../../ui/dr-ui/src/lib.rs#L2573), [`ui/dr-ui/src/lib.rs:2792`](../../ui/dr-ui/src/lib.rs#L2792), [`ui/dr-ui/src/lib.rs:2852`](../../ui/dr-ui/src/lib.rs#L2852), [`ui/dr-ui/src/lib.rs:3011`](../../ui/dr-ui/src/lib.rs#L3011), [`ui/dr-ui/src/lib.rs:3451`](../../ui/dr-ui/src/lib.rs#L3451), [`ui/dr-ui/src/lib.rs:461`](../../ui/dr-ui/src/lib.rs#L461), [`ui/dr-ui/src/lib.rs:490`](../../ui/dr-ui/src/lib.rs#L490), [`ui/dr-ui/src/library/thumbnails_fetch.rs:711`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L711), [`ui/dr-ui/src/library/thumbnails_fetch.rs:726`](../../ui/dr-ui/src/library/thumbnails_fetch.rs#L726), [`ui/dr-ui/src/library_ui/controller.rs:382`](../../ui/dr-ui/src/library_ui/controller.rs#L382), [`ui/dr-ui/src/library_ui/controller.rs:522`](../../ui/dr-ui/src/library_ui/controller.rs#L522), [`ui/dr-ui/src/library_ui/controller.rs:599`](../../ui/dr-ui/src/library_ui/controller.rs#L599), [`ui/dr-ui/src/library_ui/filter_bar.rs:415`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L415), [`ui/dr-ui/src/library_ui/grid.rs:168`](../../ui/dr-ui/src/library_ui/grid.rs#L168), [`ui/dr-ui/src/library_ui/grid.rs:180`](../../ui/dr-ui/src/library_ui/grid.rs#L180), [`ui/dr-ui/src/library_ui/grid.rs:250`](../../ui/dr-ui/src/library_ui/grid.rs#L250), [`ui/dr-ui/src/library_ui/grid.rs:382`](../../ui/dr-ui/src/library_ui/grid.rs#L382), [`ui/dr-ui/src/library_ui/grid.rs:410`](../../ui/dr-ui/src/library_ui/grid.rs#L410), [`ui/dr-ui/src/settings_ui.rs:157`](../../ui/dr-ui/src/settings_ui.rs#L157), [`ui/dr-ui/src/settings_ui.rs:542`](../../ui/dr-ui/src/settings_ui.rs#L542), [`ui/dr-ui/ui/app.slint:210`](../../ui/dr-ui/ui/app.slint#L210), [`ui/dr-ui/ui/app.slint:2119`](../../ui/dr-ui/ui/app.slint#L2119), [`ui/dr-ui/ui/app.slint:2241`](../../ui/dr-ui/ui/app.slint#L2241), [`ui/dr-ui/ui/app.slint:2648`](../../ui/dr-ui/ui/app.slint#L2648), [`ui/dr-ui/ui/app.slint:2781`](../../ui/dr-ui/ui/app.slint#L2781), [`ui/dr-ui/ui/app.slint:3252`](../../ui/dr-ui/ui/app.slint#L3252), [`ui/dr-ui/ui/app.slint:611`](../../ui/dr-ui/ui/app.slint#L611), [`ui/dr-ui/ui/app.slint:640`](../../ui/dr-ui/ui/app.slint#L640), [`ui/dr-ui/ui/app.slint:649`](../../ui/dr-ui/ui/app.slint#L649), [`ui/dr-ui/ui/app.slint:753`](../../ui/dr-ui/ui/app.slint#L753), [`ui/dr-ui/ui/collections.slint:75`](../../ui/dr-ui/ui/collections.slint#L75), [`ui/dr-ui/ui/gestures.slint:1`](../../ui/dr-ui/ui/gestures.slint#L1), [`ui/dr-ui/ui/library.slint:1053`](../../ui/dr-ui/ui/library.slint#L1053), [`ui/dr-ui/ui/library.slint:1262`](../../ui/dr-ui/ui/library.slint#L1262), [`ui/dr-ui/ui/library.slint:1287`](../../ui/dr-ui/ui/library.slint#L1287), [`ui/dr-ui/ui/library.slint:1294`](../../ui/dr-ui/ui/library.slint#L1294), [`ui/dr-ui/ui/library.slint:1327`](../../ui/dr-ui/ui/library.slint#L1327), [`ui/dr-ui/ui/library.slint:1384`](../../ui/dr-ui/ui/library.slint#L1384), [`ui/dr-ui/ui/library.slint:1391`](../../ui/dr-ui/ui/library.slint#L1391), [`ui/dr-ui/ui/library.slint:2621`](../../ui/dr-ui/ui/library.slint#L2621), [`ui/dr-ui/ui/library.slint:4224`](../../ui/dr-ui/ui/library.slint#L4224), [`ui/dr-ui/ui/library.slint:4311`](../../ui/dr-ui/ui/library.slint#L4311), [`ui/dr-ui/ui/library.slint:4370`](../../ui/dr-ui/ui/library.slint#L4370), [`ui/dr-ui/ui/library.slint:965`](../../ui/dr-ui/ui/library.slint#L965), [`ui/dr-ui/ui/library.slint:992`](../../ui/dr-ui/ui/library.slint#L992), [`ui/dr-ui/ui/library.slint:998`](../../ui/dr-ui/ui/library.slint#L998), [`ui/dr-ui/ui/settings.slint:114`](../../ui/dr-ui/ui/settings.slint#L114), [`ui/dr-ui/ui/settings.slint:656`](../../ui/dr-ui/ui/settings.slint#L656) | | FR-UI-5 | [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/develop_ui.rs:849`](../../ui/dr-ui/src/develop_ui.rs#L849), [`ui/dr-ui/src/lib.rs:3395`](../../ui/dr-ui/src/lib.rs#L3395), [`ui/dr-ui/src/lib.rs:3602`](../../ui/dr-ui/src/lib.rs#L3602), [`ui/dr-ui/src/masks_ui.rs:1584`](../../ui/dr-ui/src/masks_ui.rs#L1584), [`ui/dr-ui/ui/app.slint:2608`](../../ui/dr-ui/ui/app.slint#L2608), [`ui/dr-ui/ui/app.slint:2648`](../../ui/dr-ui/ui/app.slint#L2648), [`ui/dr-ui/ui/collections.slint:4`](../../ui/dr-ui/ui/collections.slint#L4), [`ui/dr-ui/ui/session.slint:75`](../../ui/dr-ui/ui/session.slint#L75) | | FR-UI-6 | [`ui/dr-ui/ui/widgets.slint:1`](../../ui/dr-ui/ui/widgets.slint#L1) | | FR-UI-7 | [`core/dr-pipeline/src/descriptor.rs:195`](../../core/dr-pipeline/src/descriptor.rs#L195), [`core/dr-pipeline/src/framing.rs:395`](../../core/dr-pipeline/src/framing.rs#L395), [`core/dr-types/src/settings.rs:492`](../../core/dr-types/src/settings.rs#L492), [`ui/dr-ui/src/develop/rows.rs:118`](../../ui/dr-ui/src/develop/rows.rs#L118), [`ui/dr-ui/src/lib.rs:3220`](../../ui/dr-ui/src/lib.rs#L3220), [`ui/dr-ui/ui/adjust.slint:974`](../../ui/dr-ui/ui/adjust.slint#L974), [`ui/dr-ui/ui/app.slint:2364`](../../ui/dr-ui/ui/app.slint#L2364), [`ui/dr-ui/ui/settings.slint:681`](../../ui/dr-ui/ui/settings.slint#L681), [`ui/dr-ui/ui/toolrail.slint:89`](../../ui/dr-ui/ui/toolrail.slint#L89) | -| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:81`](../../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/derived_sync.rs:894`](../../ui/dr-ui/src/derived_sync.rs#L894), [`ui/dr-ui/src/derived_sync.rs:904`](../../ui/dr-ui/src/derived_sync.rs#L904), [`ui/dr-ui/src/derived_sync.rs:986`](../../ui/dr-ui/src/derived_sync.rs#L986), [`ui/dr-ui/src/library/cells.rs:274`](../../ui/dr-ui/src/library/cells.rs#L274), [`ui/dr-ui/src/library/cells.rs:553`](../../ui/dr-ui/src/library/cells.rs#L553), [`ui/dr-ui/src/library/cells.rs:619`](../../ui/dr-ui/src/library/cells.rs#L619), [`ui/dr-ui/src/library/paths.rs:22`](../../ui/dr-ui/src/library/paths.rs#L22), [`ui/dr-ui/src/library_ui.rs:1007`](../../ui/dr-ui/src/library_ui.rs#L1007), [`ui/dr-ui/src/library_ui.rs:195`](../../ui/dr-ui/src/library_ui.rs#L195), [`ui/dr-ui/src/library_ui.rs:2192`](../../ui/dr-ui/src/library_ui.rs#L2192), [`ui/dr-ui/src/library_ui.rs:2381`](../../ui/dr-ui/src/library_ui.rs#L2381), [`ui/dr-ui/src/library_ui.rs:2398`](../../ui/dr-ui/src/library_ui.rs#L2398), [`ui/dr-ui/src/library_ui.rs:2496`](../../ui/dr-ui/src/library_ui.rs#L2496), [`ui/dr-ui/src/library_ui.rs:5811`](../../ui/dr-ui/src/library_ui.rs#L5811), [`ui/dr-ui/src/library_ui.rs:5851`](../../ui/dr-ui/src/library_ui.rs#L5851), [`ui/dr-ui/src/library_ui.rs:5880`](../../ui/dr-ui/src/library_ui.rs#L5880), [`ui/dr-ui/src/library_ui.rs:6169`](../../ui/dr-ui/src/library_ui.rs#L6169), [`ui/dr-ui/src/library_ui.rs:6405`](../../ui/dr-ui/src/library_ui.rs#L6405), [`ui/dr-ui/src/library_ui.rs:7179`](../../ui/dr-ui/src/library_ui.rs#L7179), [`ui/dr-ui/src/library_ui.rs:935`](../../ui/dr-ui/src/library_ui.rs#L935), [`ui/dr-ui/src/library_ui.rs:968`](../../ui/dr-ui/src/library_ui.rs#L968), [`ui/dr-ui/src/place.rs:1`](../../ui/dr-ui/src/place.rs#L1) | +| FR-UI-8 | [`core/dr-catalog/src/collections.rs:654`](../../core/dr-catalog/src/collections.rs#L654), [`core/dr-catalog/src/collections.rs:675`](../../core/dr-catalog/src/collections.rs#L675), [`core/dr-types/src/place.rs:1`](../../core/dr-types/src/place.rs#L1), [`ui/dr-ui/src/derived_sync.rs:216`](../../ui/dr-ui/src/derived_sync.rs#L216), [`ui/dr-ui/src/derived_sync.rs:81`](../../ui/dr-ui/src/derived_sync.rs#L81), [`ui/dr-ui/src/derived_sync.rs:894`](../../ui/dr-ui/src/derived_sync.rs#L894), [`ui/dr-ui/src/derived_sync.rs:904`](../../ui/dr-ui/src/derived_sync.rs#L904), [`ui/dr-ui/src/derived_sync.rs:986`](../../ui/dr-ui/src/derived_sync.rs#L986), [`ui/dr-ui/src/library/cells.rs:274`](../../ui/dr-ui/src/library/cells.rs#L274), [`ui/dr-ui/src/library/cells.rs:553`](../../ui/dr-ui/src/library/cells.rs#L553), [`ui/dr-ui/src/library/cells.rs:619`](../../ui/dr-ui/src/library/cells.rs#L619), [`ui/dr-ui/src/library/paths.rs:22`](../../ui/dr-ui/src/library/paths.rs#L22), [`ui/dr-ui/src/library_ui/controller.rs:189`](../../ui/dr-ui/src/library_ui/controller.rs#L189), [`ui/dr-ui/src/library_ui/controller.rs:924`](../../ui/dr-ui/src/library_ui/controller.rs#L924), [`ui/dr-ui/src/library_ui/filter_bar.rs:631`](../../ui/dr-ui/src/library_ui/filter_bar.rs#L631), [`ui/dr-ui/src/library_ui/grid.rs:201`](../../ui/dr-ui/src/library_ui/grid.rs#L201), [`ui/dr-ui/src/library_ui/grid.rs:241`](../../ui/dr-ui/src/library_ui/grid.rs#L241), [`ui/dr-ui/src/library_ui/grid.rs:270`](../../ui/dr-ui/src/library_ui/grid.rs#L270), [`ui/dr-ui/src/library_ui/grid.rs:559`](../../ui/dr-ui/src/library_ui/grid.rs#L559), [`ui/dr-ui/src/library_ui/grid.rs:683`](../../ui/dr-ui/src/library_ui/grid.rs#L683), [`ui/dr-ui/src/library_ui/open.rs:108`](../../ui/dr-ui/src/library_ui/open.rs#L108), [`ui/dr-ui/src/library_ui/open.rs:36`](../../ui/dr-ui/src/library_ui/open.rs#L36), [`ui/dr-ui/src/library_ui/open.rs:69`](../../ui/dr-ui/src/library_ui/open.rs#L69), [`ui/dr-ui/src/library_ui/open.rs:805`](../../ui/dr-ui/src/library_ui/open.rs#L805), [`ui/dr-ui/src/library_ui/open.rs:822`](../../ui/dr-ui/src/library_ui/open.rs#L822), [`ui/dr-ui/src/library_ui/open.rs:920`](../../ui/dr-ui/src/library_ui/open.rs#L920), [`ui/dr-ui/src/place.rs:1`](../../ui/dr-ui/src/place.rs#L1) | | NFR-A11Y-2 | [`ui/dr-ui/tests/ui_controls_are_accessible.rs:1`](../../ui/dr-ui/tests/ui_controls_are_accessible.rs#L1), [`ui/dr-ui/ui/adjust.slint:171`](../../ui/dr-ui/ui/adjust.slint#L171), [`ui/dr-ui/ui/app.slint:2781`](../../ui/dr-ui/ui/app.slint#L2781), [`ui/dr-ui/ui/app.slint:2819`](../../ui/dr-ui/ui/app.slint#L2819) | | NFR-A11Y-3 | [`ui/dr-ui/src/histogram.rs:356`](../../ui/dr-ui/src/histogram.rs#L356), [`ui/dr-ui/ui/histogram.slint:137`](../../ui/dr-ui/ui/histogram.slint#L137), [`ui/dr-ui/ui/library.slint:761`](../../ui/dr-ui/ui/library.slint#L761), [`ui/dr-ui/ui/library.slint:909`](../../ui/dr-ui/ui/library.slint#L909), [`ui/dr-ui/ui/peaking.slint:1`](../../ui/dr-ui/ui/peaking.slint#L1) | | NFR-ARCH-2 | [`core/dr-catalog/src/jobs.rs:1`](../../core/dr-catalog/src/jobs.rs#L1), [`ui/dr-ui/src/faces.rs:1`](../../ui/dr-ui/src/faces.rs#L1), [`ui/dr-ui/src/repairs.rs:1`](../../ui/dr-ui/src/repairs.rs#L1) | @@ -163,15 +163,15 @@ A tag naming an ID `requirements.md` does not define — what renumbering produc | NFR-P13 | [`core/dr-decode/src/preview.rs:121`](../../core/dr-decode/src/preview.rs#L121) | | NFR-P14 | [`core/dr-gpu/src/focus.rs:186`](../../core/dr-gpu/src/focus.rs#L186), [`core/dr-gpu/src/focus.rs:1`](../../core/dr-gpu/src/focus.rs#L1), [`core/dr-gpu/src/focus.rs:317`](../../core/dr-gpu/src/focus.rs#L317), [`core/dr-gpu/src/shaders/focus_peak.wgsl:1`](../../core/dr-gpu/src/shaders/focus_peak.wgsl#L1), [`ui/dr-ui/src/develop/render.rs:348`](../../ui/dr-ui/src/develop/render.rs#L348), [`ui/dr-ui/src/lib.rs:2324`](../../ui/dr-ui/src/lib.rs#L2324) | | NFR-P3 | [`tools/bench/src/thumbnails.rs:1`](../../tools/bench/src/thumbnails.rs#L1) | -| NFR-P5 | [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library/cells.rs:382`](../../ui/dr-ui/src/library/cells.rs#L382), [`ui/dr-ui/src/library/cells.rs:708`](../../ui/dr-ui/src/library/cells.rs#L708), [`ui/dr-ui/src/library_ui.rs:5005`](../../ui/dr-ui/src/library_ui.rs#L5005), [`ui/dr-ui/src/library_ui.rs:64`](../../ui/dr-ui/src/library_ui.rs#L64) | -| NFR-P9 | [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui.rs:1`](../../ui/dr-ui/src/library_ui.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1) | +| NFR-P5 | [`core/dr-catalog/src/schema.rs:564`](../../core/dr-catalog/src/schema.rs#L564), [`ui/dr-ui/src/library/cells.rs:382`](../../ui/dr-ui/src/library/cells.rs#L382), [`ui/dr-ui/src/library/cells.rs:708`](../../ui/dr-ui/src/library/cells.rs#L708), [`ui/dr-ui/src/library_ui/controller.rs:58`](../../ui/dr-ui/src/library_ui/controller.rs#L58), [`ui/dr-ui/src/library_ui/timeline.rs:365`](../../ui/dr-ui/src/library_ui/timeline.rs#L365) | +| NFR-P9 | [`core/dr-catalog/src/schema.rs:917`](../../core/dr-catalog/src/schema.rs#L917), [`core/dr-face/src/references.rs:1`](../../core/dr-face/src/references.rs#L1), [`ui/dr-ui/src/collections_ui/mod.rs:1`](../../ui/dr-ui/src/collections_ui/mod.rs#L1), [`ui/dr-ui/src/export.rs:1005`](../../ui/dr-ui/src/export.rs#L1005), [`ui/dr-ui/src/library/mod.rs:1`](../../ui/dr-ui/src/library/mod.rs#L1), [`ui/dr-ui/src/library_ui/mod.rs:1`](../../ui/dr-ui/src/library_ui/mod.rs#L1), [`ui/dr-ui/src/trash.rs:1`](../../ui/dr-ui/src/trash.rs#L1) | | NFR-PORT-1 | [`core/dr-catalog/src/walk.rs:1`](../../core/dr-catalog/src/walk.rs#L1), [`core/dr-types/src/lib.rs:272`](../../core/dr-types/src/lib.rs#L272), [`core/dr-types/src/lib.rs:305`](../../core/dr-types/src/lib.rs#L305), [`platform/dr-plat/src/dirs.rs:1`](../../platform/dr-plat/src/dirs.rs#L1), [`platform/dr-plat/src/display.rs:1`](../../platform/dr-plat/src/display.rs#L1), [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1), [`platform/dr-plat/src/storage.rs:216`](../../platform/dr-plat/src/storage.rs#L216), [`platform/dr-plat/src/storage.rs:287`](../../platform/dr-plat/src/storage.rs#L287), [`platform/dr-plat/src/storage.rs:344`](../../platform/dr-plat/src/storage.rs#L344), [`platform/dr-plat/src/volumes.rs:1`](../../platform/dr-plat/src/volumes.rs#L1), [`platform/dr-plat/src/volumes.rs:62`](../../platform/dr-plat/src/volumes.rs#L62) | | NFR-PORT-2 | [`core/dr-gpu/src/lib.rs:1`](../../core/dr-gpu/src/lib.rs#L1) | | NFR-PORT-3 | [`platform/dr-plat/src/storage.rs:1`](../../platform/dr-plat/src/storage.rs#L1) | | NFR-R1 | [`core/dr-catalog/src/schema.rs:361`](../../core/dr-catalog/src/schema.rs#L361), [`core/dr-catalog/src/sync.rs:1`](../../core/dr-catalog/src/sync.rs#L1), [`core/dr-gpu/src/lib.rs:194`](../../core/dr-gpu/src/lib.rs#L194), [`core/dr-sync/src/account.rs:240`](../../core/dr-sync/src/account.rs#L240), [`ui/dr-ui/src/library/paths.rs:69`](../../ui/dr-ui/src/library/paths.rs#L69) | -| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/recovery.rs:222`](../../core/dr-catalog/src/recovery.rs#L222), [`core/dr-catalog/src/sync.rs:61`](../../core/dr-catalog/src/sync.rs#L61), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:736`](../../ui/dr-ui/src/derived_sync.rs#L736), [`ui/dr-ui/src/library_ui.rs:4174`](../../ui/dr-ui/src/library_ui.rs#L4174), [`ui/dr-ui/src/library_ui.rs:4492`](../../ui/dr-ui/src/library_ui.rs#L4492) | +| NFR-R2 | [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1), [`core/dr-catalog/src/recovery.rs:222`](../../core/dr-catalog/src/recovery.rs#L222), [`core/dr-catalog/src/sync.rs:61`](../../core/dr-catalog/src/sync.rs#L61), [`core/dr-catalog/src/trash.rs:1`](../../core/dr-catalog/src/trash.rs#L1), [`ui/dr-ui/src/derived_sync.rs:613`](../../ui/dr-ui/src/derived_sync.rs#L613), [`ui/dr-ui/src/derived_sync.rs:736`](../../ui/dr-ui/src/derived_sync.rs#L736), [`ui/dr-ui/src/library_ui/sync.rs:22`](../../ui/dr-ui/src/library_ui/sync.rs#L22), [`ui/dr-ui/src/library_ui/sync.rs:340`](../../ui/dr-ui/src/library_ui/sync.rs#L340) | | NFR-R3 | [`core/dr-decode/src/error.rs:30`](../../core/dr-decode/src/error.rs#L30) | -| NFR-R4 | [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui.rs:3427`](../../ui/dr-ui/src/library_ui.rs#L3427), [`ui/dr-ui/src/library_ui.rs:392`](../../ui/dr-ui/src/library_ui.rs#L392), [`ui/dr-ui/src/library_ui.rs:537`](../../ui/dr-ui/src/library_ui.rs#L537), [`ui/dr-ui/src/settings_ui.rs:558`](../../ui/dr-ui/src/settings_ui.rs#L558), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | +| NFR-R4 | [`core/dr-types/src/settings.rs:92`](../../core/dr-types/src/settings.rs#L92), [`ui/dr-ui/src/library/xmp.rs:25`](../../ui/dr-ui/src/library/xmp.rs#L25), [`ui/dr-ui/src/library/xmp.rs:9`](../../ui/dr-ui/src/library/xmp.rs#L9), [`ui/dr-ui/src/library_ui/controller.rs:387`](../../ui/dr-ui/src/library_ui/controller.rs#L387), [`ui/dr-ui/src/library_ui/controller.rs:532`](../../ui/dr-ui/src/library_ui/controller.rs#L532), [`ui/dr-ui/src/library_ui/ratings_keywords.rs:503`](../../ui/dr-ui/src/library_ui/ratings_keywords.rs#L503), [`ui/dr-ui/src/settings_ui.rs:558`](../../ui/dr-ui/src/settings_ui.rs#L558), [`ui/dr-ui/src/xmp_sync.rs:1`](../../ui/dr-ui/src/xmp_sync.rs#L1), [`ui/dr-ui/ui/settings.slint:1093`](../../ui/dr-ui/ui/settings.slint#L1093), [`ui/dr-ui/ui/settings.slint:199`](../../ui/dr-ui/ui/settings.slint#L199) | | 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/keywords.rs:1`](../../core/dr-catalog/src/keywords.rs#L1), [`core/dr-catalog/src/schema.rs:1`](../../core/dr-catalog/src/schema.rs#L1) | | NFR-R6 | [`core/dr-catalog/src/error.rs:1`](../../core/dr-catalog/src/error.rs#L1), [`core/dr-catalog/src/lib.rs:255`](../../core/dr-catalog/src/lib.rs#L255), [`core/dr-catalog/src/recovery.rs:1`](../../core/dr-catalog/src/recovery.rs#L1) | | NFR-R7 | [`core/dr-gpu/src/error.rs:1`](../../core/dr-gpu/src/error.rs#L1) | diff --git a/ui/dr-ui/src/library_ui.rs b/ui/dr-ui/src/library_ui.rs deleted file mode 100644 index 833500a..0000000 --- a/ui/dr-ui/src/library_ui.rs +++ /dev/null @@ -1,8382 +0,0 @@ -//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9 -//! Drives the library grid from scan and thumbnail workers. -//! -//! Owns the bridge between three background activities and one single-threaded -//! event loop: -//! -//! - a **scan** worker walking the remote tree into the catalog -//! - a **thumbnail** worker range-fetching previews for visible cells -//! - the **grid model** Slint renders -//! -//! Nothing here blocks. Workers post through mpsc channels drained by Slint -//! timers, which is the same shape [`crate::launch_ui`] uses for login. - -use std::cell::RefCell; -use std::path::PathBuf; -use std::rc::Rc; -use std::sync::mpsc::Receiver; - -use dr_catalog::Catalog; -use dr_sync::{Account, AccountStore, Connection}; -use dr_types::FormatFilter; -use slint::{ComponentHandle, Model as _}; - -use crate::library::{self, ScanMessage, ThumbnailMessage}; -use crate::{AppWindow, GestureRow, KeywordRow, LibraryCell, PersonChip, TimelineBar, View}; - -/// A screenful before the grid has reported its geometry. -/// -/// Only used for the very first load; the grid replaces it with what it -/// actually shows as soon as it has laid out. -const INITIAL_VIEWPORT_CELLS: usize = 20; - -/// How many screenfuls the loaded window spans. -/// -/// One of them is on screen, so this buys `SCREENFULS - 1` screenfuls of -/// loaded-but-undrawn cells to scroll into, split unevenly by -/// [`window_start`]: a quarter of the window above the view, the rest below, -/// because a grid is read downward. -/// -/// Owned here rather than in the grid, alongside [`window_move`]'s rule for -/// how close the view may come to an edge before the window follows. Split -/// across the two — the grid loading three screenfuls, Rust holding on until -/// the view was three quarters of the way through them — the two numbers -/// disagreed by a quarter of a screenful, and that quarter was rows on screen -/// that no loaded cell covered. Which is the bottom row of the grid going -/// blank. -const SCREENFULS: usize = 4; - -/// Never load fewer than this, whatever the viewport reports. -/// -/// A window collapsed to a sliver would otherwise load one or two cells and -/// re-query on every scroll tick. -const MIN_WINDOW: usize = 24; - -/// Cell-size bounds for the grid's zoom. -/// -/// The lower end is where a thumbnail stops being recognisable; the upper is -/// where one screen holds so few that the grid stops being a grid. Past 256px -/// the large thumbnail class is fetched, so the top of this range is sharp -/// rather than upscaled. -const MIN_CELL_SIZE: f32 = 90.0; -const MAX_CELL_SIZE: f32 = 420.0; - -/// TRACES: NFR-P5 -/// What the grid's whole-library readouts are an answer about. -/// -/// The timeline's bars, the filter chips' counts, the "on this device" count -/// and whether the scoped collection is pinned all describe the *library*, not -/// the window over it — and [`load_window`] recomputed every one of them each -/// time the window moved, which is several times per screenful of scrolling. -/// Together they are a `MIN`/`MAX`, a `GROUP BY`, two counts and, under a -/// collection, three more queries: about 8 ms of SQLite on the thread that is -/// trying to draw the frame, for four answers that scrolling cannot change. -/// -/// So they are recomputed when this changes and not otherwise. `total` is in -/// here as the change detector as much as anything else: it is already read on -/// every load for the scrollbar, and a scan landing, a delete or a restore all -/// move it. What it cannot see — a rating edited under an unchanged count — is -/// covered because the paths that do that refresh the chips themselves. -/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people. -#[derive(Clone, PartialEq, Eq)] -struct LibraryFacts { - scope: Option, - filter: library::RatingFilter, - trash: bool, - total: usize, -} - -/// Library state for the running window. -pub struct LibraryController { - /// Shared with [`crate::collections_ui`], which edits collections against - /// the same connection. `Rc` rather than a second `Catalog::open`: two - /// handles on one SQLite file would each hold their own WAL view, so a - /// collection edited through one would not be visible through the other - /// until it committed and the reader reopened. - catalog: Rc>>, - /// Remote paths for the rows currently in the model, parallel to it. - paths: RefCell>, - /// `oc:fileid` and file length per row, parallel to the model. - file_ids: RefCell>>, - sizes: RefCell>, - /// Catalog row ids and whether each still needs its EXIF read. - image_ids: RefCell>, - needs_metadata: RefCell>, - /// When each row in the model was taken, parallel to it. - /// - /// Kept so the timeline marker can be moved from the window the grid has - /// already read rather than from a query per scroll event — see - /// [`capture_time_at`]. - captured_at: RefCell>>, - /// The size class of the pixels each row is currently showing, `None` for a - /// row still waiting. - /// - /// Parallel to the model, and the reason [`load_window`] can carry a - /// thumbnail across a reload without re-deciding whether it is sharp - /// enough: a cell holding the 256px class while the grid has since been - /// zoomed past that must keep showing what it has *and* still ask for the - /// large one. Recording the class the pixels came from is what tells those - /// two states apart — without it a carried thumbnail either blocks the - /// sharper fetch forever or is re-fetched on every scroll. - thumb_class: RefCell>>, - /// Where in the catalog the current window starts. Scrubbing moves this. - offset: RefCell, - /// The first visible ordinal, kept so returning from the develop view lands - /// where the user left rather than at the top. - /// - /// Recorded when the grid is left, not read back when it is re-entered: - /// `show-library` gates an `if` in the markup, so the whole grid subtree is - /// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to - /// row 0 before anything can restore the old position. Capturing at the - /// moment of departure is the only value the reset cannot overwrite. - /// - /// Separate from `offset`, which is the *loaded window*'s start and sits a - /// quarter-window above the view. Restoring that as a viewport position - /// would land the user consistently short of where they were. - resume_at: std::cell::Cell, - /// How many cells to load, derived from what the viewport can show. - /// - /// A fixed count is wrong in both directions: too small on a maximised 4K - /// window, wasteful on a narrow one. The grid measures itself and reports - /// its screenful; this is [`SCREENFULS`] of them. - window: RefCell, - /// What the whole-library readouts on screen were last computed for, so a - /// window that merely moved does not recompute them. See [`LibraryFacts`]. - library_facts: RefCell>, - /// How many cells the viewport shows at once, as the grid last reported. - /// - /// Kept beside `window` rather than divided back out of it, because - /// `MIN_WINDOW` can hold the window above what the screenful implies — and - /// a screenful guessed too small is exactly the mistake that leaves the - /// bottom row of the grid outside the loaded window. - viewport_cells: std::cell::Cell, - /// Which rows already have a thumbnail fetch issued, so scrolling back - /// does not refetch what is already on screen. - /// Which images have a fetch in flight or already served, keyed on - /// `(image_id, size class)`. - /// - /// **Identity, not row index.** The grid is a window over the catalog, so - /// row 7 means a different photograph after every scroll — a set of - /// indices had to be cleared on each window move, which made every visible - /// cell look unrequested and re-issued the whole screenful on every - /// scroll. - /// - /// The size class is part of the key because the two resolutions are - /// fetched independently: holding the 256px one says nothing about whether - /// the large one has been asked for. - requested: RefCell>, - /// Drains the worker that opens and verifies the catalog — see - /// [`spawn_catalog_open`]. Held for the same reason every other timer here - /// is: a `slint::Timer` stops when it is dropped, so a local one would - /// never fire. - catalog_timer: RefCell>, - scan_timer: RefCell>, - thumb_timer: RefCell>, - /// The whole-library sweep, which outlives any one grid window. - sweep_timer: RefCell>, - /// TRACES: FR-CAT-3 - /// Drains the whole-library thumbnail pass. Held for the same reason as - /// the others, and read as well as written: it is what stops a second - /// press of the button starting a pass alongside the first. - thumb_sweep_timer: RefCell>, - /// Pushing shards and the catalog to the server. - sync_timer: RefCell>, - /// Kept so a rescan can run without going back through the launch screen. - /// - /// A [`Connection`] rather than credentials beside an account: it is what - /// every worker needs, it is what `remote::connect` takes, and holding the - /// two halves separately is how they came to be threaded through fifteen - /// signatures in the wrong order. - session: RefCell>, - /// Which collection narrows the grid, owned by [`crate::collections_ui`] - /// and read here. Shared rather than passed per call because a rescan, a - /// scrub and a drop all reload the window and must all honour it. - scope: RefCell>, - /// TRACES: FR-UI-8 - /// How to open a photograph in develop. - /// - /// Held so a restored place that was left in develop can put the user back - /// there. The closure is the grid's own — `wire` is handed it and stashes a - /// clone — rather than a second route into the develop view, which would be - /// a second place for "persist the outgoing edit first" to be forgotten. - open_image: RefCell>, - /// Where this library's position is written, once one is open. - /// - /// `None` before a library has been opened, and on the local-files path, - /// which has no library to have a position in. - place_store: RefCell>, - /// What this device calls itself in a place it writes. - /// - /// [`dr_thumbs::ThumbStore::client_id`], so the place and the thumbnail - /// shards a device publishes carry the same name — which is what makes - /// `.darkroom-derived/` readable by a human wondering which machine put - /// what there. Informational only: see [`crate::place`] on why the - /// timestamp, not the device, decides an exchange. - place_device: RefCell, - /// Coalesces the writes a flick would otherwise make one of per event. - place_timer: RefCell>, - /// Drains the launch-time fetch of the server's place. Held for the reason - /// every other timer here is: a `slint::Timer` stops when it is dropped. - place_fetch_timer: RefCell>, - /// Whether a place is being applied right now. - /// - /// A restore moves the scope, the filter and the viewport, and every one of - /// those is something [`note_place`] otherwise treats as the photographer - /// having moved. Without this a restore would close its own handover latch - /// and, worse, write the record back out under a fresh timestamp — so the - /// device that had just *adopted* a place from the tablet would claim to be - /// the newer of the two. - applying_place: std::cell::Cell, - /// Whether the photographer has moved since this library was opened. - /// - /// The latch that decides whether a place arriving from another device may - /// still be applied. A handover is only welcome before the user has started - /// working: a grid that jumped somewhere else *while being scrolled*, because - /// a network round-trip finally landed, would be worse than never handing - /// over at all. - place_untouched: std::cell::Cell, - /// TRACES: FR-CAT-15 - /// Whether the grid is listing the trash rather than the library. - /// - /// Separate from `scope` rather than a sentinel id in it, for the reason - /// [`crate::collections_ui::CollectionsController`] gives: the trash is not - /// a collection, and its query *inverts* the predicate every other view - /// applies. Folding that into a type meaning "a collection" would put the - /// inversion where nothing reading `scope` expects it. - /// - /// A `Cell` because it is a `bool` read inside callbacks that already hold - /// other borrows. - viewing_trash: std::cell::Cell, - /// The selection's owner, so a rebuilt window can restore the `selected` - /// flags it just cleared. - /// - /// `Weak` because the two controllers outlive each other only through the - /// window, and an `Rc` both ways would leak both. Set once at wiring; a - /// `None` here means the grid simply draws nothing selected, which is the - /// old behaviour rather than a crash. - coll_ctl: RefCell>>, - /// Timeline view state: how far zoomed in, and around what instant. - /// - /// Zoom is a level rather than a span so the axis halves and doubles in - /// even steps; the centre is what keeps the thing you were looking at in - /// view as you zoom. - timeline_zoom: RefCell, - timeline_centre: RefCell>, - /// Pinch ratio accumulated since the last zoom step was taken. - /// - /// A pinch is continuous and zoom levels are discrete, so the ratio is - /// held until it reaches a doubling. Without it a slow spread would either - /// do nothing or, if each update were rounded, leap several levels. - pinch_accum: RefCell, - /// The instant the grid is showing. - /// - /// `None` only before the first axis has been built: `refresh_timeline` - /// seeds it from wherever the view sits, so the marker reports a position - /// from the first frame rather than resting greyed at mid-track until the - /// user happens to scroll. A scroll or a scrub then sets it directly. - current_bucket: RefCell>, - /// What the rating filter bar is narrowed to. - /// - /// Held here beside `scope` and for the same reason: a scrub, a rescan and - /// a drop all reload the window, and every one of them must honour it or - /// the filter silently lapses. - filter: RefCell, - /// TRACES: FR-CAT-9 - /// Drains the outbox uploader. Held so that a repaint while one is - /// already running does not start a second against the same channel — - /// this handle *is* the "a drain is in flight" state. - outbox_timer: RefCell>, - /// Whether the outbox might hold something, so the common case costs a - /// boolean rather than a directory walk. - /// - /// `refresh_offline` runs on scan progress as well as on a genuine - /// connectivity change, so the drain is *asked* far more often than there - /// is anything to send. Starts `true` so the first ask after launch does - /// walk — edits queued in a previous session are exactly the ones that - /// need sending, and nothing in memory knows about them. - outbox_maybe_dirty: std::cell::Cell, - /// Drains the sidecar writer. Held so a second judgement replaces the - /// timer rather than leaving two draining the same finished channel. - sidecar_timer: RefCell>, - /// TRACES: FR-CAT-13 - /// The same, for a batch of XMP writes or a reload. - xmp_timer: RefCell>, - /// Which window load the model belongs to, bumped by [`load_window`]. - /// - /// A thumbnail worker addresses cells by *row index into the window that - /// asked for it*. A reload replaces the model, so once the generation has - /// moved on every row still in flight names a different photograph — - /// applying it paints thumbnails onto unrelated cells, or marks a cell - /// "no preview" for a fetch never attempted against it. Worse, a stale - /// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which - /// by then holds the *current* batch's timer: the new worker then fetched - /// into a channel nobody drained and the grid stayed black until a scroll - /// forced another load. - /// - /// A `Cell` rather than a `RefCell`: it is read inside a timer callback - /// that already holds borrows of other fields, and a `u64` needs no borrow - /// tracking. - generation: std::cell::Cell, - /// TRACES: FR-CAT-9 - /// Whether the server is reachable, inferred from what the workers saw. - /// - /// Lives on the controller rather than in a global because it is scoped to - /// one open library: signing into a different account starts a fresh - /// judgement, and carrying the old one over would report a server down - /// that was never contacted. - reachability: RefCell, - /// TRACES: FR-PLAT-AND-2 | FR-CAT-9 - /// Why the library folder itself could not be opened, if it could not. - /// - /// Beside [`Self::reachability`] rather than inside it, because - /// `dr_sync::Reachability` models *the server*, and it is deliberately - /// unmoved by a refusal — a forbidden file must not report the network as - /// down (see its own tests). A revoked tree grant is a refusal, so folding - /// it in would either break that rule or need an exception carved through - /// it. - /// - /// Set only by a scan that failed at the root, and cleared only by one - /// that succeeded. Both states drive the same banner as being offline - /// does, because what the user can do is the same — carry on with what is - /// stored on the device — but the sentence under it is different, and so - /// is what will end it. - root_lost: RefCell>, - /// TRACES: FR-NC-6a - /// Drains the pin downloader. Held so a second pin replaces the timer - /// rather than leaving two draining the same finished channel. - /// Coalesces the reloads a run of geometry changes would otherwise each - /// demand — see [`schedule_reload`]. - geometry_timer: RefCell>, - /// The ordinal the view was on when the current run of geometry changes - /// began, so the settle returns to the photograph the user was looking at - /// rather than to wherever the re-flow left the viewport. - pending_anchor: std::cell::Cell>, - pin_timer: RefCell>, - /// TRACES: FR-NC-6a - /// Which collection the offline question is being asked about. - /// - /// Held rather than passed through the window because the prompt's three - /// answers arrive as three separate callbacks, and a dialogue that read its - /// subject back out of a string property would act on whatever the sidebar - /// had been rebuilt to say since. - offline_target: std::cell::Cell>, - /// Narrow the grid to images whose original is stored locally. - /// - /// A `Cell` beside `filter` rather than a field inside it: the rating - /// filter compiles to a SQL predicate over `versions`, while this one is a - /// predicate over the cache, and folding two different joins into one type - /// would put the cache schema inside a rating concept. - local_only: std::cell::Cell, - /// TRACES: FR-NC-6a - /// The ceiling on passively cached originals, from the settings page. - /// - /// Held here rather than read from `SettingsStore` at each use because the - /// workers that need it run on threads with no config access — the same - /// reason [`library::CacheContext`] takes it as a field. A `Cell` because - /// the settings page can move it while a library is open, and the next - /// fetch must use the new figure rather than one captured at open. - cache_budget: std::cell::Cell, - /// TRACES: FR-NC-6a - /// Whether opening an image in develop keeps its original on disk. - /// - /// Held beside the budget and for the same reason. Distinct from a zero - /// budget: this switches the passive population off entirely, while a - /// small budget still keeps a working set. A metered or small-disk device - /// wants the first. - keep_opened: std::cell::Cell, - /// TRACES: FR-NC-6a | FR-UI-4 - /// How many photographs each side of the open one are fetched ahead. - /// Mirrors `CacheSettings::fetch_ahead`, held beside the flag that - /// gates it. - fetch_ahead: std::cell::Cell, - /// TRACES: FR-CAT-13 | NFR-R4 - /// Whether judgements and keywords also go to the `.xmp` beside the - /// original. Mirrors `LibrarySettings::write_xmp_sidecars`. - write_xmp: std::cell::Cell, - /// TRACES: FR-CAT-6 - /// How many bars the capture-time axis is cut into, from the settings - /// page. - /// - /// Held here beside the cache budget and for the same reason: the axis is - /// redrawn from a dozen callbacks that have no config access, and reading - /// a JSON file on every scroll to answer "how many bars" would be the - /// wrong shape of question. A `Cell` because the page can change it while - /// a library is open. - timeline_bars: std::cell::Cell, - /// TRACES: FR-CULL-8 - /// Which face pipeline this device indexes with, from the settings page. - /// - /// Held here for the same reason as the two above: the derived sync runs - /// from a sweep's completion and from the Sync button, neither of which - /// has the settings in reach, and the shards it exports and adopts are - /// keyed on this id. A `RefCell` of the id rather than the enum so that - /// nothing here has to know how a detector becomes a model id. - face_model_id: RefCell, - /// Where every worker this module starts reports what it is doing. - /// - /// Held on the controller rather than passed to each function because the - /// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a - /// reconnect — and threading a second argument through all of them would - /// say nothing except that they all report progress. - activity: Rc, -} - -impl LibraryController { - pub fn new(activity: Rc) -> Rc { - Rc::new(Self { - activity, - catalog: Rc::new(RefCell::new(None)), - paths: RefCell::new(Vec::new()), - file_ids: RefCell::new(Vec::new()), - sizes: RefCell::new(Vec::new()), - image_ids: RefCell::new(Vec::new()), - needs_metadata: RefCell::new(Vec::new()), - captured_at: RefCell::new(Vec::new()), - thumb_class: RefCell::new(Vec::new()), - offset: RefCell::new(0), - resume_at: std::cell::Cell::new(0), - window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)), - viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS), - library_facts: RefCell::new(None), - requested: RefCell::new(Default::default()), - catalog_timer: RefCell::new(None), - scan_timer: RefCell::new(None), - thumb_timer: RefCell::new(None), - sweep_timer: RefCell::new(None), - thumb_sweep_timer: RefCell::new(None), - sync_timer: RefCell::new(None), - session: RefCell::new(None), - scope: RefCell::new(None), - open_image: RefCell::new(None), - place_store: RefCell::new(None), - place_device: RefCell::new(String::new()), - place_timer: RefCell::new(None), - place_fetch_timer: RefCell::new(None), - place_untouched: std::cell::Cell::new(true), - applying_place: std::cell::Cell::new(false), - viewing_trash: std::cell::Cell::new(false), - coll_ctl: RefCell::new(None), - timeline_zoom: RefCell::new(0), - timeline_centre: RefCell::new(None), - pinch_accum: RefCell::new(1.0), - current_bucket: RefCell::new(None), - filter: RefCell::new(library::RatingFilter::default()), - sidecar_timer: RefCell::new(None), - xmp_timer: RefCell::new(None), - generation: std::cell::Cell::new(0), - reachability: RefCell::new(dr_sync::Reachability::new()), - root_lost: RefCell::new(None), - outbox_timer: RefCell::new(None), - outbox_maybe_dirty: std::cell::Cell::new(true), - geometry_timer: RefCell::new(None), - pending_anchor: std::cell::Cell::new(None), - pin_timer: RefCell::new(None), - offline_target: std::cell::Cell::new(None), - local_only: std::cell::Cell::new(false), - // The catalog's own floor until the settings page reports what the - // user has stored, which it does at startup before any fetch. - cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()), - keep_opened: std::cell::Cell::new( - dr_types::CacheSettings::default().keep_opened_originals, - ), - fetch_ahead: std::cell::Cell::new(dr_types::CacheSettings::default().fetch_ahead), - write_xmp: std::cell::Cell::new( - dr_types::LibrarySettings::default().write_xmp_sidecars, - ), - timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars), - face_model_id: RefCell::new( - crate::inference::model_id(dr_types::FaceDetector::default()).to_string(), - ), - }) - } - - /// TRACES: FR-CULL-8 - /// Which face pipeline the next sync exports and adopts under. - /// - /// Set on every settings edit like the bars above. A sync already running - /// keeps the id it started with, which is right: its shards are half - /// written under that one. - pub fn set_face_model_id(&self, id: &str) { - if *self.face_model_id.borrow() != id { - *self.face_model_id.borrow_mut() = id.to_string(); - } - } - - /// TRACES: FR-CAT-6 - /// How many bars the capture-time axis is cut into. - /// - /// Answers whether it changed, so the caller can leave the grid alone when - /// it did not: this is set on every settings edit, and reloading the - /// window because a user changed their export folder would be a visible - /// stutter for nothing. - pub fn set_timeline_bars(&self, bars: u32) -> bool { - let bars = bars.max(1); - self.timeline_bars.replace(bars) != bars - } - - /// TRACES: FR-NC-6a - /// Whether a develop open should keep the original it downloads. - /// - /// Turning it off leaves what is already cached alone: those bytes are - /// paid for, and deleting them would make the switch destructive when it - /// only means "stop adding to this". - pub fn set_keep_opened_originals(&self, keep: bool) { - self.keep_opened.set(keep); - } - - /// TRACES: FR-NC-6a | FR-UI-4 - /// How far along the roll, each side, the next open fetches ahead. - pub fn set_fetch_ahead(&self, depth: u32) { - self.fetch_ahead.set(depth); - } - - pub fn fetch_ahead(&self) -> u32 { - self.fetch_ahead.get() - } - - /// TRACES: FR-CAT-13 | NFR-R4 - pub fn set_write_xmp_sidecars(&self, on: bool) { - self.write_xmp.set(on); - } - - /// TRACES: FR-NC-6a - /// Set the ceiling on passively cached originals, and apply it now. - /// - /// Applied immediately rather than only to later downloads: a user who has - /// just lowered the limit expects the space back, and a budget that took - /// effect only on the next fetch would leave the cache over its stated - /// ceiling for as long as they browsed nothing new. - pub fn set_cache_budget(&self, bytes: Option) { - let budget = match bytes { - Some(n) => dr_catalog::Budget::bytes(n), - None => dr_catalog::Budget::unlimited(), - }; - self.cache_budget.set(budget); - - // Best-effort: no library open means no cache to trim, and a failure - // here must not stop the setting from being stored — the ceiling still - // applies to every fetch from now on. - let Some(dir) = self.cache_dir() else { return }; - let borrow = self.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - match dr_catalog::Cache::open(&dir, budget) - .and_then(|cache| cache.enforce(catalog.connection())) - { - Ok(0) => {} - Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"), - Err(e) => log::warn!("applying the new cache budget: {e}"), - } - } - - /// TRACES: FR-CAT-9 | FR-PLAT-AND-2 - /// Whether the library cannot be reached, for either of the two reasons. - /// - /// One answer rather than two because every caller asks it for the same - /// purpose: to decide whether starting a transfer is worth attempting. - /// A revoked grant fails that question exactly as a dead network does, and - /// a sync started against it would spend its retries proving it. - pub fn is_offline(&self) -> bool { - self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some() - } - - /// Whether the grid is narrowed to locally-stored originals. - pub fn local_only(&self) -> bool { - self.local_only.get() - } - - /// TRACES: FR-NC-6a - /// The catalog id for a remote path currently in the grid. - /// - /// The cache is keyed on `ImageId` because that is what survives a - /// server-side rename (FR-NC-5), while the develop callback carries only a - /// path. `paths` and `image_ids` are parallel to the model, so this is the - /// join between the two. - pub fn image_id_for_path(&self, path: &str) -> Option { - let index = self.paths.borrow().iter().position(|p| p == path)?; - self.image_ids - .borrow() - .get(index) - .map(|id| dr_types::ImageId(*id as u64)) - } - - /// TRACES: FR-NC-6a | FR-UI-4 - /// The photographs within `depth` of `path` on the roll, closest first - /// and working outwards: next, previous, next-but-one, previous-but-one… - /// - /// That order is the point. The prefetcher serves the list one at a time - /// and drops the rest the moment the user moves, so whatever depth is - /// set, the frame most likely to be stepped to is the first thing - /// fetched — and the step most likely is one forward, which is why next - /// leads previous at every distance. - /// - /// In the roll's order, which is the loaded window's — the same rows a - /// roll pick names — so what gets fetched ahead is exactly what a step - /// lands on, filter and scope included. The window's edges cut the list - /// short; a photograph not in the window has no neighbours, and a - /// prefetch of nothing is the right answer for it. - pub fn neighbours_of(&self, path: &str, depth: u32) -> Vec { - let paths = self.paths.borrow(); - let Some(at) = paths.iter().position(|p| p == path) else { - return Vec::new(); - }; - (1..=depth as usize) - .flat_map(|d| [at.checked_add(d), at.checked_sub(d)]) - .filter_map(|i| paths.get(i?).cloned()) - .collect() - } - - /// TRACES: FR-NC-8 | FR-DEV-6 - /// The default version's uuid for an image, by its remote path. - /// - /// Taken from the catalog rather than generated, and rather than read off - /// whatever the sidecar happens to contain. The uuid is the identity a - /// cross-device merge keys on (FR-NC-8): a develop session that invented - /// one would write a *second* version beside the one the cull is stored - /// in, and the photograph would arrive on the other device holding two - /// edits that never merge. - /// - /// Creates the version if the image has none, by the same route a rating - /// does — see `dr_catalog::rating::default_version_id` for why an image - /// can legitimately arrive without one. - pub fn version_uuid_for_path(&self, path: &str) -> Option { - let image = self.image_id_for_path(path)?; - let borrow = self.catalog.borrow(); - let catalog = borrow.as_ref()?; - let conn = catalog.connection(); - - let id = match dr_catalog::rating::default_version_id(conn, image) { - Ok(id) => id, - Err(e) => { - log::debug!("no version for {path}: {e}"); - return None; - } - }; - conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| { - r.get(0) - }) - .ok() - } - - /// TRACES: FR-CAT-9 | FR-NC-10 - /// Where cached sidecars and the upload outbox live for the open library. - /// - /// Beside the catalog and the originals cache, for the same reason those - /// two sit together: all three are per-account and are discarded together. - /// A separate directory rather than a subfolder of `originals` because the - /// two have opposite lifetimes — originals are evicted under a budget - /// (FR-NC-6a), and a queued edit must never be. - pub fn sidecar_cache_dir(&self) -> Option { - let borrow = self.session.borrow(); - let (conn, _) = borrow.as_ref()?; - library::catalog_path(&conn.account) - .parent() - .map(|p| p.join("sidecars")) - } - - /// TRACES: FR-NC-6a - /// Where cached originals live for the open library. - /// - /// Beside the catalog rather than under a system cache directory: the two - /// are per-account and are discarded together, and a cached original whose - /// catalog row is gone is unreachable anyway. - pub fn cache_dir(&self) -> Option { - let borrow = self.session.borrow(); - let (conn, _) = borrow.as_ref()?; - library::catalog_path(&conn.account) - .parent() - .map(|p| p.join("originals")) - } - - /// Open the originals cache for the current library. - /// - /// Used by pinning, which writes intent against the catalog directly - /// rather than going through a fetch. - pub fn cache(&self) -> Option { - let dir = self.cache_dir()?; - match dr_catalog::Cache::open(&dir, self.cache_budget.get()) { - Ok(c) => Some(c), - Err(e) => { - // Not fatal: without a cache every open is a download, which - // is exactly the behaviour that existed before this. - log::warn!("originals cache unavailable: {e}"); - None - } - } - } - - /// TRACES: FR-NC-6a - /// Everything a full fetch needs to read and write the cache. - /// - /// Assembled here because the develop callback holds only a path, while - /// the cache is keyed on `ImageId` and lives beside a catalog whose - /// location comes from the session. `None` where any part is missing — a - /// grid row that has scrolled away, or no library open — and the fetch - /// then simply goes to the network. - pub fn cache_context(&self, path: &str) -> Option { - self.cache_context_for(self.image_id_for_path(path)?) - } - - /// TRACES: FR-EXP-7 | FR-NC-6a - /// The same, for an image named by id rather than by path. - /// - /// A batch export needs this one: its selection is by catalog id and may - /// include photographs that have scrolled out of the loaded window, where - /// [`Self::image_id_for_path`] has nothing to match against. Going through - /// the path would quietly hand those images no cache at all, and a batch of - /// three hundred would re-download every one of them. - pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option { - let borrow = self.session.borrow(); - let (conn, _) = borrow.as_ref()?; - let catalog_path = library::catalog_path(&conn.account); - let dir = catalog_path.parent()?.join("originals"); - drop(borrow); - - Some(library::CacheContext { - dir, - catalog_path, - image, - // The user's ceiling, not the catalog's floor: read at each fetch - // so a budget changed mid-session takes effect on the next one. - budget: self.cache_budget.get(), - // Gates the *write* only. Reading stays enabled either way: bytes - // already on disk were paid for, and refusing to use them because - // the user has since stopped adding new ones would re-download - // images that are sitting right there — and would make a pinned - // collection unopenable offline. - store: self.keep_opened.get(), - }) - } - - /// Toggle the local-only filter, resetting the window. - /// - /// The offset is cleared for the same reason a scope change clears it: the - /// filter changes which images exist as far as the grid is concerned, so a - /// position counted against the old set names a different photograph. - pub fn set_local_only(&self, on: bool) { - self.local_only.set(on); - *self.offset.borrow_mut() = 0; - self.requested.borrow_mut().clear(); - } - - /// The catalog handle, for [`crate::collections_ui`] to edit through. - pub fn catalog(&self) -> Rc>> { - self.catalog.clone() - } - - /// The open library's connection. - /// - /// Needed by the trash, whose move and delete go to the same account the - /// scan and thumbnail workers use. `None` before a library is opened. - pub fn session(&self) -> Option { - self.session.borrow().as_ref().map(|(c, _)| c.clone()) - } - - /// Catalog ids of the rows currently in the model, in model order. - /// - /// Selection is keyed on these rather than on row indices: the grid is a - /// window over the catalog and a scrub replaces every row, so an index - /// would silently come to mean a different photograph. - pub fn visible_ids(&self) -> Vec { - self.image_ids - .borrow() - .iter() - .map(|id| dr_types::ImageId(*id as u64)) - .collect() - } - - /// TRACES: FR-CAT-5 - /// Catalog ids of grid rows `first..=last`, loaded or not. - /// - /// The counterpart to [`Self::visible_ids`], and the reason both exist: a - /// shift-click names a run by its two ends, and everything between them is - /// usually off screen. Answered from the catalog, through the same scope, - /// filter and ordering this controller reads its window with — so the run - /// is the run the user can see themselves selecting, continued past the - /// edge of what has been loaded. - /// - /// Empty before a library is open, and empty if the query fails: a - /// selection gesture is not worth an error dialog, and the caller keeps - /// what was already selected. - pub fn ids_in_span(&self, first: usize, last: usize) -> Vec { - let borrow = self.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return Vec::new(); - }; - library::read_ids_span( - catalog, - *self.scope.borrow(), - &self.filter.borrow(), - self.viewing_trash.get(), - first, - last, - ) - .unwrap_or_else(|e| { - log::warn!("reading the selected range: {e}"); - Vec::new() - }) - } - - /// TRACES: FR-EXP-7 - /// Remote paths for a set of selected images, in the order they were given. - /// - /// Answered from the catalog rather than from `paths`, which is only the - /// loaded window. Selection is by id precisely so that it survives a scrub - /// (see [`crate::collections_ui`]), so a selection made before scrolling - /// routinely names photographs no row currently holds — and an export that - /// silently dropped those would be worse than one that refused. - /// - /// An id the catalog has never heard of is skipped rather than reported: it - /// can only mean the image was deleted between the selection and the click, - /// and there is nothing to export and nothing to fix. - /// - /// Each path comes back beside the id it belongs to, because that skipping - /// means the ids that come out are not the ids that went in — and the caller - /// still has to find each photograph's cache, which is keyed on the id. - pub fn selected_image_paths( - &self, - images: &[dr_types::ImageId], - ) -> Vec<(dr_types::ImageId, String)> { - if images.is_empty() { - return Vec::new(); - } - - let borrow = self.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return Vec::new(); - }; - - let placeholders = std::iter::repeat_n("?", images.len()) - .collect::>() - .join(","); - let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})"); - let params: Vec = images - .iter() - .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) - .collect(); - - let Ok(mut stmt) = catalog.connection().prepare(&sql) else { - return Vec::new(); - }; - let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) - }) else { - return Vec::new(); - }; - - // `IN` returns rows in whatever order suits SQLite, and the export's - // `{seq}` token counts through the batch — so the answer is put back - // into the order the caller asked in rather than the one it arrived in. - let found: std::collections::HashMap = rows.flatten().collect(); - images - .iter() - .filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone()))) - .collect() - } - - /// TRACES: FR-EXP-7 - /// The selection, addressed the way an export worker can fetch it. - /// - /// Assembled here because a worker thread can reach neither the catalog nor - /// the session, and both are needed to say where a cached original lives. - pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec { - self.selected_image_paths(images) - .into_iter() - .map(|(id, path)| crate::export::Source::Library { - path, - cache: self.cache_context_for(id), - }) - .collect() - } - - /// The open library's connection, for a full-file fetch. - /// - /// The grid's paths are remote, so opening an image means fetching it, and - /// that goes through the same account the thumbnail workers use. - pub fn credentials(&self) -> Option { - self.session.borrow().as_ref().map(|(c, _)| c.clone()) - } - - /// Narrow the grid to a collection, or to the whole library with `None`. - pub fn set_scope(&self, scope: Option) { - *self.scope.borrow_mut() = scope; - // Selecting a collection is leaving the trash. Without this, picking a - // collection while the trash was open would keep listing trashed images - // under that collection's name. - self.viewing_trash.set(false); - // A new scope is a different set of images, so the old window's - // position and its issued fetches mean nothing. - *self.offset.borrow_mut() = 0; - self.requested.borrow_mut().clear(); - } - - /// TRACES: FR-CAT-15 - /// Show the trash instead of the library. - /// - /// Clears `scope` as well: the trash is not inside a collection, and leaving - /// a stale scope set would narrow it to one on the way back out. - pub fn set_viewing_trash(&self, viewing: bool) { - self.viewing_trash.set(viewing); - if viewing { - *self.scope.borrow_mut() = None; - } - *self.offset.borrow_mut() = 0; - self.requested.borrow_mut().clear(); - } -} - -/// Reload the grid for the current scope and offset. -/// -/// The reload entry point for everything outside this module — a scope change, -/// or a drop that altered the collection being shown. -pub fn reload(window: &AppWindow, ctl: &Rc) { - load_window(window, ctl); - // TRACES: FR-UI-8 - // The scope changed, or the collection under it did. Either way this is the - // entry point every such change comes through, which is why the record is - // taken here rather than at each of the callers. - note_place(window, ctl); -} - -/// Open a library: show the grid, start a scan, then fill in thumbnails. -/// -/// Called from the launch screen's "Open library" button — the callback that -/// until now only logged its intent. -pub fn open( - window: &AppWindow, - ctl: Rc, - coll_ctl: Rc, - store: &AccountStore, - account: Account, -) { - // The credential is fetched only where the connector wants one; a folder - // library has none, and asking the keyring for it would fail the one - // backend that needs nothing. - let conn = match store.connection(&account, crate::remote::needs_secret(&account)) { - Ok(c) => c, - Err(e) => { - window.set_library_error(format!("credentials: {e}").into()); - window.set_active_view(View::Library); - return; - } - }; - - let filter = account.format_filter(); - *ctl.session.borrow_mut() = Some((conn.clone(), filter.clone())); - - // TRACES: FR-UI-8 - // Where this library's position is kept, and what this device signs it - // with. Before the catalog opens, because `adopt_catalog` is what reads the - // record back and it is the next thing to run. - *ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path( - &conn.account, - ))); - *ctl.place_device.borrow_mut() = device_id(&conn.account); - // A fresh library is one nobody has moved in yet, so a place arriving from - // another device may still be applied — see `place_untouched`. - ctl.place_untouched.set(true); - - window.set_active_view(View::Library); - window.set_library_open(true); - window.set_library_scanning(true); - window.set_library_error(slint::SharedString::new()); - window.set_library_status("Starting…".into()); - // Always visible: two folders one letter apart are easy to confuse, and a - // scan of the wrong one is indistinguishable from a broken scan. - window.set_library_root_label(library_root_label(&conn.account).into()); - - // An empty filter would walk the whole tree and match nothing, which looks - // exactly like a broken scan. Say so instead. - if filter.is_empty() { - window.set_library_scanning(false); - window.set_library_error("No formats selected — tick at least one.".into()); - return; - } - - let path = library::catalog_path(&conn.account); - - // Before the scan, not after it: the grid can be filled from disk now and - // the scan is only ever going to add to it. - // - // And it is the gate on the scan, not merely a prelude to it — a damaged - // catalog has a question on screen, and a scan writing into it while that - // question is unanswered is how the last good copy gets destroyed. That - // gate is why the scan starts from the drain below rather than from here: - // the answer no longer arrives on the next line. - // TRACES: FR-UI-8 - // Started beside the catalog open rather than after it, so the handover has - // a round trip's head start on the user deciding what to look at. It - // resolves nothing until the catalog is there — see `fetch_remote_place`. - fetch_remote_place(window, &ctl, &coll_ctl, &conn); - - open_catalog_soon(window, ctl, coll_ctl, conn, filter, path); -} - -/// Open the catalog on a worker, then show it and start the scan. -/// -/// # Why this is not `show_catalog_now` on the spot -/// -/// It was, and it was the largest blocking call on the launch path. -/// [`Catalog::open_verified`] runs `PRAGMA quick_check`, which reads **every -/// page** of the database, and then `Catalog::open`, which takes a full copy of -/// the file before a migration and rewrites its structure. On a 50,000-image -/// library that is tens of megabytes of I/O, and all of it happened before the -/// window had painted anything. -/// -/// On Android that is not a stutter but an ANR. `dr_ui::run` is called from -/// `android_main` and does not reach `window.run()` — the first `poll_events`, -/// and so the first time anything drains the activity's input channel — until -/// every line above it has finished. Five seconds of that and the system offers -/// to kill the app. -/// -/// [`crate::recovery_ui`] and `recovery`'s own module documentation both say the -/// check belongs "at startup, where a failure has a user in front of it who can -/// answer a question". That was always the intent; this is what makes it true, -/// because the question can only be asked once there is an interface to ask it -/// in. -/// -/// # What the user sees meanwhile -/// -/// `library-opening`, which the grid's empty state draws as "Opening the -/// library…" rather than as "Scanning…". They are different answers — one is -/// reading a file on this device, the other is walking a tree over the network -/// — and the grid already refuses to conflate the two states it had. -/// -/// `library-scanning` stays true throughout, which is what keeps the gate above -/// from being merely advisory: the header hides Rescan while it is set, so -/// there is no button that could start a scan into a catalog this has not -/// finished checking. -fn open_catalog_soon( - window: &AppWindow, - ctl: Rc, - coll_ctl: Rc, - conn: Connection, - filter: FormatFilter, - path: PathBuf, -) { - // Already open: a library being re-opened within one session, which is the - // case `show_catalog_now` short-circuited. There is nothing to wait for and - // nothing to say, so the scan starts immediately as it always did. - if ctl.catalog.borrow().is_some() { - begin_scan(window, &ctl, &coll_ctl, conn, filter, path); - return; - } - - window.set_library_opening(true); - window.set_library_status("Reading the catalog on this device…".into()); - - let rx = spawn_catalog_open(path.clone()); - let weak = window.as_weak(); - let held = ctl.clone(); - let timer = slint::Timer::default(); - timer.start( - slint::TimerMode::Repeated, - // Tighter than the scan's 120 ms: this one lands once, and everything - // the grid can show is waiting behind it. - std::time::Duration::from_millis(30), - move || { - let Some(w) = weak.upgrade() else { return }; - let got = match rx.try_recv() { - Ok(got) => got, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - // The worker died without answering — a panic inside SQLite, - // realistically. Treated as "no catalog to show", which is what - // the synchronous path did with any error it could not - // classify: the scan is the thing that has to work. - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - CatalogOpen::Failed("the catalog worker stopped without answering".into()) - } - }; - stop(&held.catalog_timer); - w.set_library_opening(false); - - match got { - CatalogOpen::Opened(cat) => { - adopt_catalog(&w, &held, &coll_ctl, cat); - begin_scan( - &w, - &held, - &coll_ctl, - conn.clone(), - filter.clone(), - path.clone(), - ); - } - CatalogOpen::Corrupt(detail) => { - // No scan, and this is the whole reason the scan waits for - // this answer: `Catalog::open` succeeds on a file whose - // header survived, so a scan would write ETags and image - // rows into damaged pages while the user is still reading - // the question — turning a file that had a backup into one - // where the backup is the only copy left. - // - // `offer` clears the scanning state itself, for exactly - // that reason, so nothing here does it twice. - crate::recovery_ui::offer(&w, &path, &detail); - } - CatalogOpen::Failed(why) => { - // Not surfaced: this is a first run more often than it is - // anything else, the empty state already says the scan is - // running, and an error here would contradict a scan that - // is working perfectly. If the scan fails too, it reports - // for both of them. - log::info!("no catalog to show before the scan: {why}"); - begin_scan( - &w, - &held, - &coll_ctl, - conn.clone(), - filter.clone(), - path.clone(), - ); - } - } - }, - ); - *ctl.catalog_timer.borrow_mut() = Some(timer); -} - -/// What the catalog worker found. -enum CatalogOpen { - /// Checked, opened, migrated forward and backfilled. - Opened(Catalog), - /// `PRAGMA quick_check` failed. Carries SQLite's own words, because the - /// message the user is shown is the diagnosis rather than a paraphrase. - Corrupt(String), - /// Anything else: no catalog file yet, a permission, a locked database. - Failed(String), -} - -/// Check, open and migrate the catalog on a thread of its own. -/// -/// `Catalog` holds a `rusqlite::Connection`, which is `Send` and not `Sync` — -/// exactly the shape that can be built on a worker and handed over once, which -/// is what this does. Nothing else touches the file while it runs: this is the -/// only opener on the launch path, and the scan that opens its own connection -/// does not start until the answer has landed. -fn spawn_catalog_open(path: PathBuf) -> Receiver { - let (tx, rx) = std::sync::mpsc::channel(); - - std::thread::spawn(move || { - let started = std::time::Instant::now(); - let message = match Catalog::open_verified(&path) { - Ok(cat) => { - // The number this change exists for. Worth an `info` line on - // every launch: it is the one figure that says whether a slow - // start is the catalog or something else, and it is not - // measurable from anywhere else. - log::info!( - "catalog checked and opened in {} ms", - started.elapsed().as_millis() - ); - CatalogOpen::Opened(cat) - } - Err(dr_catalog::CatalogError::Corrupt { detail }) => CatalogOpen::Corrupt(detail), - Err(e) => CatalogOpen::Failed(e.to_string()), - }; - let _ = tx.send(message); - }); - - rx -} - -/// Start the scan that brings the catalog up to date. -/// -/// Split out of [`open`] because it no longer runs there — see -/// [`open_catalog_soon`] — and shared with the two paths that reach it once the -/// catalog has been answered for. -fn begin_scan( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, - conn: Connection, - filter: FormatFilter, - path: PathBuf, -) { - log::info!( - "scanning {} for {} format(s) → {}", - if conn.account.root.is_empty() { - "" - } else { - &conn.account.root - }, - filter.iter().count(), - path.display() - ); - window.set_library_status("Starting…".into()); - - let rx = library::spawn_scan( - conn.clone(), - conn.account.root.clone(), - filter, - path.clone(), - ); - - drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); -} - -/// Drain scan progress on the UI thread. -fn drain_scan( - weak: slint::Weak, - ctl: Rc, - coll_ctl: Rc, - rx: Receiver, - catalog_path: std::path::PathBuf, -) { - let timer = slint::Timer::default(); - let ctl_cb = ctl.clone(); - - // Indeterminate for as long as it runs: a recursive walk discovers its own - // extent, so the count it reports is what it has *found*, never a fraction - // of what there is (FR-CAT-1). - // - // Named after the folder, because two accounts or two roots produce rows - // that are otherwise identical. - let title = match ctl.session.borrow().as_ref() { - Some((c, _)) if !c.account.root.is_empty() => { - format!("Scanning {}", c.account.root) - } - _ => "Scanning the library".to_string(), - }; - let job = ctl.activity.begin(crate::activity::Kind::Scan, title); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(120), - move || { - let Some(w) = weak.upgrade() else { return }; - let ctl = &ctl_cb; - - loop { - let msg = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - // A worker that died without sending must not leave - // the screen on "Scanning…" forever. - if w.get_library_scanning() { - w.set_library_scanning(false); - w.set_library_error("scan ended unexpectedly".into()); - job.fail("ended unexpectedly"); - } - stop(&ctl.scan_timer); - return; - } - }; - - match msg { - ScanMessage::Progress { - directories, - pruned, - images, - } => { - // Pruned folders are reported separately rather than - // folded into the total: they are the ETag walk paying - // off, and hiding them makes an incremental rescan look - // identical to a full one. - let status = if pruned > 0 { - format!("{directories} folders · {pruned} unchanged · {images} images") - } else { - format!("{directories} folders · {images} images") - }; - job.detail(status.clone()); - w.set_library_status(status.into()); - } - ScanMessage::Done { - found, - total, - pruned, - elapsed_ms, - judgements, - } => { - log::info!( - "scan complete: {total} images ({found} listed, \ - {pruned} folders unchanged, {judgements} judgements \ - taken in) in {elapsed_ms} ms" - ); - w.set_library_scanning(false); - - // A completed scan is the strongest possible evidence - // the server is reachable, so it clears offline mode - // without needing a probe of its own. - if ctl - .reachability - .borrow_mut() - .mark_reachable(std::time::Instant::now()) - { - log::info!("back online"); - } - // TRACES: FR-PLAT-AND-2 - // And it is the only evidence that clears a lost root, - // for the same reason: the walk began by listing the - // root, so a scan that finished is a root that opened. - // The rows it marked offline are restored one at a - // time by `library::persist`, as each file is listed - // again — this only stops the banner claiming what is - // no longer true. - if ctl.root_lost.borrow_mut().take().is_some() { - log::info!("library folder is readable again"); - } - refresh_offline(&w, ctl); - // TRACES: FR-CAT-13 - // The pull may have found an `.xmp` that disagrees - // with the catalog; the settings page offers the - // reload, and this is what tells it how many. - refresh_xmp_conflicts(&w, ctl); - - // An incremental rescan lists almost nothing, so - // reporting the listed count would read as "0 images" - // on a library that is simply up to date. - let secs = elapsed_ms as f64 / 1000.0; - let status = if pruned > 0 && found == 0 { - format!("up to date · {total} images · {secs:.1}s") - } else if pruned > 0 { - format!("{found} new or changed · {total} images · {secs:.1}s") - } else { - format!("{total} images · {secs:.1}s") - }; - // TRACES: FR-CAT-8 | FR-NC-9 - // Said out loud, because a grid that silently gains - // three hundred stars is indistinguishable from one - // that has gone wrong — and because for as long as - // this number was structurally zero, the photographer - // had no way to tell that a cull made on another - // device had failed to arrive. - let status = if judgements > 0 { - format!("{status} · {judgements} from other devices") - } else { - status - }; - job.finish(status.clone()); - w.set_library_status(status.into()); - - // Usually already open — `show_catalog_now` opened it - // before this scan started, so the grid has been - // showing the previous run's catalog all along and the - // rows this scan found are new rows in that same file. - // Only a first run, where there was nothing to open, - // reaches the second arm. - let opened = if ctl.catalog.borrow().is_some() { - Ok(()) - } else { - Catalog::open(&catalog_path).map(|cat| { - *ctl.catalog.borrow_mut() = Some(cat); - }) - }; - - match opened { - Ok(()) => { - // The sidebar is built before the grid: the - // grid's badges read collection membership, and - // the tree is where the catalog handle first - // becomes available to it. - { - let borrow = ctl.catalog.borrow(); - if let Some(cat) = borrow.as_ref() { - crate::collections_ui::refresh_tree(&w, &coll_ctl, cat); - } - } - load_window(&w, ctl); - // Everything the grid did not touch: the rest - // of the library gets a thumbnail and a date, - // so the timeline describes all of it rather - // than the part that was scrolled past. - start_sweep(&w, ctl); - } - Err(e) => w.set_library_error(format!("opening catalog: {e}").into()), - } - stop(&ctl.scan_timer); - return; - } - ScanMessage::Failed { - message, - offline, - lost_root, - } => { - log::warn!("scan failed: {message}"); - w.set_library_scanning(false); - // Recorded as a failure even where it is only the - // connection: the grid's offline banner says the server - // is unreachable, and this says which piece of work - // stopped because of it. - job.fail(message.clone()); - - if lost_root { - // TRACES: FR-PLAT-AND-2 | FR-CAT-9 - // The library folder itself could not be opened — - // a share withdrawn, an unplugged drive, and in - // time a revoked document-tree grant. The worker - // has already marked every row under this root - // offline and deleted none of them; this is the - // half the user sees. - // - // Tested first because it is also true that the - // library is unreachable, and the generic answer - // would be reached first and be less useful. - *ctl.root_lost.borrow_mut() = Some(message); - refresh_offline(&w, ctl); - // Same reason as the offline arm below: without - // this a launch that began with a revoked grant - // shows an empty grid, which is the one impression - // this whole path exists to avoid. - open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); - } else if offline { - // Not an error state. The catalog from the last - // successful scan is still on disk and still - // accurate for everything already indexed, so the - // grid keeps working — it simply cannot learn about - // anything added on the server since. - ctl.reachability - .borrow_mut() - .mark_unreachable(message, std::time::Instant::now()); - refresh_offline(&w, ctl); - // Show whatever the catalog holds. Without this the - // grid stays empty on a launch that began offline, - // which is precisely the case offline mode exists - // for. - open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); - } else { - w.set_library_error(message.into()); - } - stop(&ctl.scan_timer); - return; - } - } - } - }, - ); - - *ctl.scan_timer.borrow_mut() = Some(timer); -} - -/// Start a scan against the configured library. -/// -/// Shared by the rescan button and by the offline banner's retry, which are -/// the same operation: a scan is the only request that both proves the server -/// is reachable and brings the catalog up to date. Keeping them one function -/// is what stops "retry" from quietly becoming a weaker probe than "rescan". -pub(crate) fn start_rescan( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - let Some((conn, filter)) = ctl.session.borrow().clone() else { - return; - }; - - window.set_library_scanning(true); - window.set_library_error(slint::SharedString::new()); - window.set_library_status("Rescanning…".into()); - - let path = library::catalog_path(&conn.account); - let rx = library::spawn_scan( - conn.clone(), - conn.account.root.clone(), - filter, - path.clone(), - ); - drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); -} - -/// TRACES: FR-NC-6a | FR-NC-6c -/// What a collection would cost to take with you, and what it is holding now. -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -struct OfflineSummary { - /// Photographs in the collection and its children, deduplicated. - total: usize, - /// Of those, how many have their original on this device. - held: usize, - /// Disk those originals occupy — what releasing would give back. - held_bytes: u64, - /// What the rest would cost to fetch, from the sizes the scan recorded. - /// Zero where nothing has been stat-ed yet, which reads as "unknown" - /// rather than "free" in the label built from it. - missing_bytes: u64, -} - -impl OfflineSummary { - fn missing(self) -> usize { - self.total.saturating_sub(self.held) - } -} - -/// Read the offline summary for a set of images. -/// -/// One query with the ids inlined as placeholders — the same shape -/// [`collection_images`] uses, and for the same reason: a collection is tens to -/// thousands of rows, and a round trip per photograph to answer one dialogue is -/// not a trade worth making. -fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary { - if images.is_empty() { - return OfflineSummary::default(); - } - let placeholders = std::iter::repeat_n("?", images.len()) - .collect::>() - .join(","); - // `tier_actual`, never `tier_desired`: the question is what can be opened - // on the aeroplane, and a pin whose download has not run yet answers no. - let sql = format!( - "SELECT count(*), - coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0), - coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0), - coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0 - ELSE coalesce(i.file_size, 0) END), 0) - FROM images i - LEFT JOIN image_cache c ON c.image_id = i.id - WHERE i.id IN ({placeholders})" - ); - - let mut params: Vec = vec![rusqlite::types::Value::Integer( - dr_types::Tier::Original.stored(), - )]; - params.extend( - images - .iter() - .map(|i| rusqlite::types::Value::Integer(i.0 as i64)), - ); - - catalog - .connection() - .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { - Ok(OfflineSummary { - total: r.get::<_, i64>(0)? as usize, - held: r.get::<_, i64>(1)? as usize, - held_bytes: r.get::<_, i64>(2)? as u64, - missing_bytes: r.get::<_, i64>(3)? as u64, - }) - }) - .unwrap_or_else(|e| { - log::debug!("reading offline summary: {e}"); - OfflineSummary::default() - }) -} - -/// TRACES: FR-NC-6a | FR-NC-6c -/// Ask what should happen to a collection's local copies. -/// -/// Both answers are expensive — one commits the device to a download of -/// gigabytes, the other deletes gigabytes it already holds — so this is a -/// question rather than a toggle, and the counts and sizes go in the buttons -/// where they are read *before* the tap rather than in a second dialogue after -/// it (FR-NC-6c: an operation requiring absent data says so, with the size, -/// before starting). -fn open_offline_prompt( - window: &AppWindow, - ctl: &Rc, - id: dr_types::CollectionId, -) { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - - // Descendants, matching what the grid shows when scoped to this row: - // keeping a parent whose children hold the photographs must keep the - // photographs, or the answer would appear to do nothing. - let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { - Ok(ids) => ids, - Err(e) => { - window.set_library_error(format!("resolving collection: {e}").into()); - return; - } - }; - let images = collection_images(catalog, &ids); - let summary = offline_summary(catalog, &images); - - let name = dr_catalog::collections::tree(catalog.connection()) - .ok() - .and_then(|rows| { - rows.into_iter() - .find(|r| r.collection.id == id) - .map(|r| r.collection.name) - }) - .unwrap_or_else(|| "This collection".to_string()); - - ctl.offline_target.set(Some(id)); - window.set_offline_prompt_title(name.as_str().into()); - window.set_offline_prompt_detail( - if summary.total == 0 { - "Nothing in here yet. Put some photographs in it first.".to_string() - } else if summary.held == summary.total { - format!( - "All {} on this device · {}", - summary.total, - crate::activity::describe_bytes(summary.held_bytes) - ) - } else { - format!( - "{} photographs · {} already on this device", - summary.total, summary.held - ) - } - .as_str() - .into(), - ); - window.set_offline_prompt_keep_label( - // The size is named where the scan has recorded one. Where it has not, - // the label says what it will do and not what it will cost, which is - // honest — a "0 B" download would be a lie about a gigabyte. - if summary.missing_bytes > 0 { - format!( - "Download {} · {}", - summary.missing(), - crate::activity::describe_bytes(summary.missing_bytes) - ) - } else if summary.missing() > 0 { - format!("Download {}", summary.missing()) - } else { - "Everything is already here".to_string() - } - .as_str() - .into(), - ); - window.set_offline_prompt_release_label( - format!( - "Remove {} local copies · frees {}", - summary.held, - crate::activity::describe_bytes(summary.held_bytes) - ) - .as_str() - .into(), - ); - window.set_offline_prompt_can_keep(summary.missing() > 0); - window.set_offline_prompt_can_release(summary.held > 0); - window.set_offline_prompt_busy(window.get_library_pin_total() > 0); -} - -/// Close the offline question without answering it. -fn close_offline_prompt(window: &AppWindow, ctl: &Rc) { - ctl.offline_target.set(None); - // The title is what the prompt's visibility is bound to: one fact, so a - // dialogue cannot be up with nothing written on it. - window.set_offline_prompt_title(slint::SharedString::new()); -} - -/// TRACES: FR-NC-6a -/// Keep a collection on this device: record the pin, then start the transfer. -/// -/// Two separate things, and keeping them separate is what makes the answer feel -/// immediate — recording the intent is a local catalog write that completes at -/// once, and downloading the bytes may take a very long time. The pin also -/// survives the app being closed halfway through, which is what makes the -/// transfer resumable rather than something to start again. -fn keep_collection_offline( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - let Some(id) = ctl.offline_target.get() else { - return; - }; - let Some(cache) = ctl.cache() else { - window.set_library_error("No cache directory for this library.".into()); - return; - }; - - let images = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { - Ok(ids) => ids, - Err(e) => { - window.set_library_error(format!("resolving collection: {e}").into()); - return; - } - }; - let images = collection_images(catalog, &ids); - if images.is_empty() { - window.set_library_error("Nothing in that collection to keep offline.".into()); - return; - } - if let Err(e) = cache.pin(catalog.connection(), &images) { - window.set_library_error(format!("pinning: {e}").into()); - return; - } - images - }; - - log::info!("pinned {} image(s) for offline use", images.len()); - window.set_library_error(slint::SharedString::new()); - if ctl.scope.borrow().as_ref() == Some(&id) { - window.set_library_scope_pinned(true); - } - close_offline_prompt(window, ctl); - start_pin_fetch(window, ctl); - refresh_collection_tree(window, ctl, coll_ctl); -} - -/// TRACES: FR-NC-6a | FR-NC-6b -/// Give the disk back: release the pin *and* delete the originals it held. -/// -/// Deliberately destructive, where unpinning alone is not. "Remove the local -/// copies" is asked by someone whose device is full, and answering it by -/// withdrawing a promise and leaving the gigabytes for a future eviction to -/// notice is not an answer. Nothing is lost that cannot be fetched again: the -/// originals are on the server, and the ratings, the edit graph and the -/// thumbnails are all untouched — they are authoritative and small. -fn release_collection_offline( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - let Some(id) = ctl.offline_target.get() else { - return; - }; - let Some(cache) = ctl.cache() else { - window.set_library_error("No cache directory for this library.".into()); - return; - }; - - let released = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { - Ok(ids) => ids, - Err(e) => { - window.set_library_error(format!("resolving collection: {e}").into()); - return; - } - }; - let images = collection_images(catalog, &ids); - let outcome = match cache.release(catalog.connection(), &images) { - Ok(r) => r, - Err(e) => { - window.set_library_error(format!("removing local copies: {e}").into()); - return; - } - }; - - // TRACES: FR-NC-6c - // On a placeholder library the bookkeeping above owns no files, so it - // freed nothing — the originals are materialised in the library folder - // and only the sync client may take them back. Asking it to is what - // makes unpinning actually return the disk, and it must be a - // dehydration rather than a delete: removing a file inside a synced - // tree propagates to the server (ARCH §9.0a). - // - // Fire and forget: it is per-file work over a socket, the user has - // already been told the pin is withdrawn, and a client that refuses - // leaves the content where it is at no cost but disk. - if let Some(conn) = ctl.session() { - let path = library::catalog_path(&conn.account); - std::mem::drop(library::spawn_dehydrate(conn, path, images)); - } - outcome - }; - - let (count, freed) = released; - log::info!( - "released {count} image(s), freeing {}", - crate::activity::describe_bytes(freed) - ); - window.set_library_error(slint::SharedString::new()); - window.set_library_status( - format!( - "Removed local copies · {} freed", - crate::activity::describe_bytes(freed) - ) - .as_str() - .into(), - ); - if ctl.scope.borrow().as_ref() == Some(&id) { - window.set_library_scope_pinned(false); - } - // A download that was still running for this collection has just had its - // reason withdrawn; the worker checks `pending_pins` per file, so it stops - // finding work rather than being killed. - window.set_library_pin_total(0); - window.set_library_pin_done(0); - close_offline_prompt(window, ctl); - refresh_local_count(window, ctl); - refresh_collection_tree(window, ctl, coll_ctl); -} - -/// Redraw the sidebar, so the trays reflect what was just kept or released. -fn refresh_collection_tree( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - let borrow = ctl.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - crate::collections_ui::refresh_tree(window, coll_ctl, catalog); - } -} - -/// Every image in the given collections, deduplicated. -/// -/// An image in both a parent and a child is one photograph and must be pinned -/// once, exactly as the grid draws it once. -fn collection_images(catalog: &Catalog, ids: &[dr_types::CollectionId]) -> Vec { - if ids.is_empty() { - return Vec::new(); - } - let placeholders = std::iter::repeat_n("?", ids.len()) - .collect::>() - .join(","); - let sql = format!( - "SELECT DISTINCT image_id FROM collection_members - WHERE collection_id IN ({placeholders})" - ); - let params: Vec = ids - .iter() - .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) - .collect(); - - let Ok(mut stmt) = catalog.connection().prepare(&sql) else { - return Vec::new(); - }; - let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)) - }); - match rows { - Ok(rows) => rows.flatten().collect(), - Err(e) => { - log::debug!("listing collection images: {e}"); - Vec::new() - } - } -} - -/// TRACES: FR-NC-6a -/// Download whatever the pins still want, reporting progress. -fn start_pin_fetch(window: &AppWindow, ctl: &Rc) { - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - let Some(cache_dir) = ctl.cache_dir() else { - return; - }; - - // Offline, there is nothing to download from. The pin is already recorded, - // so it resumes on reconnect rather than being lost. - if ctl.is_offline() { - log::info!("offline: the pin is recorded and will download on reconnect"); - return; - } - - let rx = library::spawn_pin_fetch( - conn.clone(), - library::catalog_path(&conn.account), - cache_dir, - // Pinned originals are exempt from the budget, but a pin fetch also - // stores passively when it finds an image already cached, so the worker - // still needs the user's ceiling rather than the catalog's floor. - ctl.cache_budget.get(), - ); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - - // The longest-running transfer the app does, and the one most likely to be - // watched from another view — which is the whole reason the register - // exists (FR-NC-6, FR-NC-6c). - let job = ctl.activity.begin( - crate::activity::Kind::Download, - "Keeping photographs on this device", - ); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(300), - move || { - let Some(w) = weak.upgrade() else { return }; - loop { - let msg = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - w.set_library_pin_total(0); - job.fail("stopped without finishing"); - stop(&ctl_cb.pin_timer); - return; - } - }; - - match msg { - library::PinMessage::Planned { total } => { - w.set_library_pin_total(total as i32); - w.set_library_pin_done(0); - job.total(total); - } - library::PinMessage::Stored { done } => { - w.set_library_pin_done(done as i32); - job.progress(done, w.get_library_pin_total() as usize); - // The "On this device" count grows as they land, so - // the chip agrees with the progress line beside it. - refresh_local_count(&w, &ctl_cb); - } - library::PinMessage::Done { stored, bytes } => { - log::info!( - "pin complete: {stored} original(s), {:.1} MB", - bytes as f64 / 1_048_576.0 - ); - job.finish(format!( - "{stored} photograph(s) · {}", - crate::activity::describe_bytes(bytes) - )); - w.set_library_pin_total(0); - w.set_library_pin_done(0); - refresh_local_count(&w, &ctl_cb); - stop(&ctl_cb.pin_timer); - return; - } - library::PinMessage::Failed { message, offline } => { - log::warn!("pin fetch stopped: {message}"); - job.fail(message.clone()); - w.set_library_pin_total(0); - if offline { - ctl_cb - .reachability - .borrow_mut() - .mark_unreachable(message, std::time::Instant::now()); - refresh_offline(&w, &ctl_cb); - } else { - w.set_library_error(format!("keeping offline: {message}").into()); - } - refresh_local_count(&w, &ctl_cb); - stop(&ctl_cb.pin_timer); - return; - } - } - } - }, - ); - - *ctl.pin_timer.borrow_mut() = Some(timer); -} - -/// Refresh the "On this device" count from the catalog. -fn refresh_local_count(window: &AppWindow, ctl: &Rc) { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); -} - -/// TRACES: FR-NC-6a -/// Whether every image in the scoped collection is pinned. -/// -/// Read from the catalog rather than remembered, because a pin outlives the -/// session that made it: reopening the library must show the button already -/// active, or the user would pin the same collection twice. -fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool { - if images.is_empty() { - return false; - } - let placeholders = std::iter::repeat_n("?", images.len()) - .collect::>() - .join(","); - let params: Vec = images - .iter() - .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) - .collect(); - - let pinned: i64 = catalog - .connection() - .query_row( - &format!( - "SELECT count(*) FROM image_cache - WHERE pinned = 1 AND image_id IN ({placeholders})" - ), - rusqlite::params_from_iter(params.iter()), - |r| r.get(0), - ) - .unwrap_or(0); - - pinned as usize == images.len() -} - -/// TRACES: FR-CAT-9 -/// Paint the connectivity state into the window. -/// -/// Called wherever reachability may have moved, rather than by the state -/// itself: `Reachability` is in `dr-sync` and knows nothing about a window, -/// which is what keeps it testable without a display server. -fn refresh_offline(window: &AppWindow, ctl: &Rc) { - let reach = ctl.reachability.borrow(); - // TRACES: FR-PLAT-AND-2 - // A lost root wins over a dead network, and does so even when both are - // true — which is the ordinary case, since the scan that discovered the - // grant was gone was also the last request the app made. Reported the - // other way round the user is told to wait for a connection that is - // working, and the thing that would actually fix it is never mentioned. - let lost = ctl.root_lost.borrow(); - let offline = reach.is_offline() || lost.is_some(); - - window.set_library_offline(offline); - window.set_library_offline_reason(match lost.as_deref() { - Some(why) => why.into(), - None => reach.reason().unwrap_or_default().into(), - }); - window.set_library_offline_since( - // A duration is what a network outage has and a revoked permission - // does not: "for 4 minutes" invites waiting, and waiting is precisely - // what will not help here. - if lost.is_some() { - slint::SharedString::default() - } else { - reach - .offline_for(std::time::Instant::now()) - .map(describe_duration) - .unwrap_or_default() - .into() - }, - ); - drop(lost); - - // A stale scan error under an offline banner reports one problem twice. - if offline { - window.set_library_error("".into()); - } - drop(reach); - - // TRACES: FR-CAT-9 - // Back online: send whatever the outbox is still holding. - // - // Hung off the one function that paints connectivity rather than off each - // of the seven places that move it — a drain that had to be remembered at - // every call site is a drain that will be forgotten at one of them, and - // the symptom is an edit that stays queued until the app is restarted. - // - // `start_outbox_drain` is a no-op when the outbox is empty and while one - // is already running, so calling it on every repaint costs a directory - // walk that finds nothing. - if !offline { - start_outbox_drain(window, ctl); - } -} - -/// TRACES: FR-CAT-9 | FR-NC-10 -/// Upload the sidecars queued while this device had no connection. -/// -/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is -/// running" state, and a second one started beside it would drain the same -/// channel twice. -fn start_outbox_drain(window: &AppWindow, ctl: &Rc) { - if ctl.outbox_timer.borrow().is_some() { - return; - } - if !ctl.outbox_maybe_dirty.get() { - return; - } - let Some(cache_dir) = ctl.sidecar_cache_dir() else { - return; - }; - if crate::sidecar_cache::SidecarCache::open(cache_dir.clone()) - .pending() - .is_empty() - { - // Nothing there. Recorded so the next hundred repaints skip the walk; - // a write that queues sets it again. - ctl.outbox_maybe_dirty.set(false); - return; - } - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - - let rx = library::spawn_outbox_drain(conn.clone(), cache_dir); - let job = ctl - .activity - .begin(crate::activity::Kind::Upload, "Uploading queued edits"); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(250), - move || { - let Some(w) = weak.upgrade() else { return }; - match rx.try_recv() { - Ok(library::SidecarMessage::Finished { - written, - queued, - failed, - last_error, - }) => { - if failed > 0 { - log::warn!( - "{failed} queued sidecar(s) still undelivered: {}", - last_error.clone().unwrap_or_default() - ); - // Not `fail`: the edits are still safely queued, and - // reporting this as a loss would be wrong. - job.finish(format!("{written} uploaded · {queued} still queued")); - } else if written > 0 { - log::info!("{written} queued sidecar(s) uploaded"); - job.finish(format!("{written} queued edit(s) uploaded")); - w.set_library_status(format!("{written} queued edit(s) uploaded").into()); - } else { - job.finish_quietly(); - } - ctl_cb.outbox_maybe_dirty.set(queued > 0); - stop(&ctl_cb.outbox_timer); - } - Err(std::sync::mpsc::TryRecvError::Empty) => {} - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - job.finish_quietly(); - stop(&ctl_cb.outbox_timer); - } - } - }, - ); - *ctl.outbox_timer.borrow_mut() = Some(timer); -} - -/// A coarse "how long ago", for the offline banner. -/// -/// Deliberately imprecise: the user wants to know whether this just happened -/// or has been true for a while, and a live-counting seconds display would -/// draw the eye to a number that changes without meaning anything. -fn describe_duration(d: std::time::Duration) -> String { - let secs = d.as_secs(); - if secs < 60 { - "just now".to_string() - } else if secs < 3600 { - format!("{}m ago", secs / 60) - } else { - format!("{}h ago", secs / 3600) - } -} - -/// TRACES: FR-CAT-9 -/// Show the catalog after a scan that could not reach the server. -/// -/// The whole point of offline mode: a scan is how the grid normally gets its -/// catalog handle, so a failed one previously left the library empty even -/// though a complete catalog was sitting on disk from the last successful run. -/// The images are all still there, their thumbnails are in the shards, and -/// rating and collecting them are local writes. -/// -/// The sweep is deliberately **not** started — it exists to fetch headers over -/// the network, so offline it would do nothing but fail once per image. -fn open_catalog_for_offline( - window: &AppWindow, - ctl: &Rc, - catalog_path: &std::path::Path, - coll_ctl: &Rc, -) { - if ctl.catalog.borrow().is_some() { - // Already open — a rescan that failed, rather than a launch that - // began offline. The grid is showing the catalog already. - load_window(window, ctl); - return; - } - - match Catalog::open(catalog_path) { - Ok(cat) => { - crate::collections_ui::refresh_tree(window, coll_ctl, &cat); - *ctl.catalog.borrow_mut() = Some(cat); - load_window(window, ctl); - } - Err(e) => { - // No catalog and no server. This is the one genuinely empty case: - // a first run that never reached the server has nothing indexed. - log::warn!("offline with no local catalog: {e}"); - window.set_library_error( - "Offline, and this library has not been scanned on this device yet.".into(), - ); - } - } -} - -/// TRACES: FR-UI-8 -/// The grid's own route into the develop view, as something that can be held. -/// -/// Named because it is threaded through the controller as well as passed to -/// `wire`, and a `RefCell>>` written out twice is a -/// signature nobody reads. -type OpenImage = Rc; - -/// How long a run of geometry changes has to stop for before the window is -/// reloaded against it. -/// -/// Long enough to swallow a whole gesture's worth of steps, short enough that a -/// single deliberate step still feels immediate. -const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140); - -/// Reload the window once the grid's geometry has stopped changing. -/// -/// # Why this is deferred when a scroll is not -/// -/// A pinch is not one zoom step, it is a stream of them, and every step -/// changes both the column count and the capacity — two reports. Each report -/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the -/// badges and ratings for every one of them and spawn a thumbnail batch, -/// synchronously, on the thread that is trying to draw the frame. Twice per -/// step. That is why zooming juddered while scrolling the same grid is smooth: -/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame. -/// -/// None of it is urgent, because none of it is about *which* photographs are on -/// screen. A column change moves the cells and a zoom resizes them, but the -/// window holds the same images either way — the model already has them, and -/// the cells re-flow from `columns` and `cell-size` without Rust being involved -/// at all. What the reload actually recomputes is which cells begin a row, so -/// the month headings land in the right places, and which thumbnail size class -/// to ask for now. Both can wait for the gesture to finish. -/// -/// # The anchor -/// -/// Captured on the *first* report of a run rather than read when the timer -/// fires. As the grid re-flows, the viewport keeps its pixel offset while the -/// rows move underneath it, so the view drifts and reports its drift — reading -/// the anchor at the end would faithfully return to wherever it had wandered -/// to. Taking it at the start returns to the photograph the user was actually -/// looking at when they began the gesture. -fn schedule_reload(window: &AppWindow, ctl: &Rc) { - // Only the first report of a run sets it; the rest of the run reuses it. - if ctl.pending_anchor.get().is_none() { - ctl.pending_anchor.set(Some(ctl.resume_at.get())); - } - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || { - let Some(w) = weak.upgrade() else { return }; - let anchor = ctl_cb.pending_anchor.take().unwrap_or(0); - if !w.get_library_visible() { - return; - } - - // The window re-centred on the anchor, and the viewport sent back to - // it: cells are drawn at their absolute place in the library, so a - // change to `columns` moves every one of them and a viewport left - // where it was would be pointing at rows the window no longer covers. - let window_size = *ctl_cb.window.borrow(); - let total = w.get_library_total().max(0) as usize; - *ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total); - load_window(&w, &ctl_cb); - w.set_library_scroll_to(anchor as i32); - w.set_library_scroll_token(w.get_library_scroll_token() + 1); - }); - - // Replacing the slot drops the previous timer, which is what makes this - // coalesce: only the last report of a run lives long enough to fire. - *ctl.geometry_timer.borrow_mut() = Some(timer); -} - -/// Show what the catalog already holds, without waiting for the scan. -/// -/// **Blocking, and no longer on the launch path.** [`open_catalog_soon`] is -/// what a launch goes through; this is what [`crate::recovery_ui`] goes through -/// once the user has answered the damage question, where the event loop is -/// already running, the file has just been replaced under a `forget_catalog`, -/// and there is a `recovery-busy` state on screen saying so. Same reasoning as -/// `recovery_ui::answer` gives for doing the file copy itself in place: nothing -/// else is on screen to block, and a worker would buy a spinner and cost the -/// guarantee that nothing else touches the file while it is being replaced. -/// -/// # Why a launch should not be a scan -/// -/// A launch does not have to discover the library. The catalog from the last -/// run is on disk, complete, with its thumbnails in the shards beside it — -/// which is precisely the state [`open_catalog_for_offline`] already relies on -/// when the server cannot be reached. Every launch that *could* reach the -/// server threw that away and sat on "Scanning…" over an empty grid for as long -/// as a recursive WebDAV walk of the whole tree takes. On a real library, and -/// especially on a phone's connection, that walk is the entire startup time, -/// spent hiding a grid that was ready before it began. -/// -/// The scan still runs and still replaces this the moment it lands. What it no -/// longer does is gate the first paint on the network. -/// -/// Silent when there is no catalog yet: that is a genuine first run, the empty -/// state already says "Scanning…", and an error here would contradict a scan -/// that is working perfectly. `Catalog::open` creates the file in that case, so -/// what the grid reads is an empty catalog rather than a failure. -/// Returns whether it is safe to go on and scan. -/// -/// `false` means the catalog is damaged and the recovery question is up. The -/// caller must not start a scan on that answer: `Catalog::open` succeeds on a -/// file whose header survived, so the scan would write ETags and image rows -/// into damaged pages while the user is still reading the question — turning a -/// file that had a backup into one where the backup is the only copy left. -pub(crate) fn show_catalog_now( - window: &AppWindow, - ctl: &Rc, - catalog_path: &std::path::Path, - coll_ctl: &Rc, -) -> bool { - if ctl.catalog.borrow().is_some() { - return true; - } - - // Verified rather than plain: this is a moment where a full check is - // affordable and there is a user in front of it who can answer the - // question — they have just answered one. See `dr_catalog::recovery` for - // why it is not on every open. - let cat = match Catalog::open_verified(catalog_path) { - Ok(cat) => cat, - Err(dr_catalog::CatalogError::Corrupt { detail }) => { - crate::recovery_ui::offer(window, catalog_path, &detail); - return false; - } - Err(e) => { - // Not surfaced: the scan is the thing that has to work, and it is - // still running. If it fails too, it reports for both of them. - log::info!("no catalog to show before the scan: {e}"); - return true; - } - }; - - adopt_catalog(window, ctl, coll_ctl, cat); - true -} - -/// Take an opened catalog into the interface. -/// -/// The UI half of opening a catalog, separated from the opening itself so that -/// [`open_catalog_soon`] — which does the opening on a worker — and -/// [`show_catalog_now`] — which still does it in place, for -/// [`crate::recovery_ui`], where the event loop is already running and the file -/// has just been replaced — describe the grid the same way. -/// -/// Everything here is UI-thread work by necessity: it fills the sidebar and the -/// grid model. -fn adopt_catalog( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, - cat: Catalog, -) { - // Before anything else can see this catalog, and exactly once per open — - // the callers' early return on an already-open catalog is what makes it - // once. The job queue is durable, so a run that was killed mid-job left its - // row marked `Running` with nobody holding it; recovery hands those back to - // be resumed rather than lost, and drops jobs naming photographs that have - // since been deleted. - // - // Here rather than wherever a runner starts, because there is no owner - // column in `jobs`: a second recovery pass while a worker held a claim - // would take that claim away from it. - match dr_catalog::runner::recover(cat.connection()) { - Ok(r) if r.did_anything() => log::info!( - "job queue: {} interrupted job(s) resumed, \ - {} for deleted photographs dropped", - r.reclaimed, - r.reaped - ), - Ok(_) => {} - // Not surfaced. The queue is rebuildable like everything else in the - // catalog, and a grid that refuses to open because a background queue - // could not be tidied is the worse failure by some way. - Err(e) => log::warn!("job queue could not be recovered: {e}"), - } - - // The sidebar before the grid, because the grid's badges read collection - // membership — the same order the scan's completion uses. - crate::collections_ui::refresh_tree(window, coll_ctl, &cat); - *ctl.catalog.borrow_mut() = Some(cat); - - // TRACES: FR-UI-8 - // Where the photographer was, before the first window is read rather than - // after. The scope and the filter both change what the grid's query - // returns, so restoring them afterwards would mean reading the top of the - // whole library, drawing it, and then reading again — a visible jump on - // every launch, and on a remote library a screenful of thumbnails fetched - // for photographs nobody asked to see. - // - // The sidebar is refreshed above rather than below for the same ordering - // reason: a scope is restored by naming a row of a tree that has to exist. - let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load()); - match stored { - Some(place) => apply_place(window, ctl, coll_ctl, &place), - None => load_window(window, ctl), - } -} - -/// TRACES: FR-UI-8 -/// Ask the server where the photographer was, and use it if they have not -/// started working yet. -/// -/// # The two halves of a restore -/// -/// The local record is applied in [`adopt_catalog`], synchronously and before -/// the first window is read: it is on this disk, it costs nothing, and it works -/// with the network down. This is the other half — the record another device -/// left — and it cannot be applied there because it has not arrived yet. -/// -/// # Why it may be ignored when it does arrive -/// -/// A handover is welcome on the way in and unwelcome once the photographer has -/// started. A grid that jumped somewhere else mid-scroll, because a round trip -/// finally landed, would be worse than never handing over at all — the user did -/// not ask for it, cannot see why it happened, and has lost their place to a -/// feature whose entire purpose is keeping it. -/// -/// So `place_untouched` gates it: any scroll, scrub, scope change, filter or -/// opened photograph closes the latch. The record is still adopted onto disk by -/// the exchange in [`crate::derived_sync::sync_place`], so nothing is lost — -/// it simply takes effect at the next launch instead of this one. -/// -/// Silent throughout. A first launch against a library nobody has recorded a -/// place for is the ordinary case, and being offline is not a failure of -/// anything the user asked for. -fn fetch_remote_place( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, - conn: &Connection, -) { - if std::env::var_os("DARKROOM_NO_SYNC").is_some() { - return; - } - - let rx = crate::derived_sync::spawn_place_fetch(conn.clone(), conn.account.root.clone()); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let held = ctl.clone(); - let coll = coll_ctl.clone(); - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(120), - move || { - let Some(w) = weak.upgrade() else { return }; - let got = match rx.try_recv() { - Ok(got) => got, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - stop(&held.place_fetch_timer); - return; - } - }; - stop(&held.place_fetch_timer); - - let theirs = match got { - Ok(Some(place)) => place, - Ok(None) => return, - Err(e) => { - log::debug!("no place from the server: {e}"); - return; - } - }; - - // Newer than what this device has, or there is nothing to learn. - // - // The borrow is scoped rather than dropped by hand: `apply_place` - // below reaches back into the controller, and a `Ref` still held - // across it is the shape a `RefCell` panic takes. - let adopted = match held.place_store.borrow().as_ref() { - Some(store) => store.adopt(&theirs), - None => false, - }; - if !adopted { - return; - } - - // On disk either way; on screen only if nobody has moved. - if !held.place_untouched.get() { - log::info!("a newer place arrived, and will be used at the next launch"); - return; - } - // And only once the catalog is open — the ordinal it resolves to is - // a query against it. A record that lands first is left on disk, - // where `adopt_catalog` reads it. - if held.catalog.borrow().is_none() { - return; - } - log::info!("picking up where another device left off"); - apply_place(&w, &held, &coll, &theirs); - }, - ); - *ctl.place_fetch_timer.borrow_mut() = Some(timer); -} - -/// TRACES: FR-UI-8 -/// What this device signs a place with. -/// -/// The thumbnail store's client id, which is already this device's name among -/// the clients sharing a library — it is what qualifies a shard's remote -/// filename. Reusing it means the place and the shards a machine publishes -/// carry one name in `.darkroom-derived/`, rather than two that have to be -/// correlated by hand. -/// -/// Empty where the store will not open. The field is informational, so an -/// unsigned place is worth strictly more than no place at all. -fn device_id(account: &Account) -> String { - match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) { - Ok(store) => store.client_id().to_string(), - Err(e) => { - log::debug!("no device id for the place record: {e}"); - String::new() - } - } -} - -/// Drop the open catalog, so the next `show_catalog_now` opens the file -/// again rather than returning early. -/// -/// Only recovery needs this, and it needs it for a specific reason: the file -/// under that connection has been replaced. A handle to the catalog that was -/// there before is a handle to a file that no longer has a name, and every -/// read through it would return the damaged pages the recovery just moved out -/// of the way. -pub(crate) fn forget_catalog(ctl: &Rc) { - *ctl.catalog.borrow_mut() = None; -} - -/// What one cell of the outgoing model is worth keeping. -#[derive(Clone)] -struct Held { - thumbnail: slint::Image, - has_thumb: bool, - /// A completed fetch that found no preview. Worth carrying for the same - /// reason the pixels are: it is an answer about the file, and re-asking it - /// on every scroll is a fetch that will fail again. - unavailable: bool, - /// Which size class the pixels came from, so the reload can tell a cell - /// that is showing what it should from one that is showing the small class - /// while the grid has since been zoomed past it. - class: Option, -} - -/// **What the outgoing model is still holding, keyed on the photograph.** -/// -/// A reload replaces every row, and a scroll reloads once the view has -/// travelled a quarter of the loaded window — so three quarters of the cells -/// being rebuilt are the *same* photographs the user is looking at right now. -/// Rebuilding them empty blanked the whole grid to `Theme.ground` and refilled -/// it a beat later, once a worker had re-read and re-decoded every one of them -/// from the thumbnail store. That is the black flash that punctuated every -/// screenful of scrolling, and the one visible on a column change, a zoom step, -/// a filter and a return from develop. -/// -/// Keyed on `image_id` rather than on the row, because the row is exactly what -/// a reload changes. Cheap: the values are `slint::Image` handles, so this is a -/// refcount per cell and no pixels move. -fn hold_thumbnails( - previous: &slint::ModelRc, - ids: &[i64], - classes: &[Option], -) -> std::collections::HashMap { - ids.iter() - .enumerate() - .filter_map(|(row, id)| { - let cell = previous.row_data(row)?; - // Nothing to carry: a row still waiting is equally blank either - // way, and holding a default image would claim otherwise. - if !cell.has_thumb && !cell.unavailable { - return None; - } - Some(( - *id, - Held { - thumbnail: cell.thumbnail, - has_thumb: cell.has_thumb, - unavailable: cell.unavailable, - class: classes.get(row).copied().flatten(), - }, - )) - }) - .collect() -} - -/// The fetches a reloaded window does **not** have to make. -/// -/// The set used to be cleared on every load, which said "every cell here is -/// still to be fetched" — true when every row came back empty, and false now -/// that the overlap keeps its pixels. Re-requesting them re-read and re-decoded -/// three quarters of the window from the store on every scroll. -/// -/// Rebuilt rather than merely kept, and that is the half that is easy to get -/// wrong: an image served into a window the user has since scrolled away from -/// is no longer on screen, and a set that remembered it would leave that cell -/// permanently blank when they scrolled back. Only what the new model actually -/// holds counts as served — and only at the class it holds it at, so zooming -/// past the grid class still asks for the large one. -fn already_served( - held: &std::collections::HashMap, - ids: impl Iterator, -) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> { - ids.filter_map(|id| Some((id, held.get(&id)?.class?))) - .collect() -} - -/// Where the loaded window starts for a view whose first visible cell is -/// `anchor`. -/// -/// A quarter of the window sits above the view, so scrolling back has loaded -/// rows to move into, and the remaining three quarters below, because a grid -/// is read downward. Clamped to the last position where the window is still -/// full — otherwise a scrub to the very end loads a handful of cells and the -/// rest of the window addresses images that do not exist. -fn window_start(anchor: usize, window_size: usize, total: usize) -> usize { - let max_offset = total.saturating_sub(window_size.min(total)); - anchor.saturating_sub(window_size / 4).min(max_offset) -} - -/// Where the loaded window should move to for a view at `first_visible`, or -/// `None` to leave it where it is. -/// -/// # Why this is a rule and not four lines in the scroll handler -/// -/// It decides how often the grid re-reads the catalog while a finger is on it, -/// and both of its ways of being wrong are invisible in the code and obvious -/// on a tablet: too eager and every scroll stutters, too lazy and the view -/// runs off the end of the loaded rows into blank ones. -/// -/// # The rule -/// -/// The window is placed by [`window_start`], and it moves once the view comes -/// within half a screenful of an edge of what is loaded — below the *bottom* -/// of the view going down, above its top going up. -/// -/// # Measured against the view, not against a fraction of the window -/// -/// Both halves of that used to be a quarter of `window_size`, and both were -/// wrong, in opposite directions and for the same reason: a quarter of a -/// three-screenful window is three quarters of a screenful, which is not a -/// quantity either edge of the view cares about. -/// -/// - Downward it was too lax. The test asked where the *first* visible cell -/// was, so it kept the window still until `first_visible` was three quarters -/// of the way through it — by which point the bottom of the view had -/// travelled a quarter of a screenful past the last loaded cell. Those rows -/// are not in the model, so nothing is drawn for them: the last row of the -/// grid, blank, at a scroll position the user can sit at indefinitely. -/// - Upward it was too eager, and exactly so. The window is placed a quarter -/// of itself behind the view, and the old margin then declared the view too -/// close to the top at precisely that distance — so the first row scrolled -/// upward after any move re-read the catalog, rebuilt every cell, and -/// re-queried the badges and ratings, and so did the row after it. -/// -/// # And it never moves to where it already is -/// -/// That is the case the margin test alone gets wrong: at either end of a scope -/// the window is *pinned* — the first screenful cannot be placed further back -/// than zero, and the last cannot start past `max_offset` — so the margin is -/// unsatisfiable there and every row crossed in the first or last quarter of a -/// window re-read the catalog, rebuilt the model and issued a thumbnail batch -/// to arrive at the offset it already had. On a library of twenty-odd thousand -/// that is a stutter at the top and the bottom of every collection, which is -/// exactly where a cull begins and ends. -fn window_move( - first_visible: usize, - current: usize, - window_size: usize, - on_screen: usize, - total: usize, -) -> Option { - // The same placement `load_window` applies, repeated here so this compares - // against where the window would come to rest rather than where it was - // asked to go. - let desired = window_start(first_visible, window_size, total); - if desired == current { - return None; - } - - // Half a screenful of loaded cells beyond each edge of the view. Enough - // that a flick has somewhere to land before the reload it triggers has - // finished, small enough that the window still moves only about once per - // screenful of travel. - let slack = (on_screen / 2).max(1); - let above = first_visible >= current + slack; - let below = first_visible + on_screen + slack <= current + window_size; - if above && below { - return None; - } - - Some(desired) -} - -/// Fill the model from the catalog and start fetching thumbnails. -/// -/// Reads the window starting at the controller's current offset, which the -/// scrubber moves. Without a movable offset the grid could only ever show the -/// first 120 of 23,971 images. -fn load_window(window: &AppWindow, ctl: &Rc) { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - - // Everything below is scoped to the selected collection, if any: the total, - // the window, and the fetches issued for it. Reading the whole library here - // and filtering later would fetch thumbnails for images the user is not - // looking at, which on a remote library is the cost FR-NC-3 exists to - // avoid. - let scope = *ctl.scope.borrow(); - let filter = ctl.filter.borrow().clone(); - // The trash lists what every other view excludes, so it takes its own - // query rather than another predicate threaded through the scoped one. - let trash = ctl.viewing_trash.get(); - - let total = if trash { - library::total_trashed(catalog).unwrap_or(0) - } else { - library::total_images_scoped(catalog, scope, &filter).unwrap_or(0) - }; - - // What the whole-library readouts below describe. See [`LibraryFacts`]. - let facts = LibraryFacts { - scope, - filter: filter.clone(), - trash, - total, - }; - let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts); - *ctl.library_facts.borrow_mut() = Some(facts); - - // TRACES: FR-NC-6a - // Whether the newly scoped collection is already pinned. Read here rather - // than remembered, because a pin outlives the session that made it — on - // reopening the library the button has to show what the catalog says, not - // what this run happens to have done. - // - // Three queries deep — descendants, their images, and whether every one of - // them is pinned — and the answer cannot change by scrolling. - if describes_something_new { - window.set_library_scope_pinned(match scope { - Some(id) => dr_catalog::collections::descendants(catalog.connection(), id) - .map(|ids| collection_images(catalog, &ids)) - .map(|images| scope_is_pinned(catalog, &images)) - .unwrap_or(false), - None => false, - }); - } - // Read before it is overwritten: the property still holds what the library - // was last time this ran, and a library that has become shorter is the - // signal that something was deleted out from under the view. - let was = window.get_library_total().max(0) as usize; - window.set_library_total(total as i32); - let shrank = total < was; - - // Clamp so a scrub to the very end still fills the window rather than - // showing a handful of cells. - let window_size = *ctl.window.borrow(); - let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total))); - *ctl.offset.borrow_mut() = offset; - window.set_library_offset(offset as i32); - - // The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the - // whole scope — 5.7 ms on 24,000 images — and the bars do not change as the - // grid scrolls, only the marker on them does. The marker is moved by the - // scroll handler on every event, without coming through here. - // - // Every other thing that *does* change the bars — a zoom, a pan, a scrub, - // dates landing from the thumbnail worker — calls `refresh_timeline` - // itself, so this being skipped cannot leave a stale axis on screen. - if describes_something_new { - refresh_timeline(window, catalog, ctl); - } - - // TRACES: FR-CAT-7 - // Whether this scope has an order a drag can change, which is what lets the - // grid draw an insertion caret and accept a drop. - // - // Answered here because this is the one place that runs on every re-read — - // a scope change, a reorder, a collection gaining a child that turns it - // into a set — so the caret cannot outlive the scope that justified it. - // - // The trash is never reorderable. It is a view of what was deleted, ordered - // by when, and it is not a collection at all. - window.set_library_reorderable( - !trash - && scope.is_some_and(|c| { - dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false) - }), - ); - - let cells = if trash { - library::read_trashed_cells(catalog, offset, window_size) - } else { - library::read_cells_scoped(catalog, scope, &filter, offset, window_size) - }; - let cells = match cells { - Ok(c) => c, - Err(e) => { - window.set_library_error(format!("reading catalog: {e}").into()); - return; - } - }; - - // What the window currently spans, in the photographer's own terms. - let span = cells - .iter() - .filter_map(|c| c.captured_at) - .fold(None::<(i64, i64)>, |acc, t| { - Some(match acc { - None => (t, t), - Some((lo, hi)) => (lo.min(t), hi.max(t)), - }) - }); - window.set_library_window_label( - match span { - Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)), - // Nothing here has a date yet: EXIF is read as thumbnails load, so - // this fills in rather than being an error. - None => "dates not yet read".to_string(), - } - .into(), - ); - - // Month headings. The grid is ordered by capture time, so without these a - // wall of thumbnails gives no sense of *when* you are looking — the - // sidebar says it, but only if you consult it. - let columns = window.get_library_columns().max(1) as usize; - let headings = period_headings( - cells.iter().map(|c| { - c.captured_at.map(|t| { - let (y, m, _, _) = civil_from_unix(t); - (y, m) - }) - }), - offset, - columns, - ); - - // What the outgoing model is still holding — see [`hold_thumbnails`]. - let held = { - let previous = window.get_library_cells(); - let ids = ctl.image_ids.borrow(); - let classes = ctl.thumb_class.borrow(); - hold_thumbnails(&previous, &ids, &classes) - }; - - let rows: Vec = cells - .iter() - .zip(headings) - .map(|(c, heading)| { - let carried = held.get(&c.image_id); - LibraryCell { - period_heading: heading.into(), - // A freshly loaded window has no drag in flight. - lifted: false, - name: without_extension(&c.name).into(), - thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(), - has_thumb: carried.is_some_and(|h| h.has_thumb), - unavailable: carried.is_some_and(|h| h.unavailable), - // All three are filled straight after by `collections_ui`, - // which owns the selection and queries the badge counts for the - // whole window in one statement rather than one per cell. - selected: false, - collection_count: 0, - // Likewise filled by `sync_ratings` below — one query for the - // window, not one per cell. - rating: 0, - flag: 0, - // And by `bursts::sync_badges`, in one more query for the - // window. Zero is "not in a burst", which is what almost every - // photograph in a library is. - burst_count: 0, - burst_expanded: false, - burst_representative: false, - } - }) - .collect(); - - *ctl.paths.borrow_mut() = cells.iter().map(|c| c.remote_path.clone()).collect(); - *ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect(); - *ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect(); - *ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect(); - *ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect(); - *ctl.captured_at.borrow_mut() = cells.iter().map(|c| c.captured_at).collect(); - *ctl.thumb_class.borrow_mut() = cells - .iter() - .map(|c| held.get(&c.image_id).and_then(|h| h.class)) - .collect(); - *ctl.requested.borrow_mut() = already_served(&held, cells.iter().map(|c| c.image_id)); - - // The model is about to be replaced, so every thumbnail still in flight - // addresses a window that no longer exists. Bumping here — before the swap - // — is what lets `drain_thumbnails` recognise itself as stale. The rows - // those fetches would have filled are absent from `requested` above, so the - // batch started below asks for them again. - ctl.generation.set(ctl.generation.get().wrapping_add(1)); - window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows))); - - // The model is fresh, so the "in this many collections" badges are all zero - // until refilled. One query for the whole window, not one per cell. - let ids: Vec = cells - .iter() - .map(|c| dr_types::ImageId(c.image_id as u64)) - .collect(); - crate::collections_ui::sync_badges(window, catalog, &ids); - sync_ratings(window, catalog, &ids); - // How many frames each cell stands for, where it stands for several - // (FR-CULL-5). - crate::bursts::sync_badges(window, catalog, &ids); - // The rebuilt cells all carry `selected: false`, but the selection itself - // is a set of image ids and survives untouched. Without this the ticks - // vanished on every scroll — the selection was still there and still acted - // on, which is worse than losing it, because the user cannot see what the - // buttons are about to do. - if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) { - crate::collections_ui::sync_selection(window, &coll, &ids); - } - // The filter chips' counts describe the whole library, not this window — - // which is exactly why they are not recomputed for a window that moved. - // A judgement changes them and calls this itself (see `apply_judgement`), - // so a cull still watches its own chips move. - if describes_something_new { - refresh_rating_counts(window, catalog); - } - - // The library got shorter while the view was looking at it — a delete. - // - // Two things have already moved by this point and neither touches the - // viewport: the scrollable height shrank, and `offset` was re-clamped so - // the loaded window still fills. So the view is left pointing either past - // the end of the content (a blank grid) or at a different part of the - // library (an apparent jump to nowhere). Re-anchoring here puts it back on - // the photographs the user was actually looking at. - // - // Only on a shrink. Doing it on every load would fight a scrub, which sets - // exactly this property to go somewhere the user asked for. - if shrank { - restore_position(window, ctl); - } - - if total == 0 { - return; - } - request_thumbnails(window, ctl); -} - -/// Push each visible image's stars and flag into the grid model. -/// -/// One query for the window, mirroring `collections_ui::sync_badges` — 120 -/// cells is 120 round trips otherwise, on every scroll and after every -/// keystroke. -pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) { - if ids.is_empty() { - return; - } - - let found = match dr_catalog::rating::judgements(catalog.connection(), ids) { - Ok(j) => j, - Err(e) => { - // The grid is still usable without stars, so this is logged rather - // than surfaced — a failure here must not blank the library. - log::debug!("reading ratings: {e}"); - return; - } - }; - - let model = window.get_library_cells(); - for (row, id) in ids.iter().enumerate() { - // Absent means unrated, which is a real state rather than missing data. - let j = found.get(id).copied().unwrap_or_default(); - let (rating, flag) = (j.rating as i32, flag_code(j.flag)); - - if let Some(mut cell) = model.row_data(row) { - if cell.rating != rating || cell.flag != flag { - cell.rating = rating; - cell.flag = flag; - model.set_row_data(row, cell); - } - } - } -} - -/// Refresh the filter chips' per-star counts. -/// -/// Whole-library figures, deliberately: they say what narrowing to a filter -/// would show, so computing them over the current window would make each chip -/// describe the view it is meant to change. -fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) { - let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default(); - let as_i32: Vec = counts.iter().map(|n| *n as i32).collect(); - window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32))); - - // TRACES: FR-CAT-9 - // How many originals are actually here, for the "On this device" chip. - // Shown for the same reason the star counts are: a filter that silently - // empties the grid reads as broken, and this one will legitimately be zero - // on a library nothing has been downloaded from yet. - window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); -} - -// --- keywords (FR-CAT-5, FR-CAT-6) --------------------------------------- -// -// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many -// join, what a rename does to the assignments. This part owns the *interaction*: -// which photographs the sheet is acting on, and keeping what it draws honest -// about what actually landed. - -/// Redraw the keywording sheet against whatever is selected now. -/// -/// Called when the sheet opens and after every assignment, rather than on every -/// selection change: the selection moves on each arrow key and the sheet is shut -/// for almost all of them, so computing coverage over a forty-image selection -/// on each one would be work nobody is looking at. -/// -/// Re-read from the catalog rather than patched in place after a write. A word -/// applied to a selection that partly already had it moves from "3 of 12" to -/// "12 of 12", and a model updated by hand would have to reproduce the rule -/// that decides that — which is exactly the rule the catalog has just applied. -fn refresh_keywords(window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId]) { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - - let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) { - Ok(rows) => rows, - Err(e) => { - // The grid is entirely usable without the sheet, so this is logged - // rather than surfaced: a keyword read that failed must not put an - // error banner over a library the user is browsing. - log::debug!("reading keywords: {e}"); - return; - } - }; - - let model: Vec = rows - .into_iter() - .map(|row| KeywordRow { - id: row.keyword.id.0 as i32, - name: row.keyword.name.into(), - coverage: match row.coverage { - dr_catalog::Coverage::None => 0, - dr_catalog::Coverage::Some => 1, - dr_catalog::Coverage::All => 2, - }, - selected_count: row.selected_count as i32, - image_count: row.keyword.image_count as i32, - }) - .collect(); - - window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model))); -} - -/// Put a keyword on the selection, or take it off. -/// -/// # Why this does not write a sidecar -/// -/// Every other judgement in this file — a star, a flag — is written to the -/// catalog and then queued to the image's sidecar, because the sidecar is what -/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in -/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and -/// `flag` and nothing else that is not an edit-graph parameter. -/// -/// So a keyword is, for now, catalog state that reaches the user's other -/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than -/// through the sidecar. That is a real limitation and not a silent one: a -/// deleted catalog loses keywords where it would keep ratings, until the -/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same -/// queued write the stars have. -fn apply_keyword(window: &AppWindow, ctl: &Rc, word: &str, assigning: bool) { - let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else { - return; - }; - let images = coll.selected(); - - // The word as it will be *stored*, resolved before anything is written. - // The status line below quotes it back, and quoting what was typed would - // report a leading space the catalog is about to drop — leaving the user to - // wonder whether it mattered. - // - // This is also where a blank keyword is caught, which is why it happens - // before the selection check: "you typed nothing" is a better answer than - // "select an image first" to someone who pressed return on an empty field. - let word = match dr_catalog::keywords::normalise(word) { - Ok(word) => word, - Err(e) => { - // `BadName` carries text written to be read by the user rather than - // by a developer, so it is shown as it is. - window.set_library_error(format!("{e}").into()); - return; - } - }; - - // Assigning with nothing selected still means something — it puts the word - // in the vocabulary, ready for the photographs it was typed for — so only - // the removal half needs a selection to act on. - if images.is_empty() && !assigning { - window.set_library_status("Select an image first".into()); - return; - } - - let outcome = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let conn = catalog.connection(); - if assigning { - dr_catalog::keywords::assign(conn, &images, &word) - } else { - dr_catalog::keywords::unassign(conn, &images, &word) - } - }; - - let n = match outcome { - Ok(n) => n, - Err(e) => { - window.set_library_error(format!("{e}").into()); - return; - } - }; - - window.set_library_error(slint::SharedString::new()); - window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into()); - refresh_keywords(window, ctl, &images); - start_xmp_writes(window, ctl, &images); - - // A filtered grid may no longer hold what was just keyworded — taking - // "puffin" off an image while showing only puffins means it belongs - // elsewhere now. The same reasoning as a rating that falls below the star - // filter. - if !ctl.filter.borrow().is_unfiltered() { - load_window(window, ctl); - } -} - -/// What the status line says about a keyword that just landed. -/// -/// The honest count, not the requested one: "added to 3 of 12" is what -/// happened when nine of them already carried the word, and a message that -/// claimed twelve would be teaching the user that the counts are decorative. -fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String { - if selected == 0 { - return format!("Added “{word}” to the keyword list"); - } - let verb = if assigning { "Added" } else { "Removed" }; - let preposition = if assigning { "to" } else { "from" }; - if changed == 0 { - return if assigning { - format!("Every selected photograph already had “{word}”") - } else { - format!("None of the selected photographs had “{word}”") - }; - } - if changed == selected { - let what = if selected == 1 { - "1 photograph".to_string() - } else { - format!("{selected} photographs") - }; - return format!("{verb} “{word}” {preposition} {what}"); - } - format!("{verb} “{word}” {preposition} {changed} of {selected}") -} - -/// Apply a judgement to a set of images: catalog first, then sidecars. -/// -/// # Order matters -/// -/// The catalog is written **synchronously and first**, so the star appears -/// immediately and survives a restart even if the network is down. The sidecar -/// write is queued behind it on a worker thread — it is what makes the -/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and -/// rarer concern than the user seeing their keystroke take effect. -/// -/// Doing it the other way round would mean a cull that stalls on every -/// keypress waiting for a round trip, on a workflow whose entire premise is -/// speed (FR-CULL-1). -fn apply_judgement( - window: &AppWindow, - ctl: &Rc, - images: &[dr_types::ImageId], - rating: Option, - flag: Option, -) { - if images.is_empty() { - // Nothing selected. Said out loud rather than ignored: a keystroke - // that silently does nothing reads as a broken key. - window.set_library_status("Select an image first".into()); - return; - } - - let writes = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let conn = catalog.connection(); - - let wrote = match (rating, flag) { - (Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r), - (_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f), - // Neither axis named: nothing to do, and not an error. - (None, None) => return, - }; - - if let Err(e) = wrote { - window.set_library_error(format!("recording rating: {e}").into()); - return; - } - - // Report what happened, in the user's terms rather than as a count of - // rows. A bulk judgement on a selection is easy to trigger by accident - // and the status line is the only confirmation of its extent. - window.set_library_status(judgement_summary(images.len(), rating, flag).into()); - - // Refresh the grid and the chips from what actually landed, rather - // than assuming the write took: a clamped or coalesced value must show - // as what is stored. - let visible = ctl.visible_ids(); - sync_ratings(window, catalog, &visible); - refresh_rating_counts(window, catalog); - - collect_sidecar_writes(catalog, images) - }; - - // A filtered grid may no longer contain what was just judged — rating an - // image 2 while showing "★4+" means it belongs elsewhere now. Reloading - // keeps the cells and the header count honest. - if !ctl.filter.borrow().is_unfiltered() { - load_window(window, ctl); - } - - start_sidecar_writes(window, ctl, writes); - start_xmp_writes(window, ctl, images); -} - -/// What the status line says about a judgement that just landed. -fn judgement_summary(n: usize, rating: Option, flag: Option) -> String { - let what = match (rating, flag) { - (Some(0), _) => "unrated".to_string(), - (Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }), - (_, Some(dr_types::FlagState::Pick)) => "picked".to_string(), - (_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(), - (_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(), - (None, None) => return String::new(), - }; - if n == 1 { - what - } else { - format!("{n} images · {what}") - } -} - -/// TRACES: FR-DEV-6 -/// Apply copied develop settings to a selection of images. -/// -/// # Why this goes straight to the sidecars -/// -/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the -/// catalog holds no parameters at all — only a `graph_hash` — so there is -/// nothing here for the catalog to record. Nor is any image opened: applying -/// to forty frames by loading forty develop sessions would mean forty RAW -/// downloads and forty demosaics to move some numbers between two maps, which -/// is not a thing to ask of a phone. See [`crate::presets`]. -/// -/// # What the user sees -/// -/// Nothing in the grid changes — a thumbnail is rendered from the server's -/// preview and does not reflect an edit — so the status line is the only -/// confirmation, exactly as it is for a bulk judgement. The applied settings -/// appear when a target is next opened in develop, which is what reads the -/// sidecar back. -pub fn paste_settings_to_selection( - window: &AppWindow, - ctl: &Rc, - images: &[dr_types::ImageId], - preset: &dr_pipeline::Preset, - scope: dr_pipeline::Scope, -) { - if images.is_empty() { - // Said out loud rather than ignored, matching what a judgement - // keystroke does with an empty selection. - window.set_library_status("Select an image first".into()); - return; - } - - let writes = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - // A never-judged image may have no version row yet, and the query - // below joins on one. Ratings create them as a side effect; a paste - // is the first write path that can reach an image which has never - // been rated, so it has to ask for them itself. - if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) { - log::debug!("ensuring versions before a paste: {e}"); - } - collect_settings_writes(catalog, images, preset, scope) - }; - - if writes.is_empty() { - window.set_library_error("Could not find those images in the catalog.".into()); - return; - } - - let count = writes.len(); - window.set_library_status( - format!( - "Applied settings to {count} image{}.", - if count == 1 { "" } else { "s" } - ) - .into(), - ); - start_sidecar_writes(window, ctl, writes); -} - -/// Gather one settings write per image, addressed by remote path and version. -/// -/// The uuid comes from the catalog for the same reason a judgement's does: it -/// is the identity a cross-device merge keys on, and a generated one would -/// write a second version beside the one the image already has (FR-NC-8). -fn collect_settings_writes( - catalog: &Catalog, - images: &[dr_types::ImageId], - preset: &dr_pipeline::Preset, - scope: dr_pipeline::Scope, -) -> Vec { - let placeholders = std::iter::repeat_n("?", images.len()) - .collect::>() - .join(","); - let sql = format!( - "SELECT i.source_ref, v.uuid - FROM images i - JOIN versions v ON v.image_id = i.id AND v.is_default = 1 - WHERE i.id IN ({placeholders})" - ); - let params: Vec = images - .iter() - .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) - .collect(); - - let Ok(mut stmt) = catalog.connection().prepare(&sql) else { - return Vec::new(); - }; - let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(library::SidecarWrite { - image_path: r.get(0)?, - version_uuid: r.get(1)?, - amendment: library::Amendment::Settings { - preset: preset.clone(), - scope, - // A paste carries no masks, and must not: a mask is drawn - // against one photograph and describes nothing on another. - // The target keeps whatever local adjustments it already had. - masks: None, - // A paste carries no film either, and for a plainer reason - // than the masks: a preset is a parameter map, and a stock is - // not a parameter. Pasting one would be pasting a choice the - // clipboard never captured. - film: None, - // Nor snapshots: they are states of the photograph they were - // taken on, and mean nothing on another. - snapshots: None, - }, - }) - }); - - match rows { - Ok(rows) => rows.flatten().collect(), - Err(e) => { - log::debug!("collecting settings writes: {e}"); - Vec::new() - } - } -} - -/// Gather what the sidecar writer needs for each judged image. -/// -/// The version uuid comes from the catalog rather than being generated here: -/// it is the identity a cross-device merge keys on, so the sidecar and the -/// catalog must name the same version or a sync would treat one photograph's -/// judgement as two (FR-NC-8). -fn collect_sidecar_writes( - catalog: &Catalog, - images: &[dr_types::ImageId], -) -> Vec { - let placeholders = std::iter::repeat_n("?", images.len()) - .collect::>() - .join(","); - let sql = format!( - "SELECT i.source_ref, v.uuid, v.rating, v.flag - FROM images i - JOIN versions v ON v.image_id = i.id AND v.is_default = 1 - WHERE i.id IN ({placeholders})" - ); - let params: Vec = images - .iter() - .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) - .collect(); - - let Ok(mut stmt) = catalog.connection().prepare(&sql) else { - return Vec::new(); - }; - let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { - Ok(library::SidecarWrite { - image_path: r.get(0)?, - version_uuid: r.get(1)?, - amendment: library::Amendment::Judgement { - rating: r.get::<_, i64>(2)? as u8, - flag: r.get::<_, i64>(3)? as u8, - }, - }) - }); - - match rows { - Ok(rows) => rows.flatten().collect(), - Err(e) => { - log::debug!("collecting sidecar writes: {e}"); - Vec::new() - } - } -} - -/// Push judgements out to sidecars on a worker, reporting once at the end. -/// TRACES: FR-CAT-13 | NFR-R4 -/// Write these images' ratings, labels and keywords to the `.xmp` beside -/// each, where the user has switched that on. -/// -/// The record is read from the catalog *now*, whole, rather than carried -/// from the gesture: a rating and a keyword typed a second apart are two -/// writes of the same file, and the second must not carry a copy of the -/// first taken before it landed. -pub(crate) fn start_xmp_writes( - window: &AppWindow, - ctl: &Rc, - images: &[dr_types::ImageId], -) { - if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() { - return; - } - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - let writes: Vec = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let c = catalog.connection(); - images - .iter() - .filter_map(|&image| { - let version = dr_catalog::rating::default_version_id(c, image).ok()?; - let image_path: String = c - .query_row( - "SELECT source_ref FROM images WHERE id = ?1", - [image.0 as i64], - |r| r.get(0), - ) - .ok()?; - Some(library::XmpWrite { - image_path, - record: crate::xmp_sync::record_of(c, image, version), - }) - }) - .collect() - }; - if writes.is_empty() { - return; - } - - let count = writes.len(); - let rx = library::spawn_xmp_writes(conn, writes); - let job = ctl.activity.begin( - crate::activity::Kind::Upload, - format!("Writing {count} XMP sidecar(s)"), - ); - drain_xmp(window.as_weak(), ctl.clone(), rx, job, false); -} - -/// TRACES: FR-CAT-13 -/// The offered reload: take the sidecars' values for every photograph the -/// last pull found disagreeing with the catalog. -pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc) { - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - let paths: Vec = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let Some(root_id) = root_id_of(catalog, &conn.account.root) else { - return; - }; - crate::xmp_sync::conflicts(catalog, root_id) - .into_iter() - .map(|c| c.path) - .collect() - }; - if paths.is_empty() { - return; - } - let count = paths.len(); - let rx = library::spawn_xmp_reload( - conn.clone(), - conn.account.root.clone(), - library::catalog_path(&conn.account), - paths, - ); - let job = ctl.activity.begin( - crate::activity::Kind::Download, - format!("Reloading {count} XMP sidecar(s)"), - ); - drain_xmp(window.as_weak(), ctl.clone(), rx, job, true); -} - -/// Wait for a batch of XMP work to report, then say what it did. -fn drain_xmp( - weak: slint::Weak, - ctl: Rc, - rx: std::sync::mpsc::Receiver, - job: crate::activity::Activity, - reload: bool, -) { - let timer = slint::Timer::default(); - let job = RefCell::new(Some(job)); - let ctl_cb = ctl.clone(); - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(200), - move || { - let ctl = &ctl_cb; - let message = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - stop(&ctl.xmp_timer); - return; - } - }; - stop(&ctl.xmp_timer); - let library::XmpMessage::Finished { - written, - failed, - last_error, - } = message; - let status = match (reload, failed, last_error) { - (false, 0, _) => format!("{written} XMP sidecar(s) written"), - (true, 0, _) => format!("{written} photograph(s) reloaded from XMP"), - (_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"), - (_, n, None) => format!("{n} XMP sidecar(s) failed"), - }; - if let Some(job) = job.borrow_mut().take() { - if failed > 0 { - job.fail(status.clone()); - } else { - job.finish(status.clone()); - } - } - if let Some(w) = weak.upgrade() { - w.set_library_status(status.into()); - if reload { - // The grid draws what the reload changed, and the - // settings page stops offering what is settled. - let visible = ctl.visible_ids(); - if let Some(catalog) = ctl.catalog.borrow().as_ref() { - sync_ratings(&w, catalog, &visible); - refresh_rating_counts(&w, catalog); - } - refresh_xmp_conflicts(&w, ctl); - } - } - }, - ); - *ctl.xmp_timer.borrow_mut() = Some(timer); -} - -/// TRACES: FR-CAT-13 -/// How many sidecars the last pull found disagreeing with the catalog, for -/// the settings page to offer the reload against. -pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc) { - let count = (|| { - let (conn, _) = ctl.session.borrow().clone()?; - let borrow = ctl.catalog.borrow(); - let catalog = borrow.as_ref()?; - let root_id = root_id_of(catalog, &conn.account.root)?; - Some(crate::xmp_sync::conflicts(catalog, root_id).len()) - })() - .unwrap_or(0); - window.set_settings_xmp_conflicts(count as i32); -} - -fn root_id_of(catalog: &Catalog, root: &str) -> Option { - catalog - .connection() - .query_row( - "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", - [root], - |r| r.get(0), - ) - .ok() -} - -pub(crate) fn start_sidecar_writes( - window: &AppWindow, - ctl: &Rc, - writes: Vec, -) { - if writes.is_empty() { - return; - } - - // TRACES: FR-CAT-9 - // Offline is passed down rather than used to skip. - // - // It used to skip, and the reasoning was that a cull stays responsive - // because the rating is safe in the catalog. That held for judgements and - // not for edits: the catalog stores no parameters, so a pasted edit made - // offline survived nowhere at all. Every write now commits to the local - // sidecar cache first and the upload is best-effort, which keeps the - // keystroke path off the network — the original concern — without the - // write being conditional on it. - let offline = ctl.is_offline(); - - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - let Some(cache_dir) = ctl.sidecar_cache_dir() else { - return; - }; - - let count = writes.len(); - let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - - // The writer reports once at the end, so there is no per-file progress to - // show — but a cull that has just rated forty frames has forty uploads in - // flight, and "is that saved yet" deserves an answer somewhere. - let job = ctl.activity.begin( - crate::activity::Kind::Upload, - format!("Saving {count} judgement(s)"), - ); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(200), - move || { - let Some(w) = weak.upgrade() else { return }; - // One message is all this channel ever carries — the writer reports - // `Finished` once and hangs up — so this drains a single item rather - // than looping like the scan and thumbnail drains do. - match rx.try_recv() { - Ok(library::SidecarMessage::Finished { - written, - queued, - failed, - last_error, - }) => { - if failed == 0 && queued > 0 { - // Recorded locally, waiting for the server. Said out - // loud because the user has just made an edit with no - // connection and deserves to know it is safe — the - // old behaviour here was to drop it silently. - log::debug!("{queued} sidecar(s) queued for upload"); - ctl_cb.outbox_maybe_dirty.set(true); - job.finish_quietly(); - w.set_library_status( - format!("{queued} edit(s) saved · will upload when back online").into(), - ); - stop(&ctl_cb.sidecar_timer); - return; - } - if failed > 0 { - // A failed upload left the edit in the outbox. - ctl_cb.outbox_maybe_dirty.set(true); - log::warn!( - "{failed} sidecar write(s) failed: {}", - last_error.clone().unwrap_or_default() - ); - job.fail(format!( - "{written} saved · {failed} failed: {}", - last_error.clone().unwrap_or_default() - )); - // Said plainly, because the consequence is specific: - // the rating is safe in the catalog but will not - // survive deleting it. - w.set_library_status( - format!( - "{written} saved · {failed} could not be written \ - to the library folder" - ) - .into(), - ); - } else { - log::debug!("{written} sidecar(s) written"); - // Quietly: a cull produces one of these every few - // seconds and none of them is news. - job.finish_quietly(); - } - stop(&ctl_cb.sidecar_timer); - } - Err(std::sync::mpsc::TryRecvError::Empty) => {} - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - stop(&ctl_cb.sidecar_timer); - } - } - }, - ); - - *ctl.sidecar_timer.borrow_mut() = Some(timer); -} - -/// Slint carries the flag as an integer, matching the catalog's encoding. -fn flag_code(f: dr_types::FlagState) -> i32 { - match f { - dr_types::FlagState::Unflagged => 0, - dr_types::FlagState::Pick => 1, - dr_types::FlagState::Reject => 2, - } -} - -/// The flag an integer from Slint stands for. -fn flag_from_code(v: i32) -> dr_types::FlagState { - match v { - 1 => dr_types::FlagState::Pick, - 2 => dr_types::FlagState::Reject, - _ => dr_types::FlagState::Unflagged, - } -} - -/// Where a row of the loaded window sits in the fetch queue. -/// -/// On screen first, top to bottom; then the rows below the view, nearest -/// first; then the rows above it, nearest first. -/// -/// # Why the queue has an order at all -/// -/// The batch is fetched one image at a time, two round trips each, and it is -/// abandoned wholesale the moment the window moves — so whatever is at the -/// back of it on a remote library is not slow to appear, it never appears. -/// Issued in model order, the back of the queue was the bottom of the screen -/// and everything below it, and the *front* was the quarter-window of cells -/// above the view that nobody was looking at. A scroll then abandoned the -/// batch and the new one started over, again from above the view: on a -/// library still filling its store, the last rows of the grid could be -/// starved for as long as the scrolling continued. -/// -/// Below before above because that is where the view is going. Scrolling back -/// over cells already fetched is served from the store, and from `requested` -/// without a fetch at all. -fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usize) { - let past = first_on_screen + on_screen; - if row >= first_on_screen && row < past { - (0, row - first_on_screen) - } else if row >= past { - (1, row - past) - } else { - (2, first_on_screen - row) - } -} - -/// Fetch thumbnails for rows in the model that do not have one yet. -fn request_thumbnails(window: &AppWindow, ctl: &Rc) { - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - - // The drawn cell size decides which class to ask for. Chosen once for the - // batch rather than per row, and carried through to the drain so a cell it - // fills can record what it is now showing. - let cell_pixels = window.get_library_cell_size().max(1.0) as u32; - let class = dr_thumbs::ThumbSize::for_cell(cell_pixels); - - let mut wanted: Vec = { - let paths = ctl.paths.borrow(); - let file_ids = ctl.file_ids.borrow(); - let sizes = ctl.sizes.borrow(); - let image_ids = ctl.image_ids.borrow(); - let needs_md = ctl.needs_metadata.borrow(); - let mut requested = ctl.requested.borrow_mut(); - paths - .iter() - .enumerate() - .filter_map(|(i, p)| { - let image_id = *image_ids.get(i)?; - // A zoomed grid asks for detail a 256px thumbnail cannot give, - // and a wall of small cells does not pay for it. - let thumb_size = class; - // Keyed on the photograph, so scrolling back over a cell that - // has already been served does not ask for it again. - if !requested.insert((image_id, thumb_size)) { - return None; - } - Some(library::ThumbnailRequest { - row: i, - path: p.clone(), - file_id: file_ids.get(i).copied().flatten(), - size: sizes.get(i).copied().unwrap_or(0), - image_id, - needs_metadata: needs_md.get(i).copied().unwrap_or(false), - thumb_size, - // The grid is filling a cell of a known size. - full_resolution: false, - }) - }) - .collect() - }; - - if wanted.is_empty() { - return; - } - - // What is on screen, first — see [`fetch_rank`]. The rows keep addressing - // the model they were built against; only the order they are asked for in - // changes. - { - let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow()); - let on_screen = ctl.viewport_cells.get().max(1); - wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen)); - } - - let requested = wanted.len(); - let rx = library::spawn_thumbnails( - conn.clone(), - wanted, - library::thumbs_dir(&conn.account), - library::catalog_path(&conn.account), - ); - drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class); -} - -/// TRACES: FR-CAT-9 | FR-DEV-6 -/// Replace a photograph's cached thumbnail with one rendered from its edit. -/// -/// # Why the grid cannot be left alone -/// -/// A thumbnail comes from the file's embedded preview, which is the camera's -/// idea of the photograph and knows nothing about what has been done to it -/// since. So a frame could be cropped, turned upright, and pulled two stops -/// back, and the grid would go on showing the original — the one view of a -/// library where an edit is least visible is the one the photographer spends -/// most of their time in. -/// -/// # Both classes, and why -/// -/// The store keys on the size class, so replacing only the one the grid -/// happens to be drawing at leaves the other holding the unedited preview — -/// and a zoom past the class boundary would show the edit undoing itself. -/// Each class is rendered separately because they are different sizes; a -/// downscale of the large one would be a second, worse resampler than the GPU -/// already applied. -/// -/// # Silent on failure -/// -/// The edit is saved to the sidecar by the caller before this runs, so nothing -/// here can lose work. A thumbnail that could not be re-rendered is a stale -/// cell, which the next scroll past it corrects from the store — worth a log -/// line and not worth an error in front of a photographer who has just -/// finished an image. -pub fn refresh_thumbnail( - window: &AppWindow, - ctl: &Rc, - remote_path: &str, - mut render: impl FnMut(u32) -> Result<(u32, u32, Vec), String>, -) { - // Where this photograph sits in the loaded window. It is always in it: the - // develop view is reached from a cell, and the guards on the scroll and - // geometry handlers stop the window moving while it is open. - let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else { - return; - }; - let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else { - // Nothing to key the store on. A photograph the scan recorded without - // a server file id cannot have a cached thumbnail either, so there is - // nothing here to correct. - return; - }; - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - - let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) { - Ok(s) => s, - Err(e) => { - log::warn!("re-thumbnailing {remote_path}: opening the store: {e}"); - return; - } - }; - - // What the grid is drawing at, so the cell can be corrected on screen - // rather than only on disk. - let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32); - - for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] { - let (w, h, rgba) = match render(class.edge()) { - Ok(r) => r, - Err(e) => { - log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); - continue; - } - }; - - match dr_thumbs::codec::encode_rgba(w, h, &rgba) { - Ok(bytes) => { - let thumb = dr_thumbs::Thumbnail { - width: w, - height: h, - bytes, - }; - if let Err(e) = store.put(file_id, class, &thumb) { - log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); - } - } - Err(e) => { - log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}"); - continue; - } - } - - if class == drawn { - // Straight into the model, so the edit is on the cell the moment - // the grid comes back rather than after a scroll evicts and - // refetches it. - let model = window.get_library_cells(); - if let Some(mut cell) = model.row_data(row) { - cell.thumbnail = to_slint_image(w, h, &rgba); - cell.has_thumb = true; - cell.unavailable = false; - model.set_row_data(row, cell); - } - record_class(ctl, row, class); - } - } -} - -/// Note which size class a row's pixels came from. -/// -/// Silent about a row past the end: the model and this vector are rebuilt -/// together by [`load_window`], and the generation check above already refuses -/// anything addressed to a window that has since moved. -fn record_class(ctl: &Rc, row: usize, class: dr_thumbs::ThumbSize) { - if let Some(slot) = ctl.thumb_class.borrow_mut().get_mut(row) { - *slot = Some(class); - } -} - -/// Apply thumbnails to the model as they arrive. -fn drain_thumbnails( - weak: slint::Weak, - ctl: Rc, - rx: Receiver, - requested: usize, - class: dr_thumbs::ThumbSize, -) { - let timer = slint::Timer::default(); - let ctl_cb = ctl.clone(); - - // One row per batch, measured against the cells this window asked for. - // Starting a new batch does not extend the last one: a scroll abandons - // whatever the previous window wanted, and a denominator carried across - // both would describe neither. - let job = ctl - .activity - .begin(crate::activity::Kind::Thumbnails, "Loading thumbnails"); - job.total(requested); - // Which window this batch was requested for. Captured at spawn, compared on - // every tick. - let mine = ctl.generation.get(); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(100), - move || { - let Some(w) = weak.upgrade() else { return }; - - // A reload replaced the model under this worker. Two things must - // not happen now, and both did: - // - // - Applying a row. `t.row` indexes the window that asked for it, - // so after a reload it names a different photograph — thumbnails - // landed on unrelated cells, and `Unavailable` marked cells - // "no preview" for a fetch never attempted against them. - // - Calling `stop`. `thumb_timer` holds the *current* batch's timer - // by now, so a stale drain reaching `Disconnected` killed the - // live drain instead of itself. The new worker then fetched into - // a channel nobody read, and the grid stayed black until a scroll - // forced yet another load — which is the flicker being chased. - // - // Returning without stopping is deliberate: this timer is no longer - // reachable through the controller, so it is dropped with its - // receiver when the slot is overwritten, and the worker exits on - // its next failed send. - if ctl_cb.generation.get() != mine { - return; - } - - let model = w.get_library_cells(); - - loop { - let msg = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - // The worker finished or died. Either way nothing more - // is coming, so the bar must not sit part-filled - // forever. - // - // Quietly: a scroll starts one of these every second, - // and a history of them would bury anything worth - // reading. - job.finish_quietly(); - stop(&ctl_cb.thumb_timer); - return; - } - }; - - match msg { - // Bookkeeping, not an outcome — reports the split between - // store and network without advancing the bar. - ThumbnailMessage::Plan { - cached, - fetching, - dating, - } => { - // Date reads produce no cell, so they are counted into - // the bar's denominator or it finishes while work is - // still running. - job.add_total(dating); - - let mut parts = Vec::new(); - if cached > 0 { - parts.push(format!("{cached} cached")); - } - if fetching > 0 { - parts.push(format!("fetching {fetching}")); - } - if dating > 0 { - parts.push(format!("reading {dating} dates")); - } - if !parts.is_empty() { - let status = parts.join(" · "); - job.detail(status.clone()); - w.set_library_status(status.into()); - } - } - // A header-only date read. Advances the bar; draws nothing. - ThumbnailMessage::DateProgress => { - job.advance(); - } - // Dates landed, so the histogram can now be built. This is - // what makes the timeline appear on a library whose - // thumbnails were all cached. - ThumbnailMessage::DatesRecorded(n) => { - log::info!("timeline: {n} new dates"); - let borrow = ctl_cb.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - refresh_timeline(&w, catalog, &ctl_cb); - } - } - // Every real outcome advances the bar. Counting only - // successes would stall it on a library where some files - // carry no embedded preview. - ThumbnailMessage::Ready(t) => { - job.advance(); - // Bytes arrived *from the server*, so it is reachable. - // This is what clears the banner when a connection - // returns while the user is simply scrolling, without - // waiting for a probe or a manual retry. - // - // Store hits are excluded deliberately: they are read - // from local disk and say nothing about the network. A - // window that is mostly cached delivers a run of them - // before the first request is even attempted, so - // counting them declared "back online" against a server - // that was plainly down. - if !t.from_cache - && ctl_cb - .reachability - .borrow_mut() - .mark_reachable(std::time::Instant::now()) - { - log::info!("back online"); - refresh_offline(&w, &ctl_cb); - // The sweep was refused while offline, so nothing - // else would ever restart it — the grid would stay - // partially dated until the next launch. - start_sweep(&w, &ctl_cb); - } - if let Some(mut row) = model.row_data(t.row) { - row.thumbnail = to_slint_image(t.width, t.height, &t.rgba); - row.has_thumb = true; - model.set_row_data(t.row, row); - // What this cell is now showing, so the next reload - // can carry it over and know not to ask again. - record_class(&ctl_cb, t.row, class); - } - } - ThumbnailMessage::Unavailable { row, reason } => { - job.advance(); - log::debug!("thumbnail {row}: {reason}"); - if let Some(mut r) = model.row_data(row) { - r.unavailable = true; - model.set_row_data(row, r); - // A verdict is worth carrying too: "no preview" is - // an answer about the file, and re-asking it on - // every scroll is a fetch that will fail again. - record_class(&ctl_cb, row, class); - } - } - // TRACES: FR-CAT-9 - ThumbnailMessage::Offline { reason } => { - log::info!("thumbnails stopped: {reason}"); - // The batch is over, so the bar must not be left - // showing a partial fetch that will never finish — it - // would sweep for ever. - job.fail(reason.clone()); - ctl_cb - .reachability - .borrow_mut() - .mark_unreachable(reason, std::time::Instant::now()); - refresh_offline(&w, &ctl_cb); - - // Cells left without pixels stay placeholders rather - // than being marked unavailable: the images are fine, - // and a reconnect should fill them in. Marking them - // would persist a verdict about the *file* from an - // event about the *connection*. - stop(&ctl_cb.thumb_timer); - return; - } - } - } - }, - ); - - *ctl.thumb_timer.borrow_mut() = Some(timer); -} - -/// Move the timeline's zoom by whole levels. -/// -/// Shared by the wheel and the pinch so the two cannot drift apart in how they -/// clamp, or in where they choose to centre. -fn apply_zoom(window: &AppWindow, ctl: &Rc, delta: i32) { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - - // Bounded: past ~2^12 the window is minutes wide and every bucket is - // empty, which reads as a broken axis rather than a deep zoom. - let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12); - if next == *ctl.timeline_zoom.borrow() { - return; - } - *ctl.timeline_zoom.borrow_mut() = next; - - // Zooming fully out forgets the centre, so the axis returns to describing - // the whole library rather than a remembered position. - if next == 0 { - *ctl.timeline_centre.borrow_mut() = None; - } else if ctl.timeline_centre.borrow().is_none() { - // First zoom centres on wherever the grid is, else the middle. - *ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow(); - } - refresh_timeline(window, catalog, ctl); -} - -/// TRACES: NFR-R2 -/// Take the day's catalog backup if one is due, off the UI thread. -/// -/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a -/// directory listing, not the catalog — so the ordinary case of "backed up -/// this morning already" costs no thread and no connection. -fn spawn_scheduled_backup(ctl: &Rc) { - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - let catalog_path = library::catalog_path(&conn.account); - if !dr_catalog::recovery::backup_due(&catalog_path) { - return; - } - std::thread::spawn(move || { - let catalog = match dr_catalog::Catalog::open(&catalog_path) { - Ok(c) => c, - Err(e) => { - log::warn!("scheduled backup: opening the catalog: {e}"); - return; - } - }; - if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) { - log::warn!("scheduled backup: {e}"); - } - }); -} - -/// Push shards and the catalog to the server, and take what it has. -/// -/// Fired after the sweep completes, when there is a finished index worth -/// sharing, and from the Sync button for an explicit exchange. -fn start_derived_sync(window: &AppWindow, ctl: &Rc) { - // An escape hatch for running the app against a *copied* library without - // touching the account's real server. - // - // Added after doing exactly that by accident: a local test launch - // completed its thumbnail sweep, which fires this, which pushed a test - // catalog over the live one. Redirecting `XDG_DATA_HOME` isolates the - // catalog and the thumbnails but not the server, and nothing said so. - // - // The guard belongs here rather than at the call sites: the sweep firing a - // sync is correct — the point of building thumbnails is sharing them — and - // a flag checked in three places would eventually be missed in a fourth. - if std::env::var_os("DARKROOM_NO_SYNC").is_some() { - log::info!("derived sync suppressed by DARKROOM_NO_SYNC"); - return; - } - - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - // Already running: a second pass would race the first over the same - // scratch files. - if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() { - return; - } - - // TRACES: FR-CAT-9 - // Nothing to push to and nothing to take. Attempting it would upload - // shards into a timeout and light the "Syncing…" indicator over work that - // cannot start; the shards are unchanged on disk and go out on the next - // sync once the server is back. - if ctl.is_offline() { - log::debug!("offline: skipping derived sync"); - return; - } - - let catalog_path = library::catalog_path(&conn.account); - let scratch = catalog_path - .parent() - .map(|p| p.join("scratch")) - .unwrap_or_else(std::env::temp_dir); - let _ = std::fs::create_dir_all(&scratch); - - // TRACES: FR-EXP-7 | FR-NC-10 - // Drain the export outbox on the same pass, and before the shards. An - // export the user was told had succeeded is waiting here, and it is the - // one thing in this directory that exists nowhere else — a thumbnail - // shard can be rebuilt from the originals, and the catalog is an index. - // - // Fire-and-forget rather than reported: it runs on its own thread and - // clears entries as they land, so a partial run leaves the rest queued - // for next time and nothing is lost by not watching it. It reports - // through the log until an export has a place in the activity list. - { - let outbox = crate::export::outbox_dir(&conn.account); - if crate::export::pending_count(&outbox) > 0 { - let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox); - std::thread::spawn(move || { - while let Ok(msg) = rx.recv() { - match msg { - crate::export::UploadMessage::Status(s) => log::info!("export: {s}"), - crate::export::UploadMessage::Finished { - uploaded, - remaining, - error, - } => { - log::info!("export: {uploaded} uploaded, {remaining} still queued"); - if let Some(e) = error { - log::warn!("export upload stopped: {e}"); - } - } - } - } - }); - } - } - - window.set_library_syncing(true); - let rx = crate::derived_sync::spawn_sync( - conn.clone(), - conn.account.root.clone(), - library::thumbs_dir(&conn.account), - catalog_path, - library::place_path(&conn.account), - scratch, - ctl.face_model_id.borrow().clone(), - ); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - - // The sync reports stages rather than counts, so it stays indeterminate and - // says what stage it is in. - let job = ctl - .activity - .begin(crate::activity::Kind::Sync, "Syncing with the server"); - - timer.start( - slint::TimerMode::Repeated, - std::time::Duration::from_millis(300), - move || { - let Some(w) = weak.upgrade() else { return }; - loop { - let msg = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - w.set_library_syncing(false); - job.fail("stopped without finishing"); - stop(&ctl_cb.sync_timer); - return; - } - }; - - match msg { - crate::derived_sync::SyncMessage::Status(s) => { - job.detail(s.clone()); - w.set_library_status(s.into()); - } - crate::derived_sync::SyncMessage::Finished(report) => { - log::info!( - "sync: {} shard(s) up, {} down ({} thumbnails), \ - catalog {}{}", - report.shards_uploaded, - report.shards_downloaded, - report.thumbnails_adopted, - // Replacing a damaged copy is said apart from an - // ordinary push: it is the one push that discarded - // something, and a log line that called it "pushed" - // would hide the only moment worth going back to. - match (report.catalog_uploaded, report.catalog_replaced) { - (true, true) => "pushed over a damaged copy", - (true, false) => "pushed", - _ => "not pushed", - }, - if report.collections_gained > 0 { - format!(", {} collection(s) gained", report.collections_gained) - } else { - String::new() - } - ); - w.set_library_syncing(false); - let summary = format!( - "{} shard(s) up, {} down", - report.shards_uploaded, report.shards_downloaded - ); - job.finish(if report.did_anything() { - summary.clone() - } else { - "nothing to exchange".to_string() - }); - if report.did_anything() { - w.set_library_status(format!("synced · {summary}").into()); - } - // Adopted thumbnails and merged collections both change - // what the grid should show. - if report.thumbnails_adopted > 0 - || report.collections_gained > 0 - || report.members_gained > 0 - { - load_window(&w, &ctl_cb); - } - // TRACES: FR-CAT-7 - // And the sidebar, which the grid reload does not - // touch. Membership counts as a change: a sync that - // files 127 photographs into a collection both devices - // already had gains no *collection*, so keying this on - // `collections_gained` alone left the tree showing no - // number beside a collection that had just been filled. - // - // The scan already does this after it finishes; the - // sync merges the same tables and did not. - if report.collections_gained > 0 || report.members_gained > 0 { - let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()); - if let Some(coll) = coll { - let borrow = ctl_cb.catalog(); - let borrow = borrow.borrow(); - if let Some(cat) = borrow.as_ref() { - crate::collections_ui::refresh_tree(&w, &coll, cat); - } - } - } - stop(&ctl_cb.sync_timer); - return; - } - crate::derived_sync::SyncMessage::Failed(e) => { - log::warn!("sync failed: {e}"); - job.fail(e.to_string()); - w.set_library_syncing(false); - // Not an error banner: a failed sync costs nothing — - // everything is still local and the next pass retries. - w.set_library_status(format!("sync failed: {e}").into()); - stop(&ctl_cb.sync_timer); - return; - } - } - } - }, - ); - - *ctl.sync_timer.borrow_mut() = Some(timer); -} - -/// Start the whole-library sweep and report its progress. -/// -/// The grid only ever fetches what is on screen, so without this the timeline -/// describes the fraction of the library that happened to be scrolled past. -/// This covers the rest. -fn start_sweep(window: &AppWindow, ctl: &Rc) { - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - - // TRACES: FR-CAT-9 - // The sweep is nothing but network reads — one header fetch per undated - // image, across the whole library. Offline it would spend a timeout on - // every one of them, running for hours to learn nothing, while the - // progress bar implied work was happening. It resumes on reconnect, and - // the images it has already dated stay dated. - if ctl.is_offline() { - log::debug!("offline: not starting the metadata sweep"); - return; - } - - let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account)); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - - // Hours on a large library, and entirely invisible outside the grid until - // now: the register is where a user who has gone to develop can still see - // that indexing is running and how far it has got. - let job = ctl - .activity - .begin(crate::activity::Kind::Index, "Indexing capture times"); - - timer.start( - slint::TimerMode::Repeated, - // Slower than the thumbnail drain: this runs for tens of minutes and - // its progress does not need per-frame accuracy. - std::time::Duration::from_millis(400), - move || { - let Some(w) = weak.upgrade() else { return }; - - loop { - let msg = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - w.set_library_sweep_total(0); - job.fail("stopped without finishing"); - stop(&ctl_cb.sweep_timer); - return; - } - }; - - match msg { - library::SweepMessage::Total(n) => { - w.set_library_sweep_total(n as i32); - w.set_library_sweep_done(0); - job.total(n); - } - library::SweepMessage::Progress { done, dated } => { - w.set_library_sweep_done(done as i32); - job.progress(done, w.get_library_sweep_total() as usize); - // Rebuild as it goes: the histogram growing while the - // sweep runs is the visible sign it is working. - if dated > 0 { - let borrow = ctl_cb.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - refresh_timeline(&w, catalog, &ctl_cb); - } - } - } - library::SweepMessage::Finished { dated } => { - log::info!("sweep finished: {dated} dated"); - job.finish(format!("{dated} dated")); - w.set_library_sweep_total(0); - { - let borrow = ctl_cb.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - refresh_timeline(&w, catalog, &ctl_cb); - } - } - // TRACES: NFR-R2 - // The scheduled backup, at the moment the catalog is - // quietest and a day's edits have just been folded in. - // On its own thread, with its own connection: a copy - // of a 130 MB file is a second or two the Slint loop - // must not spend, and it runs beside the sync below, - // which is only a reader of the same file. - spawn_scheduled_backup(&ctl_cb); - // Now that indexing is complete, hand the result to the - // server so a second device inherits it rather than - // repeating hours of range fetches. - start_derived_sync(&w, &ctl_cb); - stop(&ctl_cb.sweep_timer); - return; - } - } - } - }, - ); - - *ctl.sweep_timer.borrow_mut() = Some(timer); -} - -/// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7 -/// Thumbnail every photograph in the library, then hand the shards over. -/// -/// # Why it is asked for rather than assumed -/// -/// The grid's own fetching is demand-driven on purpose: a remote library is -/// browsed over a connection that must not be saturated to show one screen -/// (FR-NC-3). But that leaves the thumbnail store holding only what has been -/// looked at, and the store is the one derived thing worth syncing — a second -/// device that downloads the shards gets a full grid without touching a RAW. -/// So the complete set is worth an hour of transfers *once*, on a machine -/// plugged in, at a moment the user chose. That is this. -/// -/// The sync at the end is not a separate courtesy: a filled store that never -/// leaves this device is most of the cost for none of the point. -fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { - let Some((conn, _)) = ctl.session.borrow().clone() else { - return; - }; - - // Already running. A second pass would re-fetch everything the first is - // part way through — the work list is built from what the store lacks, and - // the first pass has not filled it yet. - if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() { - return; - } - - // TRACES: FR-CAT-9 - // Every image here is a range fetch. Offline the pass would spend a - // timeout per photograph and store nothing, so it is refused rather than - // started — and said so, because this one was pressed deliberately and a - // button that silently does nothing is worse than one that declines. - if ctl.is_offline() { - log::debug!("offline: not starting the thumbnail pass"); - window.set_library_status("Offline — thumbnailing needs the server.".into()); - return; - } - - window.set_library_thumbnailing(true); - let rx = library::spawn_thumbnail_sweep( - conn.clone(), - library::catalog_path(&conn.account), - library::thumbs_dir(&conn.account), - ); - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let ctl_cb = ctl.clone(); - - let job = ctl.activity.begin( - crate::activity::Kind::Thumbnails, - "Thumbnailing the library", - ); - - // The total arrives in the first message rather than up front — counting - // it means asking the store about every image — and the progress messages - // after it carry only cumulative counts, so it is kept here rather than - // re-derived. - let total = std::cell::Cell::new(0usize); - - timer.start( - slint::TimerMode::Repeated, - // As slow as the metadata sweep's drain, and for the same reason: this - // runs for tens of minutes and a chunk lands every few seconds. - std::time::Duration::from_millis(400), - move || { - let Some(w) = weak.upgrade() else { return }; - - loop { - let msg = match rx.try_recv() { - Ok(m) => m, - Err(std::sync::mpsc::TryRecvError::Empty) => return, - Err(std::sync::mpsc::TryRecvError::Disconnected) => { - w.set_library_thumbnailing(false); - job.fail("stopped without finishing"); - stop(&ctl_cb.thumb_sweep_timer); - return; - } - }; - - match msg { - library::ThumbSweepMessage::Total(n) => { - total.set(n); - job.total(n); - w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into()); - } - library::ThumbSweepMessage::Progress { done, stored } => { - job.progress(done, total.get()); - // Dates arrive on the same headers, so the histogram - // grows as this runs — the visible sign it is working - // on a page that is not the grid. - if stored > 0 { - let borrow = ctl_cb.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - refresh_timeline(&w, catalog, &ctl_cb); - } - } - } - library::ThumbSweepMessage::Finished { - stored, - failed, - offline, - } => { - log::info!( - "thumbnail pass finished: {stored} stored, {failed} without a preview" - ); - w.set_library_thumbnailing(false); - stop(&ctl_cb.thumb_sweep_timer); - - let summary = if offline { - format!("{stored} stored · stopped, server unreachable") - } else if failed > 0 { - format!("{stored} stored · {failed} with no usable preview") - } else { - format!("{stored} stored") - }; - w.set_library_status(summary.clone().into()); - // Finished rather than failed even when the server - // dropped: the pass is resumable and what it did build - // is stored, and a red row would invite the user to - // redo work that is already done. - job.finish(summary); - - { - let borrow = ctl_cb.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - refresh_timeline(&w, catalog, &ctl_cb); - } - } - // Cells that showed a placeholder have pixels now, and - // the grid asks the store once per photograph — so - // without this the library the pass just thumbnailed - // stays blank until something else reloads the window. - if w.get_library_visible() { - ctl_cb.requested.borrow_mut().clear(); - schedule_reload(&w, &ctl_cb); - } - - // The point of the pass: the shards go to the server so - // every other device inherits them. Skipped when the - // connection is already gone — the sync would only - // discover the same thing more slowly. - if !offline { - start_derived_sync(&w, &ctl_cb); - } - // And now every photograph in the library has a - // thumbnail, which is the only moment all of its burst - // signatures can be computed. See `bursts::start_pass`, - // which owns the pass and everything it needs to drain - // itself; what it wants from here is the paths and a - // way to say the grid has something new to draw. - if let Some((conn, _)) = ctl_cb.session.borrow().clone() { - let weak_after = w.as_weak(); - let ctl_after = ctl_cb.clone(); - crate::bursts::start_pass( - library::catalog_path(&conn.account), - library::thumbs_dir(&conn.account), - move |bursts| { - let Some(w) = weak_after.upgrade() else { - return; - }; - if bursts > 0 && w.get_library_visible() { - schedule_reload(&w, &ctl_after); - } - }, - ); - } - return; - } - } - } - }, - ); - - *ctl.thumb_sweep_timer.borrow_mut() = Some(timer); -} - -/// Rebuild the timeline histogram from the catalog. -/// -/// Bucket size follows the span of the library, the way darktable's does: -/// a decade of photographs buckets by year, a single trip by day. Picking it -/// from the data rather than fixing it means the histogram is informative at -/// both scales instead of one flat bar or ten thousand slivers. -fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc) { - // Where the grid sits, if nothing has said so yet. - // - // **The marker used to rest greyed at mid-track until the first scroll or - // scrub**, on the reasoning that anchoring it would imply a choice the user - // had not made. That reads the marker as reporting an intention, and it does - // not: the sidebar's whole claim is to say *when* you are, and the answer is - // known from the first frame — the grid is at the top of the library, or at - // whatever position was restored into it. A launch that opened with the - // marker halfway down an axis whose photographs were all from the wrong end - // was simply wrong, and dimmed rather than absent, which made it look like a - // reading rather than the absence of one. - // - // Seeded here rather than at the end of the scan because this is the one - // place that decides what the marker says, and it runs on every route that - // builds the axis. Only when nothing has claimed it: a scroll or a scrub - // sets it directly, and this must never overwrite them. - let unanchored = ctl.current_bucket.borrow().is_none(); - if unanchored { - if let Some(when) = capture_time_at(ctl, catalog, ctl.resume_at.get()) { - *ctl.current_bucket.borrow_mut() = Some(when); - } - } - - // Scoped to whatever the grid is showing. A collection's histogram drawn - // over the whole library's span said almost nothing: every bar for a - // fortnight in Arosa landed in one column of a fifteen-year axis. - let scope = *ctl.scope.borrow(); - let filter = ctl.filter.borrow().clone(); - // Passed whole, date range included, and both queries below lift that - // range themselves — see `span_scoped` and `timeline_uniform`. Every other - // term still applies, because a histogram of the five-star frames is a - // fair question; the range does not, because the axis is *how a range is - // chosen* and drawing through it would empty every bin outside the band - // and leave nothing to widen back into. - let span = match library::span_scoped(catalog, scope, &filter) { - Some(s) => s, - None => { - // No dated images yet. An empty histogram is honest — EXIF is read - // as thumbnails load, so this populates as the user browses. - window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![]))); - window.set_library_timeline_label(slint::SharedString::new()); - return; - } - }; - - // A range is drawn *over* this span as a band, not substituted for it — - // see the band below. The scale a narrow range deserves is reached by - // zooming the axis instead, which is a gesture a finger has (pinch) and - // does not disturb the range while it is being adjusted. - // - // Zoom narrows the span around wherever the view sits rather than around - // the library's midpoint, so zooming in keeps what you were looking at. - let zoom = *ctl.timeline_zoom.borrow(); - let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow()); - - // A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The - // count is the user's — see `LibrarySettings::timeline_bars` for why the - // calendar units it replaced made zooming in draw a coarser picture. - let bins = ctl.timeline_bars.get().max(1); - // Calendar units survive as a *label* vocabulary: what a bin is called - // depends on how long it is, not on how the counting was done. - let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64); - let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) { - Ok(b) => b, - Err(e) => { - log::debug!("timeline: {e}"); - return; - } - }; - - // Normalise against the tallest bar. Counts vary by orders of magnitude - // between a quiet month and a wedding, so a linear scale against the total - // would render most buckets invisible. - let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1); - let mut previous: Option<(i64, i64)> = None; - - let bars: Vec = buckets - .iter() - .map(|b| { - let (y, m, _, _) = civil_from_unix(b.start); - // Label only where a period begins, so a month-bucketed axis reads - // "2024 … Mar … Apr" rather than repeating the year on every bar. - let period = match previous { - None => format!("{y}"), - Some((py, _)) if py != y => format!("{y}"), - Some((_, pm)) if pm != m && granularity_labels_months(granularity) => { - month_abbrev(m).to_string() - } - _ => String::new(), - }; - previous = Some((y, m)); - - TimelineBar { - // Square root rather than linear: it keeps a 3-image day - // visible beside a 400-image one without a log scale's - // misleading flatness. - // - // A bin holding nothing draws nothing. The floor exists so a - // quiet day is not rounded away, and empty bins are common now - // that every one of them is emitted — a sliver on each would - // draw a library that has photographs in months it does not. - height: if b.count == 0 { - 0.0 - } else { - ((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0) - }, - start: b.start as i32, - count: b.count as i32, - label: format_bucket(b.start, granularity).into(), - period_label: period.into(), - } - }) - .collect(); - - // Where the grid currently sits, as a fraction of the visible span. - // - // This is the exact inverse of what a click produces: the widget maps a y - // to a fraction of its track and `on_library_scrub_fraction` interpolates - // `from + (to - from) * f`, so the marker must invert that same expression - // or it lands somewhere other than the pointer. - // - // The earlier version sent a *bar index* instead. Bars occupy one equal - // slot each regardless of how much time they cover, and `rposition` snaps - // to the bucket's start edge, so the marker sat at the top of whichever - // slot contained the instant — near enough on a dense uniform axis, plainly - // wrong on a sparse one, and never under the click. - let current = *ctl.current_bucket.borrow(); - let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0); - - // The chosen range in those same coordinates, for the band. Both ends or - // neither: half a band is a line the user cannot tell from a handle. - let (band_from, band_to) = match (filter.captured_from, filter.captured_to) { - (Some(a), Some(b)) => (fraction_at((from, to), a), fraction_at((from, to), b)), - _ => (-1.0, -1.0), - }; - - // Which bar to light: the *bin* holding the grid's instant, not the - // instant itself. A bar's start is its bin's left edge — one of a fixed - // set of positions — so comparing it against an arbitrary capture time - // would light nothing, where before every bar started on a photograph. - // - // Taken from the bars themselves rather than recomputed, because the edges - // are integer division and a second spelling of `from + i * span / bins` - // is a second chance to be a second out. - let lit = current - .and_then(|t| { - let i = bin_of((from, to), t, bins); - buckets.get(i).map(|b| b.start) - }) - .unwrap_or(0); - window.set_library_current_bucket(lit as i32); - window.set_library_current_fraction(fraction); - window.set_library_range_from_fraction(band_from); - window.set_library_range_to_fraction(band_to); - window.set_library_timeline_anchored(current.is_some()); - window - .set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into()); - window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars))); -} - -/// Whether this bucket size is fine enough for month labels to mean anything. -/// -/// A year-bucketed axis labelled by month would put twelve labels on one bar. -/// Month-sized bins are excluded too, now that the axis has a fixed number of -/// them: a bin of about a month changes month at nearly every bar, so the rule -/// below would write a label on all sixty-four of them and the right-hand -/// strip would be a column of overlapping text. Below a month the labels are -/// occasional again, which is what makes them readable. -fn granularity_labels_months(g: dr_catalog::Granularity) -> bool { - matches!( - g, - dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour - ) -} - -/// Full month name, for the grid's headings. -fn month_name(m: i64) -> &'static str { - const NAMES: [&str; 12] = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - NAMES[((m - 1).clamp(0, 11)) as usize] -} - -fn month_abbrev(m: i64) -> &'static str { - const NAMES: [&str; 12] = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", - ]; - NAMES[((m - 1).clamp(0, 11)) as usize] -} - -/// The instant a fraction along a span names. -/// -/// One home for `from + (to - from) * f`, because three things now depend on -/// it agreeing with itself: a scrub, the marker drawn where that scrub landed, -/// and the ends of the date range dragged along the same axis. A second copy -/// is a second chance for a click and what it produces to disagree. -fn instant_at(span: (i64, i64), f: f32) -> i64 { - span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64 -} - -/// Where an instant sits along a span, 0..1. The exact inverse of -/// [`instant_at`], clamped so an instant off the end of the axis is drawn at -/// the end it went past rather than off the widget. -fn fraction_at(span: (i64, i64), t: i64) -> f32 { - ((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32 -} - -/// Which of `bins` equal bins across `span` holds `t`. -/// -/// The same arithmetic `library::timeline_uniform` groups by, in the same -/// order — integer division on the offset from the start — so the bar this -/// names is the bar that counted the photograph. Clamped, because the grid's -/// instant can sit outside a zoomed axis. -fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize { - let width = (span.1 - span.0).max(1); - let i = (t - span.0).clamp(0, width) * bins as i64 / width; - i.clamp(0, bins as i64 - 1) as usize -} - -/// Midnight UTC at or before `t`. -/// -/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive -/// holds photographs from before 1970 — would otherwise truncate *towards* -/// zero and name the following midnight, putting the day off by one. -fn day_start(t: i64) -> i64 { - t - t.rem_euclid(86_400) -} - -/// The range a drag of the band's two ends names, as it is stored. -/// -/// Whole days at both ends, which does three things. It is what the typed -/// fields mean, so a dragged range reads back out of them as the two days it -/// covers rather than as two instants nobody chose. It gives the band a floor: -/// two handles dragged onto each other name one day, not an empty grid. And it -/// makes the gesture reproducible — the same day is the same range however -/// close the finger came to a bucket edge. -/// -/// Ends dragged past each other are put back in order rather than refused: the -/// user has described the same two edges either way round. -fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) { - let (from, to) = if from <= to { (from, to) } else { (to, from) }; - let start = day_start(instant_at(span, from)); - // Inclusive of the closing day. `parse_date` gives midnight, and a range - // ending there would exclude every photograph taken on the day named. - let end = day_start(instant_at(span, to)) + 86_400 - 1; - (start, end) -} - -/// Narrow a span by a zoom level, centred on `centre`. -/// -/// Each step halves or doubles the visible duration. Clamped to the library's -/// own extent so zooming out cannot wander past the first or last photograph. -fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option) -> (i64, i64) { - let (lo, hi) = full; - if zoom <= 0 { - return (lo, hi); - } - let duration = (hi - lo).max(1); - // 2^zoom, saturating: a very deep zoom must not shift the duration to zero. - let factor = 1i64 << zoom.min(20); - let window = (duration / factor).max(3600); - - let mid = centre.unwrap_or(lo + duration / 2); - let half = window / 2; - let (mut from, mut to) = (mid - half, mid + half); - - // Slide rather than shrink at the ends, so the window keeps its size. - if from < lo { - to += lo - from; - from = lo; - } - if to > hi { - from -= to - hi; - to = hi; - } - (from.max(lo), to.min(hi)) -} - -/// Earliest and latest capture time in the catalog. -/// The timeline's extent, over exactly the images the grid is showing. -/// -/// Scrubbing and zooming must measure the same span the histogram is drawn -/// over. When this read the whole library while the bars were scoped to a -/// collection, a scrub landed at an instant the collection did not contain and -/// the view jumped somewhere the user had not asked for. -fn catalog_span(catalog: &Catalog, ctl: &Rc) -> Option<(i64, i64)> { - // `span_scoped` reports the extent with the date range lifted, which is - // what every caller here wants: they measure the axis that is on screen, - // and that axis spans the library rather than the chosen range. Measured - // through the range, narrowing it would move the ground under the very - // handles doing the narrowing — each drag re-scaling the axis, so the next - // one meant something else. - library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow()) -} - -/// TRACES: NFR-P5 -/// Capture time of the image at row `ordinal` in the grid's own ordering. -/// -/// `None` for an ordinal that lands among the undated tail, which sorts last -/// and has no place on a capture-time axis. -/// -/// # Answered from the loaded window, not from the catalog -/// -/// This moves the timeline marker, so it is called on **every scroll event** — -/// every row the view crosses, by design, or the marker would advance in jerks -/// while the photographs beside it moved smoothly. -/// -/// It used to be a query, and `LIMIT 1 OFFSET n` is not a seek: SQLite reaches -/// row `n` by producing and discarding the `n` before it. So its cost grew with -/// how far down the library the user had scrolled — 0.02 ms near the top, 0.7 ms -/// at twenty thousand — and it was paid per row crossed, on the thread drawing -/// the frame. A flick down a long library therefore got *choppier the further -/// it went*, which is a strange enough symptom to be worth naming. -/// -/// The window the grid has already read holds this answer. `window_move` keeps -/// the whole view inside that window, so the lookup is a vector index at the -/// position the ordinal has in it. -/// -/// # The fallback, and why it is also more correct than what it replaces -/// -/// Outside the loaded window — briefly, after a scrub or a keyboard jump, before -/// the load lands — it falls back to the query. That query counts in a -/// *dated-only* ordering while `ordinal` is a grid row, so the two disagree -/// wherever undated images sit in between; it is kept because it is close -/// enough for a marker that is about to be corrected, and because being wrong -/// there is what it always did. The window path has no such disagreement: it -/// reads the very cell the row belongs to. -fn capture_time_at(ctl: &Rc, catalog: &Catalog, ordinal: usize) -> Option { - { - let offset = *ctl.offset.borrow(); - let loaded = ctl.captured_at.borrow(); - if ordinal >= offset { - if let Some(when) = loaded.get(ordinal - offset) { - return *when; - } - } - } - capture_time_from_catalog(catalog, ordinal) -} - -/// The fallback of [`capture_time_at`], for a row outside the loaded window. -fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option { - catalog - .connection() - .query_row( - &format!( - "SELECT captured_at FROM images - WHERE shadowed_by IS NULL AND captured_at IS NOT NULL - AND {} - ORDER BY captured_at - LIMIT 1 OFFSET ?1", - dr_catalog::bursts::not_collapsed_away("images") - ), - [ordinal as i64], - |r| r.get::<_, i64>(0), - ) - .ok() -} - -/// Jump the grid to the first image at or after `when`. -/// -/// This is the scrub: the window moves, the library does not narrow. Every -/// image stays reachable, which is why this is a position rather than a -/// filter. -fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { - let position = { - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - // How many rows precede this instant **in the grid's own ordering**. - // That ordinal is the scroll offset, so any disagreement with the - // grid's query lands the view somewhere else entirely. - // - // The earlier version counted only dated images. With 2,400 of 19,841 - // dated, a click near the end of the axis produced an ordinal of ~2,400 - // against a grid of 19,841 rows — the view landed near the top however - // far down the axis the pointer went. - // - // Undated images sort last (`captured_at IS NULL` first in the ORDER - // BY), so they never precede a dated one and the predicate below stays - // a simple `<`. Shadowed rows are excluded here exactly as the grid - // excludes them. - // - // A burst folded up occupies one row of the grid, so it must occupy one - // row of this count as well: an ordinal taken over the unfolded library - // would overshoot by every frame hidden earlier in it. - catalog - .connection() - .query_row( - &format!( - "SELECT count(*) FROM images - WHERE shadowed_by IS NULL - AND captured_at IS NOT NULL - AND captured_at < ?1 - AND {}", - dr_catalog::bursts::not_collapsed_away("images") - ), - [when], - |r| r.get::<_, i64>(0), - ) - .unwrap_or(0) as usize - }; - - // Record where the grid now sits, which anchors the timeline marker. - // `refresh_timeline` seeds this from the view's own position when nothing - // has claimed it, so by the time a scrub arrives it is an update rather than - // the first word. - *ctl.current_bucket.borrow_mut() = Some(when); - *ctl.offset.borrow_mut() = position; - // Move the viewport as well as the window. Cells are drawn at their - // absolute place in the library, so loading rows around image 15,000 while - // the viewport sits at row 0 shows an empty grid until the user scrolls. - window.set_library_scroll_to(position as i32); - window.set_library_scroll_token(window.get_library_scroll_token() + 1); - load_window(window, ctl); -} - -/// TRACES: FR-CAT-7 -/// Put the view back where the photographer was after the library changed -/// under it. -/// -/// Deleting is the case this exists for. The grid draws each cell at its -/// absolute place in the library — `(i + offset) / columns` — and a delete does -/// two things at once: the library gets shorter, and `offset` is re-clamped -/// downward so the loaded window still fills. Neither touches `viewport-y`, so -/// the view ends up pointing at a different part of the library, or past the -/// end of it entirely. That is the "goes blank and jumps somewhere random". -/// -/// Anchored on the *ordinal the view was showing*, clamped into what is left. -/// Not on the deleted image's own position, which no longer exists, and not on -/// the top of the library, which would throw away the scroll position on every -/// delete — after removing one frame from a wall of twenty thousand, the next -/// one you want is the one that just moved into its place. -pub fn restore_position(window: &AppWindow, ctl: &Rc) { - let total = window.get_library_total().max(0) as usize; - if total == 0 { - return; - } - // The first *visible* ordinal, not the loaded window's start. - // - // Anchoring on `offset` was the first attempt and it still jumped, because - // the two are deliberately different: the window begins about a quarter of - // a screen above the view so scrolling up has rows ready, and by this point - // in `load_window` it has also been re-clamped against the new, smaller - // total. Seeking to it therefore moved the view to somewhere the user was - // not — a smaller jump than before, but the same fault. - // - // `resume_at` is what the grid last reported as its first visible image, - // which is the thing the photographer is actually looking at. - let anchor = ctl.resume_at.get().min(total - 1); - window.set_library_scroll_to(anchor as i32); - window.set_library_scroll_token(window.get_library_scroll_token() + 1); -} - -/// Format a bucket start for the histogram's hover label. -fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String { - let (y, m, d, h) = civil_from_unix(t); - match g { - dr_catalog::Granularity::Year => format!("{y}"), - dr_catalog::Granularity::Month => format!("{y}-{m:02}"), - dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"), - dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"), - } -} - -/// A capture instant as `YYYY-MM-DD`. -/// -/// Shared with the exporter, which resolves the `{date}` token from the same -/// reading so a filename and the timeline cannot disagree about what day a -/// photograph was taken. -pub fn format_date(t: i64) -> String { - dr_types::format_date(t) -} - -/// Unix seconds to a civil date, in the tuple shape this module reads it in. -/// -/// The algorithm moved to `dr_types::time` when import folder templates -/// (FR-CAT-10) became a third caller. Three readings of the same instant that -/// could drift apart is one too many: an image filed under a date the timeline -/// does not show it on is a file the user cannot find. -fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) { - let c = dr_types::civil_from_unix(t); - (c.year, c.month, c.day, c.hour) -} - -/// Copy decoded RGBA into a Slint image. -/// -/// This is a CPU copy, which is acceptable here and not in the develop path: -/// a 256px thumbnail is 256 KB and happens once per image, where the canvas -/// would pay per frame (ARCH §6.1). -fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { - let mut buf = slint::SharedPixelBuffer::::new(width, height); - let expected = (width as usize) * (height as usize) * 4; - let src = &rgba[..expected.min(rgba.len())]; - buf.make_mut_bytes()[..src.len()].copy_from_slice(src); - slint::Image::from_rgba8(buf) -} - -/// Walk the keyboard cursor through the library — the arrow keys. -/// -/// **The cursor is a library ordinal, not a row of the loaded window.** That is -/// what lets it walk past the window's edge: the window is a few screenfuls -/// around wherever the user is looking, and a cursor held as a row would stop -/// at its end or, worse, keep counting into cells belonging to different -/// photographs. Moving out of the window reloads it around the new position, -/// which is the same thing scrolling does. -/// -/// The grid supplies the step, because how far "down" is depends on how many -/// columns the window happens to be showing, and only the grid knows that. It -/// does not clamp: `Home` and `End` arrive as a step longer than the library -/// and are clamped here, where the total is known. -fn move_cursor( - window: &AppWindow, - ctl: &Rc, - coll: &Rc, - delta: i32, - extend: bool, -) { - let total = window.get_library_total().max(0) as usize; - if total == 0 { - return; - } - - let Some(from) = coll.cursor() else { - // The first press takes hold of the grid rather than moving in it. A - // key that jumped to image zero would lose wherever the user had - // scrolled to, and one that started from a cell off the top of the - // screen would appear to do nothing but scroll. - // - // The first *visible* ordinal, not the window's start: the loaded - // window deliberately begins a quarter of a screen above the view, so - // its first cell is one the user cannot see. - let at = ctl.resume_at.get().min(total - 1); - place_cursor(window, ctl, coll, at, false); - return; - }; - - // Saturating in `isize`, so a `Home` expressed as minus the library's - // length does not wrap round to the end. - let next = (from as isize) - .saturating_add(delta as isize) - .clamp(0, total as isize - 1) as usize; - if next == from { - // Already at the end being pressed toward. Nothing to move, and - // reloading the window would be a visible jerk for no movement. - return; - } - place_cursor(window, ctl, coll, next, extend); -} - -/// Put the cursor on one image, bringing the window with it. -fn place_cursor( - window: &AppWindow, - ctl: &Rc, - coll: &Rc, - at: usize, - extend: bool, -) { - // Bring the window to the cursor if it has walked out of it. Centred a - // quarter in, exactly as a scroll does it, so continuing in the same - // direction has loaded cells to move into rather than another reload on - // the very next press. - let loaded = window.get_library_cells().row_count(); - let offset = *ctl.offset.borrow(); - if at < offset || at >= offset + loaded { - let size = *ctl.window.borrow(); - let total = window.get_library_total().max(0) as usize; - *ctl.offset.borrow_mut() = window_start(at, size, total); - load_window(window, ctl); - } - - // Re-read: `load_window` clamps the offset against the library's end, so - // the window may not start where it was asked to. - let offset = *ctl.offset.borrow(); - let ids = ctl.visible_ids(); - let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else { - // The window could not be brought to the cursor — an empty or - // shrinking library. Leaving the cursor where it was is better than - // pointing it at nothing. - return; - }; - - if !extend { - // An arrow key collapses the selection onto the cursor. A *click* on - // an already-selected cell deliberately leaves the selection alone — - // that exception exists so a multi-image drag can start from one of - // its members — and there is no drag behind a keystroke. - coll.clear_selection(); - } - - crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend); - window.set_library_cursor(at as i32); -} - -/// Say where in the library the photograph now open sits. -/// -/// **After `on_open_image`, never before.** The generic open path resets the -/// readout to "1 of 1" on its way in, because until the photo roll existed the -/// grid really did hand develop a single path with nothing to walk to. It has -/// a window of them now, so the honest answer is the ordinal in the library — -/// not the row in the loaded window, which is an artefact of how much has been -/// paged in and would jump about as the window moves. -fn report_position(window: &AppWindow, ctl: &Rc, row: usize) { - let offset = *ctl.offset.borrow(); - window.set_index((offset + row) as i32); - window.set_total(window.get_library_total()); -} - -// --- where the photographer was (FR-UI-8) -------------------------------- - -/// How long a movement has to settle before it is written down. -/// -/// A flick reports a scroll several times per screenful, and a place is a file -/// write — one per event would put disk I/O inside the gesture NFR-P5 governs, -/// for a record only the *last* of which is ever read. Longer than -/// [`GEOMETRY_SETTLE`] because nothing on screen waits for this: the reload a -/// geometry change schedules is visible, and this is not. -const PLACE_SETTLE: std::time::Duration = std::time::Duration::from_millis(750); - -/// The photographer moved. Write it down, once they stop. -/// -/// Replacing the timer rather than adding one is what makes this a debounce: -/// a `slint::Timer` is stopped by being dropped, so the previous pending write -/// goes with the old handle. -fn note_place(window: &AppWindow, ctl: &Rc) { - if ctl.applying_place.get() { - // A restore is not a movement — see `applying_place`. - return; - } - - // The handover latch closes on the first movement — see `place_untouched`. - ctl.place_untouched.set(false); - - if ctl.place_store.borrow().is_none() { - return; - } - - let timer = slint::Timer::default(); - let weak = window.as_weak(); - let held = ctl.clone(); - timer.start(slint::TimerMode::SingleShot, PLACE_SETTLE, move || { - if let Some(w) = weak.upgrade() { - write_place(&w, &held); - } - }); - *ctl.place_timer.borrow_mut() = Some(timer); -} - -/// Write the place out now, without waiting for a settle. -/// -/// For the moments there may be no "later": leaving the grid for develop, and -/// coming back. Both are single deliberate acts rather than runs of events, and -/// both change the one field a debounce is most likely to lose — which view the -/// user is in. -fn write_place(window: &AppWindow, ctl: &Rc) { - if ctl.applying_place.get() { - return; - } - // Opening a photograph is as much "the photographer has started" as - // scrolling is, and this is the path that records it — so the handover - // latch closes here too, not only in `note_place`. Without it a record - // arriving from another device could pull someone out of the image they had - // just opened. - ctl.place_untouched.set(false); - - let Some(place) = current_place(window, ctl) else { - return; - }; - if let Some(store) = ctl.place_store.borrow().as_ref() { - store.save(&place); - } -} - -/// Where the photographer is, as a record that can be written down. -/// -/// `None` where there is nothing worth recording: no library open, or the -/// launch screen in front of everything. A place is never written from the -/// launch screen — the user is *choosing* a library there, and recording "you -/// were at the launch screen" would make the next launch skip the position it -/// had just been asked to keep. -fn current_place(window: &AppWindow, ctl: &Rc) -> Option { - use dr_types::{Place, PlaceScope, Screen}; - - if window.get_active_view() == View::Launch || !window.get_library_open() { - return None; - } - - // Which view, and — the same question in both — which photograph. - // - // In the grid it is the first one visible, which `resume_at` already - // tracks; in develop it is the open one, which `report_position` keeps - // `index` holding. Both are library ordinals against the same ordering, so - // the lookup below is one piece of code rather than two. - let in_library = window.get_active_view() == View::Library; - let at = if in_library { - ctl.resume_at.get() - } else { - window.get_index().max(0) as usize - }; - - // Resolved through the loaded window rather than by a query: the model has - // the path and the capture time side by side already, and this runs on a - // settle after scrolling — the one moment a query is least welcome. - let offset = *ctl.offset.borrow(); - let row = at.checked_sub(offset); - let path = row - .and_then(|r| ctl.paths.borrow().get(r).cloned()) - .unwrap_or_default(); - let captured_at = row.and_then(|r| ctl.captured_at.borrow().get(r).copied().flatten()); - - // The scope, in the vocabulary both devices share. A collection this device - // cannot name is recorded as the whole library rather than as a name - // nothing can resolve — see `collections::uuid_of`. - let scope = if ctl.viewing_trash.get() { - PlaceScope::Trash - } else { - match *ctl.scope.borrow() { - None => PlaceScope::Library, - Some(id) => { - let borrow = ctl.catalog.borrow(); - let uuid = borrow - .as_ref() - .and_then(|c| dr_catalog::collections::uuid_of(c.connection(), id).ok()) - .flatten(); - match uuid { - Some(uuid) => PlaceScope::Collection { uuid }, - None => PlaceScope::Library, - } - } - } - }; - - Some(Place { - at: library::now_secs(), - device: ctl.place_device.borrow().clone(), - screen: if in_library { - Screen::Library - } else { - Screen::Develop - }, - scope, - path, - captured_at, - filter: (&*ctl.filter.borrow()).into(), - }) -} - -/// Put the photographer back where a record says they were. -/// -/// # The order is the whole function -/// -/// Scope, then filter, then position, then — last of all — the view. Each step -/// changes what an ordinal *means*: the grid is a window over a query, and a -/// row resolved before the scope was narrowed names a photograph in a different -/// list. Resolving the path last is what makes the restored position address the -/// grid the user is actually about to see. -/// -/// # Nothing here is allowed to fail loudly -/// -/// Every step degrades to "the state we would have been in anyway". A collection -/// this device has not merged yet leaves the scope at the whole library; a -/// photograph that has been deleted falls back to when it was taken; a capture -/// time that resolves to nothing leaves the grid at the top. A place is a -/// convenience, and an application that would not open because one was stale -/// would be trading something that matters for something that does not. -pub(crate) fn apply_place( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, - place: &dr_types::Place, -) { - use dr_types::{PlaceScope, Screen}; - - ctl.applying_place.set(true); - - // --- the scope -------------------------------------------------------- - // - // Through the same callback the sidebar fires, rather than by setting the - // two controllers' fields by hand. Both of them hold a scope, and so does - // the header's label and the reorderable flag; there is exactly one piece - // of code that keeps those four in step and this is not the place to write - // a second. - let selected = match &place.scope { - PlaceScope::Library => 0, - PlaceScope::Trash => -1, - PlaceScope::Collection { uuid } => { - let borrow = ctl.catalog.borrow(); - let found = borrow - .as_ref() - .and_then(|c| dr_catalog::collections::id_for_uuid(c.connection(), uuid).ok()) - .flatten(); - drop(borrow); - match found { - Some(id) => id.0 as i32, - // Recorded on another device, and this one has not merged the - // collection yet. The whole library is the honest fallback: an - // empty grid under a name nothing can resolve reads as data - // loss. - None => { - log::info!("the stored place names a collection this device does not have yet"); - 0 - } - } - } - }; - window.invoke_collection_select(selected); - - // --- the filter ------------------------------------------------------- - *ctl.filter.borrow_mut() = (&place.filter).into(); - push_filter(window, ctl); - - // --- the position ----------------------------------------------------- - let resolved = resolve_place(window, ctl, place); - - // The filter and the scope have both moved the window to the top; this is - // what puts it back, and it has to run after them for that reason. - let total = window.get_library_total().max(0) as usize; - let resolved = resolved.filter(|(at, _)| *at < total); - match resolved { - Some((at, _)) => { - ctl.resume_at.set(at); - // Moves the capture-time marker with it — see `resume_position`. - resume_position(window, ctl, coll_ctl, None); - } - None => refilter(window, ctl), - } - - // --- and the view ----------------------------------------------------- - // - // Only where the photograph itself was found. A capture time that merely - // landed in the right week is a good answer for the grid and no answer at - // all for a canvas: develop would open on a path that no longer resolves - // and show a filename over an empty frame. - if place.screen == Screen::Develop { - if let Some((at, Found::Photograph)) = resolved { - if let Some(open) = ctl.open_image.borrow().clone() { - window.set_active_view(View::Develop); - window.set_library_roll_centre(true); - let offset = *ctl.offset.borrow(); - if let Some(row) = at.checked_sub(offset) { - window.set_library_roll_current(row as i32); - } - open(place.path.clone()); - // After the open, which resets the readout to "1 of 1" on its - // way in — the same ordering `report_position` documents. - window.set_index(at as i32); - window.set_total(window.get_library_total()); - } - } - } - - // Last, so nothing between here and the top is mistaken for the - // photographer having moved. - ctl.applying_place.set(false); -} - -/// The library ordinal a record names, or `None` if it names nothing here. -/// -/// Two ways, and the second is why a place survives its subject. The path is -/// exact and is tried first. Failing that — the photograph was deleted, moved, -/// or filtered out by the very filter this place restored — the capture time -/// puts the grid in the right week, which is close enough that the user -/// recognises where they are. -fn resolve_place( - window: &AppWindow, - ctl: &Rc, - place: &dr_types::Place, -) -> Option<(usize, Found)> { - let borrow = ctl.catalog.borrow(); - let catalog = borrow.as_ref()?; - - if !place.path.is_empty() { - match library::ordinal_of_path( - catalog, - *ctl.scope.borrow(), - &ctl.filter.borrow(), - ctl.viewing_trash.get(), - &place.path, - ) { - Ok(Some(at)) => return Some((at, Found::Photograph)), - Ok(None) => {} - Err(e) => log::debug!("resolving the stored place: {e}"), - } - } - - let when = place.captured_at?; - drop(borrow); - // Through the scrubber's own route, so "an instant, as an ordinal" has one - // spelling. It sets the viewport and the loaded window together, which is - // exactly what a restore needs, and it anchors the timeline marker on the - // way. - scrub_to(window, ctl, when); - // Where it landed, read back rather than recomputed: `scrub_to` sets the - // offset to the ordinal it resolved and `load_window` then clamps it against - // the end of the library, so the clamped value is the one the grid is - // actually showing. - Some((*ctl.offset.borrow(), Found::Thereabouts)) -} - -/// How exactly a place was resolved. -/// -/// The distinction is what stops a restore reopening develop on the wrong -/// photograph. Landing in the right week is a perfectly good answer for a grid -/// — the user recognises where they are — and no answer at all for a canvas, -/// which would open on a path that no longer resolves and show a filename over -/// an empty frame. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum Found { - /// The photograph itself is still in this grid, at this ordinal. - Photograph, - /// It is not, so this is where its capture time falls instead. - Thereabouts, -} - -/// Put the whole rating filter on screen. -/// -/// The filter bar is drawn from half a dozen properties, each of which is set -/// where its own control is handled — which is right while the user is moving -/// them one at a time, and leaves nothing that can restore all of them at once. -/// A restored place that narrowed the grid without moving the stars would show -/// a filtered library under a bar claiming it was not filtered, and the user's -/// first act would be to try to clear a filter the bar says is off. -fn push_filter(window: &AppWindow, ctl: &Rc) { - let filter = ctl.filter.borrow().clone(); - window.set_library_filter_min_rating(filter.min_rating as i32); - window.set_library_filter_unjudged(filter.unjudged); - // The bar's own numbering, from `on_library_filter_flag_changed`. It has no - // code for `Unflagged` because it cannot produce one — "nothing has judged - // this" is the `unjudged` chip, over both stars and flags — so a place can - // never carry one either, and it falls in with "no flag constraint". - window.set_library_filter_flag(match filter.flag { - Some(dr_types::FlagState::Pick) => 1, - Some(dr_types::FlagState::Reject) => 2, - _ => 0, - }); - window.set_library_local_only(filter.local_only); - ctl.local_only.set(filter.local_only); - window.set_library_filter_eyes_open(filter.eyes_open); - push_people_chips(window, ctl); -} - -/// Bring the grid back to where the photographer left it, and — coming out of -/// develop — put the keyboard cursor on the photograph they were editing. -/// -/// # Two positions, not one -/// -/// `resume_at` is where the *grid* was when it was left. `focus` is the -/// photograph develop was showing, which after a walk along the photo roll can -/// be a thousand rows away from it. Restoring only the first is what made -/// leaving develop feel like losing the frame you had just finished: the grid -/// came back to a screenful that no longer had it in. -/// -/// So both are honoured, and which one wins is decided by whether they -/// disagree. If the open photograph is inside the screenful the grid was left -/// showing — the ordinary case, where develop was opened and closed on the same -/// frame — the remembered position is used unchanged and nothing appears to -/// move. If it is not, the grid goes to the photograph, because that is the one -/// the user was last looking at. -/// -/// # Why the cursor rather than the selection -/// -/// Deliberately not [`place_cursor`], which also rewrites the selection. A -/// selection of forty photographs assembled in the grid, then one of them -/// opened to check it, must survive the trip back — discarding it is the kind -/// of silent loss that stops people using the develop view mid-cull. The -/// cursor is a position, not a judgement, so it is safe to move. -/// -/// The viewport is left to the grid's own `reveal()`, which moves as little as -/// will bring the cursor's row into view. That is what makes the two cases -/// above one rule rather than two: a cursor already on screen moves nothing. -fn resume_position( - window: &AppWindow, - ctl: &Rc, - coll: &Rc, - focus: Option, -) { - let total = window.get_library_total().max(0) as usize; - if total == 0 { - // Files named on the command line: there is no grid behind them, and - // an ordinal read off the readout would name a row of nothing. - return; - } - - let resume = ctl.resume_at.get().min(total - 1); - let focus = focus.map(|at| at.min(total - 1)); - - // A screenful, as the grid last measured itself. The comparison has to be - // against what is *visible*, not against the loaded window, which is - // several screenfuls wide — an open photograph loaded but scrolled past is - // still one the user cannot see. - let viewport = ctl.viewport_cells.get().max(1); - let anchor = match focus { - Some(at) if at >= resume && at < resume + viewport => resume, - Some(at) => at, - None => resume, - }; - - // Centred like `on_library_scrolled` does it, so scrolling either way from - // the restored position has loaded rows to move into. - let window_size = *ctl.window.borrow(); - *ctl.offset.borrow_mut() = window_start(anchor, window_size, total); - load_window(window, ctl); - - // Before the grid is shown, not after: the markup gates it on an `if`, and - // the rebuilt Flickable reads `scroll-to` in its `init`. Setting these - // afterwards would leave that init to run against the previous position. - window.set_library_scroll_to(anchor as i32); - window.set_library_scroll_token(window.get_library_scroll_token() + 1); - - if let Some(at) = focus { - coll.set_cursor(Some(at)); - window.set_library_cursor(at as i32); - } - - // The view has moved, so the capture-time marker has to move with it. - // - // `load_window` will not do it: it rebuilds the axis only when the scope, - // the filter or the total has changed, and none of them has — this is the - // same library seen from a different row. Without this the marker stayed - // wherever it was when develop was entered, which after a walk along the - // photo roll can be months away from the photographs now on screen. - reanchor_timeline(window, ctl, anchor); -} - -/// Put the capture-time marker where the view now is. -/// -/// Clearing the anchor and re-running the axis rather than computing a fraction -/// here: `refresh_timeline` seeds itself from the view's position when nothing -/// has claimed one, and it owns every other thing the sidebar draws — the bars, -/// the range band, the lit bucket. A second place that positioned the marker -/// would be a second chance to disagree with the bars under it, which is the -/// bug the `current-fraction` note in `library.slint` records. -fn reanchor_timeline(window: &AppWindow, ctl: &Rc, at: usize) { - ctl.resume_at.set(at); - *ctl.current_bucket.borrow_mut() = None; - let borrow = ctl.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - refresh_timeline(window, catalog, ctl); - } -} - -/// Connect the grid's callbacks. -pub fn wire( - window: &AppWindow, - ctl: Rc, - coll_ctl: Rc, - on_open_image: F, - on_leave_develop: Rc, -) where - F: Fn(String) + 'static, -{ - // So a rebuilt window can put the selection ticks back. Weak, or the two - // controllers would hold each other alive for the life of the process. - *ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl)); - - // TRACES: FR-CAT-13 - // The reload the settings page offers when a sidecar disagrees. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_settings_xmp_reload(move || { - if let Some(w) = weak.upgrade() { - start_xmp_reload(&w, &ctl); - } - }); - } - - // TRACES: FR-UI-3 | FR-UI-4 - // Whether the rating strip waits to be hovered or stands open. - // - // On Android touch is not evidence to be gathered, it is the platform. - // The grid latches it from the first finger it sees as well, which is what - // covers a touchscreen on the desktop — but that latch needs a press to - // reach a cell, and a quick flick never delivers one because the Flickable - // claims the gesture before the delay it would forward after. Seeding it - // here means the stars are on screen before the first touch rather than - // after it, which is the whole point of showing them. - window.set_library_touched(cfg!(target_os = "android")); - - // TRACES: FR-UI-4 - // The gesture reference. Pushed once, here, rather than on demand: the - // table is a compiled-in constant, so there is nothing to be fresh about - // and nothing to recompute — a callback to fill it would only be a way for - // it to be empty the first time the sheet opens. - window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from( - crate::gestures::rows() - .into_iter() - .map(|r| GestureRow { - heading: r.heading.into(), - title: r.title.into(), - touch: r.touch.into(), - pointer: r.pointer.into(), - keys: r.keys.into(), - }) - .collect::>(), - ))); - - // Shared rather than moved: a click and `Return` both open an image, and - // they are two callbacks. - let on_open_image: OpenImage = Rc::new(on_open_image); - // TRACES: FR-UI-8 - // And a third caller, which is not a callback at all: a place recorded in - // develop reopens it at launch. Through the same closure, so the edit-saving - // and identity bookkeeping it does are not something a restore can skip. - *ctl.open_image.borrow_mut() = Some(on_open_image.clone()); - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll_for_click = coll_ctl.clone(); - let on_open_image = on_open_image.clone(); - window.on_library_cell_clicked(move |i| { - let Some(w) = weak.upgrade() else { return }; - - // The press stayed put, so it was a tap and not a drag: whatever it - // held back can be applied now. See `collections_ui::Press`. - crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids()); - - // A ctrl- or shift-click is a selection gesture. Opening the image - // too would throw the user out of the grid mid-selection. - if coll_for_click.press_was_modified() { - return; - } - - let path = ctl.paths.borrow().get(i as usize).cloned(); - if let Some(path) = path { - // Leave the grid for the develop view. The status bar's - // "‹ Library" button comes back here. - w.set_active_view(View::Develop); - // Which cell the develop view is now showing, so the photo - // roll opens marking it rather than marking nothing. - w.set_library_roll_current(i); - // A develop session begins here, so the roll centres on this - // photograph the first time it settles rather than merely - // scrolling it into view at one edge. Raised here and not in - // `on_library_roll_pick`, which is a step *within* a session — - // see `PhotoRoll::centre-request`. - w.set_library_roll_centre(true); - on_open_image(path); - report_position(&w, &ctl, i as usize); - // TRACES: FR-UI-8 - // Written now rather than on a settle: which view you are in is - // the field a debounce is most likely to lose, and quitting - // straight from develop is exactly the case worth getting right. - write_place(&w, &ctl); - } - }); - } - - // TRACES: FR-UI-4 - // A photograph chosen from the photo roll. - // - // The same row-to-path lookup a cell click does, without the selection - // rules: the roll is a way of moving between photographs, not of building - // a set, so there is no modified press to honour and no reason to leave - // develop. `open_from_library` persists the outgoing edit before it loads - // the next one, which is what makes this safe to fire repeatedly. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let on_open_image = on_open_image.clone(); - window.on_library_roll_pick(move |i| { - let Some(w) = weak.upgrade() else { return }; - let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else { - return; - }; - w.set_library_roll_current(i); - on_open_image(path); - report_position(&w, &ctl, i as usize); - // TRACES: FR-UI-8 - // Debounced, unlike the two entries into develop: the roll is walked - // frame by frame and every step is a different photograph. - note_place(&w, &ctl); - }); - } - - wire_grid_cursor_and_zoom(window, &ctl, &coll_ctl, &on_open_image); - wire_grid_sync_and_load(window, &ctl); - wire_timeline(window, &ctl); - wire_grid_routes(window, &ctl, &coll_ctl, on_leave_develop); - wire_ratings_and_flags(window, &ctl, &coll_ctl); - wire_keywords(window, &ctl, &coll_ctl); - wire_filter_ratings_and_people(window, &ctl); - wire_filter_dates(window, &ctl); - wire_filter_scope_and_offline(window, &ctl, &coll_ctl); - - // Last, and in its own module: the answers to a damaged catalog have - // nothing to do with the library view except that they run before it - // exists. - crate::recovery_ui::wire(window, &ctl, &coll_ctl); -} - -/// --- the keyboard cursor (FR-CULL-4) ----------------------------------- -/// -/// Walking the grid with the arrows, and opening with `Return`. Together -/// with the judgement keys already bound in the grid, this is what makes a -/// culling pass a keyboard job: move, rate, move, open the doubtful one, -/// come back. A cull is thousands of decisions, and reaching for the mouse -/// between each of them is the difference between an hour and an evening. -fn wire_grid_cursor_and_zoom( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, - on_open_image: &OpenImage, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll = coll_ctl.clone(); - window.on_library_move_cursor(move |delta, extend| { - let Some(w) = weak.upgrade() else { return }; - move_cursor(&w, &ctl, &coll, delta, extend); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll = coll_ctl.clone(); - let on_open = on_open_image.clone(); - window.on_library_open_cursor(move || { - let Some(w) = weak.upgrade() else { return }; - // The cursor is a library ordinal and `paths` is the loaded - // window, so the row is the difference. A cursor outside the - // window cannot happen — moving it loads the window around it — - // but a shrinking library could leave one behind, and opening the - // wrong photograph is worse than opening none. - let Some(cursor) = coll.cursor() else { return }; - let offset = *ctl.offset.borrow(); - let row = cursor.checked_sub(offset); - let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned()); - if let Some(path) = path { - w.set_active_view(View::Develop); - // As on a click: the roll marks what is open, and centres on it - // because this too begins a session. - w.set_library_roll_current(row.unwrap_or(0) as i32); - w.set_library_roll_centre(true); - on_open(path); - report_position(&w, &ctl, row.unwrap_or(0)); - write_place(&w, &ctl); - } - }); - } - - // Ctrl+wheel or pinch over the grid resizes the cells. - // - // Geometric steps rather than fixed pixels: the same gesture should feel - // the same at 90px and at 400px, and a linear step is imperceptible at one - // end and violent at the other. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_zoom_cells(move |delta| { - let Some(w) = weak.upgrade() else { return }; - - let current = w.get_library_cell_size(); - let next = if delta > 0 { - current * 1.25 - } else { - current / 1.25 - } - .clamp(MIN_CELL_SIZE, MAX_CELL_SIZE); - - if (next - current).abs() < 0.5 { - return; - } - w.set_library_cell_size(next); - - // No need to forget anything on a class change: the class is part - // of the request key, so cells that now want the large resolution - // simply miss and ask for it, while the 256px ones they already - // hold stay served. - // - // Deferred, like every other geometry change. This one was still - // reloading inline — a full catalog re-read and model rebuild per - // step, which is what a wheel spun through six steps paid six - // times over. - schedule_reload(&w, &ctl); - }); - } - - // TRACES: FR-UI-4 - // A pinch, which is continuous where the wheel is stepped. - // - // Given the ratio since the last update rather than a direction, so the - // grid tracks the fingers instead of jumping a fixed 25% per threshold - // crossing. What the user is setting is the size class — how big they want - // a thumbnail to be — and the drawn cell follows from it by dividing the - // width, so the visible result still lands on whole column counts. Feeding - // a continuous value in is what decides *when* it crosses. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_pinch_cells(move |ratio| { - let Some(w) = weak.upgrade() else { return }; - if !(ratio.is_finite() && ratio > 0.0) { - return; - } - - let current = w.get_library_cell_size(); - let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE); - if (next - current).abs() < 0.5 { - return; - } - w.set_library_cell_size(next); - schedule_reload(&w, &ctl); - }); - } - - // TRACES: FR-UI-4 - // A pinch has begun, so the press that started it was not a press. - // - // A pinch opens as one finger on a cell — which selects it — and only - // becomes a pinch when the second lands. Without this the user is left - // holding a selection they never made, on a photograph they were only - // reaching past. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll = coll_ctl.clone(); - window.on_library_pinch_started(move || { - let Some(w) = weak.upgrade() else { return }; - crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids()); - }); - } -} - -/// Reload triggers for the grid: an explicit sync or thumbnail sweep, and -/// the geometry and scroll changes that move which rows are loaded. -fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc) { - // Explicit sync, for when the user wants the exchange now rather than - // after the next sweep. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_sync_now(move || { - if let Some(w) = weak.upgrade() { - start_derived_sync(&w, &ctl); - } - }); - } - - // TRACES: FR-CAT-3 | FR-NC-3 - // Thumbnail the whole library, from the settings page. It ends in a sync - // of its own, so this is the long version of the button above. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_thumbnail_all(move || { - if let Some(w) = weak.upgrade() { - start_thumbnail_sweep(&w, &ctl); - } - }); - } - - // A column-count change moves which cells begin a row, and month headings - // sit on row-leading cells. - // - // Guarded like the scroll below, and for the same reason: a grid being - // taken down reports its geometry collapsing on the way out, and reloading - // the window against that is work done for a page nobody is looking at. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_columns_changed(move || { - if let Some(w) = weak.upgrade() { - if !w.get_library_visible() { - return; - } - - // Deferred, and re-anchored when it lands — see - // [`schedule_reload`]. A column change moves every cell in the - // grid, because a cell is drawn at its absolute place in the - // library and the row that resolves to is `index / columns`. - // The viewport does not move with them, so without the - // re-anchor the view is left pointing at rows the loaded window - // no longer covers and the grid draws nothing at all — the - // "gallery randomly goes blank until I scroll" report, whose - // triggers are a resize, the sidebar opening, a zoom step, or - // turning the tablet over. - schedule_reload(&w, &ctl); - } - }); - } - - // The viewport changed size, so the window it can usefully hold changed - // with it. Reloading only on growth would leave a maximised-then-restored - // window over-fetching, so both directions are honoured. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_viewport_cells(move |on_screen| { - let Some(w) = weak.upgrade() else { return }; - if !w.get_library_visible() { - return; - } - let on_screen = (on_screen.max(0) as usize).max(1); - let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW); - if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() { - return; - } - ctl.viewport_cells.set(on_screen); - *ctl.window.borrow_mut() = window_size; - // Coalesced with the column change that almost always accompanies - // it: resizing the cells alters both, and reloading once per report - // meant two full rebuilds per zoom step. - schedule_reload(&w, &ctl); - }); - } - - // Scrolling moves the loaded window through the library. - // - // The whole catalog is reachable because the Flickable's viewport is sized - // to it; this keeps the 120 loaded rows centred on wherever the view is. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_scrolled(move |first_visible| { - let Some(w) = weak.upgrade() else { return }; - let first_visible = first_visible.max(0) as usize; - - // **A report from a grid that is not on screen is not a scroll.** - // - // The grid is gated on an `if`, so leaving it tears the whole - // subtree down — and a Flickable being destroyed passes its viewport - // through zero on the way out, which arrives here indistinguishable - // from the user having flung the grid to the top. Everything below - // then ran on the way *into* develop: the loaded window was reset to - // offset zero, the model was rebuilt against the first rows of the - // library, and a thumbnail batch was issued for photographs nobody - // had asked to see. Those rebuilds landed while the grid was still - // being taken apart, which is what flashed the library over the - // develop view for the first few frames after a click — and on a - // remote library it also spent a burst of requests on the top of the - // catalog every single time an image was opened. - // - // **`library-visible`, not `show-library`, and the difference is the - // whole bug.** `show-library` says "the library rather than develop" - // and stays true while Settings, Import, People or the launch screen - // replaces the window — all four of which take the grid down just as - // opening an image does. So the teardown's scroll-to-zero passed this - // guard, `resume_at` was set to 0, and coming back from any of those - // four screens landed at the top of the library however deep in it the - // user had been. The property is computed once in `app.slint` beside - // the `if` it is spelled from, so the two cannot drift apart again. - // - // The guard used to cover only `resume_at`, for a narrower version - // of the same reason. It belongs over the whole handler. - if !w.get_library_visible() { - return; - } - - // Remember where the view is, so leaving for the develop view and - // coming back returns here. Latched on every event rather than read - // at departure: by the time the grid is hidden its scroll position - // is only in the Flickable, which is about to be destroyed. - ctl.resume_at.set(first_visible); - - // TRACES: FR-UI-8 - // And where the *next launch* will look for it. Debounced: a flick - // reports several of these per screenful. - note_place(&w, &ctl); - - // The same position where a rebuilt grid will look for it. - // - // `scroll-to` is read by `seek()`, which runs on a `scroll-token` - // change and on `init` — so writing it here without bumping the - // token cannot move the grid that is on screen, and *is* what the - // next one reads when it is built. That is every route back to the - // grid at once: Settings, Import, People and the launch screen all - // tear the subtree down and rebuild it, and none of them goes - // through `on_back_to_library` to have the position replayed by - // hand. Without this they each rebuilt against whatever `scroll-to` - // was last *set* to — a stale scrub, or zero — and landed there. - w.set_library_scroll_to(first_visible as i32); - - // Move the timeline marker with the view. Scrolling the grid is a - // way of moving through time just as scrubbing is, and a marker - // that only ever moved on a scrub sat still while the photographs - // beside it advanced by months — the axis said "when you are" and - // was wrong the moment the user touched the wheel. - // - // This runs before the reload guard below, which fires only a few - // times per screenful; the marker has to follow every event or it - // would advance in visible jerks. - // - // Only the marker is moved, not the whole histogram: rebuilding - // the bars means a `GROUP BY strftime` aggregate over the library, - // far too much for every event of a flick. The bars do not change - // as the grid scrolls anyway — only where the marker sits on them. - // - // Re-running the scrub would be wrong for a second reason: it sets - // `scroll-to`, which would drive the grid from its own scroll. - { - let borrow = ctl.catalog.borrow(); - if let Some(catalog) = borrow.as_ref() { - if let Some(when) = capture_time_at(&ctl, catalog, first_visible) { - *ctl.current_bucket.borrow_mut() = Some(when); - - let zoom = *ctl.timeline_zoom.borrow(); - let centre = *ctl.timeline_centre.borrow(); - if let Some(full) = catalog_span(catalog, &ctl) { - let (from, to) = zoomed_span(full, zoom, centre); - w.set_library_current_bucket(when as i32); - w.set_library_current_fraction( - ((when - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0) - as f32, - ); - w.set_library_timeline_anchored(true); - } - } - } - } - - // Centre the window on the view, so scrolling either way has - // loaded rows ahead of it rather than only below. - let Some(offset) = window_move( - first_visible, - *ctl.offset.borrow(), - *ctl.window.borrow(), - ctl.viewport_cells.get(), - w.get_library_total().max(0) as usize, - ) else { - return; - }; - - *ctl.offset.borrow_mut() = offset; - load_window(&w, &ctl); - }); - } -} - -/// The capture-time sidebar: panning, zooming and scrubbing the axis that -/// the grid's loaded window follows. -fn wire_timeline(window: &AppWindow, ctl: &Rc) { - // Panning the timeline slides the visible span without changing its width. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_timeline_pan(move |fraction| { - let Some(w) = weak.upgrade() else { return }; - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let Some(full) = catalog_span(catalog, &ctl) else { - return; - }; - - // A fraction of the *visible* span, so dragging half the axis - // moves half a span's worth of time whatever the zoom — and a - // small movement produces a small shift rather than nothing. - let zoom = *ctl.timeline_zoom.borrow(); - let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow()); - let shift = ((to - from) as f64 * fraction as f64) as i64; - if shift == 0 { - return; - } - - let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift; - *ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1)); - refresh_timeline(&w, catalog, &ctl); - }); - } - - // The wheel zooms the axis: a sidebar is a scale, not a list. - // - // Shares `apply_zoom` with the pinch handler so the two cannot drift apart - // in how they clamp or where they centre. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_timeline_zoom(move |delta| { - if let Some(w) = weak.upgrade() { - apply_zoom(&w, &ctl, delta); - } - }); - } - - // Dragging the histogram moves the grid through time. - // - // The fraction is interpolated across the visible span rather than snapped - // to a bucket edge, so a slow drag advances continuously instead of sitting - // still and then jumping a whole month. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_scrub_fraction(move |f| { - let Some(w) = weak.upgrade() else { return }; - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let Some(full) = catalog_span(catalog, &ctl) else { - return; - }; - - let (from, to) = zoomed_span( - full, - *ctl.timeline_zoom.borrow(), - *ctl.timeline_centre.borrow(), - ); - let when = instant_at((from, to), f); - drop(borrow); - scrub_to(&w, &ctl, when); - }); - } - - // Pinch, for tablet: no wheel there, so this is the only way to reach the - // axis's zoom with a finger. - // - // A continuous ratio against discrete zoom levels, so the accumulated - // ratio is held and a level is taken each time it passes a doubling. That - // keeps a slow spread from either doing nothing or leaping several levels. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_timeline_pinch(move |ratio| { - let Some(w) = weak.upgrade() else { return }; - if !(0.01..=100.0).contains(&ratio) { - return; - } - - let mut accum = ctl.pinch_accum.borrow_mut(); - *accum *= ratio; - - // Whole doublings out of the accumulated ratio, remainder carried. - // - // `log2` rather than repeated halving: one update can carry a - // large ratio — a fast spread, or a trackpad reporting coarsely — - // and stepping once per update would turn an 8× pinch into a - // single level instead of three. - let steps = accum.log2().trunc() as i32; - if steps == 0 { - return; - } - *accum /= (2.0f32).powi(steps); - drop(accum); - - apply_zoom(&w, &ctl, steps); - }); - } -} - -/// Moving between the grid and the screens around it: the launch screen, -/// develop, and a manual rescan. -fn wire_grid_routes( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, - on_leave_develop: Rc, -) { - // Grid → launch screen. The route that was missing: once past the launch - // screen there was no way back to it, so a library pointed at the wrong - // folder could not be changed without clearing stored state by hand. - { - let weak = window.as_weak(); - window.on_library_change(move || { - if let Some(w) = weak.upgrade() { - w.set_active_view(View::Launch); - } - }); - } - - // Develop → grid. - // - // Returns to where the user left, on the photograph they were editing, - // rather than to the top. The grid is gated on an `if` in the markup, so it - // is rebuilt from nothing and its Flickable starts at row 0; the position - // has to be replayed explicitly. See [`resume_position`] for which of the - // two positions wins when they disagree. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll_ctl = coll_ctl.clone(); - window.on_back_to_library(move || { - let Some(w) = weak.upgrade() else { return }; - - // TRACES: FR-CAT-8 - // The edit is persisted on the way out rather than on every - // slider move: a save is a network round-trip, and one per drag - // frame would put an upload inside the gesture NFR-P5 governs. - // This is the moment the image stops being the open one, so it is - // the last moment its edit can be written. - on_leave_develop(); - - // Where the open photograph sits in the library, which is exactly - // what `report_position` keeps `index` holding — including after - // every step along the photo roll, which is the case that makes - // this differ from `resume_at`. - let open = w.get_index().max(0) as usize; - resume_position(&w, &ctl, &coll_ctl, Some(open)); - - w.set_active_view(View::Library); - // TRACES: FR-UI-8 - // After the view is set, not before: `current_place` reads it to - // say which view the record is of, and this is the moment it - // becomes the grid. - write_place(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll_ctl = coll_ctl.clone(); - window.on_library_rescan(move || { - let Some(w) = weak.upgrade() else { return }; - start_rescan(&w, &ctl, &coll_ctl); - }); - } -} - -/// --- ratings and flags (FR-CAT-5, FR-CULL-4) -------------------------- -fn wire_ratings_and_flags( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - // Clicking a star rates *that cell*, not the selection. The pointer names - // one photograph unambiguously, and a click that silently rated forty - // others would be a trap — the keyboard is the bulk gesture. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_cell_rated(move |row, stars| { - let Some(w) = weak.upgrade() else { return }; - let id = ctl - .image_ids - .borrow() - .get(row as usize) - .map(|id| dr_types::ImageId(*id as u64)); - let Some(id) = id else { return }; - - apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None); - }); - } - - // Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because - // it changes what the grid's query returns — see [`crate::bursts::toggle`]. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_burst_toggled(move |row| { - let Some(w) = weak.upgrade() else { return }; - let id = ctl - .image_ids - .borrow() - .get(row as usize) - .map(|id| dr_types::ImageId(*id as u64)); - let Some(id) = id else { return }; - - let changed = { - let borrow = ctl.catalog.borrow(); - match borrow.as_ref() { - Some(catalog) => crate::bursts::toggle(catalog, id), - None => false, - } - }; - if changed { - ctl.requested.borrow_mut().clear(); - load_window(&w, &ctl); - } - }); - } - - // Name the frame an open burst folds to (FR-CULL-5). The default is the - // earliest of them, chosen because it is a fact about the clock and not a - // judgement about the photograph; this is the photographer, who is the only - // one who knows which of the twelve is the keeper, saying otherwise. - // - // A repaint of the badges rather than a reload, which is what separates it - // from folding a burst up: the grid's query returns the same rows either - // way while the group is open, and only the mark on them moves. Refilled - // for the whole window in one statement — the tick has to leave the frame - // that was carrying it, and that frame is a different cell. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_burst_representative_chosen(move |row| { - let Some(w) = weak.upgrade() else { return }; - let ids: Vec = ctl - .image_ids - .borrow() - .iter() - .map(|id| dr_types::ImageId(*id as u64)) - .collect(); - let Some(id) = ids.get(row as usize).copied() else { - return; - }; - - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - if crate::bursts::choose(catalog, id) { - crate::bursts::sync_badges(&w, catalog, &ids); - } - }); - } - - // A rating or flag key. Applies to the whole selection, which is what - // makes judging a run of frames one keystroke rather than forty. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll_for_keys = coll_ctl.clone(); - window.on_library_judged(move |rating, flag| { - let Some(w) = weak.upgrade() else { return }; - let chosen = coll_for_keys.selected(); - - // Exactly one axis is meant per keystroke; the other arrives as - // -1 so a star press cannot disturb a flag or the reverse. - if rating >= 0 { - apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None); - } else if flag >= 0 { - apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag))); - } - }); - } -} - -/// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------ -/// -/// Three callbacks and no state of their own: the sheet's open/shut is local -/// to the `.slint` file, and what a keyword applies to is the grid selection -/// the collections controller already owns. A second copy of either here is -/// a second thing that can disagree with the first. -fn wire_keywords( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll_for_keywords = coll_ctl.clone(); - window.on_library_keywords_opened(move || { - let Some(w) = weak.upgrade() else { return }; - refresh_keywords(&w, &ctl, &coll_for_keywords.selected()); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_assign_keyword(move |word| { - let Some(w) = weak.upgrade() else { return }; - apply_keyword(&w, &ctl, word.as_str(), true); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_unassign_keyword(move |word| { - let Some(w) = weak.upgrade() else { return }; - apply_keyword(&w, &ctl, word.as_str(), false); - }); - } -} - -/// --- the filter bar --------------------------------------------------- -/// -/// Each of these narrows what the grid *queries*, so all three reset the -/// scroll offset: the window's position was an ordinal into a different -/// set of images and means nothing once the set changes. -fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc) { - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_min_rating_changed(move |n| { - let Some(w) = weak.upgrade() else { return }; - ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8; - // Stars and "unrated" are contradictory terms — asking for four - // stars *and* nothing judged matches nothing at all, which reads - // as a broken filter rather than an impossible question. - if n > 0 { - ctl.filter.borrow_mut().unjudged = false; - } - w.set_library_filter_min_rating(n.clamp(0, 5)); - w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_unjudged_changed(move |on| { - let Some(w) = weak.upgrade() else { return }; - { - let mut f = ctl.filter.borrow_mut(); - f.unjudged = on; - // See above: the two cannot both hold. - if on { - f.min_rating = 0; - f.flag = None; - } - } - w.set_library_filter_unjudged(on); - if on { - w.set_library_filter_min_rating(0); - w.set_library_filter_flag(0); - } - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_identity_show_photos(move |id, add| { - let Some(w) = weak.upgrade() else { return }; - let person = dr_catalog::faces::PersonId(id.max(0) as u64); - - { - let mut f = ctl.filter.borrow_mut(); - if !add { - f.people.clear(); - } - // Adding the same person twice is a no-op rather than a - // duplicate chip — and, under `All`, rather than a filter that - // silently asks for them twice and matches the same pictures. - if !f.people.contains(&person.0) { - f.people.push(person.0); - } - } - push_people_chips(&w, &ctl); - - // Leaving the Identity screen for the grid is the whole point of - // the button: the answer to "who is this" is a set of photographs, - // and they are shown where photographs are shown. - w.set_active_view(View::Library); - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_person_cleared(move |id| { - let Some(w) = weak.upgrade() else { return }; - ctl.filter - .borrow_mut() - .people - .retain(|p| *p != id.max(0) as u64); - drop_eyes_open_without_people(&w, &ctl); - push_people_chips(&w, &ctl); - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_people_listed(move || { - let Some(w) = weak.upgrade() else { return }; - push_people_roster(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_person_toggled(move |id| { - let Some(w) = weak.upgrade() else { return }; - let person = id.max(0) as u64; - { - let mut f = ctl.filter.borrow_mut(); - if let Some(at) = f.people.iter().position(|p| *p == person) { - f.people.remove(at); - } else { - f.people.push(person); - } - } - drop_eyes_open_without_people(&w, &ctl); - push_people_chips(&w, &ctl); - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_eyes_open_toggled(move || { - let Some(w) = weak.upgrade() else { return }; - let on = { - let mut f = ctl.filter.borrow_mut(); - f.eyes_open = !f.eyes_open; - f.eyes_open - }; - w.set_library_filter_eyes_open(on); - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_people_mode_toggled(move || { - let Some(w) = weak.upgrade() else { return }; - { - let mut f = ctl.filter.borrow_mut(); - f.people_mode = match f.people_mode { - library::PeopleMode::Any => library::PeopleMode::All, - library::PeopleMode::All => library::PeopleMode::Any, - }; - } - push_people_chips(&w, &ctl); - refilter(&w, &ctl); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_filter_flag_changed(move |f| { - let Some(w) = weak.upgrade() else { return }; - { - let mut filter = ctl.filter.borrow_mut(); - filter.flag = match f { - 1 => Some(dr_types::FlagState::Pick), - 2 => Some(dr_types::FlagState::Reject), - _ => None, - }; - if f > 0 { - filter.unjudged = false; - } - } - w.set_library_filter_flag(f); - w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); - refilter(&w, &ctl); - }); - } -} - -/// The date-range filter: turned on from the timeline's own visible span, -/// dragged along the axis, or typed as two dates. -fn wire_filter_dates(window: &AppWindow, ctl: &Rc) { - // Turn the range on, over the period the histogram is showing. - // - // The range is taken from the timeline rather than typed into two date - // fields: finding the fortnight is what the histogram is *for*, and having - // found it the user should not have to read the dates off the axis and key - // them back in. - // - // A starting point rather than the answer. What this puts on the axis is a - // band with two draggable ends, and dragging them is how the range is - // actually stated — on a phone it is the only way that does not need a - // keyboard. Seeding it to the whole visible span means the first drag - // narrows from what the user is already looking at. - // - // Toggling off clears both ends rather than remembering them — a range you - // cannot see the extent of is a filter that looks like an empty library. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_toggle_date_range(move || { - let Some(w) = weak.upgrade() else { return }; - // Whether the *controls* are showing, which is not the same as - // whether a range is set — and reading it from the filter was why - // pressing this did nothing. The fields are how a range gets set, - // so requiring one before they appear is a door locked from the - // inside. - let on = !w.get_library_range_active(); - if on { - // Seeded from the span the axis is drawn over, which is what - // the user is looking at when they ask for "this range". - // - // Only a seed. If there is no catalog yet, or nothing in it - // carries a capture date — dates are read from EXIF as - // thumbnails load, so a freshly opened library has none — the - // fields simply open empty and wait to be typed into. This - // used to `return` in both cases, which meant the button - // silently did nothing on exactly the libraries where a person - // is most likely to be looking for a date. - let seeded = { - let borrow = ctl.catalog.borrow(); - borrow - .as_ref() - .and_then(|catalog| catalog_span(catalog, &ctl)) - .map(|span| { - zoomed_span( - span, - *ctl.timeline_zoom.borrow(), - *ctl.timeline_centre.borrow(), - ) - }) - }; - if let Some(span) = seeded { - // Through the same days-inclusive conversion a drag uses, - // so a seeded range and a dragged one are the same kind of - // thing — and so `show_range` reads this one back out as - // the two days it covers. - let (from, to) = dragged_range(span, 0.0, 1.0); - let mut f = ctl.filter.borrow_mut(); - f.captured_from = Some(from); - f.captured_to = Some(to); - } - } else { - let mut f = ctl.filter.borrow_mut(); - f.captured_from = None; - f.captured_to = None; - } - w.set_library_range_active(on); - show_range(&w, &ctl); - refilter(&w, &ctl); - }); - } - - // TRACES: FR-CAT-6 | FR-UI-2 - // The ends of the range, dragged along the axis they were chosen from. - // - // This is the control the two typed fields were standing in for. On a - // phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field - // behind a soft keyboard, to name a day already drawn on the axis a thumb - // away. Nor was the chip a way round them — its span comes from the - // timeline's zoom and pan, and those are a wheel and a middle button, - // neither of which a touch screen has. So on Android the date range was a - // filter that could be turned on and not aimed. - // - // The span is recomputed here rather than remembered from the last draw. - // It is the same expression `refresh_timeline` uses, over the same - // date-lifted extent, so a handle dropped at a fraction of the track names - // the instant that fraction was drawn at. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_timeline_range_changed(move |a, b| { - let Some(w) = weak.upgrade() else { return }; - let borrow = ctl.catalog.borrow(); - let Some(catalog) = borrow.as_ref() else { - return; - }; - let Some(full) = catalog_span(catalog, &ctl) else { - return; - }; - let span = zoomed_span( - full, - *ctl.timeline_zoom.borrow(), - *ctl.timeline_centre.borrow(), - ); - drop(borrow); - - let (from, to) = dragged_range(span, a, b); - { - let mut f = ctl.filter.borrow_mut(); - f.captured_from = Some(from); - f.captured_to = Some(to); - } - - // A dragged range cannot be mistyped, so any standing complaint - // about the fields is now about a range that no longer exists. - w.set_library_range_invalid(false); - w.set_library_range_active(true); - show_range(&w, &ctl); - // Reloads the window, and `load_window` redraws the timeline — - // which is what puts the band back under the finger that dropped - // it, at the whole days it was snapped to. - refilter(&w, &ctl); - }); - } - - // TRACES: FR-CAT-6 - // The ends of the range, typed. - // - // The chip alone used to be the whole control and it read its span from - // the timeline's zoom, which is zero until someone zooms — so "limit to - // range" set the range to the entire library and appeared to do nothing. - // The span is shown now, and these two fields are how it is corrected. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_range_edited(move |from, to| { - let Some(w) = weak.upgrade() else { return }; - - // Both ends, or neither. A range with one end parsed and the other - // silently dropped is a filter nobody asked for, and the grid - // going empty is a poor way to find out a date was mistyped. - let (Some(from), Some(to)) = ( - dr_types::parse_date(from.as_str()), - dr_types::parse_date(to.as_str()), - ) else { - w.set_library_range_invalid(true); - return; - }; - - // Typed the other way round is a slip, not an error: the user has - // said which two days they mean and there is exactly one range - // between them. - let (from, to) = if from <= to { (from, to) } else { (to, from) }; - - { - let mut f = ctl.filter.borrow_mut(); - f.captured_from = Some(from); - // Inclusive of the last day. "To the 5th" means the whole of - // the 5th — `parse_date` returns its midnight, and a range - // ending there would exclude every photograph taken on the day - // the user named. The same rule `dragged_range` applies. - f.captured_to = Some(to + 86_400 - 1); - } - w.set_library_range_invalid(false); - w.set_library_range_active(true); - show_range(&w, &ctl); - refilter(&w, &ctl); - }); - } -} - -/// Whether the grid is scoped to what is available offline, and the -/// prompt those controls open. -fn wire_filter_scope_and_offline( - window: &AppWindow, - ctl: &Rc, - coll_ctl: &Rc, -) { - // TRACES: FR-CAT-9 - // "On this device" — the images openable without a server. Composes with - // the rating terms rather than replacing them: "five-star frames I can - // actually edit on this train" is one filter, not a mode. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_toggle_local_only(move || { - let Some(w) = weak.upgrade() else { return }; - let on = !ctl.local_only(); - ctl.set_local_only(on); - ctl.filter.borrow_mut().local_only = on; - w.set_library_local_only(on); - refilter(&w, &ctl); - }); - } - - // TRACES: FR-NC-6a - // The header's way in to the offline question, for the collection the grid - // is scoped to. It opens the same prompt the sidebar's tray and the long - // press open, rather than pinning outright: three affordances that did two - // different things — one asking, two acting — is how a user comes to avoid - // all three. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_library_toggle_pin_scope(move || { - let Some(w) = weak.upgrade() else { return }; - let Some(scope) = *ctl.scope.borrow() else { - return; - }; - open_offline_prompt(&w, &ctl, scope); - }); - } - - // TRACES: FR-NC-6a | FR-UI-4 - // The sidebar's way in: the tray on a row, tapped. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_collection_offline_menu(move |id| { - let Some(w) = weak.upgrade() else { return }; - open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64)); - }); - } - - // The hold on a sidebar row — which arms the drag that rearranges the tree - // and opens the row menu — is wired in `collections_ui`, with the rest of - // what a collection row does. It used to be here because it opened the - // offline question and nothing else; that question is now one item in that - // menu, so the gesture belongs with the menu rather than with the transfers. - - // The three answers. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll = coll_ctl.clone(); - window.on_offline_prompt_keep(move || { - let Some(w) = weak.upgrade() else { return }; - keep_collection_offline(&w, &ctl, &coll); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll = coll_ctl.clone(); - window.on_offline_prompt_release(move || { - let Some(w) = weak.upgrade() else { return }; - release_collection_offline(&w, &ctl, &coll); - }); - } - - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - window.on_offline_prompt_dismiss(move || { - let Some(w) = weak.upgrade() else { return }; - close_offline_prompt(&w, &ctl); - }); - } - - // TRACES: FR-CAT-9 - // Retry now, rather than waiting out the backoff. A user who has just - // reconnected their wifi knows something the backoff does not. - { - let weak = window.as_weak(); - let ctl = ctl.clone(); - let coll_ctl = coll_ctl.clone(); - window.on_library_retry_connection(move || { - let Some(w) = weak.upgrade() else { return }; - log::info!("retrying the connection at the user's request"); - // A rescan is the probe: it is the same request the scan worker - // makes, so a success proves reachability and repopulates the - // catalog in one pass rather than proving it twice. - start_rescan(&w, &ctl, &coll_ctl); - }); - } -} - -/// Reload the grid after the filter changed. -/// -/// The offset is reset because it is an ordinal into the filtered set: keeping -/// it would land the user in the middle of a narrowed library with no sense of -/// how they got there, or past its end entirely. -/// Put the identity filter's people on the filter bar. -/// -/// One chip each, so a selection of three is three things to look at and three -/// things to remove — a single chip saying "3 people" would be a filter the -/// user can only clear wholesale. -/// -/// The label is the People screen's own wording, unnamed groups included, so -/// the same group does not read one way on one screen and another way on the -/// next. -/// Drop the eyes-open term when the last person leaves the filter. -/// -/// The chip lives beside the people chips and goes with them; a term that -/// stayed on after its chip had gone would narrow the grid with nothing on -/// the bar to say so, which is the one state a filter bar must never be in. -fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc) { - let mut f = ctl.filter.borrow_mut(); - if f.people.is_empty() && f.eyes_open { - f.eyes_open = false; - window.set_library_filter_eyes_open(false); - } -} - -fn push_people_chips(window: &AppWindow, ctl: &Rc) { - let ids = ctl.filter.borrow().people.clone(); - if ids.is_empty() { - window - .set_library_filter_people(slint::ModelRc::new(slint::VecModel::from( - Vec::::new(), - ))); - // The tray, if it is open, has to lose its ticks with the chips: the - // roster carries `picked` and is the same fact drawn a second time. - push_people_roster(window, ctl); - return; - } - - let borrow = ctl.catalog.borrow(); - let people = borrow - .as_ref() - .and_then(|cat| dr_catalog::faces::people(cat.connection()).ok()) - .unwrap_or_default(); - - let chips: Vec = ids - .iter() - .map(|id| { - let name = people - .iter() - .find(|p| p.id.0 == *id) - .map(|p| { - if p.name.trim().is_empty() { - format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces) - } else { - p.name.clone() - } - }) - // A person deleted from under the filter still needs a chip, - // or there would be no way to take them back out of it. - .unwrap_or_else(|| "Person".to_string()); - PersonChip { - id: *id as i32, - name: name.into(), - // Not asked for on this side. The bar's job here is to say who - // the grid is narrowed to and offer a way out of it; a face - // count beside each would be a second number competing with the - // image counts already on the bar. - faces: -1, - picked: true, - } - }) - .collect(); - - window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips))); - window.set_library_filter_people_all(matches!( - ctl.filter.borrow().people_mode, - library::PeopleMode::All - )); - push_people_roster(window, ctl); -} - -/// Fill the filter bar's people tray. -/// -/// Rebuilt whole rather than patched, because `picked` is on every row and a -/// toggle changes two things at once — the chip that was pressed and, under -/// `All`, what the whole filter means. -/// -/// **Named people first, then by how much of them the library holds.** The -/// catalog orders by face count alone, which on a real library puts a dozen -/// unnamed strangers ahead of the two people the user has actually named — and -/// the tray scrolls horizontally, so anything past the first few chips costs a -/// gesture to reach. Naming somebody is the user saying they matter; the order -/// says it back. -fn push_people_roster(window: &AppWindow, ctl: &Rc) { - let picked = ctl.filter.borrow().people.clone(); - let borrow = ctl.catalog.borrow(); - let people = borrow - .as_ref() - .and_then(|cat| dr_catalog::faces::people(cat.connection()).ok()) - .unwrap_or_default(); - - // Sorted as (unnamed, -faces) pairs beside the chip rather than by - // re-reading the drawn label: "is this person named" is a fact about the - // record, and recovering it from the `Unnamed (n faces)` wording would put - // a sort key inside a string meant for a human to read. - let mut rows: Vec<(bool, i64, PersonChip)> = people - .iter() - .filter(|p| { - let on = picked.contains(&p.id.0); - // A person already in the filter always has a chip, whatever else - // is true of them: the tray is where the filter is taken apart, and - // a term with no control is a term the user cannot remove. - // - // Otherwise: nobody the user set aside, and nobody with no faces — - // a named person emptied by a split would be a chip that narrows - // the grid to nothing whatever else is on the bar. - on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0) - }) - .map(|p| { - let faces = p.confirmed_faces + p.suggested_faces; - let unnamed = p.name.trim().is_empty(); - ( - unnamed, - faces as i64, - PersonChip { - id: p.id.0 as i32, - name: if unnamed { - format!("Unnamed ({faces} faces)").into() - } else { - p.name.clone().into() - }, - faces: faces as i32, - picked: picked.contains(&p.id.0), - }, - ) - }) - .collect(); - // Stable, so the catalog's own tiebreak by name survives inside each of the - // two blocks. - rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces)); - - let rows: Vec = rows.into_iter().map(|(_, _, chip)| chip).collect(); - window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows))); -} - -fn refilter(window: &AppWindow, ctl: &Rc) { - *ctl.offset.borrow_mut() = 0; - ctl.requested.borrow_mut().clear(); - load_window(window, ctl); - // TRACES: FR-UI-8 - // A narrowed grid is part of where you are: coming back to five-star frames - // of one person and finding the whole library is the same loss of place as - // coming back to the top of it. - note_place(window, ctl); -} - -fn stop(slot: &RefCell>) { - if let Some(t) = slot.borrow().as_ref() { - t.stop(); - } -} - -/// The heading each cell of a window carries: a month name on some, nothing -/// on most. -/// -/// A heading is drawn above its row, so it can only sit on a cell that -/// begins one — stranded mid-row it would appear to label the cells to its -/// left, which belong to the month before. So a row carries a heading when -/// its first cell's month is not the one last announced. A month that begins -/// mid-row is therefore announced at the *next* row it opens, which is one -/// row late and still right about every cell under it. It used to be -/// announced only if it happened to begin a row, which at seven columns -/// meant most months were never named at all and the one heading on screen -/// was wrong about everything below its first row. -/// -/// The first cell of the window always carries one, whichever column it -/// lands in: a scrolled window would otherwise show no date at all until -/// the next month began. -fn period_headings( - months: impl Iterator>, - offset: usize, - columns: usize, -) -> Vec { - let columns = columns.max(1); - let mut announced: Option<(i64, i64)> = None; - months - .enumerate() - .map(|(i, month)| { - let Some((y, m)) = month else { - return String::new(); - }; - let begins_row = (i + offset).is_multiple_of(columns); - if i == 0 || (begins_row && announced != Some((y, m))) { - announced = Some((y, m)); - format!("{} {y}", month_name(m)) - } else { - String::new() - } - }) - .collect() -} - -/// The line in the library header that says what is being catalogued. -/// -/// For an account with a user, the user and the chosen subtree, or the -/// whole account when none was chosen. For a folder there is no user and -/// the endpoint is the library, so the folder's own name: `library`, or -/// `library/2026` when narrowed — never " · whole account", which is a -/// sentence about a server said of a directory. -fn library_root_label(account: &Account) -> String { - if account.login.is_empty() { - let folder = std::path::Path::new(&account.endpoint) - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| account.endpoint.clone()); - if account.root.is_empty() { - folder - } else { - format!("{folder}/{}", account.root) - } - } else if account.root.is_empty() { - format!("{} · whole account", account.user_id) - } else { - format!("{}/{}", account.user_id, account.root) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn a_month_that_begins_mid_row_is_still_announced() { - // Four columns; August starts in the second column of the second - // row and October in the third of the third. Neither begins a row, - // and both must still be named — on the next row each opens. - let mar = Some((2024, 3)); - let aug = Some((2024, 8)); - let oct = Some((2025, 10)); - let months = [ - mar, mar, mar, mar, // - mar, aug, aug, aug, // - aug, aug, oct, oct, // - oct, - ]; - let h = period_headings(months.into_iter(), 0, 4); - assert_eq!(h[0], "March 2024"); - assert_eq!(h[4], "", "still March at the top of row two"); - assert_eq!(h[5], "", "never mid-row"); - assert_eq!(h[8], "August 2024"); - assert_eq!(h[12], "October 2025"); - assert_eq!(h.iter().filter(|s| !s.is_empty()).count(), 3); - } - - #[test] - fn a_scrolled_window_names_its_first_cell_wherever_it_lands() { - let jan = Some((2026, 1)); - let feb = Some((2026, 2)); - // Offset 2 into a 4-column grid: the window's first cell is in the - // third column, and the row after it begins a new month. - let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4); - assert_eq!(h[0], "January 2026"); - assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2"); - assert_eq!(h[3], ""); - // Undated cells carry nothing and announce nothing. - let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4); - assert_eq!(h[0], ""); - assert_eq!(h[4], "January 2026"); - } - - #[test] - fn the_header_names_a_folder_library_by_its_folder() { - let mut folder = Account::new("folder", "/var/tmp/dr-demo/library"); - assert_eq!(library_root_label(&folder), "library"); - folder.root = "2026".into(); - assert_eq!(library_root_label(&folder), "library/2026"); - - let mut cloud = - Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan"); - assert_eq!(library_root_label(&cloud), "duncan · whole account"); - cloud.root = "PhotosRaw".into(); - assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw"); - } - - #[test] - fn a_scrub_fraction_interpolates_within_the_span() { - // The point of going fractional: a slow drag must advance - // continuously rather than sitting still until it crosses a bucket - // edge and then jumping a whole month. - let span = (0, 1_000); - assert_eq!(instant_at(span, 0.0), 0); - assert_eq!(instant_at(span, 0.5), 500); - assert_eq!(instant_at(span, 1.0), 1_000); - // Distinct fractions inside one bucket must give distinct instants. - assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11)); - } - - #[test] - fn the_marker_lands_on_the_fraction_that_was_clicked() { - // The bug this guards: the marker was placed from a *bar index* while - // the click was a fraction of the track, so it never appeared under - // the pointer. Marker and click must be inverses. - let span = (1_000, 9_000); - for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] { - let round_trip = fraction_at(span, instant_at(span, f)); - assert!( - (round_trip - f).abs() < 1e-3, - "clicked {f}, marker drawn at {round_trip}" - ); - } - } - - #[test] - fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() { - // Zooming in leaves the grid's instant outside the axis. The marker - // belongs at the edge it went past, not off the widget. - let span = (1_000, 2_000); - assert_eq!(fraction_at(span, 0), 0.0); - assert_eq!(fraction_at(span, 5_000), 1.0); - } - - #[test] - fn a_scrub_fraction_outside_the_axis_is_clamped() { - // A drag that leaves the widget still reports a position; it must land - // at an end rather than off the timeline. - let span = (100, 200); - assert_eq!(instant_at(span, -3.0), 100); - assert_eq!(instant_at(span, 9.9), 200); - } - - /// A fortnight in March 2024, in whole days, as the axis would span it. - const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800); - - #[test] - fn a_dragged_range_covers_whole_days_at_both_ends() { - // What makes a dragged range the same kind of thing as a typed one: - // `show_range` reads the closing day back out of the stored second, so - // an end left mid-afternoon would be shown as the day before. - let (from, to) = dragged_range(MARCH, 0.13, 0.61); - assert_eq!(day_start(from), from, "opens at a midnight"); - assert_eq!( - day_start(to + 1), - to + 1, - "closes at the last second of a day" - ); - assert_eq!(dr_types::format_date(from), "2024-03-02"); - assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09"); - } - - #[test] - fn ends_dragged_past_each_other_name_the_same_range() { - // Pulling the early end below the late one is how a range that was - // narrowed too far gets corrected; refusing it would make starting - // again the easier move. - assert_eq!( - dragged_range(MARCH, 0.6, 0.2), - dragged_range(MARCH, 0.2, 0.6) - ); - } - - #[test] - fn both_ends_dragged_together_name_one_day() { - // The floor the day snapping buys. Two handles on the same pixel is a - // gesture that happens; an empty grid is not what it means. - let (from, to) = dragged_range(MARCH, 0.5, 0.5); - assert_eq!(to - from, 86_399); - } - - #[test] - fn the_band_is_drawn_where_the_handle_was_dropped() { - // The band is placed by `fraction_at` and the range comes from - // `instant_at`. If those two ever disagreed the band would settle - // somewhere other than where the finger let go, which reads as the - // drag having been ignored. - for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { - let (from, _) = dragged_range(MARCH, f, f); - let drawn = fraction_at(MARCH, from); - // Within a day of the span, which is the snapping and nothing else. - let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32; - assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}"); - } - } - - #[test] - fn every_instant_on_the_axis_lands_in_a_bin() { - // The bar under a date and the bar that counted it must be the same - // bar: `bin_of` is the UI's copy of the division the SQL groups by. - let span = (1_000, 9_000); - assert_eq!(bin_of(span, 1_000, 8), 0); - assert_eq!(bin_of(span, 1_999, 8), 0); - assert_eq!(bin_of(span, 2_000, 8), 1); - // The last instant belongs to the last bin rather than to one past it. - assert_eq!(bin_of(span, 9_000, 8), 7); - } - - #[test] - fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() { - // Zooming in leaves the grid's position outside the span. The lit bar - // belongs at the edge, not at an index that does not exist. - let span = (1_000, 9_000); - assert_eq!(bin_of(span, -50_000, 8), 0); - assert_eq!(bin_of(span, 50_000, 8), 7); - // And a degenerate axis still answers with a bin. - assert_eq!(bin_of((5, 5), 5, 32), 0); - } - - #[test] - fn a_day_before_1970_starts_at_its_own_midnight() { - // `%` truncates towards zero, which on a negative timestamp names the - // *following* midnight — a scanned archive of negatives would have - // every range off by a day at the early end. - assert_eq!(day_start(-1), -86_400); - assert_eq!(day_start(-86_400), -86_400); - assert_eq!(day_start(-86_401), -172_800); - } - - /// One pinch update, as the handler applies it: fold the ratio in, take - /// out whole doublings, carry the remainder. - fn pinch_step(accum: &mut f32, ratio: f32) -> i32 { - *accum *= ratio; - let steps = accum.log2().trunc() as i32; - if steps != 0 { - *accum /= (2.0f32).powi(steps); - } - steps - } - - #[test] - fn a_pinch_accumulates_until_it_reaches_a_doubling() { - // A continuous gesture against discrete levels. Small spreads must - // accumulate rather than each being rounded to a step. - let mut accum = 1.0f32; - let mut steps = 0; - for _ in 0..12 { - steps += pinch_step(&mut accum, 1.1); - } - // 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with - // the rest carried rather than discarded or rounded up. - assert_eq!(steps, 1); - assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}"); - } - - #[test] - fn one_large_pinch_yields_every_level_it_crossed() { - // The bug this replaced: stepping at most once per update turned an - // 8x spread — three doublings — into a single zoom level, so a fast - // gesture lost most of its travel. - let mut accum = 1.0f32; - assert_eq!(pinch_step(&mut accum, 8.0), 3); - assert!((accum - 1.0).abs() < 0.001); - - let mut accum = 1.0f32; - assert_eq!(pinch_step(&mut accum, 0.125), -3); - } - - #[test] - fn pinching_in_and_back_out_returns_to_where_it_started() { - let mut accum = 1.0f32; - let steps: i32 = [2.0f32, 2.0, 0.5, 0.5] - .iter() - .map(|r| pinch_step(&mut accum, *r)) - .sum(); - assert_eq!(steps, 0); - assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}"); - } - - #[test] - fn a_pinch_below_a_doubling_takes_no_step() { - // Otherwise the axis would flicker between levels on the smallest - // finger movement. - let mut accum = 1.0f32; - assert_eq!(pinch_step(&mut accum, 1.4), 0); - assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0"); - } - - /// One zoom step, as the handler applies it. - fn zoom_cell(current: f32, delta: i32) -> f32 { - let next = if delta > 0 { - current * 1.25 - } else { - current / 1.25 - }; - next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE) - } - - #[test] - fn cell_zoom_steps_geometrically_and_reverses() { - // Geometric so the gesture feels the same at either end; a fixed pixel - // step is imperceptible at 400px and violent at 90px. - let a = zoom_cell(180.0, 1); - assert!((a - 225.0).abs() < 0.01); - assert!( - (zoom_cell(a, -1) - 180.0).abs() < 0.01, - "in then out returns" - ); - } - - #[test] - fn cell_zoom_stays_within_its_bounds() { - let mut size = 180.0; - for _ in 0..40 { - size = zoom_cell(size, 1); - } - assert_eq!(size, MAX_CELL_SIZE); - - for _ in 0..40 { - size = zoom_cell(size, -1); - } - assert_eq!(size, MIN_CELL_SIZE); - } - - #[test] - fn zooming_past_the_grid_class_asks_for_the_large_one() { - use dr_thumbs::ThumbSize; - // The point of the second class: past 256px a grid thumbnail is being - // upscaled, and the softness shows. - assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid); - assert_eq!( - ThumbSize::for_cell(zoom_cell(225.0, 1) as u32), - ThumbSize::Large - ); - // And zooming back down does not keep paying for it. - assert_eq!( - ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), - ThumbSize::Grid - ); - } - - #[test] - fn a_request_key_survives_the_window_moving() { - use dr_thumbs::ThumbSize; - use std::collections::HashSet; - - // Keyed on the photograph, not its position. The grid is a window over - // the catalog, so row 7 is a different image after every scroll — a - // set of row indices had to be cleared on each move, and every visible - // cell then looked unrequested and was re-issued. - let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new(); - - // A screenful at rows 0..3, holding images 100..103. - for id in 100..103 { - assert!(requested.insert((id, ThumbSize::Grid)), "first sight"); - } - - // Scrolled: the same photographs now occupy different rows. - for id in 100..103 { - assert!( - !requested.insert((id, ThumbSize::Grid)), - "image {id} must not be requested twice" - ); - } - - // A genuinely new photograph still is. - assert!(requested.insert((200, ThumbSize::Grid))); - } - - #[test] - fn the_two_size_classes_are_requested_independently() { - use dr_thumbs::ThumbSize; - use std::collections::HashSet; - - // Holding the 256px version says nothing about the large one, so - // zooming past the boundary must still ask. - let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new(); - assert!(requested.insert((1, ThumbSize::Grid))); - assert!( - requested.insert((1, ThumbSize::Large)), - "the large class is a separate request" - ); - assert!(!requested.insert((1, ThumbSize::Grid))); - } - - #[test] - fn zoom_zero_is_the_whole_library() { - let full = (1_000, 2_000); - assert_eq!(zoomed_span(full, 0, None), full); - // A remembered centre must not narrow the span at zoom 0. - assert_eq!(zoomed_span(full, 0, Some(1_500)), full); - } - - #[test] - fn each_zoom_step_halves_the_span() { - let day = 86_400; - let full = (0, 64 * day); - let (a, b) = zoomed_span(full, 1, Some(32 * day)); - assert_eq!(b - a, 32 * day); - let (a, b) = zoomed_span(full, 2, Some(32 * day)); - assert_eq!(b - a, 16 * day); - } - - #[test] - fn zooming_centres_on_the_given_instant() { - let day = 86_400; - let full = (0, 100 * day); - let (a, b) = zoomed_span(full, 1, Some(60 * day)); - assert_eq!((a + b) / 2, 60 * day, "centred where asked"); - } - - #[test] - fn a_window_at_the_edge_slides_rather_than_shrinking() { - // Clamping both ends would silently halve the span near the start of - // the library, so the axis would show less detail there than in the - // middle for the same zoom level. - let day = 86_400; - let full = (0, 100 * day); - let (a, b) = zoomed_span(full, 1, Some(0)); - assert_eq!(a, 0, "cannot start before the library does"); - assert_eq!(b - a, 50 * day, "keeps its width"); - - let (a, b) = zoomed_span(full, 1, Some(100 * day)); - assert_eq!(b, 100 * day); - assert_eq!(b - a, 50 * day); - } - - #[test] - fn a_deep_zoom_does_not_collapse_to_nothing() { - // A zero-width span would make every bucket empty, which reads as a - // broken axis rather than a deep zoom. - let full = (0, 86_400); - let (a, b) = zoomed_span(full, 12, Some(43_200)); - assert!(b > a, "span stays positive"); - } - - #[test] - fn a_single_instant_library_does_not_divide_by_zero() { - let full = (1_700_000_000, 1_700_000_000); - let (a, b) = zoomed_span(full, 5, None); - assert!(a <= b); - } - - #[test] - fn month_names_cover_the_year_and_clamp() { - assert_eq!(month_name(1), "January"); - assert_eq!(month_name(12), "December"); - assert_eq!(month_abbrev(3), "Mar"); - // A corrupt month must not panic the grid. - assert_eq!(month_name(0), "January"); - assert_eq!(month_name(99), "December"); - } - - #[test] - fn civil_dates_round_trip_at_boundaries() { - // Era-based date maths is silently wrong at year and leap boundaries - // if the algorithm is transcribed slightly off, and the symptom is an - // image landing in the wrong histogram bucket. - assert_eq!(civil_from_unix(0), (1970, 1, 1, 0)); - assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14)); - // Leap day. - assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0)); - // Last second of a year, and the first of the next. - assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23)); - assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0)); - } - - #[test] - fn dates_before_the_epoch_do_not_wrap() { - // Scanned film carries capture dates well before 1970; a negative - // timestamp must floor rather than truncate toward zero. - let (y, _, _, _) = civil_from_unix(-1); - assert_eq!(y, 1969); - } - - /// A bar is named after how long it is. - /// - /// The axis no longer buckets by calendar unit — it cuts the visible span - /// into a fixed number of equal bins — so the unit survives only as a - /// vocabulary for *labelling* one. What this guards is that zooming in - /// never produces a coarser label: the complaint it came from was a - /// fortnight drawn as one column of a year-per-bar axis. - #[test] - fn a_bin_is_labelled_at_its_own_scale() { - use dr_catalog::Granularity; - const DAY: i64 = 86_400; - - // Fifteen years across 32 bars is nearly six months a bar, which on a - // ratio measure is nearer a year than a month — so the bars are - // labelled by year, which is what a fifteen-year axis wants anyway. - assert_eq!( - Granularity::for_bucket(15 * 365 * DAY / 32), - Granularity::Year - ); - - // A fortnight across the same 32 bars is about ten hours a bar, which - // is nearer a day than an hour and is labelled as a date. - assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day); - - // Zoom on until a bar is an hour or two, and it is named by the hour. - assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour); - - // The property rather than the thresholds: a shorter bin never gets a - // coarser label than a longer one. - let mut previous = i64::MAX; - for days in [3650, 730, 180, 60, 14, 3, 1] { - let g = Granularity::for_bucket(days * DAY / 32); - let bucket = match g { - Granularity::Year => 365 * DAY, - Granularity::Month => 30 * DAY, - Granularity::Day => DAY, - Granularity::Hour => 3600, - }; - assert!( - bucket <= previous, - "{days} days chose a coarser bin label than the span above it" - ); - previous = bucket; - } - } - - /// Deleting must not move the view somewhere the user did not ask to be. - /// - /// The grid draws each cell at its absolute place in the library, and a - /// delete shortens the library *and* re-clamps the loaded window's offset. - /// Neither touches the viewport, so before this the grid went blank — the - /// view left pointing past the end of the content — or appeared to jump, - /// the same scroll position now addressing different photographs. - /// - /// The anchor is the last *visible* ordinal the grid reported, clamped into - /// what is left — not the loaded window's `offset`, which sits about a - /// quarter of a screen above the view and is itself re-clamped by the - /// delete. Anchoring on that was the first attempt and still jumped. - #[test] - fn a_shorter_library_anchors_the_view_instead_of_losing_it() { - // The rule `restore_position` applies, stated where it can be checked. - let anchor = |offset: usize, total: usize| -> Option { - if total == 0 { - None - } else { - Some(offset.min(total - 1)) - } - }; - - // The everyday case: one frame removed from the middle of a wall of - // twenty thousand. The view does not move — the photograph that slid - // into the gap is the one you want next. - assert_eq!(anchor(12_000, 19_999), Some(12_000)); - - // Near the end, which is where the blank came from: the view was - // showing ordinals past what now exists, so it lands on the last. - assert_eq!(anchor(19_990, 5), Some(4)); - - // Emptied entirely. Seeking into an empty library would be the same - // fault in the other direction, so there is nothing to do. - assert_eq!(anchor(500, 0), None); - - // The boundary: an offset equal to the new total is one past the end. - assert_eq!(anchor(10, 10), Some(9)); - } - - #[test] - fn bucket_labels_match_their_granularity() { - use dr_catalog::Granularity; - let t = 1_786_285_800; // 2026-08-09 14:30 UTC - assert_eq!(format_bucket(t, Granularity::Year), "2026"); - assert_eq!(format_bucket(t, Granularity::Month), "2026-08"); - assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09"); - assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00"); - } - - #[test] - fn rgba_shorter_than_declared_does_not_panic() { - // A truncated decode must degrade to a partial image, not abort the - // grid. Decoders handle untrusted input (NFR-SEC-1). - let img = to_slint_image(4, 4, &[0u8; 8]); - assert_eq!(img.size().width, 4); - } - - #[test] - fn rgba_longer_than_declared_is_truncated() { - let img = to_slint_image(2, 2, &[255u8; 1024]); - assert_eq!(img.size().width, 2); - assert_eq!(img.size().height, 2); - } - - // --- moving the loaded window ------------------------------------------ - // - // A screenful of 120 cells, four of them loaded, out of 24,000. - - const ON_SCREEN: usize = 120; - const W: usize = ON_SCREEN * SCREENFULS; - const TOTAL: usize = 24_000; - - /// Where the window lands for a view at `first_visible`, as the scroll - /// handler would leave it. - fn settle(first_visible: usize) -> usize { - window_start(first_visible, W, TOTAL) - } - - #[test] - fn a_view_well_inside_the_loaded_window_does_not_move_it() { - // The whole point of loading screenfuls either side: scrolling within - // them must not touch the catalog. - let at = settle(5_000); - assert_eq!(window_move(5_000, at, W, ON_SCREEN, TOTAL), None); - assert_eq!(window_move(5_100, at, W, ON_SCREEN, TOTAL), None); - } - - #[test] - fn the_whole_view_stays_inside_the_loaded_window() { - // The bug this rule exists for: the test used to ask only where the - // *first* visible cell was, so the window sat still while the bottom - // of the view hung a quarter of a screenful past the last loaded cell - // — rows the model does not hold and the grid therefore draws blank. - let mut at = settle(0); - for first_visible in (0..TOTAL - ON_SCREEN).step_by(10) { - if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) { - at = moved; - } - assert!( - first_visible >= at && first_visible + ON_SCREEN <= at + W, - "view {first_visible}..{} is not covered by the window {at}..{}", - first_visible + ON_SCREEN, - at + W - ); - } - } - - #[test] - fn scrolling_back_a_row_does_not_reload() { - // The mirror fault, and the more expensive one: the window is placed a - // quarter of itself behind the view, and the old margin declared the - // view too close to the top at exactly that distance. So every single - // row scrolled upward re-read the catalog and rebuilt every cell. - let at = settle(5_200); - for row in 1..=4 { - let first_visible = 5_200 - row * 10; - assert_eq!( - window_move(first_visible, at, W, ON_SCREEN, TOTAL), - None, - "row {first_visible} reloaded the window it was already inside" - ); - } - } - - #[test] - fn a_view_reaching_the_edge_of_the_loaded_window_moves_it() { - // Far enough down that scrolling on would run into rows nobody has - // read. - let at = settle(5_000); - let far = at + W - ON_SCREEN; - let moved = window_move(far, at, W, ON_SCREEN, TOTAL).expect("the window follows the view"); - assert_eq!(moved, far - W / 4, "placed a quarter behind the view"); - } - - #[test] - fn the_window_moves_about_once_a_screenful() { - // Too eager is a stutter under the finger, so the rule is checked from - // both sides: the view must cross most of a screenful between reloads. - let mut at = settle(0); - let mut last = 0; - let mut gaps = Vec::new(); - for first_visible in (0..12_000).step_by(10) { - if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) { - at = moved; - gaps.push(first_visible - last); - last = first_visible; - } - } - assert!( - gaps.iter().skip(1).all(|g| *g >= ON_SCREEN), - "reloaded after less than a screenful of travel: {gaps:?}" - ); - } - - #[test] - fn the_top_of_the_library_is_not_reloaded_on_every_row() { - // `first_visible` cannot be placed further back than zero, so the - // margin test can never be satisfied here. Before this rule every one - // of these re-read the catalog to arrive at the offset it already had, - // which is the stutter at the top of every scope. - for first_visible in [0, 6, 30, 89] { - assert_eq!( - window_move(first_visible, 0, W, ON_SCREEN, TOTAL), - None, - "row {first_visible} asked for a move to offset 0, which is where it is" - ); - } - } - - #[test] - fn the_end_of_the_library_is_not_reloaded_on_every_row() { - // The mirror of the above, and the worse of the two: the window is - // clamped to `max_offset` while the view keeps travelling past it. - let pinned = TOTAL - W; - for first_visible in [TOTAL - W / 2, TOTAL - 30, TOTAL - 1] { - assert_eq!( - window_move(first_visible, pinned, W, ON_SCREEN, TOTAL), - None, - "row {first_visible} asked for a move to the offset it already had" - ); - } - } - - #[test] - fn the_window_never_starts_past_the_last_full_screenful() { - // Otherwise a scrub to the very end loads a handful of cells and the - // rest of the window addresses images that do not exist. - let moved = - window_move(TOTAL - 1, 0, W, ON_SCREEN, TOTAL).expect("a scrub to the end moves"); - assert_eq!(moved, TOTAL - W); - } - - #[test] - fn a_library_smaller_than_the_window_stays_at_the_beginning() { - // `max_offset` is zero, so there is one valid position and the view - // must never ask for another. - assert_eq!(window_move(0, 0, W, ON_SCREEN, 40), None); - assert_eq!(window_move(39, 0, W, ON_SCREEN, 40), None); - } - - // --- which cells are fetched first ------------------------------------- - - #[test] - fn the_visible_cells_are_fetched_before_anything_else() { - // The window holds a quarter of itself above the view, and in model - // order those cells — which nobody is looking at — were fetched first, - // ahead of the whole screen. - let window: Vec = (0..W).collect(); - let first_on_screen = W / 4; - let mut order = window.clone(); - order.sort_by_key(|row| fetch_rank(*row, first_on_screen, ON_SCREEN)); - - assert_eq!( - &order[..ON_SCREEN], - &window[first_on_screen..first_on_screen + ON_SCREEN], - "the screen is not fetched first, in reading order" - ); - // The bottom row of the grid — the one reported missing — comes before - // every offscreen cell rather than after all of them. - let bottom = first_on_screen + ON_SCREEN - 1; - assert!( - order.iter().position(|r| *r == bottom).unwrap() < ON_SCREEN, - "the bottom row of the grid is still behind offscreen cells" - ); - } - - #[test] - fn offscreen_cells_are_fetched_nearest_first_below_before_above() { - let first_on_screen = W / 4; - let past = first_on_screen + ON_SCREEN; - // The row just below the view beats the row just above it, and both - // beat rows further out on their own side. - assert!( - fetch_rank(past, first_on_screen, ON_SCREEN) - < fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN) - ); - assert!( - fetch_rank(past, first_on_screen, ON_SCREEN) - < fetch_rank(past + 1, first_on_screen, ON_SCREEN) - ); - assert!( - fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN) - < fetch_rank(0, first_on_screen, ON_SCREEN) - ); - } - - // --- carrying thumbnails across a reload ------------------------------- - // - // The black flash: `load_window` rebuilds every row, and until these it - // rebuilt them empty — so the three quarters of the grid the user was - // looking at went to `Theme.ground` and back on every scroll. - - /// A model of cells, `has_thumb` set for the ids named. - fn model_of(ids: &[i64], with_pixels: &[i64]) -> slint::ModelRc { - let rows: Vec = ids - .iter() - .map(|id| LibraryCell { - has_thumb: with_pixels.contains(id), - thumbnail: if with_pixels.contains(id) { - to_slint_image(2, 2, &[255u8; 16]) - } else { - slint::Image::default() - }, - ..Default::default() - }) - .collect(); - slint::ModelRc::new(slint::VecModel::from(rows)) - } - - #[test] - fn a_reload_keeps_the_pixels_of_photographs_that_are_still_in_the_window() { - let ids = [10i64, 11, 12]; - let previous = model_of(&ids, &[10, 12]); - let classes = vec![Some(dr_thumbs::ThumbSize::Grid); 3]; - - let held = hold_thumbnails(&previous, &ids, &classes); - - assert!(held.contains_key(&10), "a drawn cell is carried"); - assert!(held.contains_key(&12)); - assert!( - !held.contains_key(&11), - "a cell still waiting has nothing to carry" - ); - } - - #[test] - fn a_no_preview_verdict_is_carried_too() { - // Otherwise every scroll re-asks a question the server has already - // answered, and the cell blinks from "no preview" back to "…". - let ids = [7i64]; - let rows = vec![LibraryCell { - unavailable: true, - ..Default::default() - }]; - let previous = slint::ModelRc::new(slint::VecModel::from(rows)); - - let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid)]); - assert!(held[&7].unavailable); - assert!(!held[&7].has_thumb); - } - - #[test] - fn a_carried_cell_is_not_fetched_again_but_a_newly_scrolled_in_one_is() { - // The scroll this describes: the window moved down by one image, so - // 11 and 12 are still on screen and 13 has just arrived. - let ids = [10i64, 11, 12]; - let previous = model_of(&ids, &[10, 11, 12]); - let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid); 3]); - - let served = already_served(&held, [11i64, 12, 13].into_iter()); - - assert!(served.contains(&(11, dr_thumbs::ThumbSize::Grid))); - assert!(served.contains(&(12, dr_thumbs::ThumbSize::Grid))); - assert!( - !served.contains(&(13, dr_thumbs::ThumbSize::Grid)), - "a photograph the window has just reached must still be fetched" - ); - } - - #[test] - fn a_photograph_scrolled_out_of_the_window_is_fetched_again_on_return() { - // The trap in keeping the set rather than rebuilding it: image 10 was - // served once, but the model that held its pixels is long gone, so the - // cell would sit blank for ever if it still counted as served. - let held = hold_thumbnails( - &model_of(&[10], &[10]), - &[10], - &[Some(dr_thumbs::ThumbSize::Grid)], - ); - - let served = already_served(&held, [40i64, 41].into_iter()); - assert!(served.is_empty(), "nothing in this window is already drawn"); - } - - #[test] - fn a_carried_thumbnail_does_not_satisfy_a_zoom_past_its_class() { - // Zooming past 256px reloads the window. The cell keeps showing the - // small thumbnail — no flash — but the large one must still be asked - // for, or the grid would stay soft until something else forced a fetch. - let held = hold_thumbnails( - &model_of(&[5], &[5]), - &[5], - &[Some(dr_thumbs::ThumbSize::Grid)], - ); - let mut served = already_served(&held, [5i64].into_iter()); - - assert!( - served.insert((5, dr_thumbs::ThumbSize::Large)), - "the large class is still unserved" - ); - assert!( - !served.insert((5, dr_thumbs::ThumbSize::Grid)), - "and the small one is not asked for twice" - ); - } - - #[test] - fn a_cell_whose_class_was_never_recorded_is_fetched_again() { - // Pixels with no class are pixels from before this bookkeeping existed - // — or from a row the drain never reached. Showing them is right; - // claiming they were served is not, because nothing knows at what size. - let held = hold_thumbnails(&model_of(&[5], &[5]), &[5], &[None]); - assert!(held.contains_key(&5), "still drawn"); - assert!(already_served(&held, [5i64].into_iter()).is_empty()); - } - - /// A thumbnail drain must be able to tell that the window it was started - /// for has been replaced. - /// - /// This is the whole of the black-grid fix, reduced to the comparison the - /// timer callback makes. `row` is an index into the window that requested - /// the fetch, so a drain that keeps writing after a reload paints - /// thumbnails onto unrelated photographs — and, worse, its `stop` lands on - /// the *current* batch's timer and leaves the new fetches undrained. - #[test] - fn a_reload_makes_an_in_flight_thumbnail_batch_stale() { - let ctl = LibraryController::new(crate::activity::ActivityLog::new()); - - // What `drain_thumbnails` captures when the batch is spawned. - let mine = ctl.generation.get(); - assert_eq!(ctl.generation.get(), mine, "its own batch is live"); - - // What `load_window` does just before swapping the model. - ctl.generation.set(ctl.generation.get().wrapping_add(1)); - - assert_ne!( - ctl.generation.get(), - mine, - "the batch must recognise itself as stale once the model is replaced" - ); - } - - /// Each load is distinct, so two reloads cannot alias back to a live batch. - #[test] - fn every_window_load_takes_a_fresh_generation() { - let ctl = LibraryController::new(crate::activity::ActivityLog::new()); - let seen: Vec = (0..4) - .map(|_| { - let g = ctl.generation.get(); - ctl.generation.set(g.wrapping_add(1)); - g - }) - .collect(); - - assert_eq!(seen, vec![0, 1, 2, 3]); - } - - /// The prefetch order is the walking order: closest first, next before - /// previous at every distance, and the window's edges cut it short. - #[test] - fn neighbours_are_listed_closest_first_working_outwards() { - let ctl = LibraryController::new(crate::activity::ActivityLog::new()); - *ctl.paths.borrow_mut() = ["a", "b", "c", "d", "e", "f"].map(String::from).to_vec(); - - assert_eq!(ctl.neighbours_of("c", 2), vec!["d", "b", "e", "a"]); - assert_eq!( - ctl.neighbours_of("c", 10), - vec!["d", "b", "e", "a", "f"], - "past an edge the other side keeps going" - ); - assert_eq!( - ctl.neighbours_of("a", 1), - vec!["b"], - "nothing before the first" - ); - assert_eq!( - ctl.neighbours_of("f", 1), - vec!["e"], - "nothing after the last" - ); - assert!( - ctl.neighbours_of("c", 0).is_empty(), - "zero fetches nothing ahead" - ); - assert!( - ctl.neighbours_of("z", 3).is_empty(), - "a photograph outside the window has no neighbours to fetch" - ); - } - - /// A controller holding a catalog of four named images. - fn with_catalog() -> Rc { - let ctl = LibraryController::new(crate::activity::ActivityLog::new()); - let catalog = Catalog::in_memory().expect("in-memory catalog"); - let c = catalog.connection(); - c.execute( - "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", - [], - ) - .expect("root"); - for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] { - c.execute( - "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)", - rusqlite::params![id, name], - ) - .expect("image"); - } - *ctl.catalog.borrow_mut() = Some(catalog); - ctl - } - - /// Just the names, for assertions that are about order rather than ids. - fn named(ctl: &Rc, images: &[dr_types::ImageId]) -> Vec { - ctl.selected_image_paths(images) - .into_iter() - .map(|(_, path)| path) - .collect() - } - - /// TRACES: FR-EXP-7 - #[test] - fn a_selection_resolves_to_paths_in_the_order_it_was_given() { - // `IN (…)` returns rows in whatever order suits SQLite, and an export's - // `{seq}` token counts through the batch — so a lookup that handed back - // the database's order would number the photographs in an order the - // user never saw. - let ctl = with_catalog(); - let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]); - assert_eq!(paths, vec!["c.CR2", "a.CR2"]); - } - - // --- what the status line says about a keyword (FR-CAT-5) ------------- - // - // Split out from the callback for the same reason `decide_drop` is: the - // sheet cannot be driven from a test, and this is the part that can - // actually mislead someone. - - /// TRACES: FR-CAT-5 - #[test] - fn a_partly_applied_keyword_reports_the_honest_count() { - // Nine of the twelve already had it. Claiming twelve is how a user - // learns that the counts are decorative. - assert_eq!( - keyword_summary("puffin", 3, 12, true), - "Added “puffin” to 3 of 12" - ); - } - - /// TRACES: FR-CAT-5 - #[test] - fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() { - assert_eq!( - keyword_summary("puffin", 0, 12, true), - "Every selected photograph already had “puffin”" - ); - assert_eq!( - keyword_summary("puffin", 0, 12, false), - "None of the selected photographs had “puffin”" - ); - } - - /// TRACES: FR-CAT-5 - #[test] - fn one_photograph_is_singular() { - // "Added to 1 photographs" is the kind of small wrongness that makes - // the rest of the interface look unfinished. - assert_eq!( - keyword_summary("puffin", 1, 1, true), - "Added “puffin” to 1 photograph" - ); - assert_eq!( - keyword_summary("puffin", 2, 2, true), - "Added “puffin” to 2 photographs" - ); - } - - /// TRACES: FR-CAT-5 - #[test] - fn removing_a_keyword_reads_as_removal() { - assert_eq!( - keyword_summary("blurry", 4, 4, false), - "Removed “blurry” from 4 photographs" - ); - } - - /// TRACES: FR-CAT-5 - #[test] - fn typing_a_word_with_nothing_selected_says_what_it_did_do() { - // It builds the vocabulary, which is a legitimate thing to do ahead of - // a shoot — so it must not report itself as having keyworded nothing. - assert_eq!( - keyword_summary("puffin", 0, 0, true), - "Added “puffin” to the keyword list" - ); - } - - /// TRACES: FR-EXP-7 - #[test] - fn a_selection_outside_the_loaded_window_still_resolves() { - // The property the catalog lookup exists for. Selection is by id and - // survives a scrub, so a selection made before scrolling routinely - // names photographs no row holds — and `paths`, the loaded window, is - // empty here precisely to prove nothing is being read from it. - let ctl = with_catalog(); - assert!(ctl.paths.borrow().is_empty()); - assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]); - } - - /// TRACES: FR-EXP-7 - #[test] - fn an_image_that_vanished_under_the_selection_is_skipped() { - // Deleted between the selection and the click. There is nothing to - // export and nothing to fix, so it drops out rather than becoming a - // failure row the user can do nothing about. - let ctl = with_catalog(); - let paths = named( - &ctl, - &[ - dr_types::ImageId(1), - dr_types::ImageId(99), - dr_types::ImageId(2), - ], - ); - assert_eq!(paths, vec!["a.CR2", "b.CR2"]); - } -} - -/// TRACES: FR-UI-2 -/// A filename as a caption: without the part that says how it is stored. -/// -/// A grid cell is about four words wide, and `.CR2` spends one of them saying -/// something the photographer already knows — every frame in a RAW library -/// ends the same way, so the extension distinguishes nothing while taking -/// room from the part that does. The name is elided under pressure, and it is -/// the *end* that goes, so an extension can push the digits that identify a -/// frame off the visible part of its own label. -/// -/// Display only. `LibraryCell::name` keeps the true filename and -/// `remote_path` the full path, because both are used to find the file again -/// and a stem is not a filename. -/// -/// **The case this is wrong for**, worth knowing before it is reported: a -/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells -/// captioned `IMG_1234`. They are still two rows with two thumbnails and two -/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one -/// photograph anyway — but the caption alone no longer separates them. -fn without_extension(name: &str) -> &str { - match name.rsplit_once('.') { - // The guard is for a leading dot: `.hidden` splits to an empty stem, - // and a dotfile's name starts with that dot rather than ending with an - // extension. A *trailing* dot needs no guard — `odd.` splits to `odd`, - // which is the better caption anyway. - Some((stem, _)) if !stem.is_empty() => stem, - _ => name, - } -} - -#[cfg(test)] -mod display_name_tests { - use super::without_extension; - - #[test] - fn an_extension_is_dropped_and_nothing_else_is() { - assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234"); - assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234"); - // Only the last one: a name can contain dots and they are part of it. - assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a"); - // Nothing to drop. - assert_eq!(without_extension("IMG_1234"), "IMG_1234"); - // A dotfile is not an extensionless name with an extension. - assert_eq!(without_extension(".hidden"), ".hidden"); - // A trailing dot is an empty extension, and dropping it is right. - assert_eq!(without_extension("odd."), "odd"); - assert_eq!(without_extension(""), ""); - } -} - -/// TRACES: FR-CAT-6 -/// Show the range the filter is actually using. -/// -/// Pushed back rather than left as the user typed it, because the filter may -/// have adjusted it — ends given backwards are swapped, and the closing day is -/// extended to include itself. A field showing something other than what is -/// being filtered on is worse than one showing nothing. -fn show_range(window: &AppWindow, ctl: &Rc) { - let f = ctl.filter.borrow(); - let text = |t: Option| -> slint::SharedString { - t.map(dr_types::format_date).unwrap_or_default().into() - }; - window.set_library_range_from(text(f.captured_from)); - // The stored end is the last second of the closing day; naming that day is - // what the user typed and what they should read back. - window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1))); -} diff --git a/ui/dr-ui/src/library_ui/controller.rs b/ui/dr-ui/src/library_ui/controller.rs new file mode 100644 index 0000000..8795d74 --- /dev/null +++ b/ui/dr-ui/src/library_ui/controller.rs @@ -0,0 +1,1045 @@ +//! Library state for the running window, and the window-sizing constants +//! that decide how much of the catalog is loaded. +//! +//! `LibraryController` is the piece every other module in this directory +//! borrows from, so its fields are `pub(super)` rather than reached through +//! accessor methods: the area of behaviour that touches one field — loading, +//! the timeline, ratings, keywords — owns its own file instead of all of it +//! living beside the fields it reads. See `docs/dev/code-health.md` CH-1. + +use std::cell::RefCell; +use std::path::PathBuf; +use std::rc::Rc; + +use dr_catalog::Catalog; +use dr_sync::Connection; +use dr_types::FormatFilter; + +use crate::library; + +/// A screenful before the grid has reported its geometry. +/// +/// Only used for the very first load; the grid replaces it with what it +/// actually shows as soon as it has laid out. +const INITIAL_VIEWPORT_CELLS: usize = 20; + +/// How many screenfuls the loaded window spans. +/// +/// One of them is on screen, so this buys `SCREENFULS - 1` screenfuls of +/// loaded-but-undrawn cells to scroll into, split unevenly by +/// [`window_start`]: a quarter of the window above the view, the rest below, +/// because a grid is read downward. +/// +/// Owned here rather than in the grid, alongside [`window_move`]'s rule for +/// how close the view may come to an edge before the window follows. Split +/// across the two — the grid loading three screenfuls, Rust holding on until +/// the view was three quarters of the way through them — the two numbers +/// disagreed by a quarter of a screenful, and that quarter was rows on screen +/// that no loaded cell covered. Which is the bottom row of the grid going +/// blank. +pub(super) const SCREENFULS: usize = 4; + +/// Never load fewer than this, whatever the viewport reports. +/// +/// A window collapsed to a sliver would otherwise load one or two cells and +/// re-query on every scroll tick. +pub(super) const MIN_WINDOW: usize = 24; + +/// Cell-size bounds for the grid's zoom. +/// +/// The lower end is where a thumbnail stops being recognisable; the upper is +/// where one screen holds so few that the grid stops being a grid. Past 256px +/// the large thumbnail class is fetched, so the top of this range is sharp +/// rather than upscaled. +pub(super) const MIN_CELL_SIZE: f32 = 90.0; + +pub(super) const MAX_CELL_SIZE: f32 = 420.0; + +/// TRACES: NFR-P5 +/// What the grid's whole-library readouts are an answer about. +/// +/// The timeline's bars, the filter chips' counts, the "on this device" count +/// and whether the scoped collection is pinned all describe the *library*, not +/// the window over it — and [`load_window`] recomputed every one of them each +/// time the window moved, which is several times per screenful of scrolling. +/// Together they are a `MIN`/`MAX`, a `GROUP BY`, two counts and, under a +/// collection, three more queries: about 8 ms of SQLite on the thread that is +/// trying to draw the frame, for four answers that scrolling cannot change. +/// +/// So they are recomputed when this changes and not otherwise. `total` is in +/// here as the change detector as much as anything else: it is already read on +/// every load for the scrollbar, and a scan landing, a delete or a restore all +/// move it. What it cannot see — a rating edited under an unchanged count — is +/// covered because the paths that do that refresh the chips themselves. +/// Not `Copy` since `RatingFilter` stopped being — it holds a set of people. +#[derive(Clone, PartialEq, Eq)] +pub(super) struct LibraryFacts { + pub(super) scope: Option, + pub(super) filter: library::RatingFilter, + pub(super) trash: bool, + pub(super) total: usize, +} + +/// Library state for the running window. +pub struct LibraryController { + /// Shared with [`crate::collections_ui`], which edits collections against + /// the same connection. `Rc` rather than a second `Catalog::open`: two + /// handles on one SQLite file would each hold their own WAL view, so a + /// collection edited through one would not be visible through the other + /// until it committed and the reader reopened. + pub(super) catalog: Rc>>, + /// Remote paths for the rows currently in the model, parallel to it. + pub(super) paths: RefCell>, + /// `oc:fileid` and file length per row, parallel to the model. + pub(super) file_ids: RefCell>>, + pub(super) sizes: RefCell>, + /// Catalog row ids and whether each still needs its EXIF read. + pub(super) image_ids: RefCell>, + pub(super) needs_metadata: RefCell>, + /// When each row in the model was taken, parallel to it. + /// + /// Kept so the timeline marker can be moved from the window the grid has + /// already read rather than from a query per scroll event — see + /// [`capture_time_at`]. + pub(super) captured_at: RefCell>>, + /// The size class of the pixels each row is currently showing, `None` for a + /// row still waiting. + /// + /// Parallel to the model, and the reason [`load_window`] can carry a + /// thumbnail across a reload without re-deciding whether it is sharp + /// enough: a cell holding the 256px class while the grid has since been + /// zoomed past that must keep showing what it has *and* still ask for the + /// large one. Recording the class the pixels came from is what tells those + /// two states apart — without it a carried thumbnail either blocks the + /// sharper fetch forever or is re-fetched on every scroll. + pub(super) thumb_class: RefCell>>, + /// Where in the catalog the current window starts. Scrubbing moves this. + pub(super) offset: RefCell, + /// The first visible ordinal, kept so returning from the develop view lands + /// where the user left rather than at the top. + /// + /// Recorded when the grid is left, not read back when it is re-entered: + /// `show-library` gates an `if` in the markup, so the whole grid subtree is + /// destroyed and rebuilt, and the rebuilt `Flickable` reports a scroll to + /// row 0 before anything can restore the old position. Capturing at the + /// moment of departure is the only value the reset cannot overwrite. + /// + /// Separate from `offset`, which is the *loaded window*'s start and sits a + /// quarter-window above the view. Restoring that as a viewport position + /// would land the user consistently short of where they were. + pub(super) resume_at: std::cell::Cell, + /// How many cells to load, derived from what the viewport can show. + /// + /// A fixed count is wrong in both directions: too small on a maximised 4K + /// window, wasteful on a narrow one. The grid measures itself and reports + /// its screenful; this is [`SCREENFULS`] of them. + pub(super) window: RefCell, + /// What the whole-library readouts on screen were last computed for, so a + /// window that merely moved does not recompute them. See [`LibraryFacts`]. + pub(super) library_facts: RefCell>, + /// How many cells the viewport shows at once, as the grid last reported. + /// + /// Kept beside `window` rather than divided back out of it, because + /// `MIN_WINDOW` can hold the window above what the screenful implies — and + /// a screenful guessed too small is exactly the mistake that leaves the + /// bottom row of the grid outside the loaded window. + pub(super) viewport_cells: std::cell::Cell, + /// Which rows already have a thumbnail fetch issued, so scrolling back + /// does not refetch what is already on screen. + /// Which images have a fetch in flight or already served, keyed on + /// `(image_id, size class)`. + /// + /// **Identity, not row index.** The grid is a window over the catalog, so + /// row 7 means a different photograph after every scroll — a set of + /// indices had to be cleared on each window move, which made every visible + /// cell look unrequested and re-issued the whole screenful on every + /// scroll. + /// + /// The size class is part of the key because the two resolutions are + /// fetched independently: holding the 256px one says nothing about whether + /// the large one has been asked for. + pub(super) requested: RefCell>, + /// Drains the worker that opens and verifies the catalog — see + /// [`spawn_catalog_open`]. Held for the same reason every other timer here + /// is: a `slint::Timer` stops when it is dropped, so a local one would + /// never fire. + pub(super) catalog_timer: RefCell>, + pub(super) scan_timer: RefCell>, + pub(super) thumb_timer: RefCell>, + /// The whole-library sweep, which outlives any one grid window. + pub(super) sweep_timer: RefCell>, + /// TRACES: FR-CAT-3 + /// Drains the whole-library thumbnail pass. Held for the same reason as + /// the others, and read as well as written: it is what stops a second + /// press of the button starting a pass alongside the first. + pub(super) thumb_sweep_timer: RefCell>, + /// Pushing shards and the catalog to the server. + pub(super) sync_timer: RefCell>, + /// Kept so a rescan can run without going back through the launch screen. + /// + /// A [`Connection`] rather than credentials beside an account: it is what + /// every worker needs, it is what `remote::connect` takes, and holding the + /// two halves separately is how they came to be threaded through fifteen + /// signatures in the wrong order. + pub(super) session: RefCell>, + /// Which collection narrows the grid, owned by [`crate::collections_ui`] + /// and read here. Shared rather than passed per call because a rescan, a + /// scrub and a drop all reload the window and must all honour it. + pub(super) scope: RefCell>, + /// TRACES: FR-UI-8 + /// How to open a photograph in develop. + /// + /// Held so a restored place that was left in develop can put the user back + /// there. The closure is the grid's own — `wire` is handed it and stashes a + /// clone — rather than a second route into the develop view, which would be + /// a second place for "persist the outgoing edit first" to be forgotten. + pub(super) open_image: RefCell>, + /// Where this library's position is written, once one is open. + /// + /// `None` before a library has been opened, and on the local-files path, + /// which has no library to have a position in. + pub(super) place_store: RefCell>, + /// What this device calls itself in a place it writes. + /// + /// [`dr_thumbs::ThumbStore::client_id`], so the place and the thumbnail + /// shards a device publishes carry the same name — which is what makes + /// `.darkroom-derived/` readable by a human wondering which machine put + /// what there. Informational only: see [`crate::place`] on why the + /// timestamp, not the device, decides an exchange. + pub(super) place_device: RefCell, + /// Coalesces the writes a flick would otherwise make one of per event. + pub(super) place_timer: RefCell>, + /// Drains the launch-time fetch of the server's place. Held for the reason + /// every other timer here is: a `slint::Timer` stops when it is dropped. + pub(super) place_fetch_timer: RefCell>, + /// Whether a place is being applied right now. + /// + /// A restore moves the scope, the filter and the viewport, and every one of + /// those is something [`note_place`] otherwise treats as the photographer + /// having moved. Without this a restore would close its own handover latch + /// and, worse, write the record back out under a fresh timestamp — so the + /// device that had just *adopted* a place from the tablet would claim to be + /// the newer of the two. + pub(super) applying_place: std::cell::Cell, + /// Whether the photographer has moved since this library was opened. + /// + /// The latch that decides whether a place arriving from another device may + /// still be applied. A handover is only welcome before the user has started + /// working: a grid that jumped somewhere else *while being scrolled*, because + /// a network round-trip finally landed, would be worse than never handing + /// over at all. + pub(super) place_untouched: std::cell::Cell, + /// TRACES: FR-CAT-15 + /// Whether the grid is listing the trash rather than the library. + /// + /// Separate from `scope` rather than a sentinel id in it, for the reason + /// [`crate::collections_ui::CollectionsController`] gives: the trash is not + /// a collection, and its query *inverts* the predicate every other view + /// applies. Folding that into a type meaning "a collection" would put the + /// inversion where nothing reading `scope` expects it. + /// + /// A `Cell` because it is a `bool` read inside callbacks that already hold + /// other borrows. + pub(super) viewing_trash: std::cell::Cell, + /// The selection's owner, so a rebuilt window can restore the `selected` + /// flags it just cleared. + /// + /// `Weak` because the two controllers outlive each other only through the + /// window, and an `Rc` both ways would leak both. Set once at wiring; a + /// `None` here means the grid simply draws nothing selected, which is the + /// old behaviour rather than a crash. + pub(super) coll_ctl: + RefCell>>, + /// Timeline view state: how far zoomed in, and around what instant. + /// + /// Zoom is a level rather than a span so the axis halves and doubles in + /// even steps; the centre is what keeps the thing you were looking at in + /// view as you zoom. + pub(super) timeline_zoom: RefCell, + pub(super) timeline_centre: RefCell>, + /// Pinch ratio accumulated since the last zoom step was taken. + /// + /// A pinch is continuous and zoom levels are discrete, so the ratio is + /// held until it reaches a doubling. Without it a slow spread would either + /// do nothing or, if each update were rounded, leap several levels. + pub(super) pinch_accum: RefCell, + /// The instant the grid is showing. + /// + /// `None` only before the first axis has been built: `refresh_timeline` + /// seeds it from wherever the view sits, so the marker reports a position + /// from the first frame rather than resting greyed at mid-track until the + /// user happens to scroll. A scroll or a scrub then sets it directly. + pub(super) current_bucket: RefCell>, + /// What the rating filter bar is narrowed to. + /// + /// Held here beside `scope` and for the same reason: a scrub, a rescan and + /// a drop all reload the window, and every one of them must honour it or + /// the filter silently lapses. + pub(super) filter: RefCell, + /// TRACES: FR-CAT-9 + /// Drains the outbox uploader. Held so that a repaint while one is + /// already running does not start a second against the same channel — + /// this handle *is* the "a drain is in flight" state. + pub(super) outbox_timer: RefCell>, + /// Whether the outbox might hold something, so the common case costs a + /// boolean rather than a directory walk. + /// + /// `refresh_offline` runs on scan progress as well as on a genuine + /// connectivity change, so the drain is *asked* far more often than there + /// is anything to send. Starts `true` so the first ask after launch does + /// walk — edits queued in a previous session are exactly the ones that + /// need sending, and nothing in memory knows about them. + pub(super) outbox_maybe_dirty: std::cell::Cell, + /// Drains the sidecar writer. Held so a second judgement replaces the + /// timer rather than leaving two draining the same finished channel. + pub(super) sidecar_timer: RefCell>, + /// TRACES: FR-CAT-13 + /// The same, for a batch of XMP writes or a reload. + pub(super) xmp_timer: RefCell>, + /// Which window load the model belongs to, bumped by [`load_window`]. + /// + /// A thumbnail worker addresses cells by *row index into the window that + /// asked for it*. A reload replaces the model, so once the generation has + /// moved on every row still in flight names a different photograph — + /// applying it paints thumbnails onto unrelated cells, or marks a cell + /// "no preview" for a fetch never attempted against it. Worse, a stale + /// drain reaching `Disconnected` would call `stop` on `thumb_timer`, which + /// by then holds the *current* batch's timer: the new worker then fetched + /// into a channel nobody drained and the grid stayed black until a scroll + /// forced another load. + /// + /// A `Cell` rather than a `RefCell`: it is read inside a timer callback + /// that already holds borrows of other fields, and a `u64` needs no borrow + /// tracking. + pub(super) generation: std::cell::Cell, + /// TRACES: FR-CAT-9 + /// Whether the server is reachable, inferred from what the workers saw. + /// + /// Lives on the controller rather than in a global because it is scoped to + /// one open library: signing into a different account starts a fresh + /// judgement, and carrying the old one over would report a server down + /// that was never contacted. + pub(super) reachability: RefCell, + /// TRACES: FR-PLAT-AND-2 | FR-CAT-9 + /// Why the library folder itself could not be opened, if it could not. + /// + /// Beside [`Self::reachability`] rather than inside it, because + /// `dr_sync::Reachability` models *the server*, and it is deliberately + /// unmoved by a refusal — a forbidden file must not report the network as + /// down (see its own tests). A revoked tree grant is a refusal, so folding + /// it in would either break that rule or need an exception carved through + /// it. + /// + /// Set only by a scan that failed at the root, and cleared only by one + /// that succeeded. Both states drive the same banner as being offline + /// does, because what the user can do is the same — carry on with what is + /// stored on the device — but the sentence under it is different, and so + /// is what will end it. + pub(super) root_lost: RefCell>, + /// TRACES: FR-NC-6a + /// Drains the pin downloader. Held so a second pin replaces the timer + /// rather than leaving two draining the same finished channel. + /// Coalesces the reloads a run of geometry changes would otherwise each + /// demand — see [`schedule_reload`]. + pub(super) geometry_timer: RefCell>, + /// The ordinal the view was on when the current run of geometry changes + /// began, so the settle returns to the photograph the user was looking at + /// rather than to wherever the re-flow left the viewport. + pub(super) pending_anchor: std::cell::Cell>, + pub(super) pin_timer: RefCell>, + /// TRACES: FR-NC-6a + /// Which collection the offline question is being asked about. + /// + /// Held rather than passed through the window because the prompt's three + /// answers arrive as three separate callbacks, and a dialogue that read its + /// subject back out of a string property would act on whatever the sidebar + /// had been rebuilt to say since. + pub(super) offline_target: std::cell::Cell>, + /// Narrow the grid to images whose original is stored locally. + /// + /// A `Cell` beside `filter` rather than a field inside it: the rating + /// filter compiles to a SQL predicate over `versions`, while this one is a + /// predicate over the cache, and folding two different joins into one type + /// would put the cache schema inside a rating concept. + pub(super) local_only: std::cell::Cell, + /// TRACES: FR-NC-6a + /// The ceiling on passively cached originals, from the settings page. + /// + /// Held here rather than read from `SettingsStore` at each use because the + /// workers that need it run on threads with no config access — the same + /// reason [`library::CacheContext`] takes it as a field. A `Cell` because + /// the settings page can move it while a library is open, and the next + /// fetch must use the new figure rather than one captured at open. + pub(super) cache_budget: std::cell::Cell, + /// TRACES: FR-NC-6a + /// Whether opening an image in develop keeps its original on disk. + /// + /// Held beside the budget and for the same reason. Distinct from a zero + /// budget: this switches the passive population off entirely, while a + /// small budget still keeps a working set. A metered or small-disk device + /// wants the first. + pub(super) keep_opened: std::cell::Cell, + /// TRACES: FR-NC-6a | FR-UI-4 + /// How many photographs each side of the open one are fetched ahead. + /// Mirrors `CacheSettings::fetch_ahead`, held beside the flag that + /// gates it. + pub(super) fetch_ahead: std::cell::Cell, + /// TRACES: FR-CAT-13 | NFR-R4 + /// Whether judgements and keywords also go to the `.xmp` beside the + /// original. Mirrors `LibrarySettings::write_xmp_sidecars`. + pub(super) write_xmp: std::cell::Cell, + /// TRACES: FR-CAT-6 + /// How many bars the capture-time axis is cut into, from the settings + /// page. + /// + /// Held here beside the cache budget and for the same reason: the axis is + /// redrawn from a dozen callbacks that have no config access, and reading + /// a JSON file on every scroll to answer "how many bars" would be the + /// wrong shape of question. A `Cell` because the page can change it while + /// a library is open. + pub(super) timeline_bars: std::cell::Cell, + /// TRACES: FR-CULL-8 + /// Which face pipeline this device indexes with, from the settings page. + /// + /// Held here for the same reason as the two above: the derived sync runs + /// from a sweep's completion and from the Sync button, neither of which + /// has the settings in reach, and the shards it exports and adopts are + /// keyed on this id. A `RefCell` of the id rather than the enum so that + /// nothing here has to know how a detector becomes a model id. + pub(super) face_model_id: RefCell, + /// Where every worker this module starts reports what it is doing. + /// + /// Held on the controller rather than passed to each function because the + /// jobs are started from a dozen callbacks — a scroll, a rescan, a pin, a + /// reconnect — and threading a second argument through all of them would + /// say nothing except that they all report progress. + pub(super) activity: Rc, +} + +impl LibraryController { + pub fn new(activity: Rc) -> Rc { + Rc::new(Self { + activity, + catalog: Rc::new(RefCell::new(None)), + paths: RefCell::new(Vec::new()), + file_ids: RefCell::new(Vec::new()), + sizes: RefCell::new(Vec::new()), + image_ids: RefCell::new(Vec::new()), + needs_metadata: RefCell::new(Vec::new()), + captured_at: RefCell::new(Vec::new()), + thumb_class: RefCell::new(Vec::new()), + offset: RefCell::new(0), + resume_at: std::cell::Cell::new(0), + window: RefCell::new((INITIAL_VIEWPORT_CELLS * SCREENFULS).max(MIN_WINDOW)), + viewport_cells: std::cell::Cell::new(INITIAL_VIEWPORT_CELLS), + library_facts: RefCell::new(None), + requested: RefCell::new(Default::default()), + catalog_timer: RefCell::new(None), + scan_timer: RefCell::new(None), + thumb_timer: RefCell::new(None), + sweep_timer: RefCell::new(None), + thumb_sweep_timer: RefCell::new(None), + sync_timer: RefCell::new(None), + session: RefCell::new(None), + scope: RefCell::new(None), + open_image: RefCell::new(None), + place_store: RefCell::new(None), + place_device: RefCell::new(String::new()), + place_timer: RefCell::new(None), + place_fetch_timer: RefCell::new(None), + place_untouched: std::cell::Cell::new(true), + applying_place: std::cell::Cell::new(false), + viewing_trash: std::cell::Cell::new(false), + coll_ctl: RefCell::new(None), + timeline_zoom: RefCell::new(0), + timeline_centre: RefCell::new(None), + pinch_accum: RefCell::new(1.0), + current_bucket: RefCell::new(None), + filter: RefCell::new(library::RatingFilter::default()), + sidecar_timer: RefCell::new(None), + xmp_timer: RefCell::new(None), + generation: std::cell::Cell::new(0), + reachability: RefCell::new(dr_sync::Reachability::new()), + root_lost: RefCell::new(None), + outbox_timer: RefCell::new(None), + outbox_maybe_dirty: std::cell::Cell::new(true), + geometry_timer: RefCell::new(None), + pending_anchor: std::cell::Cell::new(None), + pin_timer: RefCell::new(None), + offline_target: std::cell::Cell::new(None), + local_only: std::cell::Cell::new(false), + // The catalog's own floor until the settings page reports what the + // user has stored, which it does at startup before any fetch. + cache_budget: std::cell::Cell::new(dr_catalog::Budget::default()), + keep_opened: std::cell::Cell::new( + dr_types::CacheSettings::default().keep_opened_originals, + ), + fetch_ahead: std::cell::Cell::new(dr_types::CacheSettings::default().fetch_ahead), + write_xmp: std::cell::Cell::new( + dr_types::LibrarySettings::default().write_xmp_sidecars, + ), + timeline_bars: std::cell::Cell::new(dr_types::LibrarySettings::default().timeline_bars), + face_model_id: RefCell::new( + crate::inference::model_id(dr_types::FaceDetector::default()).to_string(), + ), + }) + } + + /// TRACES: FR-CULL-8 + /// Which face pipeline the next sync exports and adopts under. + /// + /// Set on every settings edit like the bars above. A sync already running + /// keeps the id it started with, which is right: its shards are half + /// written under that one. + pub fn set_face_model_id(&self, id: &str) { + if *self.face_model_id.borrow() != id { + *self.face_model_id.borrow_mut() = id.to_string(); + } + } + + /// TRACES: FR-CAT-6 + /// How many bars the capture-time axis is cut into. + /// + /// Answers whether it changed, so the caller can leave the grid alone when + /// it did not: this is set on every settings edit, and reloading the + /// window because a user changed their export folder would be a visible + /// stutter for nothing. + pub fn set_timeline_bars(&self, bars: u32) -> bool { + let bars = bars.max(1); + self.timeline_bars.replace(bars) != bars + } + + /// TRACES: FR-NC-6a + /// Whether a develop open should keep the original it downloads. + /// + /// Turning it off leaves what is already cached alone: those bytes are + /// paid for, and deleting them would make the switch destructive when it + /// only means "stop adding to this". + pub fn set_keep_opened_originals(&self, keep: bool) { + self.keep_opened.set(keep); + } + + /// TRACES: FR-NC-6a | FR-UI-4 + /// How far along the roll, each side, the next open fetches ahead. + pub fn set_fetch_ahead(&self, depth: u32) { + self.fetch_ahead.set(depth); + } + + pub fn fetch_ahead(&self) -> u32 { + self.fetch_ahead.get() + } + + /// TRACES: FR-CAT-13 | NFR-R4 + pub fn set_write_xmp_sidecars(&self, on: bool) { + self.write_xmp.set(on); + } + + /// TRACES: FR-NC-6a + /// Set the ceiling on passively cached originals, and apply it now. + /// + /// Applied immediately rather than only to later downloads: a user who has + /// just lowered the limit expects the space back, and a budget that took + /// effect only on the next fetch would leave the cache over its stated + /// ceiling for as long as they browsed nothing new. + pub fn set_cache_budget(&self, bytes: Option) { + let budget = match bytes { + Some(n) => dr_catalog::Budget::bytes(n), + None => dr_catalog::Budget::unlimited(), + }; + self.cache_budget.set(budget); + + // Best-effort: no library open means no cache to trim, and a failure + // here must not stop the setting from being stored — the ceiling still + // applies to every fetch from now on. + let Some(dir) = self.cache_dir() else { return }; + let borrow = self.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + match dr_catalog::Cache::open(&dir, budget) + .and_then(|cache| cache.enforce(catalog.connection())) + { + Ok(0) => {} + Ok(n) => log::info!("cache budget changed: evicted {n} original(s)"), + Err(e) => log::warn!("applying the new cache budget: {e}"), + } + } + + /// TRACES: FR-CAT-9 | FR-PLAT-AND-2 + /// Whether the library cannot be reached, for either of the two reasons. + /// + /// One answer rather than two because every caller asks it for the same + /// purpose: to decide whether starting a transfer is worth attempting. + /// A revoked grant fails that question exactly as a dead network does, and + /// a sync started against it would spend its retries proving it. + pub fn is_offline(&self) -> bool { + self.reachability.borrow().is_offline() || self.root_lost.borrow().is_some() + } + + /// Whether the grid is narrowed to locally-stored originals. + pub fn local_only(&self) -> bool { + self.local_only.get() + } + + /// TRACES: FR-NC-6a + /// The catalog id for a remote path currently in the grid. + /// + /// The cache is keyed on `ImageId` because that is what survives a + /// server-side rename (FR-NC-5), while the develop callback carries only a + /// path. `paths` and `image_ids` are parallel to the model, so this is the + /// join between the two. + pub fn image_id_for_path(&self, path: &str) -> Option { + let index = self.paths.borrow().iter().position(|p| p == path)?; + self.image_ids + .borrow() + .get(index) + .map(|id| dr_types::ImageId(*id as u64)) + } + + /// TRACES: FR-NC-6a | FR-UI-4 + /// The photographs within `depth` of `path` on the roll, closest first + /// and working outwards: next, previous, next-but-one, previous-but-one… + /// + /// That order is the point. The prefetcher serves the list one at a time + /// and drops the rest the moment the user moves, so whatever depth is + /// set, the frame most likely to be stepped to is the first thing + /// fetched — and the step most likely is one forward, which is why next + /// leads previous at every distance. + /// + /// In the roll's order, which is the loaded window's — the same rows a + /// roll pick names — so what gets fetched ahead is exactly what a step + /// lands on, filter and scope included. The window's edges cut the list + /// short; a photograph not in the window has no neighbours, and a + /// prefetch of nothing is the right answer for it. + pub fn neighbours_of(&self, path: &str, depth: u32) -> Vec { + let paths = self.paths.borrow(); + let Some(at) = paths.iter().position(|p| p == path) else { + return Vec::new(); + }; + (1..=depth as usize) + .flat_map(|d| [at.checked_add(d), at.checked_sub(d)]) + .filter_map(|i| paths.get(i?).cloned()) + .collect() + } + + /// TRACES: FR-NC-8 | FR-DEV-6 + /// The default version's uuid for an image, by its remote path. + /// + /// Taken from the catalog rather than generated, and rather than read off + /// whatever the sidecar happens to contain. The uuid is the identity a + /// cross-device merge keys on (FR-NC-8): a develop session that invented + /// one would write a *second* version beside the one the cull is stored + /// in, and the photograph would arrive on the other device holding two + /// edits that never merge. + /// + /// Creates the version if the image has none, by the same route a rating + /// does — see `dr_catalog::rating::default_version_id` for why an image + /// can legitimately arrive without one. + pub fn version_uuid_for_path(&self, path: &str) -> Option { + let image = self.image_id_for_path(path)?; + let borrow = self.catalog.borrow(); + let catalog = borrow.as_ref()?; + let conn = catalog.connection(); + + let id = match dr_catalog::rating::default_version_id(conn, image) { + Ok(id) => id, + Err(e) => { + log::debug!("no version for {path}: {e}"); + return None; + } + }; + conn.query_row("SELECT uuid FROM versions WHERE id = ?1", [id], |r| { + r.get(0) + }) + .ok() + } + + /// TRACES: FR-CAT-9 | FR-NC-10 + /// Where cached sidecars and the upload outbox live for the open library. + /// + /// Beside the catalog and the originals cache, for the same reason those + /// two sit together: all three are per-account and are discarded together. + /// A separate directory rather than a subfolder of `originals` because the + /// two have opposite lifetimes — originals are evicted under a budget + /// (FR-NC-6a), and a queued edit must never be. + pub fn sidecar_cache_dir(&self) -> Option { + let borrow = self.session.borrow(); + let (conn, _) = borrow.as_ref()?; + library::catalog_path(&conn.account) + .parent() + .map(|p| p.join("sidecars")) + } + + /// TRACES: FR-NC-6a + /// Where cached originals live for the open library. + /// + /// Beside the catalog rather than under a system cache directory: the two + /// are per-account and are discarded together, and a cached original whose + /// catalog row is gone is unreachable anyway. + pub fn cache_dir(&self) -> Option { + let borrow = self.session.borrow(); + let (conn, _) = borrow.as_ref()?; + library::catalog_path(&conn.account) + .parent() + .map(|p| p.join("originals")) + } + + /// Open the originals cache for the current library. + /// + /// Used by pinning, which writes intent against the catalog directly + /// rather than going through a fetch. + pub fn cache(&self) -> Option { + let dir = self.cache_dir()?; + match dr_catalog::Cache::open(&dir, self.cache_budget.get()) { + Ok(c) => Some(c), + Err(e) => { + // Not fatal: without a cache every open is a download, which + // is exactly the behaviour that existed before this. + log::warn!("originals cache unavailable: {e}"); + None + } + } + } + + /// TRACES: FR-NC-6a + /// Everything a full fetch needs to read and write the cache. + /// + /// Assembled here because the develop callback holds only a path, while + /// the cache is keyed on `ImageId` and lives beside a catalog whose + /// location comes from the session. `None` where any part is missing — a + /// grid row that has scrolled away, or no library open — and the fetch + /// then simply goes to the network. + pub fn cache_context(&self, path: &str) -> Option { + self.cache_context_for(self.image_id_for_path(path)?) + } + + /// TRACES: FR-EXP-7 | FR-NC-6a + /// The same, for an image named by id rather than by path. + /// + /// A batch export needs this one: its selection is by catalog id and may + /// include photographs that have scrolled out of the loaded window, where + /// [`Self::image_id_for_path`] has nothing to match against. Going through + /// the path would quietly hand those images no cache at all, and a batch of + /// three hundred would re-download every one of them. + pub fn cache_context_for(&self, image: dr_types::ImageId) -> Option { + let borrow = self.session.borrow(); + let (conn, _) = borrow.as_ref()?; + let catalog_path = library::catalog_path(&conn.account); + let dir = catalog_path.parent()?.join("originals"); + drop(borrow); + + Some(library::CacheContext { + dir, + catalog_path, + image, + // The user's ceiling, not the catalog's floor: read at each fetch + // so a budget changed mid-session takes effect on the next one. + budget: self.cache_budget.get(), + // Gates the *write* only. Reading stays enabled either way: bytes + // already on disk were paid for, and refusing to use them because + // the user has since stopped adding new ones would re-download + // images that are sitting right there — and would make a pinned + // collection unopenable offline. + store: self.keep_opened.get(), + }) + } + + /// Toggle the local-only filter, resetting the window. + /// + /// The offset is cleared for the same reason a scope change clears it: the + /// filter changes which images exist as far as the grid is concerned, so a + /// position counted against the old set names a different photograph. + pub fn set_local_only(&self, on: bool) { + self.local_only.set(on); + *self.offset.borrow_mut() = 0; + self.requested.borrow_mut().clear(); + } + + /// The catalog handle, for [`crate::collections_ui`] to edit through. + pub fn catalog(&self) -> Rc>> { + self.catalog.clone() + } + + /// The open library's connection. + /// + /// Needed by the trash, whose move and delete go to the same account the + /// scan and thumbnail workers use. `None` before a library is opened. + pub fn session(&self) -> Option { + self.session.borrow().as_ref().map(|(c, _)| c.clone()) + } + + /// Catalog ids of the rows currently in the model, in model order. + /// + /// Selection is keyed on these rather than on row indices: the grid is a + /// window over the catalog and a scrub replaces every row, so an index + /// would silently come to mean a different photograph. + pub fn visible_ids(&self) -> Vec { + self.image_ids + .borrow() + .iter() + .map(|id| dr_types::ImageId(*id as u64)) + .collect() + } + + /// TRACES: FR-CAT-5 + /// Catalog ids of grid rows `first..=last`, loaded or not. + /// + /// The counterpart to [`Self::visible_ids`], and the reason both exist: a + /// shift-click names a run by its two ends, and everything between them is + /// usually off screen. Answered from the catalog, through the same scope, + /// filter and ordering this controller reads its window with — so the run + /// is the run the user can see themselves selecting, continued past the + /// edge of what has been loaded. + /// + /// Empty before a library is open, and empty if the query fails: a + /// selection gesture is not worth an error dialog, and the caller keeps + /// what was already selected. + pub fn ids_in_span(&self, first: usize, last: usize) -> Vec { + let borrow = self.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return Vec::new(); + }; + library::read_ids_span( + catalog, + *self.scope.borrow(), + &self.filter.borrow(), + self.viewing_trash.get(), + first, + last, + ) + .unwrap_or_else(|e| { + log::warn!("reading the selected range: {e}"); + Vec::new() + }) + } + + /// TRACES: FR-EXP-7 + /// Remote paths for a set of selected images, in the order they were given. + /// + /// Answered from the catalog rather than from `paths`, which is only the + /// loaded window. Selection is by id precisely so that it survives a scrub + /// (see [`crate::collections_ui`]), so a selection made before scrolling + /// routinely names photographs no row currently holds — and an export that + /// silently dropped those would be worse than one that refused. + /// + /// An id the catalog has never heard of is skipped rather than reported: it + /// can only mean the image was deleted between the selection and the click, + /// and there is nothing to export and nothing to fix. + /// + /// Each path comes back beside the id it belongs to, because that skipping + /// means the ids that come out are not the ids that went in — and the caller + /// still has to find each photograph's cache, which is keyed on the id. + pub fn selected_image_paths( + &self, + images: &[dr_types::ImageId], + ) -> Vec<(dr_types::ImageId, String)> { + if images.is_empty() { + return Vec::new(); + } + + let borrow = self.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return Vec::new(); + }; + + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!("SELECT id, source_ref FROM images WHERE id IN ({placeholders})"); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let Ok(mut stmt) = catalog.connection().prepare(&sql) else { + return Vec::new(); + }; + let Ok(rows) = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok((r.get::<_, i64>(0)?, r.get::<_, String>(1)?)) + }) else { + return Vec::new(); + }; + + // `IN` returns rows in whatever order suits SQLite, and the export's + // `{seq}` token counts through the batch — so the answer is put back + // into the order the caller asked in rather than the one it arrived in. + let found: std::collections::HashMap = rows.flatten().collect(); + images + .iter() + .filter_map(|id| found.get(&(id.0 as i64)).map(|path| (*id, path.clone()))) + .collect() + } + + /// TRACES: FR-EXP-7 + /// The selection, addressed the way an export worker can fetch it. + /// + /// Assembled here because a worker thread can reach neither the catalog nor + /// the session, and both are needed to say where a cached original lives. + pub fn export_sources(&self, images: &[dr_types::ImageId]) -> Vec { + self.selected_image_paths(images) + .into_iter() + .map(|(id, path)| crate::export::Source::Library { + path, + cache: self.cache_context_for(id), + }) + .collect() + } + + /// The open library's connection, for a full-file fetch. + /// + /// The grid's paths are remote, so opening an image means fetching it, and + /// that goes through the same account the thumbnail workers use. + pub fn credentials(&self) -> Option { + self.session.borrow().as_ref().map(|(c, _)| c.clone()) + } + + /// Narrow the grid to a collection, or to the whole library with `None`. + pub fn set_scope(&self, scope: Option) { + *self.scope.borrow_mut() = scope; + // Selecting a collection is leaving the trash. Without this, picking a + // collection while the trash was open would keep listing trashed images + // under that collection's name. + self.viewing_trash.set(false); + // A new scope is a different set of images, so the old window's + // position and its issued fetches mean nothing. + *self.offset.borrow_mut() = 0; + self.requested.borrow_mut().clear(); + } + + /// TRACES: FR-CAT-15 + /// Show the trash instead of the library. + /// + /// Clears `scope` as well: the trash is not inside a collection, and leaving + /// a stale scope set would narrow it to one on the way back out. + pub fn set_viewing_trash(&self, viewing: bool) { + self.viewing_trash.set(viewing); + if viewing { + *self.scope.borrow_mut() = None; + } + *self.offset.borrow_mut() = 0; + self.requested.borrow_mut().clear(); + } +} + +/// TRACES: FR-UI-8 +/// The grid's own route into the develop view, as something that can be held. +/// +/// Named because it is threaded through the controller as well as passed to +/// `wire`, and a `RefCell>>` written out twice is a +/// signature nobody reads. +pub(super) type OpenImage = Rc; + +pub(super) fn stop(slot: &RefCell>) { + if let Some(t) = slot.borrow().as_ref() { + t.stop(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The prefetch order is the walking order: closest first, next before + /// previous at every distance, and the window's edges cut it short. + #[test] + fn neighbours_are_listed_closest_first_working_outwards() { + let ctl = LibraryController::new(crate::activity::ActivityLog::new()); + *ctl.paths.borrow_mut() = ["a", "b", "c", "d", "e", "f"].map(String::from).to_vec(); + + assert_eq!(ctl.neighbours_of("c", 2), vec!["d", "b", "e", "a"]); + assert_eq!( + ctl.neighbours_of("c", 10), + vec!["d", "b", "e", "a", "f"], + "past an edge the other side keeps going" + ); + assert_eq!( + ctl.neighbours_of("a", 1), + vec!["b"], + "nothing before the first" + ); + assert_eq!( + ctl.neighbours_of("f", 1), + vec!["e"], + "nothing after the last" + ); + assert!( + ctl.neighbours_of("c", 0).is_empty(), + "zero fetches nothing ahead" + ); + assert!( + ctl.neighbours_of("z", 3).is_empty(), + "a photograph outside the window has no neighbours to fetch" + ); + } + + /// A controller holding a catalog of four named images. + fn with_catalog() -> Rc { + let ctl = LibraryController::new(crate::activity::ActivityLog::new()); + let catalog = Catalog::in_memory().expect("in-memory catalog"); + let c = catalog.connection(); + c.execute( + "INSERT INTO roots(id, kind, label) VALUES (1, 'remote', 'lib')", + [], + ) + .expect("root"); + for (id, name) in [(1i64, "a.CR2"), (2, "b.CR2"), (3, "c.CR2"), (4, "d.CR2")] { + c.execute( + "INSERT INTO images(id, root_id, source_ref, added_at) VALUES (?1, 1, ?2, 0)", + rusqlite::params![id, name], + ) + .expect("image"); + } + *ctl.catalog.borrow_mut() = Some(catalog); + ctl + } + + /// Just the names, for assertions that are about order rather than ids. + fn named(ctl: &Rc, images: &[dr_types::ImageId]) -> Vec { + ctl.selected_image_paths(images) + .into_iter() + .map(|(_, path)| path) + .collect() + } + + /// TRACES: FR-EXP-7 + #[test] + fn a_selection_resolves_to_paths_in_the_order_it_was_given() { + // `IN (…)` returns rows in whatever order suits SQLite, and an export's + // `{seq}` token counts through the batch — so a lookup that handed back + // the database's order would number the photographs in an order the + // user never saw. + let ctl = with_catalog(); + let paths = named(&ctl, &[dr_types::ImageId(3), dr_types::ImageId(1)]); + assert_eq!(paths, vec!["c.CR2", "a.CR2"]); + } + + /// TRACES: FR-EXP-7 + #[test] + fn a_selection_outside_the_loaded_window_still_resolves() { + // The property the catalog lookup exists for. Selection is by id and + // survives a scrub, so a selection made before scrolling routinely + // names photographs no row holds — and `paths`, the loaded window, is + // empty here precisely to prove nothing is being read from it. + let ctl = with_catalog(); + assert!(ctl.paths.borrow().is_empty()); + assert_eq!(named(&ctl, &[dr_types::ImageId(4)]), ["d.CR2"]); + } + + /// TRACES: FR-EXP-7 + #[test] + fn an_image_that_vanished_under_the_selection_is_skipped() { + // Deleted between the selection and the click. There is nothing to + // export and nothing to fix, so it drops out rather than becoming a + // failure row the user can do nothing about. + let ctl = with_catalog(); + let paths = named( + &ctl, + &[ + dr_types::ImageId(1), + dr_types::ImageId(99), + dr_types::ImageId(2), + ], + ); + assert_eq!(paths, vec!["a.CR2", "b.CR2"]); + } +} diff --git a/ui/dr-ui/src/library_ui/filter_bar.rs b/ui/dr-ui/src/library_ui/filter_bar.rs new file mode 100644 index 0000000..dfd02b2 --- /dev/null +++ b/ui/dr-ui/src/library_ui/filter_bar.rs @@ -0,0 +1,738 @@ +//! The filter bar: ratings, flags, people, dates and the offline scope, and +//! reloading the grid once one of them changes. +//! +//! Each callback narrows or widens [`LibraryController`]'s +//! `RatingFilter` and reloads the window through [`refilter`], which is the +//! one place the offset reset and the place record both live — narrowing a +//! filter is choosing a different set of images, so a position counted +//! against the old set means nothing. See `docs/dev/code-health.md` CH-1. + +use std::rc::Rc; + +use slint::ComponentHandle; + +use crate::library; +use crate::{AppWindow, PersonChip, View}; + +use super::controller::LibraryController; +use super::offline::{ + close_offline_prompt, keep_collection_offline, open_offline_prompt, release_collection_offline, +}; +use super::open::start_rescan; +use super::timeline::{catalog_span, dragged_range, month_name, note_place, zoomed_span}; +use super::window::load_window; + +/// --- the filter bar --------------------------------------------------- +/// +/// Each of these narrows what the grid *queries*, so all three reset the +/// scroll offset: the window's position was an ordinal into a different +/// set of images and means nothing once the set changes. +pub(super) fn wire_filter_ratings_and_people(window: &AppWindow, ctl: &Rc) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_min_rating_changed(move |n| { + let Some(w) = weak.upgrade() else { return }; + ctl.filter.borrow_mut().min_rating = n.clamp(0, 5) as u8; + // Stars and "unrated" are contradictory terms — asking for four + // stars *and* nothing judged matches nothing at all, which reads + // as a broken filter rather than an impossible question. + if n > 0 { + ctl.filter.borrow_mut().unjudged = false; + } + w.set_library_filter_min_rating(n.clamp(0, 5)); + w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_unjudged_changed(move |on| { + let Some(w) = weak.upgrade() else { return }; + { + let mut f = ctl.filter.borrow_mut(); + f.unjudged = on; + // See above: the two cannot both hold. + if on { + f.min_rating = 0; + f.flag = None; + } + } + w.set_library_filter_unjudged(on); + if on { + w.set_library_filter_min_rating(0); + w.set_library_filter_flag(0); + } + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_identity_show_photos(move |id, add| { + let Some(w) = weak.upgrade() else { return }; + let person = dr_catalog::faces::PersonId(id.max(0) as u64); + + { + let mut f = ctl.filter.borrow_mut(); + if !add { + f.people.clear(); + } + // Adding the same person twice is a no-op rather than a + // duplicate chip — and, under `All`, rather than a filter that + // silently asks for them twice and matches the same pictures. + if !f.people.contains(&person.0) { + f.people.push(person.0); + } + } + push_people_chips(&w, &ctl); + + // Leaving the Identity screen for the grid is the whole point of + // the button: the answer to "who is this" is a set of photographs, + // and they are shown where photographs are shown. + w.set_active_view(View::Library); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_person_cleared(move |id| { + let Some(w) = weak.upgrade() else { return }; + ctl.filter + .borrow_mut() + .people + .retain(|p| *p != id.max(0) as u64); + drop_eyes_open_without_people(&w, &ctl); + push_people_chips(&w, &ctl); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_people_listed(move || { + let Some(w) = weak.upgrade() else { return }; + push_people_roster(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_person_toggled(move |id| { + let Some(w) = weak.upgrade() else { return }; + let person = id.max(0) as u64; + { + let mut f = ctl.filter.borrow_mut(); + if let Some(at) = f.people.iter().position(|p| *p == person) { + f.people.remove(at); + } else { + f.people.push(person); + } + } + drop_eyes_open_without_people(&w, &ctl); + push_people_chips(&w, &ctl); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_eyes_open_toggled(move || { + let Some(w) = weak.upgrade() else { return }; + let on = { + let mut f = ctl.filter.borrow_mut(); + f.eyes_open = !f.eyes_open; + f.eyes_open + }; + w.set_library_filter_eyes_open(on); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_people_mode_toggled(move || { + let Some(w) = weak.upgrade() else { return }; + { + let mut f = ctl.filter.borrow_mut(); + f.people_mode = match f.people_mode { + library::PeopleMode::Any => library::PeopleMode::All, + library::PeopleMode::All => library::PeopleMode::Any, + }; + } + push_people_chips(&w, &ctl); + refilter(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_filter_flag_changed(move |f| { + let Some(w) = weak.upgrade() else { return }; + { + let mut filter = ctl.filter.borrow_mut(); + filter.flag = match f { + 1 => Some(dr_types::FlagState::Pick), + 2 => Some(dr_types::FlagState::Reject), + _ => None, + }; + if f > 0 { + filter.unjudged = false; + } + } + w.set_library_filter_flag(f); + w.set_library_filter_unjudged(ctl.filter.borrow().unjudged); + refilter(&w, &ctl); + }); + } +} + +/// The date-range filter: turned on from the timeline's own visible span, +/// dragged along the axis, or typed as two dates. +pub(super) fn wire_filter_dates(window: &AppWindow, ctl: &Rc) { + // Turn the range on, over the period the histogram is showing. + // + // The range is taken from the timeline rather than typed into two date + // fields: finding the fortnight is what the histogram is *for*, and having + // found it the user should not have to read the dates off the axis and key + // them back in. + // + // A starting point rather than the answer. What this puts on the axis is a + // band with two draggable ends, and dragging them is how the range is + // actually stated — on a phone it is the only way that does not need a + // keyboard. Seeding it to the whole visible span means the first drag + // narrows from what the user is already looking at. + // + // Toggling off clears both ends rather than remembering them — a range you + // cannot see the extent of is a filter that looks like an empty library. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_toggle_date_range(move || { + let Some(w) = weak.upgrade() else { return }; + // Whether the *controls* are showing, which is not the same as + // whether a range is set — and reading it from the filter was why + // pressing this did nothing. The fields are how a range gets set, + // so requiring one before they appear is a door locked from the + // inside. + let on = !w.get_library_range_active(); + if on { + // Seeded from the span the axis is drawn over, which is what + // the user is looking at when they ask for "this range". + // + // Only a seed. If there is no catalog yet, or nothing in it + // carries a capture date — dates are read from EXIF as + // thumbnails load, so a freshly opened library has none — the + // fields simply open empty and wait to be typed into. This + // used to `return` in both cases, which meant the button + // silently did nothing on exactly the libraries where a person + // is most likely to be looking for a date. + let seeded = { + let borrow = ctl.catalog.borrow(); + borrow + .as_ref() + .and_then(|catalog| catalog_span(catalog, &ctl)) + .map(|span| { + zoomed_span( + span, + *ctl.timeline_zoom.borrow(), + *ctl.timeline_centre.borrow(), + ) + }) + }; + if let Some(span) = seeded { + // Through the same days-inclusive conversion a drag uses, + // so a seeded range and a dragged one are the same kind of + // thing — and so `show_range` reads this one back out as + // the two days it covers. + let (from, to) = dragged_range(span, 0.0, 1.0); + let mut f = ctl.filter.borrow_mut(); + f.captured_from = Some(from); + f.captured_to = Some(to); + } + } else { + let mut f = ctl.filter.borrow_mut(); + f.captured_from = None; + f.captured_to = None; + } + w.set_library_range_active(on); + show_range(&w, &ctl); + refilter(&w, &ctl); + }); + } + + // TRACES: FR-CAT-6 | FR-UI-2 + // The ends of the range, dragged along the axis they were chosen from. + // + // This is the control the two typed fields were standing in for. On a + // phone they could not do the job: `YYYY-MM-DD` keyed into a 108px field + // behind a soft keyboard, to name a day already drawn on the axis a thumb + // away. Nor was the chip a way round them — its span comes from the + // timeline's zoom and pan, and those are a wheel and a middle button, + // neither of which a touch screen has. So on Android the date range was a + // filter that could be turned on and not aimed. + // + // The span is recomputed here rather than remembered from the last draw. + // It is the same expression `refresh_timeline` uses, over the same + // date-lifted extent, so a handle dropped at a fraction of the track names + // the instant that fraction was drawn at. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_range_changed(move |a, b| { + let Some(w) = weak.upgrade() else { return }; + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(full) = catalog_span(catalog, &ctl) else { + return; + }; + let span = zoomed_span( + full, + *ctl.timeline_zoom.borrow(), + *ctl.timeline_centre.borrow(), + ); + drop(borrow); + + let (from, to) = dragged_range(span, a, b); + { + let mut f = ctl.filter.borrow_mut(); + f.captured_from = Some(from); + f.captured_to = Some(to); + } + + // A dragged range cannot be mistyped, so any standing complaint + // about the fields is now about a range that no longer exists. + w.set_library_range_invalid(false); + w.set_library_range_active(true); + show_range(&w, &ctl); + // Reloads the window, and `load_window` redraws the timeline — + // which is what puts the band back under the finger that dropped + // it, at the whole days it was snapped to. + refilter(&w, &ctl); + }); + } + + // TRACES: FR-CAT-6 + // The ends of the range, typed. + // + // The chip alone used to be the whole control and it read its span from + // the timeline's zoom, which is zero until someone zooms — so "limit to + // range" set the range to the entire library and appeared to do nothing. + // The span is shown now, and these two fields are how it is corrected. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_range_edited(move |from, to| { + let Some(w) = weak.upgrade() else { return }; + + // Both ends, or neither. A range with one end parsed and the other + // silently dropped is a filter nobody asked for, and the grid + // going empty is a poor way to find out a date was mistyped. + let (Some(from), Some(to)) = ( + dr_types::parse_date(from.as_str()), + dr_types::parse_date(to.as_str()), + ) else { + w.set_library_range_invalid(true); + return; + }; + + // Typed the other way round is a slip, not an error: the user has + // said which two days they mean and there is exactly one range + // between them. + let (from, to) = if from <= to { (from, to) } else { (to, from) }; + + { + let mut f = ctl.filter.borrow_mut(); + f.captured_from = Some(from); + // Inclusive of the last day. "To the 5th" means the whole of + // the 5th — `parse_date` returns its midnight, and a range + // ending there would exclude every photograph taken on the day + // the user named. The same rule `dragged_range` applies. + f.captured_to = Some(to + 86_400 - 1); + } + w.set_library_range_invalid(false); + w.set_library_range_active(true); + show_range(&w, &ctl); + refilter(&w, &ctl); + }); + } +} + +/// Whether the grid is scoped to what is available offline, and the +/// prompt those controls open. +pub(super) fn wire_filter_scope_and_offline( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + // TRACES: FR-CAT-9 + // "On this device" — the images openable without a server. Composes with + // the rating terms rather than replacing them: "five-star frames I can + // actually edit on this train" is one filter, not a mode. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_toggle_local_only(move || { + let Some(w) = weak.upgrade() else { return }; + let on = !ctl.local_only(); + ctl.set_local_only(on); + ctl.filter.borrow_mut().local_only = on; + w.set_library_local_only(on); + refilter(&w, &ctl); + }); + } + + // TRACES: FR-NC-6a + // The header's way in to the offline question, for the collection the grid + // is scoped to. It opens the same prompt the sidebar's tray and the long + // press open, rather than pinning outright: three affordances that did two + // different things — one asking, two acting — is how a user comes to avoid + // all three. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_toggle_pin_scope(move || { + let Some(w) = weak.upgrade() else { return }; + let Some(scope) = *ctl.scope.borrow() else { + return; + }; + open_offline_prompt(&w, &ctl, scope); + }); + } + + // TRACES: FR-NC-6a | FR-UI-4 + // The sidebar's way in: the tray on a row, tapped. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_collection_offline_menu(move |id| { + let Some(w) = weak.upgrade() else { return }; + open_offline_prompt(&w, &ctl, dr_types::CollectionId(id as u64)); + }); + } + + // The hold on a sidebar row — which arms the drag that rearranges the tree + // and opens the row menu — is wired in `collections_ui`, with the rest of + // what a collection row does. It used to be here because it opened the + // offline question and nothing else; that question is now one item in that + // menu, so the gesture belongs with the menu rather than with the transfers. + + // The three answers. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll = coll_ctl.clone(); + window.on_offline_prompt_keep(move || { + let Some(w) = weak.upgrade() else { return }; + keep_collection_offline(&w, &ctl, &coll); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll = coll_ctl.clone(); + window.on_offline_prompt_release(move || { + let Some(w) = weak.upgrade() else { return }; + release_collection_offline(&w, &ctl, &coll); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_offline_prompt_dismiss(move || { + let Some(w) = weak.upgrade() else { return }; + close_offline_prompt(&w, &ctl); + }); + } + + // TRACES: FR-CAT-9 + // Retry now, rather than waiting out the backoff. A user who has just + // reconnected their wifi knows something the backoff does not. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_ctl = coll_ctl.clone(); + window.on_library_retry_connection(move || { + let Some(w) = weak.upgrade() else { return }; + log::info!("retrying the connection at the user's request"); + // A rescan is the probe: it is the same request the scan worker + // makes, so a success proves reachability and repopulates the + // catalog in one pass rather than proving it twice. + start_rescan(&w, &ctl, &coll_ctl); + }); + } +} + +/// Reload the grid after the filter changed. +/// +/// The offset is reset because it is an ordinal into the filtered set: keeping +/// it would land the user in the middle of a narrowed library with no sense of +/// how they got there, or past its end entirely. +/// Put the identity filter's people on the filter bar. +/// +/// One chip each, so a selection of three is three things to look at and three +/// things to remove — a single chip saying "3 people" would be a filter the +/// user can only clear wholesale. +/// +/// The label is the People screen's own wording, unnamed groups included, so +/// the same group does not read one way on one screen and another way on the +/// next. +/// Drop the eyes-open term when the last person leaves the filter. +/// +/// The chip lives beside the people chips and goes with them; a term that +/// stayed on after its chip had gone would narrow the grid with nothing on +/// the bar to say so, which is the one state a filter bar must never be in. +fn drop_eyes_open_without_people(window: &AppWindow, ctl: &Rc) { + let mut f = ctl.filter.borrow_mut(); + if f.people.is_empty() && f.eyes_open { + f.eyes_open = false; + window.set_library_filter_eyes_open(false); + } +} + +pub(super) fn push_people_chips(window: &AppWindow, ctl: &Rc) { + let ids = ctl.filter.borrow().people.clone(); + if ids.is_empty() { + window + .set_library_filter_people(slint::ModelRc::new(slint::VecModel::from( + Vec::::new(), + ))); + // The tray, if it is open, has to lose its ticks with the chips: the + // roster carries `picked` and is the same fact drawn a second time. + push_people_roster(window, ctl); + return; + } + + let borrow = ctl.catalog.borrow(); + let people = borrow + .as_ref() + .and_then(|cat| dr_catalog::faces::people(cat.connection()).ok()) + .unwrap_or_default(); + + let chips: Vec = ids + .iter() + .map(|id| { + let name = people + .iter() + .find(|p| p.id.0 == *id) + .map(|p| { + if p.name.trim().is_empty() { + format!("Unnamed ({} faces)", p.confirmed_faces + p.suggested_faces) + } else { + p.name.clone() + } + }) + // A person deleted from under the filter still needs a chip, + // or there would be no way to take them back out of it. + .unwrap_or_else(|| "Person".to_string()); + PersonChip { + id: *id as i32, + name: name.into(), + // Not asked for on this side. The bar's job here is to say who + // the grid is narrowed to and offer a way out of it; a face + // count beside each would be a second number competing with the + // image counts already on the bar. + faces: -1, + picked: true, + } + }) + .collect(); + + window.set_library_filter_people(slint::ModelRc::new(slint::VecModel::from(chips))); + window.set_library_filter_people_all(matches!( + ctl.filter.borrow().people_mode, + library::PeopleMode::All + )); + push_people_roster(window, ctl); +} + +/// Fill the filter bar's people tray. +/// +/// Rebuilt whole rather than patched, because `picked` is on every row and a +/// toggle changes two things at once — the chip that was pressed and, under +/// `All`, what the whole filter means. +/// +/// **Named people first, then by how much of them the library holds.** The +/// catalog orders by face count alone, which on a real library puts a dozen +/// unnamed strangers ahead of the two people the user has actually named — and +/// the tray scrolls horizontally, so anything past the first few chips costs a +/// gesture to reach. Naming somebody is the user saying they matter; the order +/// says it back. +fn push_people_roster(window: &AppWindow, ctl: &Rc) { + let picked = ctl.filter.borrow().people.clone(); + let borrow = ctl.catalog.borrow(); + let people = borrow + .as_ref() + .and_then(|cat| dr_catalog::faces::people(cat.connection()).ok()) + .unwrap_or_default(); + + // Sorted as (unnamed, -faces) pairs beside the chip rather than by + // re-reading the drawn label: "is this person named" is a fact about the + // record, and recovering it from the `Unnamed (n faces)` wording would put + // a sort key inside a string meant for a human to read. + let mut rows: Vec<(bool, i64, PersonChip)> = people + .iter() + .filter(|p| { + let on = picked.contains(&p.id.0); + // A person already in the filter always has a chip, whatever else + // is true of them: the tray is where the filter is taken apart, and + // a term with no control is a term the user cannot remove. + // + // Otherwise: nobody the user set aside, and nobody with no faces — + // a named person emptied by a split would be a chip that narrows + // the grid to nothing whatever else is on the bar. + on || (!p.ignored && p.confirmed_faces + p.suggested_faces > 0) + }) + .map(|p| { + let faces = p.confirmed_faces + p.suggested_faces; + let unnamed = p.name.trim().is_empty(); + ( + unnamed, + faces as i64, + PersonChip { + id: p.id.0 as i32, + name: if unnamed { + format!("Unnamed ({faces} faces)").into() + } else { + p.name.clone().into() + }, + faces: faces as i32, + picked: picked.contains(&p.id.0), + }, + ) + }) + .collect(); + // Stable, so the catalog's own tiebreak by name survives inside each of the + // two blocks. + rows.sort_by_key(|(unnamed, faces, _)| (*unnamed, -faces)); + + let rows: Vec = rows.into_iter().map(|(_, _, chip)| chip).collect(); + window.set_library_people(slint::ModelRc::new(slint::VecModel::from(rows))); +} + +pub(super) fn refilter(window: &AppWindow, ctl: &Rc) { + *ctl.offset.borrow_mut() = 0; + ctl.requested.borrow_mut().clear(); + load_window(window, ctl); + // TRACES: FR-UI-8 + // A narrowed grid is part of where you are: coming back to five-star frames + // of one person and finding the whole library is the same loss of place as + // coming back to the top of it. + note_place(window, ctl); +} + +/// The heading each cell of a window carries: a month name on some, nothing +/// on most. +/// +/// A heading is drawn above its row, so it can only sit on a cell that +/// begins one — stranded mid-row it would appear to label the cells to its +/// left, which belong to the month before. So a row carries a heading when +/// its first cell's month is not the one last announced. A month that begins +/// mid-row is therefore announced at the *next* row it opens, which is one +/// row late and still right about every cell under it. It used to be +/// announced only if it happened to begin a row, which at seven columns +/// meant most months were never named at all and the one heading on screen +/// was wrong about everything below its first row. +/// +/// The first cell of the window always carries one, whichever column it +/// lands in: a scrolled window would otherwise show no date at all until +/// the next month began. +pub(super) fn period_headings( + months: impl Iterator>, + offset: usize, + columns: usize, +) -> Vec { + let columns = columns.max(1); + let mut announced: Option<(i64, i64)> = None; + months + .enumerate() + .map(|(i, month)| { + let Some((y, m)) = month else { + return String::new(); + }; + let begins_row = (i + offset).is_multiple_of(columns); + if i == 0 || (begins_row && announced != Some((y, m))) { + announced = Some((y, m)); + format!("{} {y}", month_name(m)) + } else { + String::new() + } + }) + .collect() +} + +/// TRACES: FR-CAT-6 +/// Show the range the filter is actually using. +/// +/// Pushed back rather than left as the user typed it, because the filter may +/// have adjusted it — ends given backwards are swapped, and the closing day is +/// extended to include itself. A field showing something other than what is +/// being filtered on is worse than one showing nothing. +fn show_range(window: &AppWindow, ctl: &Rc) { + let f = ctl.filter.borrow(); + let text = |t: Option| -> slint::SharedString { + t.map(dr_types::format_date).unwrap_or_default().into() + }; + window.set_library_range_from(text(f.captured_from)); + // The stored end is the last second of the closing day; naming that day is + // what the user typed and what they should read back. + window.set_library_range_to(text(f.captured_to.map(|t| t - 86_400 + 1))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_month_that_begins_mid_row_is_still_announced() { + // Four columns; August starts in the second column of the second + // row and October in the third of the third. Neither begins a row, + // and both must still be named — on the next row each opens. + let mar = Some((2024, 3)); + let aug = Some((2024, 8)); + let oct = Some((2025, 10)); + let months = [ + mar, mar, mar, mar, // + mar, aug, aug, aug, // + aug, aug, oct, oct, // + oct, + ]; + let h = period_headings(months.into_iter(), 0, 4); + assert_eq!(h[0], "March 2024"); + assert_eq!(h[4], "", "still March at the top of row two"); + assert_eq!(h[5], "", "never mid-row"); + assert_eq!(h[8], "August 2024"); + assert_eq!(h[12], "October 2025"); + assert_eq!(h.iter().filter(|s| !s.is_empty()).count(), 3); + } + + #[test] + fn a_scrolled_window_names_its_first_cell_wherever_it_lands() { + let jan = Some((2026, 1)); + let feb = Some((2026, 2)); + // Offset 2 into a 4-column grid: the window's first cell is in the + // third column, and the row after it begins a new month. + let h = period_headings([jan, jan, feb, feb, feb].into_iter(), 2, 4); + assert_eq!(h[0], "January 2026"); + assert_eq!(h[2], "February 2026", "cell 2 begins a row at offset 2"); + assert_eq!(h[3], ""); + // Undated cells carry nothing and announce nothing. + let h = period_headings([None, jan, None, None, jan].into_iter(), 0, 4); + assert_eq!(h[0], ""); + assert_eq!(h[4], "January 2026"); + } +} diff --git a/ui/dr-ui/src/library_ui/grid.rs b/ui/dr-ui/src/library_ui/grid.rs new file mode 100644 index 0000000..4c6c0e3 --- /dev/null +++ b/ui/dr-ui/src/library_ui/grid.rs @@ -0,0 +1,758 @@ +//! Wiring the grid itself: the keyboard cursor, cell-size zoom, the reload +//! triggers a scroll or a geometry change schedules, and the routes into and +//! out of the develop view. +//! +//! Everything here is a `window.on_...` registration or a small helper next +//! to the one callback that uses it; the work each callback delegates to — +//! loading a window, moving the timeline, saving a place — lives in the +//! module that owns that concern. See `docs/dev/code-health.md` CH-1. + +use std::rc::Rc; + +use slint::{ComponentHandle, Model as _}; + +use crate::AppWindow; +use crate::{GestureRow, View}; + +use super::controller::{ + LibraryController, OpenImage, MAX_CELL_SIZE, MIN_CELL_SIZE, MIN_WINDOW, SCREENFULS, +}; +use super::filter_bar::{ + wire_filter_dates, wire_filter_ratings_and_people, wire_filter_scope_and_offline, +}; +use super::open::{schedule_reload, start_rescan}; +use super::ratings_keywords::{start_xmp_reload, wire_keywords, wire_ratings_and_flags}; +use super::sync::{start_derived_sync, start_thumbnail_sweep}; +use super::timeline::{ + capture_time_at, catalog_span, note_place, resume_position, wire_timeline, write_place, + zoomed_span, +}; +use super::window::{load_window, window_move, window_start}; + +/// Walk the keyboard cursor through the library — the arrow keys. +/// +/// **The cursor is a library ordinal, not a row of the loaded window.** That is +/// what lets it walk past the window's edge: the window is a few screenfuls +/// around wherever the user is looking, and a cursor held as a row would stop +/// at its end or, worse, keep counting into cells belonging to different +/// photographs. Moving out of the window reloads it around the new position, +/// which is the same thing scrolling does. +/// +/// The grid supplies the step, because how far "down" is depends on how many +/// columns the window happens to be showing, and only the grid knows that. It +/// does not clamp: `Home` and `End` arrive as a step longer than the library +/// and are clamped here, where the total is known. +fn move_cursor( + window: &AppWindow, + ctl: &Rc, + coll: &Rc, + delta: i32, + extend: bool, +) { + let total = window.get_library_total().max(0) as usize; + if total == 0 { + return; + } + + let Some(from) = coll.cursor() else { + // The first press takes hold of the grid rather than moving in it. A + // key that jumped to image zero would lose wherever the user had + // scrolled to, and one that started from a cell off the top of the + // screen would appear to do nothing but scroll. + // + // The first *visible* ordinal, not the window's start: the loaded + // window deliberately begins a quarter of a screen above the view, so + // its first cell is one the user cannot see. + let at = ctl.resume_at.get().min(total - 1); + place_cursor(window, ctl, coll, at, false); + return; + }; + + // Saturating in `isize`, so a `Home` expressed as minus the library's + // length does not wrap round to the end. + let next = (from as isize) + .saturating_add(delta as isize) + .clamp(0, total as isize - 1) as usize; + if next == from { + // Already at the end being pressed toward. Nothing to move, and + // reloading the window would be a visible jerk for no movement. + return; + } + place_cursor(window, ctl, coll, next, extend); +} + +/// Put the cursor on one image, bringing the window with it. +fn place_cursor( + window: &AppWindow, + ctl: &Rc, + coll: &Rc, + at: usize, + extend: bool, +) { + // Bring the window to the cursor if it has walked out of it. Centred a + // quarter in, exactly as a scroll does it, so continuing in the same + // direction has loaded cells to move into rather than another reload on + // the very next press. + let loaded = window.get_library_cells().row_count(); + let offset = *ctl.offset.borrow(); + if at < offset || at >= offset + loaded { + let size = *ctl.window.borrow(); + let total = window.get_library_total().max(0) as usize; + *ctl.offset.borrow_mut() = window_start(at, size, total); + load_window(window, ctl); + } + + // Re-read: `load_window` clamps the offset against the library's end, so + // the window may not start where it was asked to. + let offset = *ctl.offset.borrow(); + let ids = ctl.visible_ids(); + let Some(row) = at.checked_sub(offset).filter(|r| *r < ids.len()) else { + // The window could not be brought to the cursor — an empty or + // shrinking library. Leaving the cursor where it was is better than + // pointing it at nothing. + return; + }; + + if !extend { + // An arrow key collapses the selection onto the cursor. A *click* on + // an already-selected cell deliberately leaves the selection alone — + // that exception exists so a multi-image drag can start from one of + // its members — and there is no drag behind a keystroke. + coll.clear_selection(); + } + + crate::collections_ui::select_row(window, coll, &ids, offset, row, false, extend); + window.set_library_cursor(at as i32); +} + +/// Say where in the library the photograph now open sits. +/// +/// **After `on_open_image`, never before.** The generic open path resets the +/// readout to "1 of 1" on its way in, because until the photo roll existed the +/// grid really did hand develop a single path with nothing to walk to. It has +/// a window of them now, so the honest answer is the ordinal in the library — +/// not the row in the loaded window, which is an artefact of how much has been +/// paged in and would jump about as the window moves. +fn report_position(window: &AppWindow, ctl: &Rc, row: usize) { + let offset = *ctl.offset.borrow(); + window.set_index((offset + row) as i32); + window.set_total(window.get_library_total()); +} + +/// Connect the grid's callbacks. +pub fn wire( + window: &AppWindow, + ctl: Rc, + coll_ctl: Rc, + on_open_image: F, + on_leave_develop: Rc, +) where + F: Fn(String) + 'static, +{ + // So a rebuilt window can put the selection ticks back. Weak, or the two + // controllers would hold each other alive for the life of the process. + *ctl.coll_ctl.borrow_mut() = Some(Rc::downgrade(&coll_ctl)); + + // TRACES: FR-CAT-13 + // The reload the settings page offers when a sidecar disagrees. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_settings_xmp_reload(move || { + if let Some(w) = weak.upgrade() { + start_xmp_reload(&w, &ctl); + } + }); + } + + // TRACES: FR-UI-3 | FR-UI-4 + // Whether the rating strip waits to be hovered or stands open. + // + // On Android touch is not evidence to be gathered, it is the platform. + // The grid latches it from the first finger it sees as well, which is what + // covers a touchscreen on the desktop — but that latch needs a press to + // reach a cell, and a quick flick never delivers one because the Flickable + // claims the gesture before the delay it would forward after. Seeding it + // here means the stars are on screen before the first touch rather than + // after it, which is the whole point of showing them. + window.set_library_touched(cfg!(target_os = "android")); + + // TRACES: FR-UI-4 + // The gesture reference. Pushed once, here, rather than on demand: the + // table is a compiled-in constant, so there is nothing to be fresh about + // and nothing to recompute — a callback to fill it would only be a way for + // it to be empty the first time the sheet opens. + window.set_library_gestures(slint::ModelRc::new(slint::VecModel::from( + crate::gestures::rows() + .into_iter() + .map(|r| GestureRow { + heading: r.heading.into(), + title: r.title.into(), + touch: r.touch.into(), + pointer: r.pointer.into(), + keys: r.keys.into(), + }) + .collect::>(), + ))); + + // Shared rather than moved: a click and `Return` both open an image, and + // they are two callbacks. + let on_open_image: OpenImage = Rc::new(on_open_image); + // TRACES: FR-UI-8 + // And a third caller, which is not a callback at all: a place recorded in + // develop reopens it at launch. Through the same closure, so the edit-saving + // and identity bookkeeping it does are not something a restore can skip. + *ctl.open_image.borrow_mut() = Some(on_open_image.clone()); + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_click = coll_ctl.clone(); + let on_open_image = on_open_image.clone(); + window.on_library_cell_clicked(move |i| { + let Some(w) = weak.upgrade() else { return }; + + // The press stayed put, so it was a tap and not a drag: whatever it + // held back can be applied now. See `collections_ui::Press`. + crate::collections_ui::commit_press(&w, &coll_for_click, &ctl.visible_ids()); + + // A ctrl- or shift-click is a selection gesture. Opening the image + // too would throw the user out of the grid mid-selection. + if coll_for_click.press_was_modified() { + return; + } + + let path = ctl.paths.borrow().get(i as usize).cloned(); + if let Some(path) = path { + // Leave the grid for the develop view. The status bar's + // "‹ Library" button comes back here. + w.set_active_view(View::Develop); + // Which cell the develop view is now showing, so the photo + // roll opens marking it rather than marking nothing. + w.set_library_roll_current(i); + // A develop session begins here, so the roll centres on this + // photograph the first time it settles rather than merely + // scrolling it into view at one edge. Raised here and not in + // `on_library_roll_pick`, which is a step *within* a session — + // see `PhotoRoll::centre-request`. + w.set_library_roll_centre(true); + on_open_image(path); + report_position(&w, &ctl, i as usize); + // TRACES: FR-UI-8 + // Written now rather than on a settle: which view you are in is + // the field a debounce is most likely to lose, and quitting + // straight from develop is exactly the case worth getting right. + write_place(&w, &ctl); + } + }); + } + + // TRACES: FR-UI-4 + // A photograph chosen from the photo roll. + // + // The same row-to-path lookup a cell click does, without the selection + // rules: the roll is a way of moving between photographs, not of building + // a set, so there is no modified press to honour and no reason to leave + // develop. `open_from_library` persists the outgoing edit before it loads + // the next one, which is what makes this safe to fire repeatedly. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let on_open_image = on_open_image.clone(); + window.on_library_roll_pick(move |i| { + let Some(w) = weak.upgrade() else { return }; + let Some(path) = ctl.paths.borrow().get(i as usize).cloned() else { + return; + }; + w.set_library_roll_current(i); + on_open_image(path); + report_position(&w, &ctl, i as usize); + // TRACES: FR-UI-8 + // Debounced, unlike the two entries into develop: the roll is walked + // frame by frame and every step is a different photograph. + note_place(&w, &ctl); + }); + } + + wire_grid_cursor_and_zoom(window, &ctl, &coll_ctl, &on_open_image); + wire_grid_sync_and_load(window, &ctl); + wire_timeline(window, &ctl); + wire_grid_routes(window, &ctl, &coll_ctl, on_leave_develop); + wire_ratings_and_flags(window, &ctl, &coll_ctl); + wire_keywords(window, &ctl, &coll_ctl); + wire_filter_ratings_and_people(window, &ctl); + wire_filter_dates(window, &ctl); + wire_filter_scope_and_offline(window, &ctl, &coll_ctl); + + // Last, and in its own module: the answers to a damaged catalog have + // nothing to do with the library view except that they run before it + // exists. + crate::recovery_ui::wire(window, &ctl, &coll_ctl); +} + +/// --- the keyboard cursor (FR-CULL-4) ----------------------------------- +/// +/// Walking the grid with the arrows, and opening with `Return`. Together +/// with the judgement keys already bound in the grid, this is what makes a +/// culling pass a keyboard job: move, rate, move, open the doubtful one, +/// come back. A cull is thousands of decisions, and reaching for the mouse +/// between each of them is the difference between an hour and an evening. +fn wire_grid_cursor_and_zoom( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, + on_open_image: &OpenImage, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll = coll_ctl.clone(); + window.on_library_move_cursor(move |delta, extend| { + let Some(w) = weak.upgrade() else { return }; + move_cursor(&w, &ctl, &coll, delta, extend); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll = coll_ctl.clone(); + let on_open = on_open_image.clone(); + window.on_library_open_cursor(move || { + let Some(w) = weak.upgrade() else { return }; + // The cursor is a library ordinal and `paths` is the loaded + // window, so the row is the difference. A cursor outside the + // window cannot happen — moving it loads the window around it — + // but a shrinking library could leave one behind, and opening the + // wrong photograph is worse than opening none. + let Some(cursor) = coll.cursor() else { return }; + let offset = *ctl.offset.borrow(); + let row = cursor.checked_sub(offset); + let path = row.and_then(|row| ctl.paths.borrow().get(row).cloned()); + if let Some(path) = path { + w.set_active_view(View::Develop); + // As on a click: the roll marks what is open, and centres on it + // because this too begins a session. + w.set_library_roll_current(row.unwrap_or(0) as i32); + w.set_library_roll_centre(true); + on_open(path); + report_position(&w, &ctl, row.unwrap_or(0)); + write_place(&w, &ctl); + } + }); + } + + // Ctrl+wheel or pinch over the grid resizes the cells. + // + // Geometric steps rather than fixed pixels: the same gesture should feel + // the same at 90px and at 400px, and a linear step is imperceptible at one + // end and violent at the other. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_zoom_cells(move |delta| { + let Some(w) = weak.upgrade() else { return }; + + let current = w.get_library_cell_size(); + let next = if delta > 0 { + current * 1.25 + } else { + current / 1.25 + } + .clamp(MIN_CELL_SIZE, MAX_CELL_SIZE); + + if (next - current).abs() < 0.5 { + return; + } + w.set_library_cell_size(next); + + // No need to forget anything on a class change: the class is part + // of the request key, so cells that now want the large resolution + // simply miss and ask for it, while the 256px ones they already + // hold stay served. + // + // Deferred, like every other geometry change. This one was still + // reloading inline — a full catalog re-read and model rebuild per + // step, which is what a wheel spun through six steps paid six + // times over. + schedule_reload(&w, &ctl); + }); + } + + // TRACES: FR-UI-4 + // A pinch, which is continuous where the wheel is stepped. + // + // Given the ratio since the last update rather than a direction, so the + // grid tracks the fingers instead of jumping a fixed 25% per threshold + // crossing. What the user is setting is the size class — how big they want + // a thumbnail to be — and the drawn cell follows from it by dividing the + // width, so the visible result still lands on whole column counts. Feeding + // a continuous value in is what decides *when* it crosses. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_pinch_cells(move |ratio| { + let Some(w) = weak.upgrade() else { return }; + if !(ratio.is_finite() && ratio > 0.0) { + return; + } + + let current = w.get_library_cell_size(); + let next = (current * ratio).clamp(MIN_CELL_SIZE, MAX_CELL_SIZE); + if (next - current).abs() < 0.5 { + return; + } + w.set_library_cell_size(next); + schedule_reload(&w, &ctl); + }); + } + + // TRACES: FR-UI-4 + // A pinch has begun, so the press that started it was not a press. + // + // A pinch opens as one finger on a cell — which selects it — and only + // becomes a pinch when the second lands. Without this the user is left + // holding a selection they never made, on a photograph they were only + // reaching past. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll = coll_ctl.clone(); + window.on_library_pinch_started(move || { + let Some(w) = weak.upgrade() else { return }; + crate::collections_ui::cancel_press(&w, &coll, &ctl.visible_ids()); + }); + } +} + +/// Reload triggers for the grid: an explicit sync or thumbnail sweep, and +/// the geometry and scroll changes that move which rows are loaded. +fn wire_grid_sync_and_load(window: &AppWindow, ctl: &Rc) { + // Explicit sync, for when the user wants the exchange now rather than + // after the next sweep. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_sync_now(move || { + if let Some(w) = weak.upgrade() { + start_derived_sync(&w, &ctl); + } + }); + } + + // TRACES: FR-CAT-3 | FR-NC-3 + // Thumbnail the whole library, from the settings page. It ends in a sync + // of its own, so this is the long version of the button above. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_thumbnail_all(move || { + if let Some(w) = weak.upgrade() { + start_thumbnail_sweep(&w, &ctl); + } + }); + } + + // A column-count change moves which cells begin a row, and month headings + // sit on row-leading cells. + // + // Guarded like the scroll below, and for the same reason: a grid being + // taken down reports its geometry collapsing on the way out, and reloading + // the window against that is work done for a page nobody is looking at. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_columns_changed(move || { + if let Some(w) = weak.upgrade() { + if !w.get_library_visible() { + return; + } + + // Deferred, and re-anchored when it lands — see + // [`schedule_reload`]. A column change moves every cell in the + // grid, because a cell is drawn at its absolute place in the + // library and the row that resolves to is `index / columns`. + // The viewport does not move with them, so without the + // re-anchor the view is left pointing at rows the loaded window + // no longer covers and the grid draws nothing at all — the + // "gallery randomly goes blank until I scroll" report, whose + // triggers are a resize, the sidebar opening, a zoom step, or + // turning the tablet over. + schedule_reload(&w, &ctl); + } + }); + } + + // The viewport changed size, so the window it can usefully hold changed + // with it. Reloading only on growth would leave a maximised-then-restored + // window over-fetching, so both directions are honoured. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_viewport_cells(move |on_screen| { + let Some(w) = weak.upgrade() else { return }; + if !w.get_library_visible() { + return; + } + let on_screen = (on_screen.max(0) as usize).max(1); + let window_size = (on_screen * SCREENFULS).max(MIN_WINDOW); + if window_size == *ctl.window.borrow() && on_screen == ctl.viewport_cells.get() { + return; + } + ctl.viewport_cells.set(on_screen); + *ctl.window.borrow_mut() = window_size; + // Coalesced with the column change that almost always accompanies + // it: resizing the cells alters both, and reloading once per report + // meant two full rebuilds per zoom step. + schedule_reload(&w, &ctl); + }); + } + + // Scrolling moves the loaded window through the library. + // + // The whole catalog is reachable because the Flickable's viewport is sized + // to it; this keeps the 120 loaded rows centred on wherever the view is. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_scrolled(move |first_visible| { + let Some(w) = weak.upgrade() else { return }; + let first_visible = first_visible.max(0) as usize; + + // **A report from a grid that is not on screen is not a scroll.** + // + // The grid is gated on an `if`, so leaving it tears the whole + // subtree down — and a Flickable being destroyed passes its viewport + // through zero on the way out, which arrives here indistinguishable + // from the user having flung the grid to the top. Everything below + // then ran on the way *into* develop: the loaded window was reset to + // offset zero, the model was rebuilt against the first rows of the + // library, and a thumbnail batch was issued for photographs nobody + // had asked to see. Those rebuilds landed while the grid was still + // being taken apart, which is what flashed the library over the + // develop view for the first few frames after a click — and on a + // remote library it also spent a burst of requests on the top of the + // catalog every single time an image was opened. + // + // **`library-visible`, not `show-library`, and the difference is the + // whole bug.** `show-library` says "the library rather than develop" + // and stays true while Settings, Import, People or the launch screen + // replaces the window — all four of which take the grid down just as + // opening an image does. So the teardown's scroll-to-zero passed this + // guard, `resume_at` was set to 0, and coming back from any of those + // four screens landed at the top of the library however deep in it the + // user had been. The property is computed once in `app.slint` beside + // the `if` it is spelled from, so the two cannot drift apart again. + // + // The guard used to cover only `resume_at`, for a narrower version + // of the same reason. It belongs over the whole handler. + if !w.get_library_visible() { + return; + } + + // Remember where the view is, so leaving for the develop view and + // coming back returns here. Latched on every event rather than read + // at departure: by the time the grid is hidden its scroll position + // is only in the Flickable, which is about to be destroyed. + ctl.resume_at.set(first_visible); + + // TRACES: FR-UI-8 + // And where the *next launch* will look for it. Debounced: a flick + // reports several of these per screenful. + note_place(&w, &ctl); + + // The same position where a rebuilt grid will look for it. + // + // `scroll-to` is read by `seek()`, which runs on a `scroll-token` + // change and on `init` — so writing it here without bumping the + // token cannot move the grid that is on screen, and *is* what the + // next one reads when it is built. That is every route back to the + // grid at once: Settings, Import, People and the launch screen all + // tear the subtree down and rebuild it, and none of them goes + // through `on_back_to_library` to have the position replayed by + // hand. Without this they each rebuilt against whatever `scroll-to` + // was last *set* to — a stale scrub, or zero — and landed there. + w.set_library_scroll_to(first_visible as i32); + + // Move the timeline marker with the view. Scrolling the grid is a + // way of moving through time just as scrubbing is, and a marker + // that only ever moved on a scrub sat still while the photographs + // beside it advanced by months — the axis said "when you are" and + // was wrong the moment the user touched the wheel. + // + // This runs before the reload guard below, which fires only a few + // times per screenful; the marker has to follow every event or it + // would advance in visible jerks. + // + // Only the marker is moved, not the whole histogram: rebuilding + // the bars means a `GROUP BY strftime` aggregate over the library, + // far too much for every event of a flick. The bars do not change + // as the grid scrolls anyway — only where the marker sits on them. + // + // Re-running the scrub would be wrong for a second reason: it sets + // `scroll-to`, which would drive the grid from its own scroll. + { + let borrow = ctl.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + if let Some(when) = capture_time_at(&ctl, catalog, first_visible) { + *ctl.current_bucket.borrow_mut() = Some(when); + + let zoom = *ctl.timeline_zoom.borrow(); + let centre = *ctl.timeline_centre.borrow(); + if let Some(full) = catalog_span(catalog, &ctl) { + let (from, to) = zoomed_span(full, zoom, centre); + w.set_library_current_bucket(when as i32); + w.set_library_current_fraction( + ((when - from) as f64 / (to - from).max(1) as f64).clamp(0.0, 1.0) + as f32, + ); + w.set_library_timeline_anchored(true); + } + } + } + } + + // Centre the window on the view, so scrolling either way has + // loaded rows ahead of it rather than only below. + let Some(offset) = window_move( + first_visible, + *ctl.offset.borrow(), + *ctl.window.borrow(), + ctl.viewport_cells.get(), + w.get_library_total().max(0) as usize, + ) else { + return; + }; + + *ctl.offset.borrow_mut() = offset; + load_window(&w, &ctl); + }); + } +} + +/// Moving between the grid and the screens around it: the launch screen, +/// develop, and a manual rescan. +fn wire_grid_routes( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, + on_leave_develop: Rc, +) { + // Grid → launch screen. The route that was missing: once past the launch + // screen there was no way back to it, so a library pointed at the wrong + // folder could not be changed without clearing stored state by hand. + { + let weak = window.as_weak(); + window.on_library_change(move || { + if let Some(w) = weak.upgrade() { + w.set_active_view(View::Launch); + } + }); + } + + // Develop → grid. + // + // Returns to where the user left, on the photograph they were editing, + // rather than to the top. The grid is gated on an `if` in the markup, so it + // is rebuilt from nothing and its Flickable starts at row 0; the position + // has to be replayed explicitly. See [`resume_position`] for which of the + // two positions wins when they disagree. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_ctl = coll_ctl.clone(); + window.on_back_to_library(move || { + let Some(w) = weak.upgrade() else { return }; + + // TRACES: FR-CAT-8 + // The edit is persisted on the way out rather than on every + // slider move: a save is a network round-trip, and one per drag + // frame would put an upload inside the gesture NFR-P5 governs. + // This is the moment the image stops being the open one, so it is + // the last moment its edit can be written. + on_leave_develop(); + + // Where the open photograph sits in the library, which is exactly + // what `report_position` keeps `index` holding — including after + // every step along the photo roll, which is the case that makes + // this differ from `resume_at`. + let open = w.get_index().max(0) as usize; + resume_position(&w, &ctl, &coll_ctl, Some(open)); + + w.set_active_view(View::Library); + // TRACES: FR-UI-8 + // After the view is set, not before: `current_place` reads it to + // say which view the record is of, and this is the moment it + // becomes the grid. + write_place(&w, &ctl); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_ctl = coll_ctl.clone(); + window.on_library_rescan(move || { + let Some(w) = weak.upgrade() else { return }; + start_rescan(&w, &ctl, &coll_ctl); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One zoom step, as the handler applies it. + fn zoom_cell(current: f32, delta: i32) -> f32 { + let next = if delta > 0 { + current * 1.25 + } else { + current / 1.25 + }; + next.clamp(MIN_CELL_SIZE, MAX_CELL_SIZE) + } + + #[test] + fn cell_zoom_steps_geometrically_and_reverses() { + // Geometric so the gesture feels the same at either end; a fixed pixel + // step is imperceptible at 400px and violent at 90px. + let a = zoom_cell(180.0, 1); + assert!((a - 225.0).abs() < 0.01); + assert!( + (zoom_cell(a, -1) - 180.0).abs() < 0.01, + "in then out returns" + ); + } + + #[test] + fn cell_zoom_stays_within_its_bounds() { + let mut size = 180.0; + for _ in 0..40 { + size = zoom_cell(size, 1); + } + assert_eq!(size, MAX_CELL_SIZE); + + for _ in 0..40 { + size = zoom_cell(size, -1); + } + assert_eq!(size, MIN_CELL_SIZE); + } + + #[test] + fn zooming_past_the_grid_class_asks_for_the_large_one() { + use dr_thumbs::ThumbSize; + // The point of the second class: past 256px a grid thumbnail is being + // upscaled, and the softness shows. + assert_eq!(ThumbSize::for_cell(180), ThumbSize::Grid); + assert_eq!( + ThumbSize::for_cell(zoom_cell(225.0, 1) as u32), + ThumbSize::Large + ); + // And zooming back down does not keep paying for it. + assert_eq!( + ThumbSize::for_cell(zoom_cell(281.0, -1) as u32), + ThumbSize::Grid + ); + } +} diff --git a/ui/dr-ui/src/library_ui/mod.rs b/ui/dr-ui/src/library_ui/mod.rs new file mode 100644 index 0000000..ca22e93 --- /dev/null +++ b/ui/dr-ui/src/library_ui/mod.rs @@ -0,0 +1,56 @@ +//! TRACES: FR-CAT-4 | FR-NC-3 | NFR-P9 +//! Drives the library grid from scan and thumbnail workers. +//! +//! Owns the bridge between three background activities and one single-threaded +//! event loop: +//! +//! - a **scan** worker walking the remote tree into the catalog +//! - a **thumbnail** worker range-fetching previews for visible cells +//! - the **grid model** Slint renders +//! +//! Nothing here blocks. Workers post through mpsc channels drained by Slint +//! timers, which is the same shape [`crate::launch_ui`] uses for login. +//! +//! # Why thumbnails are fetched, not derived from the scan +//! +//! A scan yields paths and sizes, nothing visual. Each thumbnail costs its own +//! range request, so they are fetched **only for cells the grid actually +//! wants** — never for the whole library up front. On the reference library +//! that is the difference between a few MB and ~370 GB (ARCH §6.7). +//! +//! Split into one module per area of behaviour (docs/dev/code-health.md CH-1): +//! `controller` holds the running state every other module reads and writes +//! through `pub(super)` fields; `open` is the launch-to-scan cycle and the +//! worker that checks the catalog file before either touches it; `offline` +//! is what of a collection is on this device and the prompt that offers to +//! change it; `window` fills the grid model from the catalog and drains the +//! thumbnail fetch; `sync` is the background passes that reach beyond the +//! loaded window — the metadata sweep, the whole-library thumbnail pass, and +//! the exchange with the server; `ratings_keywords` applies a judgement or a +//! keyword to a selection and queues the sidecar and XMP writes behind it; +//! `timeline` is the capture-time sidebar and the photographer's place, +//! which move together closely enough to live in one file; `grid` wires the +//! grid's own callbacks — the keyboard cursor, cell-size zoom, the routes +//! into and out of develop; `filter_bar` wires the rating, people, date and +//! offline-scope filters. `mod.rs` re-exports what the rest of the crate +//! calls by `library_ui::` path, so nothing outside this module needs to +//! change. + +mod controller; +mod filter_bar; +mod grid; +mod offline; +mod open; +mod ratings_keywords; +mod sync; +mod timeline; +mod window; + +pub use controller::LibraryController; +pub use grid::wire; +pub(crate) use open::{forget_catalog, show_catalog_now, start_rescan}; +pub use open::{open, reload}; +pub use ratings_keywords::paste_settings_to_selection; +pub(crate) use ratings_keywords::start_sidecar_writes; +pub use timeline::format_date; +pub use window::refresh_thumbnail; diff --git a/ui/dr-ui/src/library_ui/offline.rs b/ui/dr-ui/src/library_ui/offline.rs new file mode 100644 index 0000000..ba726d7 --- /dev/null +++ b/ui/dr-ui/src/library_ui/offline.rs @@ -0,0 +1,690 @@ +//! Keeping a collection's originals on this device: the offline prompt, the +//! pin fetch, and the connectivity banner every worker in this directory +//! paints through [`refresh_offline`]. +//! +//! Split out because it is one coherent user question — "what of this is on +//! my device, and what would keeping or releasing it cost" — with its own +//! catalog queries, distinct from loading a window over the grid or scanning +//! the tree. See `docs/dev/code-health.md` CH-1. + +use std::rc::Rc; + +use dr_catalog::Catalog; +use slint::ComponentHandle; + +use crate::library; +use crate::AppWindow; + +use super::controller::{stop, LibraryController}; + +/// TRACES: FR-NC-6a | FR-NC-6c +/// What a collection would cost to take with you, and what it is holding now. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +struct OfflineSummary { + /// Photographs in the collection and its children, deduplicated. + total: usize, + /// Of those, how many have their original on this device. + held: usize, + /// Disk those originals occupy — what releasing would give back. + held_bytes: u64, + /// What the rest would cost to fetch, from the sizes the scan recorded. + /// Zero where nothing has been stat-ed yet, which reads as "unknown" + /// rather than "free" in the label built from it. + missing_bytes: u64, +} + +impl OfflineSummary { + fn missing(self) -> usize { + self.total.saturating_sub(self.held) + } +} + +/// Read the offline summary for a set of images. +/// +/// One query with the ids inlined as placeholders — the same shape +/// [`collection_images`] uses, and for the same reason: a collection is tens to +/// thousands of rows, and a round trip per photograph to answer one dialogue is +/// not a trade worth making. +fn offline_summary(catalog: &Catalog, images: &[dr_types::ImageId]) -> OfflineSummary { + if images.is_empty() { + return OfflineSummary::default(); + } + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + // `tier_actual`, never `tier_desired`: the question is what can be opened + // on the aeroplane, and a pin whose download has not run yet answers no. + let sql = format!( + "SELECT count(*), + coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 1 ELSE 0 END), 0), + coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN c.bytes ELSE 0 END), 0), + coalesce(sum(CASE WHEN c.tier_actual >= ?1 THEN 0 + ELSE coalesce(i.file_size, 0) END), 0) + FROM images i + LEFT JOIN image_cache c ON c.image_id = i.id + WHERE i.id IN ({placeholders})" + ); + + let mut params: Vec = vec![rusqlite::types::Value::Integer( + dr_types::Tier::Original.stored(), + )]; + params.extend( + images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)), + ); + + catalog + .connection() + .query_row(&sql, rusqlite::params_from_iter(params.iter()), |r| { + Ok(OfflineSummary { + total: r.get::<_, i64>(0)? as usize, + held: r.get::<_, i64>(1)? as usize, + held_bytes: r.get::<_, i64>(2)? as u64, + missing_bytes: r.get::<_, i64>(3)? as u64, + }) + }) + .unwrap_or_else(|e| { + log::debug!("reading offline summary: {e}"); + OfflineSummary::default() + }) +} + +/// TRACES: FR-NC-6a | FR-NC-6c +/// Ask what should happen to a collection's local copies. +/// +/// Both answers are expensive — one commits the device to a download of +/// gigabytes, the other deletes gigabytes it already holds — so this is a +/// question rather than a toggle, and the counts and sizes go in the buttons +/// where they are read *before* the tap rather than in a second dialogue after +/// it (FR-NC-6c: an operation requiring absent data says so, with the size, +/// before starting). +pub(super) fn open_offline_prompt( + window: &AppWindow, + ctl: &Rc, + id: dr_types::CollectionId, +) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + // Descendants, matching what the grid shows when scoped to this row: + // keeping a parent whose children hold the photographs must keep the + // photographs, or the answer would appear to do nothing. + let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { + Ok(ids) => ids, + Err(e) => { + window.set_library_error(format!("resolving collection: {e}").into()); + return; + } + }; + let images = collection_images(catalog, &ids); + let summary = offline_summary(catalog, &images); + + let name = dr_catalog::collections::tree(catalog.connection()) + .ok() + .and_then(|rows| { + rows.into_iter() + .find(|r| r.collection.id == id) + .map(|r| r.collection.name) + }) + .unwrap_or_else(|| "This collection".to_string()); + + ctl.offline_target.set(Some(id)); + window.set_offline_prompt_title(name.as_str().into()); + window.set_offline_prompt_detail( + if summary.total == 0 { + "Nothing in here yet. Put some photographs in it first.".to_string() + } else if summary.held == summary.total { + format!( + "All {} on this device · {}", + summary.total, + crate::activity::describe_bytes(summary.held_bytes) + ) + } else { + format!( + "{} photographs · {} already on this device", + summary.total, summary.held + ) + } + .as_str() + .into(), + ); + window.set_offline_prompt_keep_label( + // The size is named where the scan has recorded one. Where it has not, + // the label says what it will do and not what it will cost, which is + // honest — a "0 B" download would be a lie about a gigabyte. + if summary.missing_bytes > 0 { + format!( + "Download {} · {}", + summary.missing(), + crate::activity::describe_bytes(summary.missing_bytes) + ) + } else if summary.missing() > 0 { + format!("Download {}", summary.missing()) + } else { + "Everything is already here".to_string() + } + .as_str() + .into(), + ); + window.set_offline_prompt_release_label( + format!( + "Remove {} local copies · frees {}", + summary.held, + crate::activity::describe_bytes(summary.held_bytes) + ) + .as_str() + .into(), + ); + window.set_offline_prompt_can_keep(summary.missing() > 0); + window.set_offline_prompt_can_release(summary.held > 0); + window.set_offline_prompt_busy(window.get_library_pin_total() > 0); +} + +/// Close the offline question without answering it. +pub(super) fn close_offline_prompt(window: &AppWindow, ctl: &Rc) { + ctl.offline_target.set(None); + // The title is what the prompt's visibility is bound to: one fact, so a + // dialogue cannot be up with nothing written on it. + window.set_offline_prompt_title(slint::SharedString::new()); +} + +/// TRACES: FR-NC-6a +/// Keep a collection on this device: record the pin, then start the transfer. +/// +/// Two separate things, and keeping them separate is what makes the answer feel +/// immediate — recording the intent is a local catalog write that completes at +/// once, and downloading the bytes may take a very long time. The pin also +/// survives the app being closed halfway through, which is what makes the +/// transfer resumable rather than something to start again. +pub(super) fn keep_collection_offline( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + let Some(id) = ctl.offline_target.get() else { + return; + }; + let Some(cache) = ctl.cache() else { + window.set_library_error("No cache directory for this library.".into()); + return; + }; + + let images = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { + Ok(ids) => ids, + Err(e) => { + window.set_library_error(format!("resolving collection: {e}").into()); + return; + } + }; + let images = collection_images(catalog, &ids); + if images.is_empty() { + window.set_library_error("Nothing in that collection to keep offline.".into()); + return; + } + if let Err(e) = cache.pin(catalog.connection(), &images) { + window.set_library_error(format!("pinning: {e}").into()); + return; + } + images + }; + + log::info!("pinned {} image(s) for offline use", images.len()); + window.set_library_error(slint::SharedString::new()); + if ctl.scope.borrow().as_ref() == Some(&id) { + window.set_library_scope_pinned(true); + } + close_offline_prompt(window, ctl); + start_pin_fetch(window, ctl); + refresh_collection_tree(window, ctl, coll_ctl); +} + +/// TRACES: FR-NC-6a | FR-NC-6b +/// Give the disk back: release the pin *and* delete the originals it held. +/// +/// Deliberately destructive, where unpinning alone is not. "Remove the local +/// copies" is asked by someone whose device is full, and answering it by +/// withdrawing a promise and leaving the gigabytes for a future eviction to +/// notice is not an answer. Nothing is lost that cannot be fetched again: the +/// originals are on the server, and the ratings, the edit graph and the +/// thumbnails are all untouched — they are authoritative and small. +pub(super) fn release_collection_offline( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + let Some(id) = ctl.offline_target.get() else { + return; + }; + let Some(cache) = ctl.cache() else { + window.set_library_error("No cache directory for this library.".into()); + return; + }; + + let released = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let ids = match dr_catalog::collections::descendants(catalog.connection(), id) { + Ok(ids) => ids, + Err(e) => { + window.set_library_error(format!("resolving collection: {e}").into()); + return; + } + }; + let images = collection_images(catalog, &ids); + let outcome = match cache.release(catalog.connection(), &images) { + Ok(r) => r, + Err(e) => { + window.set_library_error(format!("removing local copies: {e}").into()); + return; + } + }; + + // TRACES: FR-NC-6c + // On a placeholder library the bookkeeping above owns no files, so it + // freed nothing — the originals are materialised in the library folder + // and only the sync client may take them back. Asking it to is what + // makes unpinning actually return the disk, and it must be a + // dehydration rather than a delete: removing a file inside a synced + // tree propagates to the server (ARCH §9.0a). + // + // Fire and forget: it is per-file work over a socket, the user has + // already been told the pin is withdrawn, and a client that refuses + // leaves the content where it is at no cost but disk. + if let Some(conn) = ctl.session() { + let path = library::catalog_path(&conn.account); + std::mem::drop(library::spawn_dehydrate(conn, path, images)); + } + outcome + }; + + let (count, freed) = released; + log::info!( + "released {count} image(s), freeing {}", + crate::activity::describe_bytes(freed) + ); + window.set_library_error(slint::SharedString::new()); + window.set_library_status( + format!( + "Removed local copies · {} freed", + crate::activity::describe_bytes(freed) + ) + .as_str() + .into(), + ); + if ctl.scope.borrow().as_ref() == Some(&id) { + window.set_library_scope_pinned(false); + } + // A download that was still running for this collection has just had its + // reason withdrawn; the worker checks `pending_pins` per file, so it stops + // finding work rather than being killed. + window.set_library_pin_total(0); + window.set_library_pin_done(0); + close_offline_prompt(window, ctl); + refresh_local_count(window, ctl); + refresh_collection_tree(window, ctl, coll_ctl); +} + +/// Redraw the sidebar, so the trays reflect what was just kept or released. +fn refresh_collection_tree( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + let borrow = ctl.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + crate::collections_ui::refresh_tree(window, coll_ctl, catalog); + } +} + +/// Every image in the given collections, deduplicated. +/// +/// An image in both a parent and a child is one photograph and must be pinned +/// once, exactly as the grid draws it once. +pub(super) fn collection_images( + catalog: &Catalog, + ids: &[dr_types::CollectionId], +) -> Vec { + if ids.is_empty() { + return Vec::new(); + } + let placeholders = std::iter::repeat_n("?", ids.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT DISTINCT image_id FROM collection_members + WHERE collection_id IN ({placeholders})" + ); + let params: Vec = ids + .iter() + .map(|c| rusqlite::types::Value::Integer(c.0 as i64)) + .collect(); + + let Ok(mut stmt) = catalog.connection().prepare(&sql) else { + return Vec::new(); + }; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(dr_types::ImageId(r.get::<_, i64>(0)? as u64)) + }); + match rows { + Ok(rows) => rows.flatten().collect(), + Err(e) => { + log::debug!("listing collection images: {e}"); + Vec::new() + } + } +} + +/// TRACES: FR-NC-6a +/// Download whatever the pins still want, reporting progress. +fn start_pin_fetch(window: &AppWindow, ctl: &Rc) { + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + let Some(cache_dir) = ctl.cache_dir() else { + return; + }; + + // Offline, there is nothing to download from. The pin is already recorded, + // so it resumes on reconnect rather than being lost. + if ctl.is_offline() { + log::info!("offline: the pin is recorded and will download on reconnect"); + return; + } + + let rx = library::spawn_pin_fetch( + conn.clone(), + library::catalog_path(&conn.account), + cache_dir, + // Pinned originals are exempt from the budget, but a pin fetch also + // stores passively when it finds an image already cached, so the worker + // still needs the user's ceiling rather than the catalog's floor. + ctl.cache_budget.get(), + ); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + // The longest-running transfer the app does, and the one most likely to be + // watched from another view — which is the whole reason the register + // exists (FR-NC-6, FR-NC-6c). + let job = ctl.activity.begin( + crate::activity::Kind::Download, + "Keeping photographs on this device", + ); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(300), + move || { + let Some(w) = weak.upgrade() else { return }; + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_pin_total(0); + job.fail("stopped without finishing"); + stop(&ctl_cb.pin_timer); + return; + } + }; + + match msg { + library::PinMessage::Planned { total } => { + w.set_library_pin_total(total as i32); + w.set_library_pin_done(0); + job.total(total); + } + library::PinMessage::Stored { done } => { + w.set_library_pin_done(done as i32); + job.progress(done, w.get_library_pin_total() as usize); + // The "On this device" count grows as they land, so + // the chip agrees with the progress line beside it. + refresh_local_count(&w, &ctl_cb); + } + library::PinMessage::Done { stored, bytes } => { + log::info!( + "pin complete: {stored} original(s), {:.1} MB", + bytes as f64 / 1_048_576.0 + ); + job.finish(format!( + "{stored} photograph(s) · {}", + crate::activity::describe_bytes(bytes) + )); + w.set_library_pin_total(0); + w.set_library_pin_done(0); + refresh_local_count(&w, &ctl_cb); + stop(&ctl_cb.pin_timer); + return; + } + library::PinMessage::Failed { message, offline } => { + log::warn!("pin fetch stopped: {message}"); + job.fail(message.clone()); + w.set_library_pin_total(0); + if offline { + ctl_cb + .reachability + .borrow_mut() + .mark_unreachable(message, std::time::Instant::now()); + refresh_offline(&w, &ctl_cb); + } else { + w.set_library_error(format!("keeping offline: {message}").into()); + } + refresh_local_count(&w, &ctl_cb); + stop(&ctl_cb.pin_timer); + return; + } + } + } + }, + ); + + *ctl.pin_timer.borrow_mut() = Some(timer); +} + +/// Refresh the "On this device" count from the catalog. +fn refresh_local_count(window: &AppWindow, ctl: &Rc) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); +} + +/// TRACES: FR-NC-6a +/// Whether every image in the scoped collection is pinned. +/// +/// Read from the catalog rather than remembered, because a pin outlives the +/// session that made it: reopening the library must show the button already +/// active, or the user would pin the same collection twice. +pub(super) fn scope_is_pinned(catalog: &Catalog, images: &[dr_types::ImageId]) -> bool { + if images.is_empty() { + return false; + } + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let pinned: i64 = catalog + .connection() + .query_row( + &format!( + "SELECT count(*) FROM image_cache + WHERE pinned = 1 AND image_id IN ({placeholders})" + ), + rusqlite::params_from_iter(params.iter()), + |r| r.get(0), + ) + .unwrap_or(0); + + pinned as usize == images.len() +} + +/// TRACES: FR-CAT-9 +/// Paint the connectivity state into the window. +/// +/// Called wherever reachability may have moved, rather than by the state +/// itself: `Reachability` is in `dr-sync` and knows nothing about a window, +/// which is what keeps it testable without a display server. +pub(super) fn refresh_offline(window: &AppWindow, ctl: &Rc) { + let reach = ctl.reachability.borrow(); + // TRACES: FR-PLAT-AND-2 + // A lost root wins over a dead network, and does so even when both are + // true — which is the ordinary case, since the scan that discovered the + // grant was gone was also the last request the app made. Reported the + // other way round the user is told to wait for a connection that is + // working, and the thing that would actually fix it is never mentioned. + let lost = ctl.root_lost.borrow(); + let offline = reach.is_offline() || lost.is_some(); + + window.set_library_offline(offline); + window.set_library_offline_reason(match lost.as_deref() { + Some(why) => why.into(), + None => reach.reason().unwrap_or_default().into(), + }); + window.set_library_offline_since( + // A duration is what a network outage has and a revoked permission + // does not: "for 4 minutes" invites waiting, and waiting is precisely + // what will not help here. + if lost.is_some() { + slint::SharedString::default() + } else { + reach + .offline_for(std::time::Instant::now()) + .map(describe_duration) + .unwrap_or_default() + .into() + }, + ); + drop(lost); + + // A stale scan error under an offline banner reports one problem twice. + if offline { + window.set_library_error("".into()); + } + drop(reach); + + // TRACES: FR-CAT-9 + // Back online: send whatever the outbox is still holding. + // + // Hung off the one function that paints connectivity rather than off each + // of the seven places that move it — a drain that had to be remembered at + // every call site is a drain that will be forgotten at one of them, and + // the symptom is an edit that stays queued until the app is restarted. + // + // `start_outbox_drain` is a no-op when the outbox is empty and while one + // is already running, so calling it on every repaint costs a directory + // walk that finds nothing. + if !offline { + start_outbox_drain(window, ctl); + } +} + +/// TRACES: FR-CAT-9 | FR-NC-10 +/// Upload the sidecars queued while this device had no connection. +/// +/// Guarded on the timer rather than on a flag: the timer *is* the "a drain is +/// running" state, and a second one started beside it would drain the same +/// channel twice. +fn start_outbox_drain(window: &AppWindow, ctl: &Rc) { + if ctl.outbox_timer.borrow().is_some() { + return; + } + if !ctl.outbox_maybe_dirty.get() { + return; + } + let Some(cache_dir) = ctl.sidecar_cache_dir() else { + return; + }; + if crate::sidecar_cache::SidecarCache::open(cache_dir.clone()) + .pending() + .is_empty() + { + // Nothing there. Recorded so the next hundred repaints skip the walk; + // a write that queues sets it again. + ctl.outbox_maybe_dirty.set(false); + return; + } + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + + let rx = library::spawn_outbox_drain(conn.clone(), cache_dir); + let job = ctl + .activity + .begin(crate::activity::Kind::Upload, "Uploading queued edits"); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(250), + move || { + let Some(w) = weak.upgrade() else { return }; + match rx.try_recv() { + Ok(library::SidecarMessage::Finished { + written, + queued, + failed, + last_error, + }) => { + if failed > 0 { + log::warn!( + "{failed} queued sidecar(s) still undelivered: {}", + last_error.clone().unwrap_or_default() + ); + // Not `fail`: the edits are still safely queued, and + // reporting this as a loss would be wrong. + job.finish(format!("{written} uploaded · {queued} still queued")); + } else if written > 0 { + log::info!("{written} queued sidecar(s) uploaded"); + job.finish(format!("{written} queued edit(s) uploaded")); + w.set_library_status(format!("{written} queued edit(s) uploaded").into()); + } else { + job.finish_quietly(); + } + ctl_cb.outbox_maybe_dirty.set(queued > 0); + stop(&ctl_cb.outbox_timer); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + job.finish_quietly(); + stop(&ctl_cb.outbox_timer); + } + } + }, + ); + *ctl.outbox_timer.borrow_mut() = Some(timer); +} + +/// A coarse "how long ago", for the offline banner. +/// +/// Deliberately imprecise: the user wants to know whether this just happened +/// or has been true for a while, and a live-counting seconds display would +/// draw the eye to a number that changes without meaning anything. +fn describe_duration(d: std::time::Duration) -> String { + let secs = d.as_secs(); + if secs < 60 { + "just now".to_string() + } else if secs < 3600 { + format!("{}m ago", secs / 60) + } else { + format!("{}h ago", secs / 3600) + } +} diff --git a/ui/dr-ui/src/library_ui/open.rs b/ui/dr-ui/src/library_ui/open.rs new file mode 100644 index 0000000..7822b79 --- /dev/null +++ b/ui/dr-ui/src/library_ui/open.rs @@ -0,0 +1,995 @@ +//! Opening a library and bringing its catalog up to date: the launch path, +//! the background scan, and the worker that checks the catalog file before +//! either touches it. +//! +//! Split from the loading of one window over the catalog (`window`) because +//! this is the coarser cycle around it — once per library open or rescan +//! rather than once per scroll — and from the offline/pin bookkeeping +//! (`offline`) it hands off to on a failure. See `docs/dev/code-health.md` +//! CH-1. + +use std::path::PathBuf; +use std::rc::Rc; +use std::sync::mpsc::Receiver; + +use dr_catalog::Catalog; +use dr_sync::{Account, AccountStore, Connection}; +use dr_types::FormatFilter; +use slint::ComponentHandle; + +use crate::library::{self, ScanMessage}; +use crate::{AppWindow, View}; + +use super::controller::{stop, LibraryController}; +use super::offline::refresh_offline; +use super::ratings_keywords::refresh_xmp_conflicts; +use super::sync::start_sweep; +use super::timeline::{apply_place, note_place}; +use super::window::{load_window, window_start}; + +/// Reload the grid for the current scope and offset. +/// +/// The reload entry point for everything outside this module — a scope change, +/// or a drop that altered the collection being shown. +pub fn reload(window: &AppWindow, ctl: &Rc) { + load_window(window, ctl); + // TRACES: FR-UI-8 + // The scope changed, or the collection under it did. Either way this is the + // entry point every such change comes through, which is why the record is + // taken here rather than at each of the callers. + note_place(window, ctl); +} + +/// Open a library: show the grid, start a scan, then fill in thumbnails. +/// +/// Called from the launch screen's "Open library" button — the callback that +/// until now only logged its intent. +pub fn open( + window: &AppWindow, + ctl: Rc, + coll_ctl: Rc, + store: &AccountStore, + account: Account, +) { + // The credential is fetched only where the connector wants one; a folder + // library has none, and asking the keyring for it would fail the one + // backend that needs nothing. + let conn = match store.connection(&account, crate::remote::needs_secret(&account)) { + Ok(c) => c, + Err(e) => { + window.set_library_error(format!("credentials: {e}").into()); + window.set_active_view(View::Library); + return; + } + }; + + let filter = account.format_filter(); + *ctl.session.borrow_mut() = Some((conn.clone(), filter.clone())); + + // TRACES: FR-UI-8 + // Where this library's position is kept, and what this device signs it + // with. Before the catalog opens, because `adopt_catalog` is what reads the + // record back and it is the next thing to run. + *ctl.place_store.borrow_mut() = Some(crate::place::PlaceStore::open_at(library::place_path( + &conn.account, + ))); + *ctl.place_device.borrow_mut() = device_id(&conn.account); + // A fresh library is one nobody has moved in yet, so a place arriving from + // another device may still be applied — see `place_untouched`. + ctl.place_untouched.set(true); + + window.set_active_view(View::Library); + window.set_library_open(true); + window.set_library_scanning(true); + window.set_library_error(slint::SharedString::new()); + window.set_library_status("Starting…".into()); + // Always visible: two folders one letter apart are easy to confuse, and a + // scan of the wrong one is indistinguishable from a broken scan. + window.set_library_root_label(library_root_label(&conn.account).into()); + + // An empty filter would walk the whole tree and match nothing, which looks + // exactly like a broken scan. Say so instead. + if filter.is_empty() { + window.set_library_scanning(false); + window.set_library_error("No formats selected — tick at least one.".into()); + return; + } + + let path = library::catalog_path(&conn.account); + + // Before the scan, not after it: the grid can be filled from disk now and + // the scan is only ever going to add to it. + // + // And it is the gate on the scan, not merely a prelude to it — a damaged + // catalog has a question on screen, and a scan writing into it while that + // question is unanswered is how the last good copy gets destroyed. That + // gate is why the scan starts from the drain below rather than from here: + // the answer no longer arrives on the next line. + // TRACES: FR-UI-8 + // Started beside the catalog open rather than after it, so the handover has + // a round trip's head start on the user deciding what to look at. It + // resolves nothing until the catalog is there — see `fetch_remote_place`. + fetch_remote_place(window, &ctl, &coll_ctl, &conn); + + open_catalog_soon(window, ctl, coll_ctl, conn, filter, path); +} + +/// Open the catalog on a worker, then show it and start the scan. +/// +/// # Why this is not `show_catalog_now` on the spot +/// +/// It was, and it was the largest blocking call on the launch path. +/// [`Catalog::open_verified`] runs `PRAGMA quick_check`, which reads **every +/// page** of the database, and then `Catalog::open`, which takes a full copy of +/// the file before a migration and rewrites its structure. On a 50,000-image +/// library that is tens of megabytes of I/O, and all of it happened before the +/// window had painted anything. +/// +/// On Android that is not a stutter but an ANR. `dr_ui::run` is called from +/// `android_main` and does not reach `window.run()` — the first `poll_events`, +/// and so the first time anything drains the activity's input channel — until +/// every line above it has finished. Five seconds of that and the system offers +/// to kill the app. +/// +/// [`crate::recovery_ui`] and `recovery`'s own module documentation both say the +/// check belongs "at startup, where a failure has a user in front of it who can +/// answer a question". That was always the intent; this is what makes it true, +/// because the question can only be asked once there is an interface to ask it +/// in. +/// +/// # What the user sees meanwhile +/// +/// `library-opening`, which the grid's empty state draws as "Opening the +/// library…" rather than as "Scanning…". They are different answers — one is +/// reading a file on this device, the other is walking a tree over the network +/// — and the grid already refuses to conflate the two states it had. +/// +/// `library-scanning` stays true throughout, which is what keeps the gate above +/// from being merely advisory: the header hides Rescan while it is set, so +/// there is no button that could start a scan into a catalog this has not +/// finished checking. +fn open_catalog_soon( + window: &AppWindow, + ctl: Rc, + coll_ctl: Rc, + conn: Connection, + filter: FormatFilter, + path: PathBuf, +) { + // Already open: a library being re-opened within one session, which is the + // case `show_catalog_now` short-circuited. There is nothing to wait for and + // nothing to say, so the scan starts immediately as it always did. + if ctl.catalog.borrow().is_some() { + begin_scan(window, &ctl, &coll_ctl, conn, filter, path); + return; + } + + window.set_library_opening(true); + window.set_library_status("Reading the catalog on this device…".into()); + + let rx = spawn_catalog_open(path.clone()); + let weak = window.as_weak(); + let held = ctl.clone(); + let timer = slint::Timer::default(); + timer.start( + slint::TimerMode::Repeated, + // Tighter than the scan's 120 ms: this one lands once, and everything + // the grid can show is waiting behind it. + std::time::Duration::from_millis(30), + move || { + let Some(w) = weak.upgrade() else { return }; + let got = match rx.try_recv() { + Ok(got) => got, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + // The worker died without answering — a panic inside SQLite, + // realistically. Treated as "no catalog to show", which is what + // the synchronous path did with any error it could not + // classify: the scan is the thing that has to work. + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + CatalogOpen::Failed("the catalog worker stopped without answering".into()) + } + }; + stop(&held.catalog_timer); + w.set_library_opening(false); + + match got { + CatalogOpen::Opened(cat) => { + adopt_catalog(&w, &held, &coll_ctl, cat); + begin_scan( + &w, + &held, + &coll_ctl, + conn.clone(), + filter.clone(), + path.clone(), + ); + } + CatalogOpen::Corrupt(detail) => { + // No scan, and this is the whole reason the scan waits for + // this answer: `Catalog::open` succeeds on a file whose + // header survived, so a scan would write ETags and image + // rows into damaged pages while the user is still reading + // the question — turning a file that had a backup into one + // where the backup is the only copy left. + // + // `offer` clears the scanning state itself, for exactly + // that reason, so nothing here does it twice. + crate::recovery_ui::offer(&w, &path, &detail); + } + CatalogOpen::Failed(why) => { + // Not surfaced: this is a first run more often than it is + // anything else, the empty state already says the scan is + // running, and an error here would contradict a scan that + // is working perfectly. If the scan fails too, it reports + // for both of them. + log::info!("no catalog to show before the scan: {why}"); + begin_scan( + &w, + &held, + &coll_ctl, + conn.clone(), + filter.clone(), + path.clone(), + ); + } + } + }, + ); + *ctl.catalog_timer.borrow_mut() = Some(timer); +} + +/// What the catalog worker found. +enum CatalogOpen { + /// Checked, opened, migrated forward and backfilled. + Opened(Catalog), + /// `PRAGMA quick_check` failed. Carries SQLite's own words, because the + /// message the user is shown is the diagnosis rather than a paraphrase. + Corrupt(String), + /// Anything else: no catalog file yet, a permission, a locked database. + Failed(String), +} + +/// Check, open and migrate the catalog on a thread of its own. +/// +/// `Catalog` holds a `rusqlite::Connection`, which is `Send` and not `Sync` — +/// exactly the shape that can be built on a worker and handed over once, which +/// is what this does. Nothing else touches the file while it runs: this is the +/// only opener on the launch path, and the scan that opens its own connection +/// does not start until the answer has landed. +fn spawn_catalog_open(path: PathBuf) -> Receiver { + let (tx, rx) = std::sync::mpsc::channel(); + + std::thread::spawn(move || { + let started = std::time::Instant::now(); + let message = match Catalog::open_verified(&path) { + Ok(cat) => { + // The number this change exists for. Worth an `info` line on + // every launch: it is the one figure that says whether a slow + // start is the catalog or something else, and it is not + // measurable from anywhere else. + log::info!( + "catalog checked and opened in {} ms", + started.elapsed().as_millis() + ); + CatalogOpen::Opened(cat) + } + Err(dr_catalog::CatalogError::Corrupt { detail }) => CatalogOpen::Corrupt(detail), + Err(e) => CatalogOpen::Failed(e.to_string()), + }; + let _ = tx.send(message); + }); + + rx +} + +/// Start the scan that brings the catalog up to date. +/// +/// Split out of [`open`] because it no longer runs there — see +/// [`open_catalog_soon`] — and shared with the two paths that reach it once the +/// catalog has been answered for. +fn begin_scan( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, + conn: Connection, + filter: FormatFilter, + path: PathBuf, +) { + log::info!( + "scanning {} for {} format(s) → {}", + if conn.account.root.is_empty() { + "" + } else { + &conn.account.root + }, + filter.iter().count(), + path.display() + ); + window.set_library_status("Starting…".into()); + + let rx = library::spawn_scan( + conn.clone(), + conn.account.root.clone(), + filter, + path.clone(), + ); + + drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); +} + +/// Drain scan progress on the UI thread. +fn drain_scan( + weak: slint::Weak, + ctl: Rc, + coll_ctl: Rc, + rx: Receiver, + catalog_path: std::path::PathBuf, +) { + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + + // Indeterminate for as long as it runs: a recursive walk discovers its own + // extent, so the count it reports is what it has *found*, never a fraction + // of what there is (FR-CAT-1). + // + // Named after the folder, because two accounts or two roots produce rows + // that are otherwise identical. + let title = match ctl.session.borrow().as_ref() { + Some((c, _)) if !c.account.root.is_empty() => { + format!("Scanning {}", c.account.root) + } + _ => "Scanning the library".to_string(), + }; + let job = ctl.activity.begin(crate::activity::Kind::Scan, title); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(120), + move || { + let Some(w) = weak.upgrade() else { return }; + let ctl = &ctl_cb; + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // A worker that died without sending must not leave + // the screen on "Scanning…" forever. + if w.get_library_scanning() { + w.set_library_scanning(false); + w.set_library_error("scan ended unexpectedly".into()); + job.fail("ended unexpectedly"); + } + stop(&ctl.scan_timer); + return; + } + }; + + match msg { + ScanMessage::Progress { + directories, + pruned, + images, + } => { + // Pruned folders are reported separately rather than + // folded into the total: they are the ETag walk paying + // off, and hiding them makes an incremental rescan look + // identical to a full one. + let status = if pruned > 0 { + format!("{directories} folders · {pruned} unchanged · {images} images") + } else { + format!("{directories} folders · {images} images") + }; + job.detail(status.clone()); + w.set_library_status(status.into()); + } + ScanMessage::Done { + found, + total, + pruned, + elapsed_ms, + judgements, + } => { + log::info!( + "scan complete: {total} images ({found} listed, \ + {pruned} folders unchanged, {judgements} judgements \ + taken in) in {elapsed_ms} ms" + ); + w.set_library_scanning(false); + + // A completed scan is the strongest possible evidence + // the server is reachable, so it clears offline mode + // without needing a probe of its own. + if ctl + .reachability + .borrow_mut() + .mark_reachable(std::time::Instant::now()) + { + log::info!("back online"); + } + // TRACES: FR-PLAT-AND-2 + // And it is the only evidence that clears a lost root, + // for the same reason: the walk began by listing the + // root, so a scan that finished is a root that opened. + // The rows it marked offline are restored one at a + // time by `library::persist`, as each file is listed + // again — this only stops the banner claiming what is + // no longer true. + if ctl.root_lost.borrow_mut().take().is_some() { + log::info!("library folder is readable again"); + } + refresh_offline(&w, ctl); + // TRACES: FR-CAT-13 + // The pull may have found an `.xmp` that disagrees + // with the catalog; the settings page offers the + // reload, and this is what tells it how many. + refresh_xmp_conflicts(&w, ctl); + + // An incremental rescan lists almost nothing, so + // reporting the listed count would read as "0 images" + // on a library that is simply up to date. + let secs = elapsed_ms as f64 / 1000.0; + let status = if pruned > 0 && found == 0 { + format!("up to date · {total} images · {secs:.1}s") + } else if pruned > 0 { + format!("{found} new or changed · {total} images · {secs:.1}s") + } else { + format!("{total} images · {secs:.1}s") + }; + // TRACES: FR-CAT-8 | FR-NC-9 + // Said out loud, because a grid that silently gains + // three hundred stars is indistinguishable from one + // that has gone wrong — and because for as long as + // this number was structurally zero, the photographer + // had no way to tell that a cull made on another + // device had failed to arrive. + let status = if judgements > 0 { + format!("{status} · {judgements} from other devices") + } else { + status + }; + job.finish(status.clone()); + w.set_library_status(status.into()); + + // Usually already open — `show_catalog_now` opened it + // before this scan started, so the grid has been + // showing the previous run's catalog all along and the + // rows this scan found are new rows in that same file. + // Only a first run, where there was nothing to open, + // reaches the second arm. + let opened = if ctl.catalog.borrow().is_some() { + Ok(()) + } else { + Catalog::open(&catalog_path).map(|cat| { + *ctl.catalog.borrow_mut() = Some(cat); + }) + }; + + match opened { + Ok(()) => { + // The sidebar is built before the grid: the + // grid's badges read collection membership, and + // the tree is where the catalog handle first + // becomes available to it. + { + let borrow = ctl.catalog.borrow(); + if let Some(cat) = borrow.as_ref() { + crate::collections_ui::refresh_tree(&w, &coll_ctl, cat); + } + } + load_window(&w, ctl); + // Everything the grid did not touch: the rest + // of the library gets a thumbnail and a date, + // so the timeline describes all of it rather + // than the part that was scrolled past. + start_sweep(&w, ctl); + } + Err(e) => w.set_library_error(format!("opening catalog: {e}").into()), + } + stop(&ctl.scan_timer); + return; + } + ScanMessage::Failed { + message, + offline, + lost_root, + } => { + log::warn!("scan failed: {message}"); + w.set_library_scanning(false); + // Recorded as a failure even where it is only the + // connection: the grid's offline banner says the server + // is unreachable, and this says which piece of work + // stopped because of it. + job.fail(message.clone()); + + if lost_root { + // TRACES: FR-PLAT-AND-2 | FR-CAT-9 + // The library folder itself could not be opened — + // a share withdrawn, an unplugged drive, and in + // time a revoked document-tree grant. The worker + // has already marked every row under this root + // offline and deleted none of them; this is the + // half the user sees. + // + // Tested first because it is also true that the + // library is unreachable, and the generic answer + // would be reached first and be less useful. + *ctl.root_lost.borrow_mut() = Some(message); + refresh_offline(&w, ctl); + // Same reason as the offline arm below: without + // this a launch that began with a revoked grant + // shows an empty grid, which is the one impression + // this whole path exists to avoid. + open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); + } else if offline { + // Not an error state. The catalog from the last + // successful scan is still on disk and still + // accurate for everything already indexed, so the + // grid keeps working — it simply cannot learn about + // anything added on the server since. + ctl.reachability + .borrow_mut() + .mark_unreachable(message, std::time::Instant::now()); + refresh_offline(&w, ctl); + // Show whatever the catalog holds. Without this the + // grid stays empty on a launch that began offline, + // which is precisely the case offline mode exists + // for. + open_catalog_for_offline(&w, ctl, &catalog_path, &coll_ctl); + } else { + w.set_library_error(message.into()); + } + stop(&ctl.scan_timer); + return; + } + } + } + }, + ); + + *ctl.scan_timer.borrow_mut() = Some(timer); +} + +/// Start a scan against the configured library. +/// +/// Shared by the rescan button and by the offline banner's retry, which are +/// the same operation: a scan is the only request that both proves the server +/// is reachable and brings the catalog up to date. Keeping them one function +/// is what stops "retry" from quietly becoming a weaker probe than "rescan". +pub(crate) fn start_rescan( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + let Some((conn, filter)) = ctl.session.borrow().clone() else { + return; + }; + + window.set_library_scanning(true); + window.set_library_error(slint::SharedString::new()); + window.set_library_status("Rescanning…".into()); + + let path = library::catalog_path(&conn.account); + let rx = library::spawn_scan( + conn.clone(), + conn.account.root.clone(), + filter, + path.clone(), + ); + drain_scan(window.as_weak(), ctl.clone(), coll_ctl.clone(), rx, path); +} + +/// TRACES: FR-CAT-9 +/// Show the catalog after a scan that could not reach the server. +/// +/// The whole point of offline mode: a scan is how the grid normally gets its +/// catalog handle, so a failed one previously left the library empty even +/// though a complete catalog was sitting on disk from the last successful run. +/// The images are all still there, their thumbnails are in the shards, and +/// rating and collecting them are local writes. +/// +/// The sweep is deliberately **not** started — it exists to fetch headers over +/// the network, so offline it would do nothing but fail once per image. +fn open_catalog_for_offline( + window: &AppWindow, + ctl: &Rc, + catalog_path: &std::path::Path, + coll_ctl: &Rc, +) { + if ctl.catalog.borrow().is_some() { + // Already open — a rescan that failed, rather than a launch that + // began offline. The grid is showing the catalog already. + load_window(window, ctl); + return; + } + + match Catalog::open(catalog_path) { + Ok(cat) => { + crate::collections_ui::refresh_tree(window, coll_ctl, &cat); + *ctl.catalog.borrow_mut() = Some(cat); + load_window(window, ctl); + } + Err(e) => { + // No catalog and no server. This is the one genuinely empty case: + // a first run that never reached the server has nothing indexed. + log::warn!("offline with no local catalog: {e}"); + window.set_library_error( + "Offline, and this library has not been scanned on this device yet.".into(), + ); + } + } +} + +/// How long a run of geometry changes has to stop for before the window is +/// reloaded against it. +/// +/// Long enough to swallow a whole gesture's worth of steps, short enough that a +/// single deliberate step still feels immediate. +const GEOMETRY_SETTLE: std::time::Duration = std::time::Duration::from_millis(140); + +/// Reload the window once the grid's geometry has stopped changing. +/// +/// # Why this is deferred when a scroll is not +/// +/// A pinch is not one zoom step, it is a stream of them, and every step +/// changes both the column count and the capacity — two reports. Each report +/// used to re-query the catalog, rebuild all 360 rows of the model, re-read the +/// badges and ratings for every one of them and spawn a thumbnail batch, +/// synchronously, on the thread that is trying to draw the frame. Twice per +/// step. That is why zooming juddered while scrolling the same grid is smooth: +/// a scroll reloads a few times per screenful, a zoom reloaded twice a frame. +/// +/// None of it is urgent, because none of it is about *which* photographs are on +/// screen. A column change moves the cells and a zoom resizes them, but the +/// window holds the same images either way — the model already has them, and +/// the cells re-flow from `columns` and `cell-size` without Rust being involved +/// at all. What the reload actually recomputes is which cells begin a row, so +/// the month headings land in the right places, and which thumbnail size class +/// to ask for now. Both can wait for the gesture to finish. +/// +/// # The anchor +/// +/// Captured on the *first* report of a run rather than read when the timer +/// fires. As the grid re-flows, the viewport keeps its pixel offset while the +/// rows move underneath it, so the view drifts and reports its drift — reading +/// the anchor at the end would faithfully return to wherever it had wandered +/// to. Taking it at the start returns to the photograph the user was actually +/// looking at when they began the gesture. +pub(super) fn schedule_reload(window: &AppWindow, ctl: &Rc) { + // Only the first report of a run sets it; the rest of the run reuses it. + if ctl.pending_anchor.get().is_none() { + ctl.pending_anchor.set(Some(ctl.resume_at.get())); + } + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + timer.start(slint::TimerMode::SingleShot, GEOMETRY_SETTLE, move || { + let Some(w) = weak.upgrade() else { return }; + let anchor = ctl_cb.pending_anchor.take().unwrap_or(0); + if !w.get_library_visible() { + return; + } + + // The window re-centred on the anchor, and the viewport sent back to + // it: cells are drawn at their absolute place in the library, so a + // change to `columns` moves every one of them and a viewport left + // where it was would be pointing at rows the window no longer covers. + let window_size = *ctl_cb.window.borrow(); + let total = w.get_library_total().max(0) as usize; + *ctl_cb.offset.borrow_mut() = window_start(anchor, window_size, total); + load_window(&w, &ctl_cb); + w.set_library_scroll_to(anchor as i32); + w.set_library_scroll_token(w.get_library_scroll_token() + 1); + }); + + // Replacing the slot drops the previous timer, which is what makes this + // coalesce: only the last report of a run lives long enough to fire. + *ctl.geometry_timer.borrow_mut() = Some(timer); +} + +/// Show what the catalog already holds, without waiting for the scan. +/// +/// **Blocking, and no longer on the launch path.** [`open_catalog_soon`] is +/// what a launch goes through; this is what [`crate::recovery_ui`] goes through +/// once the user has answered the damage question, where the event loop is +/// already running, the file has just been replaced under a `forget_catalog`, +/// and there is a `recovery-busy` state on screen saying so. Same reasoning as +/// `recovery_ui::answer` gives for doing the file copy itself in place: nothing +/// else is on screen to block, and a worker would buy a spinner and cost the +/// guarantee that nothing else touches the file while it is being replaced. +/// +/// # Why a launch should not be a scan +/// +/// A launch does not have to discover the library. The catalog from the last +/// run is on disk, complete, with its thumbnails in the shards beside it — +/// which is precisely the state [`open_catalog_for_offline`] already relies on +/// when the server cannot be reached. Every launch that *could* reach the +/// server threw that away and sat on "Scanning…" over an empty grid for as long +/// as a recursive WebDAV walk of the whole tree takes. On a real library, and +/// especially on a phone's connection, that walk is the entire startup time, +/// spent hiding a grid that was ready before it began. +/// +/// The scan still runs and still replaces this the moment it lands. What it no +/// longer does is gate the first paint on the network. +/// +/// Silent when there is no catalog yet: that is a genuine first run, the empty +/// state already says "Scanning…", and an error here would contradict a scan +/// that is working perfectly. `Catalog::open` creates the file in that case, so +/// what the grid reads is an empty catalog rather than a failure. +/// Returns whether it is safe to go on and scan. +/// +/// `false` means the catalog is damaged and the recovery question is up. The +/// caller must not start a scan on that answer: `Catalog::open` succeeds on a +/// file whose header survived, so the scan would write ETags and image rows +/// into damaged pages while the user is still reading the question — turning a +/// file that had a backup into one where the backup is the only copy left. +pub(crate) fn show_catalog_now( + window: &AppWindow, + ctl: &Rc, + catalog_path: &std::path::Path, + coll_ctl: &Rc, +) -> bool { + if ctl.catalog.borrow().is_some() { + return true; + } + + // Verified rather than plain: this is a moment where a full check is + // affordable and there is a user in front of it who can answer the + // question — they have just answered one. See `dr_catalog::recovery` for + // why it is not on every open. + let cat = match Catalog::open_verified(catalog_path) { + Ok(cat) => cat, + Err(dr_catalog::CatalogError::Corrupt { detail }) => { + crate::recovery_ui::offer(window, catalog_path, &detail); + return false; + } + Err(e) => { + // Not surfaced: the scan is the thing that has to work, and it is + // still running. If it fails too, it reports for both of them. + log::info!("no catalog to show before the scan: {e}"); + return true; + } + }; + + adopt_catalog(window, ctl, coll_ctl, cat); + true +} + +/// Take an opened catalog into the interface. +/// +/// The UI half of opening a catalog, separated from the opening itself so that +/// [`open_catalog_soon`] — which does the opening on a worker — and +/// [`show_catalog_now`] — which still does it in place, for +/// [`crate::recovery_ui`], where the event loop is already running and the file +/// has just been replaced — describe the grid the same way. +/// +/// Everything here is UI-thread work by necessity: it fills the sidebar and the +/// grid model. +fn adopt_catalog( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, + cat: Catalog, +) { + // Before anything else can see this catalog, and exactly once per open — + // the callers' early return on an already-open catalog is what makes it + // once. The job queue is durable, so a run that was killed mid-job left its + // row marked `Running` with nobody holding it; recovery hands those back to + // be resumed rather than lost, and drops jobs naming photographs that have + // since been deleted. + // + // Here rather than wherever a runner starts, because there is no owner + // column in `jobs`: a second recovery pass while a worker held a claim + // would take that claim away from it. + match dr_catalog::runner::recover(cat.connection()) { + Ok(r) if r.did_anything() => log::info!( + "job queue: {} interrupted job(s) resumed, \ + {} for deleted photographs dropped", + r.reclaimed, + r.reaped + ), + Ok(_) => {} + // Not surfaced. The queue is rebuildable like everything else in the + // catalog, and a grid that refuses to open because a background queue + // could not be tidied is the worse failure by some way. + Err(e) => log::warn!("job queue could not be recovered: {e}"), + } + + // The sidebar before the grid, because the grid's badges read collection + // membership — the same order the scan's completion uses. + crate::collections_ui::refresh_tree(window, coll_ctl, &cat); + *ctl.catalog.borrow_mut() = Some(cat); + + // TRACES: FR-UI-8 + // Where the photographer was, before the first window is read rather than + // after. The scope and the filter both change what the grid's query + // returns, so restoring them afterwards would mean reading the top of the + // whole library, drawing it, and then reading again — a visible jump on + // every launch, and on a remote library a screenful of thumbnails fetched + // for photographs nobody asked to see. + // + // The sidebar is refreshed above rather than below for the same ordering + // reason: a scope is restored by naming a row of a tree that has to exist. + let stored = ctl.place_store.borrow().as_ref().and_then(|s| s.load()); + match stored { + Some(place) => apply_place(window, ctl, coll_ctl, &place), + None => load_window(window, ctl), + } +} + +/// TRACES: FR-UI-8 +/// Ask the server where the photographer was, and use it if they have not +/// started working yet. +/// +/// # The two halves of a restore +/// +/// The local record is applied in [`adopt_catalog`], synchronously and before +/// the first window is read: it is on this disk, it costs nothing, and it works +/// with the network down. This is the other half — the record another device +/// left — and it cannot be applied there because it has not arrived yet. +/// +/// # Why it may be ignored when it does arrive +/// +/// A handover is welcome on the way in and unwelcome once the photographer has +/// started. A grid that jumped somewhere else mid-scroll, because a round trip +/// finally landed, would be worse than never handing over at all — the user did +/// not ask for it, cannot see why it happened, and has lost their place to a +/// feature whose entire purpose is keeping it. +/// +/// So `place_untouched` gates it: any scroll, scrub, scope change, filter or +/// opened photograph closes the latch. The record is still adopted onto disk by +/// the exchange in [`crate::derived_sync::sync_place`], so nothing is lost — +/// it simply takes effect at the next launch instead of this one. +/// +/// Silent throughout. A first launch against a library nobody has recorded a +/// place for is the ordinary case, and being offline is not a failure of +/// anything the user asked for. +fn fetch_remote_place( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, + conn: &Connection, +) { + if std::env::var_os("DARKROOM_NO_SYNC").is_some() { + return; + } + + let rx = crate::derived_sync::spawn_place_fetch(conn.clone(), conn.account.root.clone()); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let held = ctl.clone(); + let coll = coll_ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(120), + move || { + let Some(w) = weak.upgrade() else { return }; + let got = match rx.try_recv() { + Ok(got) => got, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + stop(&held.place_fetch_timer); + return; + } + }; + stop(&held.place_fetch_timer); + + let theirs = match got { + Ok(Some(place)) => place, + Ok(None) => return, + Err(e) => { + log::debug!("no place from the server: {e}"); + return; + } + }; + + // Newer than what this device has, or there is nothing to learn. + // + // The borrow is scoped rather than dropped by hand: `apply_place` + // below reaches back into the controller, and a `Ref` still held + // across it is the shape a `RefCell` panic takes. + let adopted = match held.place_store.borrow().as_ref() { + Some(store) => store.adopt(&theirs), + None => false, + }; + if !adopted { + return; + } + + // On disk either way; on screen only if nobody has moved. + if !held.place_untouched.get() { + log::info!("a newer place arrived, and will be used at the next launch"); + return; + } + // And only once the catalog is open — the ordinal it resolves to is + // a query against it. A record that lands first is left on disk, + // where `adopt_catalog` reads it. + if held.catalog.borrow().is_none() { + return; + } + log::info!("picking up where another device left off"); + apply_place(&w, &held, &coll, &theirs); + }, + ); + *ctl.place_fetch_timer.borrow_mut() = Some(timer); +} + +/// TRACES: FR-UI-8 +/// What this device signs a place with. +/// +/// The thumbnail store's client id, which is already this device's name among +/// the clients sharing a library — it is what qualifies a shard's remote +/// filename. Reusing it means the place and the shards a machine publishes +/// carry one name in `.darkroom-derived/`, rather than two that have to be +/// correlated by hand. +/// +/// Empty where the store will not open. The field is informational, so an +/// unsigned place is worth strictly more than no place at all. +fn device_id(account: &Account) -> String { + match dr_thumbs::ThumbStore::open(&library::thumbs_dir(account)) { + Ok(store) => store.client_id().to_string(), + Err(e) => { + log::debug!("no device id for the place record: {e}"); + String::new() + } + } +} + +/// Drop the open catalog, so the next `show_catalog_now` opens the file +/// again rather than returning early. +/// +/// Only recovery needs this, and it needs it for a specific reason: the file +/// under that connection has been replaced. A handle to the catalog that was +/// there before is a handle to a file that no longer has a name, and every +/// read through it would return the damaged pages the recovery just moved out +/// of the way. +pub(crate) fn forget_catalog(ctl: &Rc) { + *ctl.catalog.borrow_mut() = None; +} + +/// The line in the library header that says what is being catalogued. +/// +/// For an account with a user, the user and the chosen subtree, or the +/// whole account when none was chosen. For a folder there is no user and +/// the endpoint is the library, so the folder's own name: `library`, or +/// `library/2026` when narrowed — never " · whole account", which is a +/// sentence about a server said of a directory. +fn library_root_label(account: &Account) -> String { + if account.login.is_empty() { + let folder = std::path::Path::new(&account.endpoint) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| account.endpoint.clone()); + if account.root.is_empty() { + folder + } else { + format!("{folder}/{}", account.root) + } + } else if account.root.is_empty() { + format!("{} · whole account", account.user_id) + } else { + format!("{}/{}", account.user_id, account.root) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_header_names_a_folder_library_by_its_folder() { + let mut folder = Account::new("folder", "/var/tmp/dr-demo/library"); + assert_eq!(library_root_label(&folder), "library"); + folder.root = "2026".into(); + assert_eq!(library_root_label(&folder), "library/2026"); + + let mut cloud = + Account::new("nextcloud", "https://cloud.example").with_login("duncan", "duncan"); + assert_eq!(library_root_label(&cloud), "duncan · whole account"); + cloud.root = "PhotosRaw".into(); + assert_eq!(library_root_label(&cloud), "duncan/PhotosRaw"); + } +} diff --git a/ui/dr-ui/src/library_ui/ratings_keywords.rs b/ui/dr-ui/src/library_ui/ratings_keywords.rs new file mode 100644 index 0000000..6cd6f38 --- /dev/null +++ b/ui/dr-ui/src/library_ui/ratings_keywords.rs @@ -0,0 +1,1030 @@ +//! Ratings, flags, bursts and keywords: applying a judgement to a selection, +//! keeping the grid and the filter chips honest about what landed, and the +//! sidecar and XMP writes a judgement or a paste of develop settings queues +//! behind it. +//! +//! `dr_catalog::rating` and `dr_catalog::keywords` own the data rules; this +//! owns which photographs a keystroke or a sheet acts on, and reporting what +//! actually changed rather than what was asked for. See +//! `docs/dev/code-health.md` CH-1. + +use std::cell::RefCell; +use std::rc::Rc; + +use dr_catalog::Catalog; +use slint::{ComponentHandle, Model as _}; + +use crate::library; +use crate::{AppWindow, KeywordRow}; + +use super::controller::{stop, LibraryController}; +use super::window::load_window; + +/// Push each visible image's stars and flag into the grid model. +/// +/// One query for the window, mirroring `collections_ui::sync_badges` — 120 +/// cells is 120 round trips otherwise, on every scroll and after every +/// keystroke. +pub fn sync_ratings(window: &AppWindow, catalog: &Catalog, ids: &[dr_types::ImageId]) { + if ids.is_empty() { + return; + } + + let found = match dr_catalog::rating::judgements(catalog.connection(), ids) { + Ok(j) => j, + Err(e) => { + // The grid is still usable without stars, so this is logged rather + // than surfaced — a failure here must not blank the library. + log::debug!("reading ratings: {e}"); + return; + } + }; + + let model = window.get_library_cells(); + for (row, id) in ids.iter().enumerate() { + // Absent means unrated, which is a real state rather than missing data. + let j = found.get(id).copied().unwrap_or_default(); + let (rating, flag) = (j.rating as i32, flag_code(j.flag)); + + if let Some(mut cell) = model.row_data(row) { + if cell.rating != rating || cell.flag != flag { + cell.rating = rating; + cell.flag = flag; + model.set_row_data(row, cell); + } + } + } +} + +/// Refresh the filter chips' per-star counts. +/// +/// Whole-library figures, deliberately: they say what narrowing to a filter +/// would show, so computing them over the current window would make each chip +/// describe the view it is meant to change. +pub(super) fn refresh_rating_counts(window: &AppWindow, catalog: &Catalog) { + let counts = dr_catalog::rating::rating_histogram(catalog.connection()).unwrap_or_default(); + let as_i32: Vec = counts.iter().map(|n| *n as i32).collect(); + window.set_library_rating_counts(slint::ModelRc::new(slint::VecModel::from(as_i32))); + + // TRACES: FR-CAT-9 + // How many originals are actually here, for the "On this device" chip. + // Shown for the same reason the star counts are: a filter that silently + // empties the grid reads as broken, and this one will legitimately be zero + // on a library nothing has been downloaded from yet. + window.set_library_local_count(library::local_original_count(catalog).unwrap_or(0) as i32); +} + +// --- keywords (FR-CAT-5, FR-CAT-6) --------------------------------------- +// +// `dr_catalog::keywords` owns the data rules — the vocabulary, the many-to-many +// join, what a rename does to the assignments. This part owns the *interaction*: +// which photographs the sheet is acting on, and keeping what it draws honest +// about what actually landed. + +/// Redraw the keywording sheet against whatever is selected now. +/// +/// Called when the sheet opens and after every assignment, rather than on every +/// selection change: the selection moves on each arrow key and the sheet is shut +/// for almost all of them, so computing coverage over a forty-image selection +/// on each one would be work nobody is looking at. +/// +/// Re-read from the catalog rather than patched in place after a write. A word +/// applied to a selection that partly already had it moves from "3 of 12" to +/// "12 of 12", and a model updated by hand would have to reproduce the rule +/// that decides that — which is exactly the rule the catalog has just applied. +fn refresh_keywords(window: &AppWindow, ctl: &Rc, images: &[dr_types::ImageId]) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + let rows = match dr_catalog::keywords::for_images(catalog.connection(), images) { + Ok(rows) => rows, + Err(e) => { + // The grid is entirely usable without the sheet, so this is logged + // rather than surfaced: a keyword read that failed must not put an + // error banner over a library the user is browsing. + log::debug!("reading keywords: {e}"); + return; + } + }; + + let model: Vec = rows + .into_iter() + .map(|row| KeywordRow { + id: row.keyword.id.0 as i32, + name: row.keyword.name.into(), + coverage: match row.coverage { + dr_catalog::Coverage::None => 0, + dr_catalog::Coverage::Some => 1, + dr_catalog::Coverage::All => 2, + }, + selected_count: row.selected_count as i32, + image_count: row.keyword.image_count as i32, + }) + .collect(); + + window.set_library_keywords(slint::ModelRc::new(slint::VecModel::from(model))); +} + +/// Put a keyword on the selection, or take it off. +/// +/// # Why this does not write a sidecar +/// +/// Every other judgement in this file — a star, a flag — is written to the +/// catalog and then queued to the image's sidecar, because the sidecar is what +/// makes it survive a catalog rebuild (ARCH §6.12). A keyword has no place in +/// the sidecar format yet: `dr_pipeline::sidecar::Version` carries `rating` and +/// `flag` and nothing else that is not an edit-graph parameter. +/// +/// So a keyword is, for now, catalog state that reaches the user's other +/// devices through the *catalog* merge ([`dr_catalog::merge`]) rather than +/// through the sidecar. That is a real limitation and not a silent one: a +/// deleted catalog loses keywords where it would keep ratings, until the +/// sidecar gains a `dc:subject` field (FR-CAT-13) and this grows the same +/// queued write the stars have. +fn apply_keyword(window: &AppWindow, ctl: &Rc, word: &str, assigning: bool) { + let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|c| c.upgrade()) else { + return; + }; + let images = coll.selected(); + + // The word as it will be *stored*, resolved before anything is written. + // The status line below quotes it back, and quoting what was typed would + // report a leading space the catalog is about to drop — leaving the user to + // wonder whether it mattered. + // + // This is also where a blank keyword is caught, which is why it happens + // before the selection check: "you typed nothing" is a better answer than + // "select an image first" to someone who pressed return on an empty field. + let word = match dr_catalog::keywords::normalise(word) { + Ok(word) => word, + Err(e) => { + // `BadName` carries text written to be read by the user rather than + // by a developer, so it is shown as it is. + window.set_library_error(format!("{e}").into()); + return; + } + }; + + // Assigning with nothing selected still means something — it puts the word + // in the vocabulary, ready for the photographs it was typed for — so only + // the removal half needs a selection to act on. + if images.is_empty() && !assigning { + window.set_library_status("Select an image first".into()); + return; + } + + let outcome = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let conn = catalog.connection(); + if assigning { + dr_catalog::keywords::assign(conn, &images, &word) + } else { + dr_catalog::keywords::unassign(conn, &images, &word) + } + }; + + let n = match outcome { + Ok(n) => n, + Err(e) => { + window.set_library_error(format!("{e}").into()); + return; + } + }; + + window.set_library_error(slint::SharedString::new()); + window.set_library_status(keyword_summary(&word, n, images.len(), assigning).into()); + refresh_keywords(window, ctl, &images); + start_xmp_writes(window, ctl, &images); + + // A filtered grid may no longer hold what was just keyworded — taking + // "puffin" off an image while showing only puffins means it belongs + // elsewhere now. The same reasoning as a rating that falls below the star + // filter. + if !ctl.filter.borrow().is_unfiltered() { + load_window(window, ctl); + } +} + +/// What the status line says about a keyword that just landed. +/// +/// The honest count, not the requested one: "added to 3 of 12" is what +/// happened when nine of them already carried the word, and a message that +/// claimed twelve would be teaching the user that the counts are decorative. +fn keyword_summary(word: &str, changed: usize, selected: usize, assigning: bool) -> String { + if selected == 0 { + return format!("Added “{word}” to the keyword list"); + } + let verb = if assigning { "Added" } else { "Removed" }; + let preposition = if assigning { "to" } else { "from" }; + if changed == 0 { + return if assigning { + format!("Every selected photograph already had “{word}”") + } else { + format!("None of the selected photographs had “{word}”") + }; + } + if changed == selected { + let what = if selected == 1 { + "1 photograph".to_string() + } else { + format!("{selected} photographs") + }; + return format!("{verb} “{word}” {preposition} {what}"); + } + format!("{verb} “{word}” {preposition} {changed} of {selected}") +} + +/// Apply a judgement to a set of images: catalog first, then sidecars. +/// +/// # Order matters +/// +/// The catalog is written **synchronously and first**, so the star appears +/// immediately and survives a restart even if the network is down. The sidecar +/// write is queued behind it on a worker thread — it is what makes the +/// judgement survive a *catalog rebuild* (ARCH §6.12), which is a slower and +/// rarer concern than the user seeing their keystroke take effect. +/// +/// Doing it the other way round would mean a cull that stalls on every +/// keypress waiting for a round trip, on a workflow whose entire premise is +/// speed (FR-CULL-1). +fn apply_judgement( + window: &AppWindow, + ctl: &Rc, + images: &[dr_types::ImageId], + rating: Option, + flag: Option, +) { + if images.is_empty() { + // Nothing selected. Said out loud rather than ignored: a keystroke + // that silently does nothing reads as a broken key. + window.set_library_status("Select an image first".into()); + return; + } + + let writes = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let conn = catalog.connection(); + + let wrote = match (rating, flag) { + (Some(r), _) => dr_catalog::rating::set_rating_many(conn, images, r), + (_, Some(f)) => dr_catalog::rating::set_flag_many(conn, images, f), + // Neither axis named: nothing to do, and not an error. + (None, None) => return, + }; + + if let Err(e) = wrote { + window.set_library_error(format!("recording rating: {e}").into()); + return; + } + + // Report what happened, in the user's terms rather than as a count of + // rows. A bulk judgement on a selection is easy to trigger by accident + // and the status line is the only confirmation of its extent. + window.set_library_status(judgement_summary(images.len(), rating, flag).into()); + + // Refresh the grid and the chips from what actually landed, rather + // than assuming the write took: a clamped or coalesced value must show + // as what is stored. + let visible = ctl.visible_ids(); + sync_ratings(window, catalog, &visible); + refresh_rating_counts(window, catalog); + + collect_sidecar_writes(catalog, images) + }; + + // A filtered grid may no longer contain what was just judged — rating an + // image 2 while showing "★4+" means it belongs elsewhere now. Reloading + // keeps the cells and the header count honest. + if !ctl.filter.borrow().is_unfiltered() { + load_window(window, ctl); + } + + start_sidecar_writes(window, ctl, writes); + start_xmp_writes(window, ctl, images); +} + +/// What the status line says about a judgement that just landed. +fn judgement_summary(n: usize, rating: Option, flag: Option) -> String { + let what = match (rating, flag) { + (Some(0), _) => "unrated".to_string(), + (Some(r), _) => format!("{r} star{}", if r == 1 { "" } else { "s" }), + (_, Some(dr_types::FlagState::Pick)) => "picked".to_string(), + (_, Some(dr_types::FlagState::Reject)) => "rejected".to_string(), + (_, Some(dr_types::FlagState::Unflagged)) => "unflagged".to_string(), + (None, None) => return String::new(), + }; + if n == 1 { + what + } else { + format!("{n} images · {what}") + } +} + +/// TRACES: FR-DEV-6 +/// Apply copied develop settings to a selection of images. +/// +/// # Why this goes straight to the sidecars +/// +/// The sidecar is the authoritative store for an edit (ARCH §6.12) and the +/// catalog holds no parameters at all — only a `graph_hash` — so there is +/// nothing here for the catalog to record. Nor is any image opened: applying +/// to forty frames by loading forty develop sessions would mean forty RAW +/// downloads and forty demosaics to move some numbers between two maps, which +/// is not a thing to ask of a phone. See [`crate::presets`]. +/// +/// # What the user sees +/// +/// Nothing in the grid changes — a thumbnail is rendered from the server's +/// preview and does not reflect an edit — so the status line is the only +/// confirmation, exactly as it is for a bulk judgement. The applied settings +/// appear when a target is next opened in develop, which is what reads the +/// sidecar back. +pub fn paste_settings_to_selection( + window: &AppWindow, + ctl: &Rc, + images: &[dr_types::ImageId], + preset: &dr_pipeline::Preset, + scope: dr_pipeline::Scope, +) { + if images.is_empty() { + // Said out loud rather than ignored, matching what a judgement + // keystroke does with an empty selection. + window.set_library_status("Select an image first".into()); + return; + } + + let writes = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + // A never-judged image may have no version row yet, and the query + // below joins on one. Ratings create them as a side effect; a paste + // is the first write path that can reach an image which has never + // been rated, so it has to ask for them itself. + if let Err(e) = dr_catalog::rating::ensure_default_versions(catalog.connection()) { + log::debug!("ensuring versions before a paste: {e}"); + } + collect_settings_writes(catalog, images, preset, scope) + }; + + if writes.is_empty() { + window.set_library_error("Could not find those images in the catalog.".into()); + return; + } + + let count = writes.len(); + window.set_library_status( + format!( + "Applied settings to {count} image{}.", + if count == 1 { "" } else { "s" } + ) + .into(), + ); + start_sidecar_writes(window, ctl, writes); +} + +/// Gather one settings write per image, addressed by remote path and version. +/// +/// The uuid comes from the catalog for the same reason a judgement's does: it +/// is the identity a cross-device merge keys on, and a generated one would +/// write a second version beside the one the image already has (FR-NC-8). +fn collect_settings_writes( + catalog: &Catalog, + images: &[dr_types::ImageId], + preset: &dr_pipeline::Preset, + scope: dr_pipeline::Scope, +) -> Vec { + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT i.source_ref, v.uuid + FROM images i + JOIN versions v ON v.image_id = i.id AND v.is_default = 1 + WHERE i.id IN ({placeholders})" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let Ok(mut stmt) = catalog.connection().prepare(&sql) else { + return Vec::new(); + }; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(library::SidecarWrite { + image_path: r.get(0)?, + version_uuid: r.get(1)?, + amendment: library::Amendment::Settings { + preset: preset.clone(), + scope, + // A paste carries no masks, and must not: a mask is drawn + // against one photograph and describes nothing on another. + // The target keeps whatever local adjustments it already had. + masks: None, + // A paste carries no film either, and for a plainer reason + // than the masks: a preset is a parameter map, and a stock is + // not a parameter. Pasting one would be pasting a choice the + // clipboard never captured. + film: None, + // Nor snapshots: they are states of the photograph they were + // taken on, and mean nothing on another. + snapshots: None, + }, + }) + }); + + match rows { + Ok(rows) => rows.flatten().collect(), + Err(e) => { + log::debug!("collecting settings writes: {e}"); + Vec::new() + } + } +} + +/// Gather what the sidecar writer needs for each judged image. +/// +/// The version uuid comes from the catalog rather than being generated here: +/// it is the identity a cross-device merge keys on, so the sidecar and the +/// catalog must name the same version or a sync would treat one photograph's +/// judgement as two (FR-NC-8). +fn collect_sidecar_writes( + catalog: &Catalog, + images: &[dr_types::ImageId], +) -> Vec { + let placeholders = std::iter::repeat_n("?", images.len()) + .collect::>() + .join(","); + let sql = format!( + "SELECT i.source_ref, v.uuid, v.rating, v.flag + FROM images i + JOIN versions v ON v.image_id = i.id AND v.is_default = 1 + WHERE i.id IN ({placeholders})" + ); + let params: Vec = images + .iter() + .map(|i| rusqlite::types::Value::Integer(i.0 as i64)) + .collect(); + + let Ok(mut stmt) = catalog.connection().prepare(&sql) else { + return Vec::new(); + }; + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |r| { + Ok(library::SidecarWrite { + image_path: r.get(0)?, + version_uuid: r.get(1)?, + amendment: library::Amendment::Judgement { + rating: r.get::<_, i64>(2)? as u8, + flag: r.get::<_, i64>(3)? as u8, + }, + }) + }); + + match rows { + Ok(rows) => rows.flatten().collect(), + Err(e) => { + log::debug!("collecting sidecar writes: {e}"); + Vec::new() + } + } +} + +/// Push judgements out to sidecars on a worker, reporting once at the end. +/// TRACES: FR-CAT-13 | NFR-R4 +/// Write these images' ratings, labels and keywords to the `.xmp` beside +/// each, where the user has switched that on. +/// +/// The record is read from the catalog *now*, whole, rather than carried +/// from the gesture: a rating and a keyword typed a second apart are two +/// writes of the same file, and the second must not carry a copy of the +/// first taken before it landed. +pub(crate) fn start_xmp_writes( + window: &AppWindow, + ctl: &Rc, + images: &[dr_types::ImageId], +) { + if !ctl.write_xmp.get() || images.is_empty() || ctl.is_offline() { + return; + } + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + let writes: Vec = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let c = catalog.connection(); + images + .iter() + .filter_map(|&image| { + let version = dr_catalog::rating::default_version_id(c, image).ok()?; + let image_path: String = c + .query_row( + "SELECT source_ref FROM images WHERE id = ?1", + [image.0 as i64], + |r| r.get(0), + ) + .ok()?; + Some(library::XmpWrite { + image_path, + record: crate::xmp_sync::record_of(c, image, version), + }) + }) + .collect() + }; + if writes.is_empty() { + return; + } + + let count = writes.len(); + let rx = library::spawn_xmp_writes(conn, writes); + let job = ctl.activity.begin( + crate::activity::Kind::Upload, + format!("Writing {count} XMP sidecar(s)"), + ); + drain_xmp(window.as_weak(), ctl.clone(), rx, job, false); +} + +/// TRACES: FR-CAT-13 +/// The offered reload: take the sidecars' values for every photograph the +/// last pull found disagreeing with the catalog. +pub(crate) fn start_xmp_reload(window: &AppWindow, ctl: &Rc) { + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + let paths: Vec = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(root_id) = root_id_of(catalog, &conn.account.root) else { + return; + }; + crate::xmp_sync::conflicts(catalog, root_id) + .into_iter() + .map(|c| c.path) + .collect() + }; + if paths.is_empty() { + return; + } + let count = paths.len(); + let rx = library::spawn_xmp_reload( + conn.clone(), + conn.account.root.clone(), + library::catalog_path(&conn.account), + paths, + ); + let job = ctl.activity.begin( + crate::activity::Kind::Download, + format!("Reloading {count} XMP sidecar(s)"), + ); + drain_xmp(window.as_weak(), ctl.clone(), rx, job, true); +} + +/// Wait for a batch of XMP work to report, then say what it did. +fn drain_xmp( + weak: slint::Weak, + ctl: Rc, + rx: std::sync::mpsc::Receiver, + job: crate::activity::Activity, + reload: bool, +) { + let timer = slint::Timer::default(); + let job = RefCell::new(Some(job)); + let ctl_cb = ctl.clone(); + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(200), + move || { + let ctl = &ctl_cb; + let message = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + stop(&ctl.xmp_timer); + return; + } + }; + stop(&ctl.xmp_timer); + let library::XmpMessage::Finished { + written, + failed, + last_error, + } = message; + let status = match (reload, failed, last_error) { + (false, 0, _) => format!("{written} XMP sidecar(s) written"), + (true, 0, _) => format!("{written} photograph(s) reloaded from XMP"), + (_, n, Some(e)) => format!("{n} XMP sidecar(s) failed: {e}"), + (_, n, None) => format!("{n} XMP sidecar(s) failed"), + }; + if let Some(job) = job.borrow_mut().take() { + if failed > 0 { + job.fail(status.clone()); + } else { + job.finish(status.clone()); + } + } + if let Some(w) = weak.upgrade() { + w.set_library_status(status.into()); + if reload { + // The grid draws what the reload changed, and the + // settings page stops offering what is settled. + let visible = ctl.visible_ids(); + if let Some(catalog) = ctl.catalog.borrow().as_ref() { + sync_ratings(&w, catalog, &visible); + refresh_rating_counts(&w, catalog); + } + refresh_xmp_conflicts(&w, ctl); + } + } + }, + ); + *ctl.xmp_timer.borrow_mut() = Some(timer); +} + +/// TRACES: FR-CAT-13 +/// How many sidecars the last pull found disagreeing with the catalog, for +/// the settings page to offer the reload against. +pub(crate) fn refresh_xmp_conflicts(window: &AppWindow, ctl: &Rc) { + let count = (|| { + let (conn, _) = ctl.session.borrow().clone()?; + let borrow = ctl.catalog.borrow(); + let catalog = borrow.as_ref()?; + let root_id = root_id_of(catalog, &conn.account.root)?; + Some(crate::xmp_sync::conflicts(catalog, root_id).len()) + })() + .unwrap_or(0); + window.set_settings_xmp_conflicts(count as i32); +} + +fn root_id_of(catalog: &Catalog, root: &str) -> Option { + catalog + .connection() + .query_row( + "SELECT id FROM roots WHERE label = ?1 AND kind = 'remote'", + [root], + |r| r.get(0), + ) + .ok() +} + +pub(crate) fn start_sidecar_writes( + window: &AppWindow, + ctl: &Rc, + writes: Vec, +) { + if writes.is_empty() { + return; + } + + // TRACES: FR-CAT-9 + // Offline is passed down rather than used to skip. + // + // It used to skip, and the reasoning was that a cull stays responsive + // because the rating is safe in the catalog. That held for judgements and + // not for edits: the catalog stores no parameters, so a pasted edit made + // offline survived nowhere at all. Every write now commits to the local + // sidecar cache first and the upload is best-effort, which keeps the + // keystroke path off the network — the original concern — without the + // write being conditional on it. + let offline = ctl.is_offline(); + + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + let Some(cache_dir) = ctl.sidecar_cache_dir() else { + return; + }; + + let count = writes.len(); + let rx = library::spawn_sidecar_writes(conn.clone(), writes, cache_dir, offline); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + // The writer reports once at the end, so there is no per-file progress to + // show — but a cull that has just rated forty frames has forty uploads in + // flight, and "is that saved yet" deserves an answer somewhere. + let job = ctl.activity.begin( + crate::activity::Kind::Upload, + format!("Saving {count} judgement(s)"), + ); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(200), + move || { + let Some(w) = weak.upgrade() else { return }; + // One message is all this channel ever carries — the writer reports + // `Finished` once and hangs up — so this drains a single item rather + // than looping like the scan and thumbnail drains do. + match rx.try_recv() { + Ok(library::SidecarMessage::Finished { + written, + queued, + failed, + last_error, + }) => { + if failed == 0 && queued > 0 { + // Recorded locally, waiting for the server. Said out + // loud because the user has just made an edit with no + // connection and deserves to know it is safe — the + // old behaviour here was to drop it silently. + log::debug!("{queued} sidecar(s) queued for upload"); + ctl_cb.outbox_maybe_dirty.set(true); + job.finish_quietly(); + w.set_library_status( + format!("{queued} edit(s) saved · will upload when back online").into(), + ); + stop(&ctl_cb.sidecar_timer); + return; + } + if failed > 0 { + // A failed upload left the edit in the outbox. + ctl_cb.outbox_maybe_dirty.set(true); + log::warn!( + "{failed} sidecar write(s) failed: {}", + last_error.clone().unwrap_or_default() + ); + job.fail(format!( + "{written} saved · {failed} failed: {}", + last_error.clone().unwrap_or_default() + )); + // Said plainly, because the consequence is specific: + // the rating is safe in the catalog but will not + // survive deleting it. + w.set_library_status( + format!( + "{written} saved · {failed} could not be written \ + to the library folder" + ) + .into(), + ); + } else { + log::debug!("{written} sidecar(s) written"); + // Quietly: a cull produces one of these every few + // seconds and none of them is news. + job.finish_quietly(); + } + stop(&ctl_cb.sidecar_timer); + } + Err(std::sync::mpsc::TryRecvError::Empty) => {} + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + stop(&ctl_cb.sidecar_timer); + } + } + }, + ); + + *ctl.sidecar_timer.borrow_mut() = Some(timer); +} + +/// Slint carries the flag as an integer, matching the catalog's encoding. +fn flag_code(f: dr_types::FlagState) -> i32 { + match f { + dr_types::FlagState::Unflagged => 0, + dr_types::FlagState::Pick => 1, + dr_types::FlagState::Reject => 2, + } +} + +/// The flag an integer from Slint stands for. +fn flag_from_code(v: i32) -> dr_types::FlagState { + match v { + 1 => dr_types::FlagState::Pick, + 2 => dr_types::FlagState::Reject, + _ => dr_types::FlagState::Unflagged, + } +} + +/// --- ratings and flags (FR-CAT-5, FR-CULL-4) -------------------------- +pub(super) fn wire_ratings_and_flags( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + // Clicking a star rates *that cell*, not the selection. The pointer names + // one photograph unambiguously, and a click that silently rated forty + // others would be a trap — the keyboard is the bulk gesture. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_cell_rated(move |row, stars| { + let Some(w) = weak.upgrade() else { return }; + let id = ctl + .image_ids + .borrow() + .get(row as usize) + .map(|id| dr_types::ImageId(*id as u64)); + let Some(id) = id else { return }; + + apply_judgement(&w, &ctl, &[id], Some(stars.clamp(0, 5) as u8), None); + }); + } + + // Fold a burst up, or open it out (FR-CULL-5). A reload rather than a repaint, because + // it changes what the grid's query returns — see [`crate::bursts::toggle`]. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_burst_toggled(move |row| { + let Some(w) = weak.upgrade() else { return }; + let id = ctl + .image_ids + .borrow() + .get(row as usize) + .map(|id| dr_types::ImageId(*id as u64)); + let Some(id) = id else { return }; + + let changed = { + let borrow = ctl.catalog.borrow(); + match borrow.as_ref() { + Some(catalog) => crate::bursts::toggle(catalog, id), + None => false, + } + }; + if changed { + ctl.requested.borrow_mut().clear(); + load_window(&w, &ctl); + } + }); + } + + // Name the frame an open burst folds to (FR-CULL-5). The default is the + // earliest of them, chosen because it is a fact about the clock and not a + // judgement about the photograph; this is the photographer, who is the only + // one who knows which of the twelve is the keeper, saying otherwise. + // + // A repaint of the badges rather than a reload, which is what separates it + // from folding a burst up: the grid's query returns the same rows either + // way while the group is open, and only the mark on them moves. Refilled + // for the whole window in one statement — the tick has to leave the frame + // that was carrying it, and that frame is a different cell. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_burst_representative_chosen(move |row| { + let Some(w) = weak.upgrade() else { return }; + let ids: Vec = ctl + .image_ids + .borrow() + .iter() + .map(|id| dr_types::ImageId(*id as u64)) + .collect(); + let Some(id) = ids.get(row as usize).copied() else { + return; + }; + + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + if crate::bursts::choose(catalog, id) { + crate::bursts::sync_badges(&w, catalog, &ids); + } + }); + } + + // A rating or flag key. Applies to the whole selection, which is what + // makes judging a run of frames one keystroke rather than forty. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_keys = coll_ctl.clone(); + window.on_library_judged(move |rating, flag| { + let Some(w) = weak.upgrade() else { return }; + let chosen = coll_for_keys.selected(); + + // Exactly one axis is meant per keystroke; the other arrives as + // -1 so a star press cannot disturb a flag or the reverse. + if rating >= 0 { + apply_judgement(&w, &ctl, &chosen, Some(rating.clamp(0, 5) as u8), None); + } else if flag >= 0 { + apply_judgement(&w, &ctl, &chosen, None, Some(flag_from_code(flag))); + } + }); + } +} + +/// --- keywords (FR-CAT-5, FR-CAT-6) ------------------------------------ +/// +/// Three callbacks and no state of their own: the sheet's open/shut is local +/// to the `.slint` file, and what a keyword applies to is the grid selection +/// the collections controller already owns. A second copy of either here is +/// a second thing that can disagree with the first. +pub(super) fn wire_keywords( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, +) { + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + let coll_for_keywords = coll_ctl.clone(); + window.on_library_keywords_opened(move || { + let Some(w) = weak.upgrade() else { return }; + refresh_keywords(&w, &ctl, &coll_for_keywords.selected()); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_assign_keyword(move |word| { + let Some(w) = weak.upgrade() else { return }; + apply_keyword(&w, &ctl, word.as_str(), true); + }); + } + + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_unassign_keyword(move |word| { + let Some(w) = weak.upgrade() else { return }; + apply_keyword(&w, &ctl, word.as_str(), false); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- what the status line says about a keyword (FR-CAT-5) ------------- + // + // Split out from the callback for the same reason `decide_drop` is: the + // sheet cannot be driven from a test, and this is the part that can + // actually mislead someone. + + /// TRACES: FR-CAT-5 + #[test] + fn a_partly_applied_keyword_reports_the_honest_count() { + // Nine of the twelve already had it. Claiming twelve is how a user + // learns that the counts are decorative. + assert_eq!( + keyword_summary("puffin", 3, 12, true), + "Added “puffin” to 3 of 12" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn a_keyword_that_changed_nothing_says_so_rather_than_claiming_success() { + assert_eq!( + keyword_summary("puffin", 0, 12, true), + "Every selected photograph already had “puffin”" + ); + assert_eq!( + keyword_summary("puffin", 0, 12, false), + "None of the selected photographs had “puffin”" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn one_photograph_is_singular() { + // "Added to 1 photographs" is the kind of small wrongness that makes + // the rest of the interface look unfinished. + assert_eq!( + keyword_summary("puffin", 1, 1, true), + "Added “puffin” to 1 photograph" + ); + assert_eq!( + keyword_summary("puffin", 2, 2, true), + "Added “puffin” to 2 photographs" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn removing_a_keyword_reads_as_removal() { + assert_eq!( + keyword_summary("blurry", 4, 4, false), + "Removed “blurry” from 4 photographs" + ); + } + + /// TRACES: FR-CAT-5 + #[test] + fn typing_a_word_with_nothing_selected_says_what_it_did_do() { + // It builds the vocabulary, which is a legitimate thing to do ahead of + // a shoot — so it must not report itself as having keyworded nothing. + assert_eq!( + keyword_summary("puffin", 0, 0, true), + "Added “puffin” to the keyword list" + ); + } +} diff --git a/ui/dr-ui/src/library_ui/sync.rs b/ui/dr-ui/src/library_ui/sync.rs new file mode 100644 index 0000000..3e4549f --- /dev/null +++ b/ui/dr-ui/src/library_ui/sync.rs @@ -0,0 +1,538 @@ +//! The background passes that reach beyond the loaded window: the day's +//! catalog backup, the metadata sweep that dates every image rather than +//! only the ones scrolled past, the whole-library thumbnail pass, and the +//! exchange with the server that follows each of them. +//! +//! Split from `window` because these run on their own timers over the whole +//! library rather than in response to what the grid is showing. +//! See `docs/dev/code-health.md` CH-1. + +use std::rc::Rc; + +use slint::ComponentHandle; + +use crate::library; +use crate::AppWindow; + +use super::controller::{stop, LibraryController}; +use super::open::schedule_reload; +use super::timeline::refresh_timeline; +use super::window::load_window; + +/// TRACES: NFR-R2 +/// Take the day's catalog backup if one is due, off the UI thread. +/// +/// Decided cheaply first — [`dr_catalog::recovery::backup_due`] reads a +/// directory listing, not the catalog — so the ordinary case of "backed up +/// this morning already" costs no thread and no connection. +fn spawn_scheduled_backup(ctl: &Rc) { + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + let catalog_path = library::catalog_path(&conn.account); + if !dr_catalog::recovery::backup_due(&catalog_path) { + return; + } + std::thread::spawn(move || { + let catalog = match dr_catalog::Catalog::open(&catalog_path) { + Ok(c) => c, + Err(e) => { + log::warn!("scheduled backup: opening the catalog: {e}"); + return; + } + }; + if let Err(e) = dr_catalog::recovery::backup_if_due(catalog.connection(), &catalog_path) { + log::warn!("scheduled backup: {e}"); + } + }); +} + +/// Push shards and the catalog to the server, and take what it has. +/// +/// Fired after the sweep completes, when there is a finished index worth +/// sharing, and from the Sync button for an explicit exchange. +pub(super) fn start_derived_sync(window: &AppWindow, ctl: &Rc) { + // An escape hatch for running the app against a *copied* library without + // touching the account's real server. + // + // Added after doing exactly that by accident: a local test launch + // completed its thumbnail sweep, which fires this, which pushed a test + // catalog over the live one. Redirecting `XDG_DATA_HOME` isolates the + // catalog and the thumbnails but not the server, and nothing said so. + // + // The guard belongs here rather than at the call sites: the sweep firing a + // sync is correct — the point of building thumbnails is sharing them — and + // a flag checked in three places would eventually be missed in a fourth. + if std::env::var_os("DARKROOM_NO_SYNC").is_some() { + log::info!("derived sync suppressed by DARKROOM_NO_SYNC"); + return; + } + + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + // Already running: a second pass would race the first over the same + // scratch files. + if ctl.sync_timer.borrow().is_some() && window.get_library_syncing() { + return; + } + + // TRACES: FR-CAT-9 + // Nothing to push to and nothing to take. Attempting it would upload + // shards into a timeout and light the "Syncing…" indicator over work that + // cannot start; the shards are unchanged on disk and go out on the next + // sync once the server is back. + if ctl.is_offline() { + log::debug!("offline: skipping derived sync"); + return; + } + + let catalog_path = library::catalog_path(&conn.account); + let scratch = catalog_path + .parent() + .map(|p| p.join("scratch")) + .unwrap_or_else(std::env::temp_dir); + let _ = std::fs::create_dir_all(&scratch); + + // TRACES: FR-EXP-7 | FR-NC-10 + // Drain the export outbox on the same pass, and before the shards. An + // export the user was told had succeeded is waiting here, and it is the + // one thing in this directory that exists nowhere else — a thumbnail + // shard can be rebuilt from the originals, and the catalog is an index. + // + // Fire-and-forget rather than reported: it runs on its own thread and + // clears entries as they land, so a partial run leaves the rest queued + // for next time and nothing is lost by not watching it. It reports + // through the log until an export has a place in the activity list. + { + let outbox = crate::export::outbox_dir(&conn.account); + if crate::export::pending_count(&outbox) > 0 { + let rx = crate::export::spawn_upload(conn.clone(), conn.account.root.clone(), outbox); + std::thread::spawn(move || { + while let Ok(msg) = rx.recv() { + match msg { + crate::export::UploadMessage::Status(s) => log::info!("export: {s}"), + crate::export::UploadMessage::Finished { + uploaded, + remaining, + error, + } => { + log::info!("export: {uploaded} uploaded, {remaining} still queued"); + if let Some(e) = error { + log::warn!("export upload stopped: {e}"); + } + } + } + } + }); + } + } + + window.set_library_syncing(true); + let rx = crate::derived_sync::spawn_sync( + conn.clone(), + conn.account.root.clone(), + library::thumbs_dir(&conn.account), + catalog_path, + library::place_path(&conn.account), + scratch, + ctl.face_model_id.borrow().clone(), + ); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + // The sync reports stages rather than counts, so it stays indeterminate and + // says what stage it is in. + let job = ctl + .activity + .begin(crate::activity::Kind::Sync, "Syncing with the server"); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(300), + move || { + let Some(w) = weak.upgrade() else { return }; + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_syncing(false); + job.fail("stopped without finishing"); + stop(&ctl_cb.sync_timer); + return; + } + }; + + match msg { + crate::derived_sync::SyncMessage::Status(s) => { + job.detail(s.clone()); + w.set_library_status(s.into()); + } + crate::derived_sync::SyncMessage::Finished(report) => { + log::info!( + "sync: {} shard(s) up, {} down ({} thumbnails), \ + catalog {}{}", + report.shards_uploaded, + report.shards_downloaded, + report.thumbnails_adopted, + // Replacing a damaged copy is said apart from an + // ordinary push: it is the one push that discarded + // something, and a log line that called it "pushed" + // would hide the only moment worth going back to. + match (report.catalog_uploaded, report.catalog_replaced) { + (true, true) => "pushed over a damaged copy", + (true, false) => "pushed", + _ => "not pushed", + }, + if report.collections_gained > 0 { + format!(", {} collection(s) gained", report.collections_gained) + } else { + String::new() + } + ); + w.set_library_syncing(false); + let summary = format!( + "{} shard(s) up, {} down", + report.shards_uploaded, report.shards_downloaded + ); + job.finish(if report.did_anything() { + summary.clone() + } else { + "nothing to exchange".to_string() + }); + if report.did_anything() { + w.set_library_status(format!("synced · {summary}").into()); + } + // Adopted thumbnails and merged collections both change + // what the grid should show. + if report.thumbnails_adopted > 0 + || report.collections_gained > 0 + || report.members_gained > 0 + { + load_window(&w, &ctl_cb); + } + // TRACES: FR-CAT-7 + // And the sidebar, which the grid reload does not + // touch. Membership counts as a change: a sync that + // files 127 photographs into a collection both devices + // already had gains no *collection*, so keying this on + // `collections_gained` alone left the tree showing no + // number beside a collection that had just been filled. + // + // The scan already does this after it finishes; the + // sync merges the same tables and did not. + if report.collections_gained > 0 || report.members_gained > 0 { + let coll = ctl_cb.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()); + if let Some(coll) = coll { + let borrow = ctl_cb.catalog(); + let borrow = borrow.borrow(); + if let Some(cat) = borrow.as_ref() { + crate::collections_ui::refresh_tree(&w, &coll, cat); + } + } + } + stop(&ctl_cb.sync_timer); + return; + } + crate::derived_sync::SyncMessage::Failed(e) => { + log::warn!("sync failed: {e}"); + job.fail(e.to_string()); + w.set_library_syncing(false); + // Not an error banner: a failed sync costs nothing — + // everything is still local and the next pass retries. + w.set_library_status(format!("sync failed: {e}").into()); + stop(&ctl_cb.sync_timer); + return; + } + } + } + }, + ); + + *ctl.sync_timer.borrow_mut() = Some(timer); +} + +/// Start the whole-library sweep and report its progress. +/// +/// The grid only ever fetches what is on screen, so without this the timeline +/// describes the fraction of the library that happened to be scrolled past. +/// This covers the rest. +pub(super) fn start_sweep(window: &AppWindow, ctl: &Rc) { + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + + // TRACES: FR-CAT-9 + // The sweep is nothing but network reads — one header fetch per undated + // image, across the whole library. Offline it would spend a timeout on + // every one of them, running for hours to learn nothing, while the + // progress bar implied work was happening. It resumes on reconnect, and + // the images it has already dated stay dated. + if ctl.is_offline() { + log::debug!("offline: not starting the metadata sweep"); + return; + } + + let rx = library::spawn_sweep(conn.clone(), library::catalog_path(&conn.account)); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + // Hours on a large library, and entirely invisible outside the grid until + // now: the register is where a user who has gone to develop can still see + // that indexing is running and how far it has got. + let job = ctl + .activity + .begin(crate::activity::Kind::Index, "Indexing capture times"); + + timer.start( + slint::TimerMode::Repeated, + // Slower than the thumbnail drain: this runs for tens of minutes and + // its progress does not need per-frame accuracy. + std::time::Duration::from_millis(400), + move || { + let Some(w) = weak.upgrade() else { return }; + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_sweep_total(0); + job.fail("stopped without finishing"); + stop(&ctl_cb.sweep_timer); + return; + } + }; + + match msg { + library::SweepMessage::Total(n) => { + w.set_library_sweep_total(n as i32); + w.set_library_sweep_done(0); + job.total(n); + } + library::SweepMessage::Progress { done, dated } => { + w.set_library_sweep_done(done as i32); + job.progress(done, w.get_library_sweep_total() as usize); + // Rebuild as it goes: the histogram growing while the + // sweep runs is the visible sign it is working. + if dated > 0 { + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + } + library::SweepMessage::Finished { dated } => { + log::info!("sweep finished: {dated} dated"); + job.finish(format!("{dated} dated")); + w.set_library_sweep_total(0); + { + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + // TRACES: NFR-R2 + // The scheduled backup, at the moment the catalog is + // quietest and a day's edits have just been folded in. + // On its own thread, with its own connection: a copy + // of a 130 MB file is a second or two the Slint loop + // must not spend, and it runs beside the sync below, + // which is only a reader of the same file. + spawn_scheduled_backup(&ctl_cb); + // Now that indexing is complete, hand the result to the + // server so a second device inherits it rather than + // repeating hours of range fetches. + start_derived_sync(&w, &ctl_cb); + stop(&ctl_cb.sweep_timer); + return; + } + } + } + }, + ); + + *ctl.sweep_timer.borrow_mut() = Some(timer); +} + +/// TRACES: FR-CAT-3 | FR-NC-3 | FR-NC-7 +/// Thumbnail every photograph in the library, then hand the shards over. +/// +/// # Why it is asked for rather than assumed +/// +/// The grid's own fetching is demand-driven on purpose: a remote library is +/// browsed over a connection that must not be saturated to show one screen +/// (FR-NC-3). But that leaves the thumbnail store holding only what has been +/// looked at, and the store is the one derived thing worth syncing — a second +/// device that downloads the shards gets a full grid without touching a RAW. +/// So the complete set is worth an hour of transfers *once*, on a machine +/// plugged in, at a moment the user chose. That is this. +/// +/// The sync at the end is not a separate courtesy: a filled store that never +/// leaves this device is most of the cost for none of the point. +pub(super) fn start_thumbnail_sweep(window: &AppWindow, ctl: &Rc) { + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + + // Already running. A second pass would re-fetch everything the first is + // part way through — the work list is built from what the store lacks, and + // the first pass has not filled it yet. + if ctl.thumb_sweep_timer.borrow().is_some() && window.get_library_thumbnailing() { + return; + } + + // TRACES: FR-CAT-9 + // Every image here is a range fetch. Offline the pass would spend a + // timeout per photograph and store nothing, so it is refused rather than + // started — and said so, because this one was pressed deliberately and a + // button that silently does nothing is worse than one that declines. + if ctl.is_offline() { + log::debug!("offline: not starting the thumbnail pass"); + window.set_library_status("Offline — thumbnailing needs the server.".into()); + return; + } + + window.set_library_thumbnailing(true); + let rx = library::spawn_thumbnail_sweep( + conn.clone(), + library::catalog_path(&conn.account), + library::thumbs_dir(&conn.account), + ); + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let ctl_cb = ctl.clone(); + + let job = ctl.activity.begin( + crate::activity::Kind::Thumbnails, + "Thumbnailing the library", + ); + + // The total arrives in the first message rather than up front — counting + // it means asking the store about every image — and the progress messages + // after it carry only cumulative counts, so it is kept here rather than + // re-derived. + let total = std::cell::Cell::new(0usize); + + timer.start( + slint::TimerMode::Repeated, + // As slow as the metadata sweep's drain, and for the same reason: this + // runs for tens of minutes and a chunk lands every few seconds. + std::time::Duration::from_millis(400), + move || { + let Some(w) = weak.upgrade() else { return }; + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + w.set_library_thumbnailing(false); + job.fail("stopped without finishing"); + stop(&ctl_cb.thumb_sweep_timer); + return; + } + }; + + match msg { + library::ThumbSweepMessage::Total(n) => { + total.set(n); + job.total(n); + w.set_library_status(format!("Thumbnailing {n} photograph(s)…").into()); + } + library::ThumbSweepMessage::Progress { done, stored } => { + job.progress(done, total.get()); + // Dates arrive on the same headers, so the histogram + // grows as this runs — the visible sign it is working + // on a page that is not the grid. + if stored > 0 { + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + } + library::ThumbSweepMessage::Finished { + stored, + failed, + offline, + } => { + log::info!( + "thumbnail pass finished: {stored} stored, {failed} without a preview" + ); + w.set_library_thumbnailing(false); + stop(&ctl_cb.thumb_sweep_timer); + + let summary = if offline { + format!("{stored} stored · stopped, server unreachable") + } else if failed > 0 { + format!("{stored} stored · {failed} with no usable preview") + } else { + format!("{stored} stored") + }; + w.set_library_status(summary.clone().into()); + // Finished rather than failed even when the server + // dropped: the pass is resumable and what it did build + // is stored, and a red row would invite the user to + // redo work that is already done. + job.finish(summary); + + { + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + // Cells that showed a placeholder have pixels now, and + // the grid asks the store once per photograph — so + // without this the library the pass just thumbnailed + // stays blank until something else reloads the window. + if w.get_library_visible() { + ctl_cb.requested.borrow_mut().clear(); + schedule_reload(&w, &ctl_cb); + } + + // The point of the pass: the shards go to the server so + // every other device inherits them. Skipped when the + // connection is already gone — the sync would only + // discover the same thing more slowly. + if !offline { + start_derived_sync(&w, &ctl_cb); + } + // And now every photograph in the library has a + // thumbnail, which is the only moment all of its burst + // signatures can be computed. See `bursts::start_pass`, + // which owns the pass and everything it needs to drain + // itself; what it wants from here is the paths and a + // way to say the grid has something new to draw. + if let Some((conn, _)) = ctl_cb.session.borrow().clone() { + let weak_after = w.as_weak(); + let ctl_after = ctl_cb.clone(); + crate::bursts::start_pass( + library::catalog_path(&conn.account), + library::thumbs_dir(&conn.account), + move |bursts| { + let Some(w) = weak_after.upgrade() else { + return; + }; + if bursts > 0 && w.get_library_visible() { + schedule_reload(&w, &ctl_after); + } + }, + ); + } + return; + } + } + } + }, + ); + + *ctl.thumb_sweep_timer.borrow_mut() = Some(timer); +} diff --git a/ui/dr-ui/src/library_ui/timeline.rs b/ui/dr-ui/src/library_ui/timeline.rs new file mode 100644 index 0000000..d9a2af0 --- /dev/null +++ b/ui/dr-ui/src/library_ui/timeline.rs @@ -0,0 +1,1485 @@ +//! The capture-time sidebar — the histogram, its zoom and scrub — and the +//! photographer's place: where they were, restored on launch or handed over +//! from another device, and written back down as they move. +//! +//! The two are one file because a restored place ends by moving the timeline +//! marker and a scrub is a restore of one instant, so the functions call +//! back and forth constantly; keeping them apart would mean most calls +//! crossing a module boundary rather than a few. See +//! `docs/dev/code-health.md` CH-1. + +use std::rc::Rc; + +use dr_catalog::Catalog; +use slint::ComponentHandle; + +use crate::library; +use crate::{AppWindow, TimelineBar, View}; + +use super::controller::LibraryController; +use super::filter_bar::{push_people_chips, refilter}; +use super::window::{load_window, window_start}; + +/// Move the timeline's zoom by whole levels. +/// +/// Shared by the wheel and the pinch so the two cannot drift apart in how they +/// clamp, or in where they choose to centre. +fn apply_zoom(window: &AppWindow, ctl: &Rc, delta: i32) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + // Bounded: past ~2^12 the window is minutes wide and every bucket is + // empty, which reads as a broken axis rather than a deep zoom. + let next = (*ctl.timeline_zoom.borrow() + delta).clamp(0, 12); + if next == *ctl.timeline_zoom.borrow() { + return; + } + *ctl.timeline_zoom.borrow_mut() = next; + + // Zooming fully out forgets the centre, so the axis returns to describing + // the whole library rather than a remembered position. + if next == 0 { + *ctl.timeline_centre.borrow_mut() = None; + } else if ctl.timeline_centre.borrow().is_none() { + // First zoom centres on wherever the grid is, else the middle. + *ctl.timeline_centre.borrow_mut() = *ctl.current_bucket.borrow(); + } + refresh_timeline(window, catalog, ctl); +} + +/// Rebuild the timeline histogram from the catalog. +/// +/// Bucket size follows the span of the library, the way darktable's does: +/// a decade of photographs buckets by year, a single trip by day. Picking it +/// from the data rather than fixing it means the histogram is informative at +/// both scales instead of one flat bar or ten thousand slivers. +pub(super) fn refresh_timeline(window: &AppWindow, catalog: &Catalog, ctl: &Rc) { + // Where the grid sits, if nothing has said so yet. + // + // **The marker used to rest greyed at mid-track until the first scroll or + // scrub**, on the reasoning that anchoring it would imply a choice the user + // had not made. That reads the marker as reporting an intention, and it does + // not: the sidebar's whole claim is to say *when* you are, and the answer is + // known from the first frame — the grid is at the top of the library, or at + // whatever position was restored into it. A launch that opened with the + // marker halfway down an axis whose photographs were all from the wrong end + // was simply wrong, and dimmed rather than absent, which made it look like a + // reading rather than the absence of one. + // + // Seeded here rather than at the end of the scan because this is the one + // place that decides what the marker says, and it runs on every route that + // builds the axis. Only when nothing has claimed it: a scroll or a scrub + // sets it directly, and this must never overwrite them. + let unanchored = ctl.current_bucket.borrow().is_none(); + if unanchored { + if let Some(when) = capture_time_at(ctl, catalog, ctl.resume_at.get()) { + *ctl.current_bucket.borrow_mut() = Some(when); + } + } + + // Scoped to whatever the grid is showing. A collection's histogram drawn + // over the whole library's span said almost nothing: every bar for a + // fortnight in Arosa landed in one column of a fifteen-year axis. + let scope = *ctl.scope.borrow(); + let filter = ctl.filter.borrow().clone(); + // Passed whole, date range included, and both queries below lift that + // range themselves — see `span_scoped` and `timeline_uniform`. Every other + // term still applies, because a histogram of the five-star frames is a + // fair question; the range does not, because the axis is *how a range is + // chosen* and drawing through it would empty every bin outside the band + // and leave nothing to widen back into. + let span = match library::span_scoped(catalog, scope, &filter) { + Some(s) => s, + None => { + // No dated images yet. An empty histogram is honest — EXIF is read + // as thumbnails load, so this populates as the user browses. + window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(vec![]))); + window.set_library_timeline_label(slint::SharedString::new()); + return; + } + }; + + // A range is drawn *over* this span as a band, not substituted for it — + // see the band below. The scale a narrow range deserves is reached by + // zooming the axis instead, which is a gesture a finger has (pinch) and + // does not disturb the range while it is being adjusted. + // + // Zoom narrows the span around wherever the view sits rather than around + // the library's midpoint, so zooming in keeps what you were looking at. + let zoom = *ctl.timeline_zoom.borrow(); + let (from, to) = zoomed_span(span, zoom, *ctl.timeline_centre.borrow()); + + // A fixed number of equal bins, re-cut on every zoom (FR-CAT-6). The + // count is the user's — see `LibrarySettings::timeline_bars` for why the + // calendar units it replaced made zooming in draw a coarser picture. + let bins = ctl.timeline_bars.get().max(1); + // Calendar units survive as a *label* vocabulary: what a bin is called + // depends on how long it is, not on how the counting was done. + let granularity = dr_catalog::Granularity::for_bucket((to - from).max(1) / bins as i64); + let buckets = match library::timeline_uniform(catalog, scope, &filter, from, to, bins) { + Ok(b) => b, + Err(e) => { + log::debug!("timeline: {e}"); + return; + } + }; + + // Normalise against the tallest bar. Counts vary by orders of magnitude + // between a quiet month and a wedding, so a linear scale against the total + // would render most buckets invisible. + let peak = buckets.iter().map(|b| b.count).max().unwrap_or(1).max(1); + let mut previous: Option<(i64, i64)> = None; + + let bars: Vec = buckets + .iter() + .map(|b| { + let (y, m, _, _) = civil_from_unix(b.start); + // Label only where a period begins, so a month-bucketed axis reads + // "2024 … Mar … Apr" rather than repeating the year on every bar. + let period = match previous { + None => format!("{y}"), + Some((py, _)) if py != y => format!("{y}"), + Some((_, pm)) if pm != m && granularity_labels_months(granularity) => { + month_abbrev(m).to_string() + } + _ => String::new(), + }; + previous = Some((y, m)); + + TimelineBar { + // Square root rather than linear: it keeps a 3-image day + // visible beside a 400-image one without a log scale's + // misleading flatness. + // + // A bin holding nothing draws nothing. The floor exists so a + // quiet day is not rounded away, and empty bins are common now + // that every one of them is emitted — a sliver on each would + // draw a library that has photographs in months it does not. + height: if b.count == 0 { + 0.0 + } else { + ((b.count as f32 / peak as f32).sqrt()).clamp(0.02, 1.0) + }, + start: b.start as i32, + count: b.count as i32, + label: format_bucket(b.start, granularity).into(), + period_label: period.into(), + } + }) + .collect(); + + // Where the grid currently sits, as a fraction of the visible span. + // + // This is the exact inverse of what a click produces: the widget maps a y + // to a fraction of its track and `on_library_scrub_fraction` interpolates + // `from + (to - from) * f`, so the marker must invert that same expression + // or it lands somewhere other than the pointer. + // + // The earlier version sent a *bar index* instead. Bars occupy one equal + // slot each regardless of how much time they cover, and `rposition` snaps + // to the bucket's start edge, so the marker sat at the top of whichever + // slot contained the instant — near enough on a dense uniform axis, plainly + // wrong on a sparse one, and never under the click. + let current = *ctl.current_bucket.borrow(); + let fraction = current.map(|t| fraction_at((from, to), t)).unwrap_or(-1.0); + + // The chosen range in those same coordinates, for the band. Both ends or + // neither: half a band is a line the user cannot tell from a handle. + let (band_from, band_to) = match (filter.captured_from, filter.captured_to) { + (Some(a), Some(b)) => (fraction_at((from, to), a), fraction_at((from, to), b)), + _ => (-1.0, -1.0), + }; + + // Which bar to light: the *bin* holding the grid's instant, not the + // instant itself. A bar's start is its bin's left edge — one of a fixed + // set of positions — so comparing it against an arbitrary capture time + // would light nothing, where before every bar started on a photograph. + // + // Taken from the bars themselves rather than recomputed, because the edges + // are integer division and a second spelling of `from + i * span / bins` + // is a second chance to be a second out. + let lit = current + .and_then(|t| { + let i = bin_of((from, to), t, bins); + buckets.get(i).map(|b| b.start) + }) + .unwrap_or(0); + window.set_library_current_bucket(lit as i32); + window.set_library_current_fraction(fraction); + window.set_library_range_from_fraction(band_from); + window.set_library_range_to_fraction(band_to); + window.set_library_timeline_anchored(current.is_some()); + window + .set_library_timeline_label(format!("{} – {}", format_date(from), format_date(to)).into()); + window.set_library_timeline(slint::ModelRc::new(slint::VecModel::from(bars))); +} + +/// Whether this bucket size is fine enough for month labels to mean anything. +/// +/// A year-bucketed axis labelled by month would put twelve labels on one bar. +/// Month-sized bins are excluded too, now that the axis has a fixed number of +/// them: a bin of about a month changes month at nearly every bar, so the rule +/// below would write a label on all sixty-four of them and the right-hand +/// strip would be a column of overlapping text. Below a month the labels are +/// occasional again, which is what makes them readable. +fn granularity_labels_months(g: dr_catalog::Granularity) -> bool { + matches!( + g, + dr_catalog::Granularity::Day | dr_catalog::Granularity::Hour + ) +} + +/// Full month name, for the grid's headings. +pub(super) fn month_name(m: i64) -> &'static str { + const NAMES: [&str; 12] = [ + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", + ]; + NAMES[((m - 1).clamp(0, 11)) as usize] +} + +fn month_abbrev(m: i64) -> &'static str { + const NAMES: [&str; 12] = [ + "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + ]; + NAMES[((m - 1).clamp(0, 11)) as usize] +} + +/// The instant a fraction along a span names. +/// +/// One home for `from + (to - from) * f`, because three things now depend on +/// it agreeing with itself: a scrub, the marker drawn where that scrub landed, +/// and the ends of the date range dragged along the same axis. A second copy +/// is a second chance for a click and what it produces to disagree. +fn instant_at(span: (i64, i64), f: f32) -> i64 { + span.0 + ((span.1 - span.0) as f64 * f.clamp(0.0, 1.0) as f64) as i64 +} + +/// Where an instant sits along a span, 0..1. The exact inverse of +/// [`instant_at`], clamped so an instant off the end of the axis is drawn at +/// the end it went past rather than off the widget. +fn fraction_at(span: (i64, i64), t: i64) -> f32 { + ((t - span.0) as f64 / (span.1 - span.0).max(1) as f64).clamp(0.0, 1.0) as f32 +} + +/// Which of `bins` equal bins across `span` holds `t`. +/// +/// The same arithmetic `library::timeline_uniform` groups by, in the same +/// order — integer division on the offset from the start — so the bar this +/// names is the bar that counted the photograph. Clamped, because the grid's +/// instant can sit outside a zoomed axis. +fn bin_of(span: (i64, i64), t: i64, bins: u32) -> usize { + let width = (span.1 - span.0).max(1); + let i = (t - span.0).clamp(0, width) * bins as i64 / width; + i.clamp(0, bins as i64 - 1) as usize +} + +/// Midnight UTC at or before `t`. +/// +/// `rem_euclid` rather than `%`: a negative timestamp — a scanned archive +/// holds photographs from before 1970 — would otherwise truncate *towards* +/// zero and name the following midnight, putting the day off by one. +fn day_start(t: i64) -> i64 { + t - t.rem_euclid(86_400) +} + +/// The range a drag of the band's two ends names, as it is stored. +/// +/// Whole days at both ends, which does three things. It is what the typed +/// fields mean, so a dragged range reads back out of them as the two days it +/// covers rather than as two instants nobody chose. It gives the band a floor: +/// two handles dragged onto each other name one day, not an empty grid. And it +/// makes the gesture reproducible — the same day is the same range however +/// close the finger came to a bucket edge. +/// +/// Ends dragged past each other are put back in order rather than refused: the +/// user has described the same two edges either way round. +pub(super) fn dragged_range(span: (i64, i64), from: f32, to: f32) -> (i64, i64) { + let (from, to) = if from <= to { (from, to) } else { (to, from) }; + let start = day_start(instant_at(span, from)); + // Inclusive of the closing day. `parse_date` gives midnight, and a range + // ending there would exclude every photograph taken on the day named. + let end = day_start(instant_at(span, to)) + 86_400 - 1; + (start, end) +} + +/// Narrow a span by a zoom level, centred on `centre`. +/// +/// Each step halves or doubles the visible duration. Clamped to the library's +/// own extent so zooming out cannot wander past the first or last photograph. +pub(super) fn zoomed_span(full: (i64, i64), zoom: i32, centre: Option) -> (i64, i64) { + let (lo, hi) = full; + if zoom <= 0 { + return (lo, hi); + } + let duration = (hi - lo).max(1); + // 2^zoom, saturating: a very deep zoom must not shift the duration to zero. + let factor = 1i64 << zoom.min(20); + let window = (duration / factor).max(3600); + + let mid = centre.unwrap_or(lo + duration / 2); + let half = window / 2; + let (mut from, mut to) = (mid - half, mid + half); + + // Slide rather than shrink at the ends, so the window keeps its size. + if from < lo { + to += lo - from; + from = lo; + } + if to > hi { + from -= to - hi; + to = hi; + } + (from.max(lo), to.min(hi)) +} + +/// Earliest and latest capture time in the catalog. +/// The timeline's extent, over exactly the images the grid is showing. +/// +/// Scrubbing and zooming must measure the same span the histogram is drawn +/// over. When this read the whole library while the bars were scoped to a +/// collection, a scrub landed at an instant the collection did not contain and +/// the view jumped somewhere the user had not asked for. +pub(super) fn catalog_span(catalog: &Catalog, ctl: &Rc) -> Option<(i64, i64)> { + // `span_scoped` reports the extent with the date range lifted, which is + // what every caller here wants: they measure the axis that is on screen, + // and that axis spans the library rather than the chosen range. Measured + // through the range, narrowing it would move the ground under the very + // handles doing the narrowing — each drag re-scaling the axis, so the next + // one meant something else. + library::span_scoped(catalog, *ctl.scope.borrow(), &ctl.filter.borrow()) +} + +/// TRACES: NFR-P5 +/// Capture time of the image at row `ordinal` in the grid's own ordering. +/// +/// `None` for an ordinal that lands among the undated tail, which sorts last +/// and has no place on a capture-time axis. +/// +/// # Answered from the loaded window, not from the catalog +/// +/// This moves the timeline marker, so it is called on **every scroll event** — +/// every row the view crosses, by design, or the marker would advance in jerks +/// while the photographs beside it moved smoothly. +/// +/// It used to be a query, and `LIMIT 1 OFFSET n` is not a seek: SQLite reaches +/// row `n` by producing and discarding the `n` before it. So its cost grew with +/// how far down the library the user had scrolled — 0.02 ms near the top, 0.7 ms +/// at twenty thousand — and it was paid per row crossed, on the thread drawing +/// the frame. A flick down a long library therefore got *choppier the further +/// it went*, which is a strange enough symptom to be worth naming. +/// +/// The window the grid has already read holds this answer. `window_move` keeps +/// the whole view inside that window, so the lookup is a vector index at the +/// position the ordinal has in it. +/// +/// # The fallback, and why it is also more correct than what it replaces +/// +/// Outside the loaded window — briefly, after a scrub or a keyboard jump, before +/// the load lands — it falls back to the query. That query counts in a +/// *dated-only* ordering while `ordinal` is a grid row, so the two disagree +/// wherever undated images sit in between; it is kept because it is close +/// enough for a marker that is about to be corrected, and because being wrong +/// there is what it always did. The window path has no such disagreement: it +/// reads the very cell the row belongs to. +pub(super) fn capture_time_at( + ctl: &Rc, + catalog: &Catalog, + ordinal: usize, +) -> Option { + { + let offset = *ctl.offset.borrow(); + let loaded = ctl.captured_at.borrow(); + if ordinal >= offset { + if let Some(when) = loaded.get(ordinal - offset) { + return *when; + } + } + } + capture_time_from_catalog(catalog, ordinal) +} + +/// The fallback of [`capture_time_at`], for a row outside the loaded window. +fn capture_time_from_catalog(catalog: &Catalog, ordinal: usize) -> Option { + catalog + .connection() + .query_row( + &format!( + "SELECT captured_at FROM images + WHERE shadowed_by IS NULL AND captured_at IS NOT NULL + AND {} + ORDER BY captured_at + LIMIT 1 OFFSET ?1", + dr_catalog::bursts::not_collapsed_away("images") + ), + [ordinal as i64], + |r| r.get::<_, i64>(0), + ) + .ok() +} + +/// Jump the grid to the first image at or after `when`. +/// +/// This is the scrub: the window moves, the library does not narrow. Every +/// image stays reachable, which is why this is a position rather than a +/// filter. +fn scrub_to(window: &AppWindow, ctl: &Rc, when: i64) { + let position = { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + // How many rows precede this instant **in the grid's own ordering**. + // That ordinal is the scroll offset, so any disagreement with the + // grid's query lands the view somewhere else entirely. + // + // The earlier version counted only dated images. With 2,400 of 19,841 + // dated, a click near the end of the axis produced an ordinal of ~2,400 + // against a grid of 19,841 rows — the view landed near the top however + // far down the axis the pointer went. + // + // Undated images sort last (`captured_at IS NULL` first in the ORDER + // BY), so they never precede a dated one and the predicate below stays + // a simple `<`. Shadowed rows are excluded here exactly as the grid + // excludes them. + // + // A burst folded up occupies one row of the grid, so it must occupy one + // row of this count as well: an ordinal taken over the unfolded library + // would overshoot by every frame hidden earlier in it. + catalog + .connection() + .query_row( + &format!( + "SELECT count(*) FROM images + WHERE shadowed_by IS NULL + AND captured_at IS NOT NULL + AND captured_at < ?1 + AND {}", + dr_catalog::bursts::not_collapsed_away("images") + ), + [when], + |r| r.get::<_, i64>(0), + ) + .unwrap_or(0) as usize + }; + + // Record where the grid now sits, which anchors the timeline marker. + // `refresh_timeline` seeds this from the view's own position when nothing + // has claimed it, so by the time a scrub arrives it is an update rather than + // the first word. + *ctl.current_bucket.borrow_mut() = Some(when); + *ctl.offset.borrow_mut() = position; + // Move the viewport as well as the window. Cells are drawn at their + // absolute place in the library, so loading rows around image 15,000 while + // the viewport sits at row 0 shows an empty grid until the user scrolls. + window.set_library_scroll_to(position as i32); + window.set_library_scroll_token(window.get_library_scroll_token() + 1); + load_window(window, ctl); +} + +/// TRACES: FR-CAT-7 +/// Put the view back where the photographer was after the library changed +/// under it. +/// +/// Deleting is the case this exists for. The grid draws each cell at its +/// absolute place in the library — `(i + offset) / columns` — and a delete does +/// two things at once: the library gets shorter, and `offset` is re-clamped +/// downward so the loaded window still fills. Neither touches `viewport-y`, so +/// the view ends up pointing at a different part of the library, or past the +/// end of it entirely. That is the "goes blank and jumps somewhere random". +/// +/// Anchored on the *ordinal the view was showing*, clamped into what is left. +/// Not on the deleted image's own position, which no longer exists, and not on +/// the top of the library, which would throw away the scroll position on every +/// delete — after removing one frame from a wall of twenty thousand, the next +/// one you want is the one that just moved into its place. +pub fn restore_position(window: &AppWindow, ctl: &Rc) { + let total = window.get_library_total().max(0) as usize; + if total == 0 { + return; + } + // The first *visible* ordinal, not the loaded window's start. + // + // Anchoring on `offset` was the first attempt and it still jumped, because + // the two are deliberately different: the window begins about a quarter of + // a screen above the view so scrolling up has rows ready, and by this point + // in `load_window` it has also been re-clamped against the new, smaller + // total. Seeking to it therefore moved the view to somewhere the user was + // not — a smaller jump than before, but the same fault. + // + // `resume_at` is what the grid last reported as its first visible image, + // which is the thing the photographer is actually looking at. + let anchor = ctl.resume_at.get().min(total - 1); + window.set_library_scroll_to(anchor as i32); + window.set_library_scroll_token(window.get_library_scroll_token() + 1); +} + +/// Format a bucket start for the histogram's hover label. +fn format_bucket(t: i64, g: dr_catalog::Granularity) -> String { + let (y, m, d, h) = civil_from_unix(t); + match g { + dr_catalog::Granularity::Year => format!("{y}"), + dr_catalog::Granularity::Month => format!("{y}-{m:02}"), + dr_catalog::Granularity::Day => format!("{y}-{m:02}-{d:02}"), + dr_catalog::Granularity::Hour => format!("{y}-{m:02}-{d:02} {h:02}:00"), + } +} + +/// A capture instant as `YYYY-MM-DD`. +/// +/// Shared with the exporter, which resolves the `{date}` token from the same +/// reading so a filename and the timeline cannot disagree about what day a +/// photograph was taken. +pub fn format_date(t: i64) -> String { + dr_types::format_date(t) +} + +/// Unix seconds to a civil date, in the tuple shape this module reads it in. +/// +/// The algorithm moved to `dr_types::time` when import folder templates +/// (FR-CAT-10) became a third caller. Three readings of the same instant that +/// could drift apart is one too many: an image filed under a date the timeline +/// does not show it on is a file the user cannot find. +pub(super) fn civil_from_unix(t: i64) -> (i64, i64, i64, i64) { + let c = dr_types::civil_from_unix(t); + (c.year, c.month, c.day, c.hour) +} + +// --- where the photographer was (FR-UI-8) -------------------------------- + +/// How long a movement has to settle before it is written down. +/// +/// A flick reports a scroll several times per screenful, and a place is a file +/// write — one per event would put disk I/O inside the gesture NFR-P5 governs, +/// for a record only the *last* of which is ever read. Longer than +/// [`GEOMETRY_SETTLE`] because nothing on screen waits for this: the reload a +/// geometry change schedules is visible, and this is not. +const PLACE_SETTLE: std::time::Duration = std::time::Duration::from_millis(750); + +/// The photographer moved. Write it down, once they stop. +/// +/// Replacing the timer rather than adding one is what makes this a debounce: +/// a `slint::Timer` is stopped by being dropped, so the previous pending write +/// goes with the old handle. +pub(super) fn note_place(window: &AppWindow, ctl: &Rc) { + if ctl.applying_place.get() { + // A restore is not a movement — see `applying_place`. + return; + } + + // The handover latch closes on the first movement — see `place_untouched`. + ctl.place_untouched.set(false); + + if ctl.place_store.borrow().is_none() { + return; + } + + let timer = slint::Timer::default(); + let weak = window.as_weak(); + let held = ctl.clone(); + timer.start(slint::TimerMode::SingleShot, PLACE_SETTLE, move || { + if let Some(w) = weak.upgrade() { + write_place(&w, &held); + } + }); + *ctl.place_timer.borrow_mut() = Some(timer); +} + +/// Write the place out now, without waiting for a settle. +/// +/// For the moments there may be no "later": leaving the grid for develop, and +/// coming back. Both are single deliberate acts rather than runs of events, and +/// both change the one field a debounce is most likely to lose — which view the +/// user is in. +pub(super) fn write_place(window: &AppWindow, ctl: &Rc) { + if ctl.applying_place.get() { + return; + } + // Opening a photograph is as much "the photographer has started" as + // scrolling is, and this is the path that records it — so the handover + // latch closes here too, not only in `note_place`. Without it a record + // arriving from another device could pull someone out of the image they had + // just opened. + ctl.place_untouched.set(false); + + let Some(place) = current_place(window, ctl) else { + return; + }; + if let Some(store) = ctl.place_store.borrow().as_ref() { + store.save(&place); + } +} + +/// Where the photographer is, as a record that can be written down. +/// +/// `None` where there is nothing worth recording: no library open, or the +/// launch screen in front of everything. A place is never written from the +/// launch screen — the user is *choosing* a library there, and recording "you +/// were at the launch screen" would make the next launch skip the position it +/// had just been asked to keep. +fn current_place(window: &AppWindow, ctl: &Rc) -> Option { + use dr_types::{Place, PlaceScope, Screen}; + + if window.get_active_view() == View::Launch || !window.get_library_open() { + return None; + } + + // Which view, and — the same question in both — which photograph. + // + // In the grid it is the first one visible, which `resume_at` already + // tracks; in develop it is the open one, which `report_position` keeps + // `index` holding. Both are library ordinals against the same ordering, so + // the lookup below is one piece of code rather than two. + let in_library = window.get_active_view() == View::Library; + let at = if in_library { + ctl.resume_at.get() + } else { + window.get_index().max(0) as usize + }; + + // Resolved through the loaded window rather than by a query: the model has + // the path and the capture time side by side already, and this runs on a + // settle after scrolling — the one moment a query is least welcome. + let offset = *ctl.offset.borrow(); + let row = at.checked_sub(offset); + let path = row + .and_then(|r| ctl.paths.borrow().get(r).cloned()) + .unwrap_or_default(); + let captured_at = row.and_then(|r| ctl.captured_at.borrow().get(r).copied().flatten()); + + // The scope, in the vocabulary both devices share. A collection this device + // cannot name is recorded as the whole library rather than as a name + // nothing can resolve — see `collections::uuid_of`. + let scope = if ctl.viewing_trash.get() { + PlaceScope::Trash + } else { + match *ctl.scope.borrow() { + None => PlaceScope::Library, + Some(id) => { + let borrow = ctl.catalog.borrow(); + let uuid = borrow + .as_ref() + .and_then(|c| dr_catalog::collections::uuid_of(c.connection(), id).ok()) + .flatten(); + match uuid { + Some(uuid) => PlaceScope::Collection { uuid }, + None => PlaceScope::Library, + } + } + } + }; + + Some(Place { + at: library::now_secs(), + device: ctl.place_device.borrow().clone(), + screen: if in_library { + Screen::Library + } else { + Screen::Develop + }, + scope, + path, + captured_at, + filter: (&*ctl.filter.borrow()).into(), + }) +} + +/// Put the photographer back where a record says they were. +/// +/// # The order is the whole function +/// +/// Scope, then filter, then position, then — last of all — the view. Each step +/// changes what an ordinal *means*: the grid is a window over a query, and a +/// row resolved before the scope was narrowed names a photograph in a different +/// list. Resolving the path last is what makes the restored position address the +/// grid the user is actually about to see. +/// +/// # Nothing here is allowed to fail loudly +/// +/// Every step degrades to "the state we would have been in anyway". A collection +/// this device has not merged yet leaves the scope at the whole library; a +/// photograph that has been deleted falls back to when it was taken; a capture +/// time that resolves to nothing leaves the grid at the top. A place is a +/// convenience, and an application that would not open because one was stale +/// would be trading something that matters for something that does not. +pub(crate) fn apply_place( + window: &AppWindow, + ctl: &Rc, + coll_ctl: &Rc, + place: &dr_types::Place, +) { + use dr_types::{PlaceScope, Screen}; + + ctl.applying_place.set(true); + + // --- the scope -------------------------------------------------------- + // + // Through the same callback the sidebar fires, rather than by setting the + // two controllers' fields by hand. Both of them hold a scope, and so does + // the header's label and the reorderable flag; there is exactly one piece + // of code that keeps those four in step and this is not the place to write + // a second. + let selected = match &place.scope { + PlaceScope::Library => 0, + PlaceScope::Trash => -1, + PlaceScope::Collection { uuid } => { + let borrow = ctl.catalog.borrow(); + let found = borrow + .as_ref() + .and_then(|c| dr_catalog::collections::id_for_uuid(c.connection(), uuid).ok()) + .flatten(); + drop(borrow); + match found { + Some(id) => id.0 as i32, + // Recorded on another device, and this one has not merged the + // collection yet. The whole library is the honest fallback: an + // empty grid under a name nothing can resolve reads as data + // loss. + None => { + log::info!("the stored place names a collection this device does not have yet"); + 0 + } + } + } + }; + window.invoke_collection_select(selected); + + // --- the filter ------------------------------------------------------- + *ctl.filter.borrow_mut() = (&place.filter).into(); + push_filter(window, ctl); + + // --- the position ----------------------------------------------------- + let resolved = resolve_place(window, ctl, place); + + // The filter and the scope have both moved the window to the top; this is + // what puts it back, and it has to run after them for that reason. + let total = window.get_library_total().max(0) as usize; + let resolved = resolved.filter(|(at, _)| *at < total); + match resolved { + Some((at, _)) => { + ctl.resume_at.set(at); + // Moves the capture-time marker with it — see `resume_position`. + resume_position(window, ctl, coll_ctl, None); + } + None => refilter(window, ctl), + } + + // --- and the view ----------------------------------------------------- + // + // Only where the photograph itself was found. A capture time that merely + // landed in the right week is a good answer for the grid and no answer at + // all for a canvas: develop would open on a path that no longer resolves + // and show a filename over an empty frame. + if place.screen == Screen::Develop { + if let Some((at, Found::Photograph)) = resolved { + if let Some(open) = ctl.open_image.borrow().clone() { + window.set_active_view(View::Develop); + window.set_library_roll_centre(true); + let offset = *ctl.offset.borrow(); + if let Some(row) = at.checked_sub(offset) { + window.set_library_roll_current(row as i32); + } + open(place.path.clone()); + // After the open, which resets the readout to "1 of 1" on its + // way in — the same ordering `report_position` documents. + window.set_index(at as i32); + window.set_total(window.get_library_total()); + } + } + } + + // Last, so nothing between here and the top is mistaken for the + // photographer having moved. + ctl.applying_place.set(false); +} + +/// The library ordinal a record names, or `None` if it names nothing here. +/// +/// Two ways, and the second is why a place survives its subject. The path is +/// exact and is tried first. Failing that — the photograph was deleted, moved, +/// or filtered out by the very filter this place restored — the capture time +/// puts the grid in the right week, which is close enough that the user +/// recognises where they are. +fn resolve_place( + window: &AppWindow, + ctl: &Rc, + place: &dr_types::Place, +) -> Option<(usize, Found)> { + let borrow = ctl.catalog.borrow(); + let catalog = borrow.as_ref()?; + + if !place.path.is_empty() { + match library::ordinal_of_path( + catalog, + *ctl.scope.borrow(), + &ctl.filter.borrow(), + ctl.viewing_trash.get(), + &place.path, + ) { + Ok(Some(at)) => return Some((at, Found::Photograph)), + Ok(None) => {} + Err(e) => log::debug!("resolving the stored place: {e}"), + } + } + + let when = place.captured_at?; + drop(borrow); + // Through the scrubber's own route, so "an instant, as an ordinal" has one + // spelling. It sets the viewport and the loaded window together, which is + // exactly what a restore needs, and it anchors the timeline marker on the + // way. + scrub_to(window, ctl, when); + // Where it landed, read back rather than recomputed: `scrub_to` sets the + // offset to the ordinal it resolved and `load_window` then clamps it against + // the end of the library, so the clamped value is the one the grid is + // actually showing. + Some((*ctl.offset.borrow(), Found::Thereabouts)) +} + +/// How exactly a place was resolved. +/// +/// The distinction is what stops a restore reopening develop on the wrong +/// photograph. Landing in the right week is a perfectly good answer for a grid +/// — the user recognises where they are — and no answer at all for a canvas, +/// which would open on a path that no longer resolves and show a filename over +/// an empty frame. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Found { + /// The photograph itself is still in this grid, at this ordinal. + Photograph, + /// It is not, so this is where its capture time falls instead. + Thereabouts, +} + +/// Put the whole rating filter on screen. +/// +/// The filter bar is drawn from half a dozen properties, each of which is set +/// where its own control is handled — which is right while the user is moving +/// them one at a time, and leaves nothing that can restore all of them at once. +/// A restored place that narrowed the grid without moving the stars would show +/// a filtered library under a bar claiming it was not filtered, and the user's +/// first act would be to try to clear a filter the bar says is off. +fn push_filter(window: &AppWindow, ctl: &Rc) { + let filter = ctl.filter.borrow().clone(); + window.set_library_filter_min_rating(filter.min_rating as i32); + window.set_library_filter_unjudged(filter.unjudged); + // The bar's own numbering, from `on_library_filter_flag_changed`. It has no + // code for `Unflagged` because it cannot produce one — "nothing has judged + // this" is the `unjudged` chip, over both stars and flags — so a place can + // never carry one either, and it falls in with "no flag constraint". + window.set_library_filter_flag(match filter.flag { + Some(dr_types::FlagState::Pick) => 1, + Some(dr_types::FlagState::Reject) => 2, + _ => 0, + }); + window.set_library_local_only(filter.local_only); + ctl.local_only.set(filter.local_only); + window.set_library_filter_eyes_open(filter.eyes_open); + push_people_chips(window, ctl); +} + +/// Bring the grid back to where the photographer left it, and — coming out of +/// develop — put the keyboard cursor on the photograph they were editing. +/// +/// # Two positions, not one +/// +/// `resume_at` is where the *grid* was when it was left. `focus` is the +/// photograph develop was showing, which after a walk along the photo roll can +/// be a thousand rows away from it. Restoring only the first is what made +/// leaving develop feel like losing the frame you had just finished: the grid +/// came back to a screenful that no longer had it in. +/// +/// So both are honoured, and which one wins is decided by whether they +/// disagree. If the open photograph is inside the screenful the grid was left +/// showing — the ordinary case, where develop was opened and closed on the same +/// frame — the remembered position is used unchanged and nothing appears to +/// move. If it is not, the grid goes to the photograph, because that is the one +/// the user was last looking at. +/// +/// # Why the cursor rather than the selection +/// +/// Deliberately not [`place_cursor`], which also rewrites the selection. A +/// selection of forty photographs assembled in the grid, then one of them +/// opened to check it, must survive the trip back — discarding it is the kind +/// of silent loss that stops people using the develop view mid-cull. The +/// cursor is a position, not a judgement, so it is safe to move. +/// +/// The viewport is left to the grid's own `reveal()`, which moves as little as +/// will bring the cursor's row into view. That is what makes the two cases +/// above one rule rather than two: a cursor already on screen moves nothing. +pub(super) fn resume_position( + window: &AppWindow, + ctl: &Rc, + coll: &Rc, + focus: Option, +) { + let total = window.get_library_total().max(0) as usize; + if total == 0 { + // Files named on the command line: there is no grid behind them, and + // an ordinal read off the readout would name a row of nothing. + return; + } + + let resume = ctl.resume_at.get().min(total - 1); + let focus = focus.map(|at| at.min(total - 1)); + + // A screenful, as the grid last measured itself. The comparison has to be + // against what is *visible*, not against the loaded window, which is + // several screenfuls wide — an open photograph loaded but scrolled past is + // still one the user cannot see. + let viewport = ctl.viewport_cells.get().max(1); + let anchor = match focus { + Some(at) if at >= resume && at < resume + viewport => resume, + Some(at) => at, + None => resume, + }; + + // Centred like `on_library_scrolled` does it, so scrolling either way from + // the restored position has loaded rows to move into. + let window_size = *ctl.window.borrow(); + *ctl.offset.borrow_mut() = window_start(anchor, window_size, total); + load_window(window, ctl); + + // Before the grid is shown, not after: the markup gates it on an `if`, and + // the rebuilt Flickable reads `scroll-to` in its `init`. Setting these + // afterwards would leave that init to run against the previous position. + window.set_library_scroll_to(anchor as i32); + window.set_library_scroll_token(window.get_library_scroll_token() + 1); + + if let Some(at) = focus { + coll.set_cursor(Some(at)); + window.set_library_cursor(at as i32); + } + + // The view has moved, so the capture-time marker has to move with it. + // + // `load_window` will not do it: it rebuilds the axis only when the scope, + // the filter or the total has changed, and none of them has — this is the + // same library seen from a different row. Without this the marker stayed + // wherever it was when develop was entered, which after a walk along the + // photo roll can be months away from the photographs now on screen. + reanchor_timeline(window, ctl, anchor); +} + +/// Put the capture-time marker where the view now is. +/// +/// Clearing the anchor and re-running the axis rather than computing a fraction +/// here: `refresh_timeline` seeds itself from the view's position when nothing +/// has claimed one, and it owns every other thing the sidebar draws — the bars, +/// the range band, the lit bucket. A second place that positioned the marker +/// would be a second chance to disagree with the bars under it, which is the +/// bug the `current-fraction` note in `library.slint` records. +fn reanchor_timeline(window: &AppWindow, ctl: &Rc, at: usize) { + ctl.resume_at.set(at); + *ctl.current_bucket.borrow_mut() = None; + let borrow = ctl.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(window, catalog, ctl); + } +} + +/// The capture-time sidebar: panning, zooming and scrubbing the axis that +/// the grid's loaded window follows. +pub(super) fn wire_timeline(window: &AppWindow, ctl: &Rc) { + // Panning the timeline slides the visible span without changing its width. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_pan(move |fraction| { + let Some(w) = weak.upgrade() else { return }; + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(full) = catalog_span(catalog, &ctl) else { + return; + }; + + // A fraction of the *visible* span, so dragging half the axis + // moves half a span's worth of time whatever the zoom — and a + // small movement produces a small shift rather than nothing. + let zoom = *ctl.timeline_zoom.borrow(); + let (from, to) = zoomed_span(full, zoom, *ctl.timeline_centre.borrow()); + let shift = ((to - from) as f64 * fraction as f64) as i64; + if shift == 0 { + return; + } + + let centre = ctl.timeline_centre.borrow().unwrap_or((from + to) / 2) + shift; + *ctl.timeline_centre.borrow_mut() = Some(centre.clamp(full.0, full.1)); + refresh_timeline(&w, catalog, &ctl); + }); + } + + // The wheel zooms the axis: a sidebar is a scale, not a list. + // + // Shares `apply_zoom` with the pinch handler so the two cannot drift apart + // in how they clamp or where they centre. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_zoom(move |delta| { + if let Some(w) = weak.upgrade() { + apply_zoom(&w, &ctl, delta); + } + }); + } + + // Dragging the histogram moves the grid through time. + // + // The fraction is interpolated across the visible span rather than snapped + // to a bucket edge, so a slow drag advances continuously instead of sitting + // still and then jumping a whole month. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_scrub_fraction(move |f| { + let Some(w) = weak.upgrade() else { return }; + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + let Some(full) = catalog_span(catalog, &ctl) else { + return; + }; + + let (from, to) = zoomed_span( + full, + *ctl.timeline_zoom.borrow(), + *ctl.timeline_centre.borrow(), + ); + let when = instant_at((from, to), f); + drop(borrow); + scrub_to(&w, &ctl, when); + }); + } + + // Pinch, for tablet: no wheel there, so this is the only way to reach the + // axis's zoom with a finger. + // + // A continuous ratio against discrete zoom levels, so the accumulated + // ratio is held and a level is taken each time it passes a doubling. That + // keeps a slow spread from either doing nothing or leaping several levels. + { + let weak = window.as_weak(); + let ctl = ctl.clone(); + window.on_library_timeline_pinch(move |ratio| { + let Some(w) = weak.upgrade() else { return }; + if !(0.01..=100.0).contains(&ratio) { + return; + } + + let mut accum = ctl.pinch_accum.borrow_mut(); + *accum *= ratio; + + // Whole doublings out of the accumulated ratio, remainder carried. + // + // `log2` rather than repeated halving: one update can carry a + // large ratio — a fast spread, or a trackpad reporting coarsely — + // and stepping once per update would turn an 8× pinch into a + // single level instead of three. + let steps = accum.log2().trunc() as i32; + if steps == 0 { + return; + } + *accum /= (2.0f32).powi(steps); + drop(accum); + + apply_zoom(&w, &ctl, steps); + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_scrub_fraction_interpolates_within_the_span() { + // The point of going fractional: a slow drag must advance + // continuously rather than sitting still until it crosses a bucket + // edge and then jumping a whole month. + let span = (0, 1_000); + assert_eq!(instant_at(span, 0.0), 0); + assert_eq!(instant_at(span, 0.5), 500); + assert_eq!(instant_at(span, 1.0), 1_000); + // Distinct fractions inside one bucket must give distinct instants. + assert_ne!(instant_at(span, 0.10), instant_at(span, 0.11)); + } + + #[test] + fn the_marker_lands_on_the_fraction_that_was_clicked() { + // The bug this guards: the marker was placed from a *bar index* while + // the click was a fraction of the track, so it never appeared under + // the pointer. Marker and click must be inverses. + let span = (1_000, 9_000); + for f in [0.0_f32, 0.1, 0.25, 0.5, 0.75, 1.0] { + let round_trip = fraction_at(span, instant_at(span, f)); + assert!( + (round_trip - f).abs() < 1e-3, + "clicked {f}, marker drawn at {round_trip}" + ); + } + } + + #[test] + fn an_instant_outside_the_visible_span_pins_the_marker_to_an_end() { + // Zooming in leaves the grid's instant outside the axis. The marker + // belongs at the edge it went past, not off the widget. + let span = (1_000, 2_000); + assert_eq!(fraction_at(span, 0), 0.0); + assert_eq!(fraction_at(span, 5_000), 1.0); + } + + #[test] + fn a_scrub_fraction_outside_the_axis_is_clamped() { + // A drag that leaves the widget still reports a position; it must land + // at an end rather than off the timeline. + let span = (100, 200); + assert_eq!(instant_at(span, -3.0), 100); + assert_eq!(instant_at(span, 9.9), 200); + } + + /// A fortnight in March 2024, in whole days, as the axis would span it. + const MARCH: (i64, i64) = (1_709_251_200, 1_710_460_800); + + #[test] + fn a_dragged_range_covers_whole_days_at_both_ends() { + // What makes a dragged range the same kind of thing as a typed one: + // `show_range` reads the closing day back out of the stored second, so + // an end left mid-afternoon would be shown as the day before. + let (from, to) = dragged_range(MARCH, 0.13, 0.61); + assert_eq!(day_start(from), from, "opens at a midnight"); + assert_eq!( + day_start(to + 1), + to + 1, + "closes at the last second of a day" + ); + assert_eq!(dr_types::format_date(from), "2024-03-02"); + assert_eq!(dr_types::format_date(to - 86_400 + 1), "2024-03-09"); + } + + #[test] + fn ends_dragged_past_each_other_name_the_same_range() { + // Pulling the early end below the late one is how a range that was + // narrowed too far gets corrected; refusing it would make starting + // again the easier move. + assert_eq!( + dragged_range(MARCH, 0.6, 0.2), + dragged_range(MARCH, 0.2, 0.6) + ); + } + + #[test] + fn both_ends_dragged_together_name_one_day() { + // The floor the day snapping buys. Two handles on the same pixel is a + // gesture that happens; an empty grid is not what it means. + let (from, to) = dragged_range(MARCH, 0.5, 0.5); + assert_eq!(to - from, 86_399); + } + + #[test] + fn the_band_is_drawn_where_the_handle_was_dropped() { + // The band is placed by `fraction_at` and the range comes from + // `instant_at`. If those two ever disagreed the band would settle + // somewhere other than where the finger let go, which reads as the + // drag having been ignored. + for f in [0.0_f32, 0.25, 0.5, 0.75, 1.0] { + let (from, _) = dragged_range(MARCH, f, f); + let drawn = fraction_at(MARCH, from); + // Within a day of the span, which is the snapping and nothing else. + let day = 86_400.0 / (MARCH.1 - MARCH.0) as f32; + assert!((drawn - f).abs() <= day, "dropped at {f}, drawn at {drawn}"); + } + } + + #[test] + fn every_instant_on_the_axis_lands_in_a_bin() { + // The bar under a date and the bar that counted it must be the same + // bar: `bin_of` is the UI's copy of the division the SQL groups by. + let span = (1_000, 9_000); + assert_eq!(bin_of(span, 1_000, 8), 0); + assert_eq!(bin_of(span, 1_999, 8), 0); + assert_eq!(bin_of(span, 2_000, 8), 1); + // The last instant belongs to the last bin rather than to one past it. + assert_eq!(bin_of(span, 9_000, 8), 7); + } + + #[test] + fn an_instant_off_the_zoomed_axis_lands_at_the_end_it_went_past() { + // Zooming in leaves the grid's position outside the span. The lit bar + // belongs at the edge, not at an index that does not exist. + let span = (1_000, 9_000); + assert_eq!(bin_of(span, -50_000, 8), 0); + assert_eq!(bin_of(span, 50_000, 8), 7); + // And a degenerate axis still answers with a bin. + assert_eq!(bin_of((5, 5), 5, 32), 0); + } + + #[test] + fn a_day_before_1970_starts_at_its_own_midnight() { + // `%` truncates towards zero, which on a negative timestamp names the + // *following* midnight — a scanned archive of negatives would have + // every range off by a day at the early end. + assert_eq!(day_start(-1), -86_400); + assert_eq!(day_start(-86_400), -86_400); + assert_eq!(day_start(-86_401), -172_800); + } + + /// One pinch update, as the handler applies it: fold the ratio in, take + /// out whole doublings, carry the remainder. + fn pinch_step(accum: &mut f32, ratio: f32) -> i32 { + *accum *= ratio; + let steps = accum.log2().trunc() as i32; + if steps != 0 { + *accum /= (2.0f32).powi(steps); + } + steps + } + + #[test] + fn a_pinch_accumulates_until_it_reaches_a_doubling() { + // A continuous gesture against discrete levels. Small spreads must + // accumulate rather than each being rounded to a step. + let mut accum = 1.0f32; + let mut steps = 0; + for _ in 0..12 { + steps += pinch_step(&mut accum, 1.1); + } + // 1.1^12 is 3.138x, which is log2 = 1.65 — one whole doubling, with + // the rest carried rather than discarded or rounded up. + assert_eq!(steps, 1); + assert!((accum - 1.569).abs() < 0.01, "remainder carried: {accum}"); + } + + #[test] + fn one_large_pinch_yields_every_level_it_crossed() { + // The bug this replaced: stepping at most once per update turned an + // 8x spread — three doublings — into a single zoom level, so a fast + // gesture lost most of its travel. + let mut accum = 1.0f32; + assert_eq!(pinch_step(&mut accum, 8.0), 3); + assert!((accum - 1.0).abs() < 0.001); + + let mut accum = 1.0f32; + assert_eq!(pinch_step(&mut accum, 0.125), -3); + } + + #[test] + fn pinching_in_and_back_out_returns_to_where_it_started() { + let mut accum = 1.0f32; + let steps: i32 = [2.0f32, 2.0, 0.5, 0.5] + .iter() + .map(|r| pinch_step(&mut accum, *r)) + .sum(); + assert_eq!(steps, 0); + assert!((accum - 1.0).abs() < 0.001, "no drift: {accum}"); + } + + #[test] + fn a_pinch_below_a_doubling_takes_no_step() { + // Otherwise the axis would flicker between levels on the smallest + // finger movement. + let mut accum = 1.0f32; + assert_eq!(pinch_step(&mut accum, 1.4), 0); + assert_eq!(pinch_step(&mut accum, 0.72), 0, "back roughly to 1.0"); + } + + #[test] + fn zoom_zero_is_the_whole_library() { + let full = (1_000, 2_000); + assert_eq!(zoomed_span(full, 0, None), full); + // A remembered centre must not narrow the span at zoom 0. + assert_eq!(zoomed_span(full, 0, Some(1_500)), full); + } + + #[test] + fn each_zoom_step_halves_the_span() { + let day = 86_400; + let full = (0, 64 * day); + let (a, b) = zoomed_span(full, 1, Some(32 * day)); + assert_eq!(b - a, 32 * day); + let (a, b) = zoomed_span(full, 2, Some(32 * day)); + assert_eq!(b - a, 16 * day); + } + + #[test] + fn zooming_centres_on_the_given_instant() { + let day = 86_400; + let full = (0, 100 * day); + let (a, b) = zoomed_span(full, 1, Some(60 * day)); + assert_eq!((a + b) / 2, 60 * day, "centred where asked"); + } + + #[test] + fn a_window_at_the_edge_slides_rather_than_shrinking() { + // Clamping both ends would silently halve the span near the start of + // the library, so the axis would show less detail there than in the + // middle for the same zoom level. + let day = 86_400; + let full = (0, 100 * day); + let (a, b) = zoomed_span(full, 1, Some(0)); + assert_eq!(a, 0, "cannot start before the library does"); + assert_eq!(b - a, 50 * day, "keeps its width"); + + let (a, b) = zoomed_span(full, 1, Some(100 * day)); + assert_eq!(b, 100 * day); + assert_eq!(b - a, 50 * day); + } + + #[test] + fn a_deep_zoom_does_not_collapse_to_nothing() { + // A zero-width span would make every bucket empty, which reads as a + // broken axis rather than a deep zoom. + let full = (0, 86_400); + let (a, b) = zoomed_span(full, 12, Some(43_200)); + assert!(b > a, "span stays positive"); + } + + #[test] + fn a_single_instant_library_does_not_divide_by_zero() { + let full = (1_700_000_000, 1_700_000_000); + let (a, b) = zoomed_span(full, 5, None); + assert!(a <= b); + } + + #[test] + fn month_names_cover_the_year_and_clamp() { + assert_eq!(month_name(1), "January"); + assert_eq!(month_name(12), "December"); + assert_eq!(month_abbrev(3), "Mar"); + // A corrupt month must not panic the grid. + assert_eq!(month_name(0), "January"); + assert_eq!(month_name(99), "December"); + } + + #[test] + fn civil_dates_round_trip_at_boundaries() { + // Era-based date maths is silently wrong at year and leap boundaries + // if the algorithm is transcribed slightly off, and the symptom is an + // image landing in the wrong histogram bucket. + assert_eq!(civil_from_unix(0), (1970, 1, 1, 0)); + assert_eq!(civil_from_unix(1_786_285_800), (2026, 8, 9, 14)); + // Leap day. + assert_eq!(civil_from_unix(1_709_164_800), (2024, 2, 29, 0)); + // Last second of a year, and the first of the next. + assert_eq!(civil_from_unix(1_735_689_599), (2024, 12, 31, 23)); + assert_eq!(civil_from_unix(1_735_689_600), (2025, 1, 1, 0)); + } + + #[test] + fn dates_before_the_epoch_do_not_wrap() { + // Scanned film carries capture dates well before 1970; a negative + // timestamp must floor rather than truncate toward zero. + let (y, _, _, _) = civil_from_unix(-1); + assert_eq!(y, 1969); + } + + /// A bar is named after how long it is. + /// + /// The axis no longer buckets by calendar unit — it cuts the visible span + /// into a fixed number of equal bins — so the unit survives only as a + /// vocabulary for *labelling* one. What this guards is that zooming in + /// never produces a coarser label: the complaint it came from was a + /// fortnight drawn as one column of a year-per-bar axis. + #[test] + fn a_bin_is_labelled_at_its_own_scale() { + use dr_catalog::Granularity; + const DAY: i64 = 86_400; + + // Fifteen years across 32 bars is nearly six months a bar, which on a + // ratio measure is nearer a year than a month — so the bars are + // labelled by year, which is what a fifteen-year axis wants anyway. + assert_eq!( + Granularity::for_bucket(15 * 365 * DAY / 32), + Granularity::Year + ); + + // A fortnight across the same 32 bars is about ten hours a bar, which + // is nearer a day than an hour and is labelled as a date. + assert_eq!(Granularity::for_bucket(14 * DAY / 32), Granularity::Day); + + // Zoom on until a bar is an hour or two, and it is named by the hour. + assert_eq!(Granularity::for_bucket(2 * 3600), Granularity::Hour); + + // The property rather than the thresholds: a shorter bin never gets a + // coarser label than a longer one. + let mut previous = i64::MAX; + for days in [3650, 730, 180, 60, 14, 3, 1] { + let g = Granularity::for_bucket(days * DAY / 32); + let bucket = match g { + Granularity::Year => 365 * DAY, + Granularity::Month => 30 * DAY, + Granularity::Day => DAY, + Granularity::Hour => 3600, + }; + assert!( + bucket <= previous, + "{days} days chose a coarser bin label than the span above it" + ); + previous = bucket; + } + } + + /// Deleting must not move the view somewhere the user did not ask to be. + /// + /// The grid draws each cell at its absolute place in the library, and a + /// delete shortens the library *and* re-clamps the loaded window's offset. + /// Neither touches the viewport, so before this the grid went blank — the + /// view left pointing past the end of the content — or appeared to jump, + /// the same scroll position now addressing different photographs. + /// + /// The anchor is the last *visible* ordinal the grid reported, clamped into + /// what is left — not the loaded window's `offset`, which sits about a + /// quarter of a screen above the view and is itself re-clamped by the + /// delete. Anchoring on that was the first attempt and still jumped. + #[test] + fn a_shorter_library_anchors_the_view_instead_of_losing_it() { + // The rule `restore_position` applies, stated where it can be checked. + let anchor = |offset: usize, total: usize| -> Option { + if total == 0 { + None + } else { + Some(offset.min(total - 1)) + } + }; + + // The everyday case: one frame removed from the middle of a wall of + // twenty thousand. The view does not move — the photograph that slid + // into the gap is the one you want next. + assert_eq!(anchor(12_000, 19_999), Some(12_000)); + + // Near the end, which is where the blank came from: the view was + // showing ordinals past what now exists, so it lands on the last. + assert_eq!(anchor(19_990, 5), Some(4)); + + // Emptied entirely. Seeking into an empty library would be the same + // fault in the other direction, so there is nothing to do. + assert_eq!(anchor(500, 0), None); + + // The boundary: an offset equal to the new total is one past the end. + assert_eq!(anchor(10, 10), Some(9)); + } + + #[test] + fn bucket_labels_match_their_granularity() { + use dr_catalog::Granularity; + let t = 1_786_285_800; // 2026-08-09 14:30 UTC + assert_eq!(format_bucket(t, Granularity::Year), "2026"); + assert_eq!(format_bucket(t, Granularity::Month), "2026-08"); + assert_eq!(format_bucket(t, Granularity::Day), "2026-08-09"); + assert_eq!(format_bucket(t, Granularity::Hour), "2026-08-09 14:00"); + } +} diff --git a/ui/dr-ui/src/library_ui/window.rs b/ui/dr-ui/src/library_ui/window.rs new file mode 100644 index 0000000..80b7fa1 --- /dev/null +++ b/ui/dr-ui/src/library_ui/window.rs @@ -0,0 +1,1311 @@ +//! Filling the grid model from the catalog: which window of it is loaded, +//! which cells it can carry across a reload without redecoding, and the +//! thumbnail fetch and drain that fill the rest in. +//! +//! This is the piece [`crate::library_ui`]'s catalog reads are proportional +//! to (`docs/catalog.md` §1): [`load_window`] reads one screenful's worth +//! rather than the library, and [`hold_thumbnails`] is what keeps a scroll +//! from redecoding pixels already on screen. See `docs/dev/code-health.md` +//! CH-1. + +use std::rc::Rc; +use std::sync::mpsc::Receiver; + +use slint::{ComponentHandle, Model as _}; + +use crate::library::{self, ThumbnailMessage}; +use crate::{AppWindow, LibraryCell}; + +use super::controller::{stop, LibraryController, LibraryFacts}; +use super::filter_bar::period_headings; +use super::offline::{collection_images, refresh_offline, scope_is_pinned}; +use super::ratings_keywords::{refresh_rating_counts, sync_ratings}; +use super::sync::start_sweep; +use super::timeline::{civil_from_unix, format_date, refresh_timeline, restore_position}; + +/// What one cell of the outgoing model is worth keeping. +#[derive(Clone)] +struct Held { + thumbnail: slint::Image, + has_thumb: bool, + /// A completed fetch that found no preview. Worth carrying for the same + /// reason the pixels are: it is an answer about the file, and re-asking it + /// on every scroll is a fetch that will fail again. + unavailable: bool, + /// Which size class the pixels came from, so the reload can tell a cell + /// that is showing what it should from one that is showing the small class + /// while the grid has since been zoomed past it. + class: Option, +} + +/// **What the outgoing model is still holding, keyed on the photograph.** +/// +/// A reload replaces every row, and a scroll reloads once the view has +/// travelled a quarter of the loaded window — so three quarters of the cells +/// being rebuilt are the *same* photographs the user is looking at right now. +/// Rebuilding them empty blanked the whole grid to `Theme.ground` and refilled +/// it a beat later, once a worker had re-read and re-decoded every one of them +/// from the thumbnail store. That is the black flash that punctuated every +/// screenful of scrolling, and the one visible on a column change, a zoom step, +/// a filter and a return from develop. +/// +/// Keyed on `image_id` rather than on the row, because the row is exactly what +/// a reload changes. Cheap: the values are `slint::Image` handles, so this is a +/// refcount per cell and no pixels move. +fn hold_thumbnails( + previous: &slint::ModelRc, + ids: &[i64], + classes: &[Option], +) -> std::collections::HashMap { + ids.iter() + .enumerate() + .filter_map(|(row, id)| { + let cell = previous.row_data(row)?; + // Nothing to carry: a row still waiting is equally blank either + // way, and holding a default image would claim otherwise. + if !cell.has_thumb && !cell.unavailable { + return None; + } + Some(( + *id, + Held { + thumbnail: cell.thumbnail, + has_thumb: cell.has_thumb, + unavailable: cell.unavailable, + class: classes.get(row).copied().flatten(), + }, + )) + }) + .collect() +} + +/// The fetches a reloaded window does **not** have to make. +/// +/// The set used to be cleared on every load, which said "every cell here is +/// still to be fetched" — true when every row came back empty, and false now +/// that the overlap keeps its pixels. Re-requesting them re-read and re-decoded +/// three quarters of the window from the store on every scroll. +/// +/// Rebuilt rather than merely kept, and that is the half that is easy to get +/// wrong: an image served into a window the user has since scrolled away from +/// is no longer on screen, and a set that remembered it would leave that cell +/// permanently blank when they scrolled back. Only what the new model actually +/// holds counts as served — and only at the class it holds it at, so zooming +/// past the grid class still asks for the large one. +fn already_served( + held: &std::collections::HashMap, + ids: impl Iterator, +) -> std::collections::HashSet<(i64, dr_thumbs::ThumbSize)> { + ids.filter_map(|id| Some((id, held.get(&id)?.class?))) + .collect() +} + +/// Where the loaded window starts for a view whose first visible cell is +/// `anchor`. +/// +/// A quarter of the window sits above the view, so scrolling back has loaded +/// rows to move into, and the remaining three quarters below, because a grid +/// is read downward. Clamped to the last position where the window is still +/// full — otherwise a scrub to the very end loads a handful of cells and the +/// rest of the window addresses images that do not exist. +pub(super) fn window_start(anchor: usize, window_size: usize, total: usize) -> usize { + let max_offset = total.saturating_sub(window_size.min(total)); + anchor.saturating_sub(window_size / 4).min(max_offset) +} + +/// Where the loaded window should move to for a view at `first_visible`, or +/// `None` to leave it where it is. +/// +/// # Why this is a rule and not four lines in the scroll handler +/// +/// It decides how often the grid re-reads the catalog while a finger is on it, +/// and both of its ways of being wrong are invisible in the code and obvious +/// on a tablet: too eager and every scroll stutters, too lazy and the view +/// runs off the end of the loaded rows into blank ones. +/// +/// # The rule +/// +/// The window is placed by [`window_start`], and it moves once the view comes +/// within half a screenful of an edge of what is loaded — below the *bottom* +/// of the view going down, above its top going up. +/// +/// # Measured against the view, not against a fraction of the window +/// +/// Both halves of that used to be a quarter of `window_size`, and both were +/// wrong, in opposite directions and for the same reason: a quarter of a +/// three-screenful window is three quarters of a screenful, which is not a +/// quantity either edge of the view cares about. +/// +/// - Downward it was too lax. The test asked where the *first* visible cell +/// was, so it kept the window still until `first_visible` was three quarters +/// of the way through it — by which point the bottom of the view had +/// travelled a quarter of a screenful past the last loaded cell. Those rows +/// are not in the model, so nothing is drawn for them: the last row of the +/// grid, blank, at a scroll position the user can sit at indefinitely. +/// - Upward it was too eager, and exactly so. The window is placed a quarter +/// of itself behind the view, and the old margin then declared the view too +/// close to the top at precisely that distance — so the first row scrolled +/// upward after any move re-read the catalog, rebuilt every cell, and +/// re-queried the badges and ratings, and so did the row after it. +/// +/// # And it never moves to where it already is +/// +/// That is the case the margin test alone gets wrong: at either end of a scope +/// the window is *pinned* — the first screenful cannot be placed further back +/// than zero, and the last cannot start past `max_offset` — so the margin is +/// unsatisfiable there and every row crossed in the first or last quarter of a +/// window re-read the catalog, rebuilt the model and issued a thumbnail batch +/// to arrive at the offset it already had. On a library of twenty-odd thousand +/// that is a stutter at the top and the bottom of every collection, which is +/// exactly where a cull begins and ends. +pub(super) fn window_move( + first_visible: usize, + current: usize, + window_size: usize, + on_screen: usize, + total: usize, +) -> Option { + // The same placement `load_window` applies, repeated here so this compares + // against where the window would come to rest rather than where it was + // asked to go. + let desired = window_start(first_visible, window_size, total); + if desired == current { + return None; + } + + // Half a screenful of loaded cells beyond each edge of the view. Enough + // that a flick has somewhere to land before the reload it triggers has + // finished, small enough that the window still moves only about once per + // screenful of travel. + let slack = (on_screen / 2).max(1); + let above = first_visible >= current + slack; + let below = first_visible + on_screen + slack <= current + window_size; + if above && below { + return None; + } + + Some(desired) +} + +/// Fill the model from the catalog and start fetching thumbnails. +/// +/// Reads the window starting at the controller's current offset, which the +/// scrubber moves. Without a movable offset the grid could only ever show the +/// first 120 of 23,971 images. +pub(super) fn load_window(window: &AppWindow, ctl: &Rc) { + let borrow = ctl.catalog.borrow(); + let Some(catalog) = borrow.as_ref() else { + return; + }; + + // Everything below is scoped to the selected collection, if any: the total, + // the window, and the fetches issued for it. Reading the whole library here + // and filtering later would fetch thumbnails for images the user is not + // looking at, which on a remote library is the cost FR-NC-3 exists to + // avoid. + let scope = *ctl.scope.borrow(); + let filter = ctl.filter.borrow().clone(); + // The trash lists what every other view excludes, so it takes its own + // query rather than another predicate threaded through the scoped one. + let trash = ctl.viewing_trash.get(); + + let total = if trash { + library::total_trashed(catalog).unwrap_or(0) + } else { + library::total_images_scoped(catalog, scope, &filter).unwrap_or(0) + }; + + // What the whole-library readouts below describe. See [`LibraryFacts`]. + let facts = LibraryFacts { + scope, + filter: filter.clone(), + trash, + total, + }; + let describes_something_new = ctl.library_facts.borrow().as_ref() != Some(&facts); + *ctl.library_facts.borrow_mut() = Some(facts); + + // TRACES: FR-NC-6a + // Whether the newly scoped collection is already pinned. Read here rather + // than remembered, because a pin outlives the session that made it — on + // reopening the library the button has to show what the catalog says, not + // what this run happens to have done. + // + // Three queries deep — descendants, their images, and whether every one of + // them is pinned — and the answer cannot change by scrolling. + if describes_something_new { + window.set_library_scope_pinned(match scope { + Some(id) => dr_catalog::collections::descendants(catalog.connection(), id) + .map(|ids| collection_images(catalog, &ids)) + .map(|images| scope_is_pinned(catalog, &images)) + .unwrap_or(false), + None => false, + }); + } + // Read before it is overwritten: the property still holds what the library + // was last time this ran, and a library that has become shorter is the + // signal that something was deleted out from under the view. + let was = window.get_library_total().max(0) as usize; + window.set_library_total(total as i32); + let shrank = total < was; + + // Clamp so a scrub to the very end still fills the window rather than + // showing a handful of cells. + let window_size = *ctl.window.borrow(); + let offset = (*ctl.offset.borrow()).min(total.saturating_sub(window_size.min(total))); + *ctl.offset.borrow_mut() = offset; + window.set_library_offset(offset as i32); + + // The axis the scrubber draws. A `MIN`/`MAX` and a `GROUP BY` over the + // whole scope — 5.7 ms on 24,000 images — and the bars do not change as the + // grid scrolls, only the marker on them does. The marker is moved by the + // scroll handler on every event, without coming through here. + // + // Every other thing that *does* change the bars — a zoom, a pan, a scrub, + // dates landing from the thumbnail worker — calls `refresh_timeline` + // itself, so this being skipped cannot leave a stale axis on screen. + if describes_something_new { + refresh_timeline(window, catalog, ctl); + } + + // TRACES: FR-CAT-7 + // Whether this scope has an order a drag can change, which is what lets the + // grid draw an insertion caret and accept a drop. + // + // Answered here because this is the one place that runs on every re-read — + // a scope change, a reorder, a collection gaining a child that turns it + // into a set — so the caret cannot outlive the scope that justified it. + // + // The trash is never reorderable. It is a view of what was deleted, ordered + // by when, and it is not a collection at all. + window.set_library_reorderable( + !trash + && scope.is_some_and(|c| { + dr_catalog::collections::orders_manually(catalog.connection(), c).unwrap_or(false) + }), + ); + + let cells = if trash { + library::read_trashed_cells(catalog, offset, window_size) + } else { + library::read_cells_scoped(catalog, scope, &filter, offset, window_size) + }; + let cells = match cells { + Ok(c) => c, + Err(e) => { + window.set_library_error(format!("reading catalog: {e}").into()); + return; + } + }; + + // What the window currently spans, in the photographer's own terms. + let span = cells + .iter() + .filter_map(|c| c.captured_at) + .fold(None::<(i64, i64)>, |acc, t| { + Some(match acc { + None => (t, t), + Some((lo, hi)) => (lo.min(t), hi.max(t)), + }) + }); + window.set_library_window_label( + match span { + Some((lo, hi)) => format!("{} – {}", format_date(lo), format_date(hi)), + // Nothing here has a date yet: EXIF is read as thumbnails load, so + // this fills in rather than being an error. + None => "dates not yet read".to_string(), + } + .into(), + ); + + // Month headings. The grid is ordered by capture time, so without these a + // wall of thumbnails gives no sense of *when* you are looking — the + // sidebar says it, but only if you consult it. + let columns = window.get_library_columns().max(1) as usize; + let headings = period_headings( + cells.iter().map(|c| { + c.captured_at.map(|t| { + let (y, m, _, _) = civil_from_unix(t); + (y, m) + }) + }), + offset, + columns, + ); + + // What the outgoing model is still holding — see [`hold_thumbnails`]. + let held = { + let previous = window.get_library_cells(); + let ids = ctl.image_ids.borrow(); + let classes = ctl.thumb_class.borrow(); + hold_thumbnails(&previous, &ids, &classes) + }; + + let rows: Vec = cells + .iter() + .zip(headings) + .map(|(c, heading)| { + let carried = held.get(&c.image_id); + LibraryCell { + period_heading: heading.into(), + // A freshly loaded window has no drag in flight. + lifted: false, + name: without_extension(&c.name).into(), + thumbnail: carried.map(|h| h.thumbnail.clone()).unwrap_or_default(), + has_thumb: carried.is_some_and(|h| h.has_thumb), + unavailable: carried.is_some_and(|h| h.unavailable), + // All three are filled straight after by `collections_ui`, + // which owns the selection and queries the badge counts for the + // whole window in one statement rather than one per cell. + selected: false, + collection_count: 0, + // Likewise filled by `sync_ratings` below — one query for the + // window, not one per cell. + rating: 0, + flag: 0, + // And by `bursts::sync_badges`, in one more query for the + // window. Zero is "not in a burst", which is what almost every + // photograph in a library is. + burst_count: 0, + burst_expanded: false, + burst_representative: false, + } + }) + .collect(); + + *ctl.paths.borrow_mut() = cells.iter().map(|c| c.remote_path.clone()).collect(); + *ctl.file_ids.borrow_mut() = cells.iter().map(|c| c.file_id).collect(); + *ctl.sizes.borrow_mut() = cells.iter().map(|c| c.size).collect(); + *ctl.image_ids.borrow_mut() = cells.iter().map(|c| c.image_id).collect(); + *ctl.needs_metadata.borrow_mut() = cells.iter().map(|c| c.metadata_state < 2).collect(); + *ctl.captured_at.borrow_mut() = cells.iter().map(|c| c.captured_at).collect(); + *ctl.thumb_class.borrow_mut() = cells + .iter() + .map(|c| held.get(&c.image_id).and_then(|h| h.class)) + .collect(); + *ctl.requested.borrow_mut() = already_served(&held, cells.iter().map(|c| c.image_id)); + + // The model is about to be replaced, so every thumbnail still in flight + // addresses a window that no longer exists. Bumping here — before the swap + // — is what lets `drain_thumbnails` recognise itself as stale. The rows + // those fetches would have filled are absent from `requested` above, so the + // batch started below asks for them again. + ctl.generation.set(ctl.generation.get().wrapping_add(1)); + window.set_library_cells(slint::ModelRc::new(slint::VecModel::from(rows))); + + // The model is fresh, so the "in this many collections" badges are all zero + // until refilled. One query for the whole window, not one per cell. + let ids: Vec = cells + .iter() + .map(|c| dr_types::ImageId(c.image_id as u64)) + .collect(); + crate::collections_ui::sync_badges(window, catalog, &ids); + sync_ratings(window, catalog, &ids); + // How many frames each cell stands for, where it stands for several + // (FR-CULL-5). + crate::bursts::sync_badges(window, catalog, &ids); + // The rebuilt cells all carry `selected: false`, but the selection itself + // is a set of image ids and survives untouched. Without this the ticks + // vanished on every scroll — the selection was still there and still acted + // on, which is worse than losing it, because the user cannot see what the + // buttons are about to do. + if let Some(coll) = ctl.coll_ctl.borrow().as_ref().and_then(|w| w.upgrade()) { + crate::collections_ui::sync_selection(window, &coll, &ids); + } + // The filter chips' counts describe the whole library, not this window — + // which is exactly why they are not recomputed for a window that moved. + // A judgement changes them and calls this itself (see `apply_judgement`), + // so a cull still watches its own chips move. + if describes_something_new { + refresh_rating_counts(window, catalog); + } + + // The library got shorter while the view was looking at it — a delete. + // + // Two things have already moved by this point and neither touches the + // viewport: the scrollable height shrank, and `offset` was re-clamped so + // the loaded window still fills. So the view is left pointing either past + // the end of the content (a blank grid) or at a different part of the + // library (an apparent jump to nowhere). Re-anchoring here puts it back on + // the photographs the user was actually looking at. + // + // Only on a shrink. Doing it on every load would fight a scrub, which sets + // exactly this property to go somewhere the user asked for. + if shrank { + restore_position(window, ctl); + } + + if total == 0 { + return; + } + request_thumbnails(window, ctl); +} + +/// Where a row of the loaded window sits in the fetch queue. +/// +/// On screen first, top to bottom; then the rows below the view, nearest +/// first; then the rows above it, nearest first. +/// +/// # Why the queue has an order at all +/// +/// The batch is fetched one image at a time, two round trips each, and it is +/// abandoned wholesale the moment the window moves — so whatever is at the +/// back of it on a remote library is not slow to appear, it never appears. +/// Issued in model order, the back of the queue was the bottom of the screen +/// and everything below it, and the *front* was the quarter-window of cells +/// above the view that nobody was looking at. A scroll then abandoned the +/// batch and the new one started over, again from above the view: on a +/// library still filling its store, the last rows of the grid could be +/// starved for as long as the scrolling continued. +/// +/// Below before above because that is where the view is going. Scrolling back +/// over cells already fetched is served from the store, and from `requested` +/// without a fetch at all. +fn fetch_rank(row: usize, first_on_screen: usize, on_screen: usize) -> (u8, usize) { + let past = first_on_screen + on_screen; + if row >= first_on_screen && row < past { + (0, row - first_on_screen) + } else if row >= past { + (1, row - past) + } else { + (2, first_on_screen - row) + } +} + +/// Fetch thumbnails for rows in the model that do not have one yet. +fn request_thumbnails(window: &AppWindow, ctl: &Rc) { + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + + // The drawn cell size decides which class to ask for. Chosen once for the + // batch rather than per row, and carried through to the drain so a cell it + // fills can record what it is now showing. + let cell_pixels = window.get_library_cell_size().max(1.0) as u32; + let class = dr_thumbs::ThumbSize::for_cell(cell_pixels); + + let mut wanted: Vec = { + let paths = ctl.paths.borrow(); + let file_ids = ctl.file_ids.borrow(); + let sizes = ctl.sizes.borrow(); + let image_ids = ctl.image_ids.borrow(); + let needs_md = ctl.needs_metadata.borrow(); + let mut requested = ctl.requested.borrow_mut(); + paths + .iter() + .enumerate() + .filter_map(|(i, p)| { + let image_id = *image_ids.get(i)?; + // A zoomed grid asks for detail a 256px thumbnail cannot give, + // and a wall of small cells does not pay for it. + let thumb_size = class; + // Keyed on the photograph, so scrolling back over a cell that + // has already been served does not ask for it again. + if !requested.insert((image_id, thumb_size)) { + return None; + } + Some(library::ThumbnailRequest { + row: i, + path: p.clone(), + file_id: file_ids.get(i).copied().flatten(), + size: sizes.get(i).copied().unwrap_or(0), + image_id, + needs_metadata: needs_md.get(i).copied().unwrap_or(false), + thumb_size, + // The grid is filling a cell of a known size. + full_resolution: false, + }) + }) + .collect() + }; + + if wanted.is_empty() { + return; + } + + // What is on screen, first — see [`fetch_rank`]. The rows keep addressing + // the model they were built against; only the order they are asked for in + // changes. + { + let first_on_screen = ctl.resume_at.get().saturating_sub(*ctl.offset.borrow()); + let on_screen = ctl.viewport_cells.get().max(1); + wanted.sort_by_key(|r| fetch_rank(r.row, first_on_screen, on_screen)); + } + + let requested = wanted.len(); + let rx = library::spawn_thumbnails( + conn.clone(), + wanted, + library::thumbs_dir(&conn.account), + library::catalog_path(&conn.account), + ); + drain_thumbnails(window.as_weak(), ctl.clone(), rx, requested, class); +} + +/// TRACES: FR-CAT-9 | FR-DEV-6 +/// Replace a photograph's cached thumbnail with one rendered from its edit. +/// +/// # Why the grid cannot be left alone +/// +/// A thumbnail comes from the file's embedded preview, which is the camera's +/// idea of the photograph and knows nothing about what has been done to it +/// since. So a frame could be cropped, turned upright, and pulled two stops +/// back, and the grid would go on showing the original — the one view of a +/// library where an edit is least visible is the one the photographer spends +/// most of their time in. +/// +/// # Both classes, and why +/// +/// The store keys on the size class, so replacing only the one the grid +/// happens to be drawing at leaves the other holding the unedited preview — +/// and a zoom past the class boundary would show the edit undoing itself. +/// Each class is rendered separately because they are different sizes; a +/// downscale of the large one would be a second, worse resampler than the GPU +/// already applied. +/// +/// # Silent on failure +/// +/// The edit is saved to the sidecar by the caller before this runs, so nothing +/// here can lose work. A thumbnail that could not be re-rendered is a stale +/// cell, which the next scroll past it corrects from the store — worth a log +/// line and not worth an error in front of a photographer who has just +/// finished an image. +pub fn refresh_thumbnail( + window: &AppWindow, + ctl: &Rc, + remote_path: &str, + mut render: impl FnMut(u32) -> Result<(u32, u32, Vec), String>, +) { + // Where this photograph sits in the loaded window. It is always in it: the + // develop view is reached from a cell, and the guards on the scroll and + // geometry handlers stop the window moving while it is open. + let Some(row) = ctl.paths.borrow().iter().position(|p| p == remote_path) else { + return; + }; + let Some(file_id) = ctl.file_ids.borrow().get(row).copied().flatten() else { + // Nothing to key the store on. A photograph the scan recorded without + // a server file id cannot have a cached thumbnail either, so there is + // nothing here to correct. + return; + }; + let Some((conn, _)) = ctl.session.borrow().clone() else { + return; + }; + + let mut store = match dr_thumbs::ThumbStore::open(&library::thumbs_dir(&conn.account)) { + Ok(s) => s, + Err(e) => { + log::warn!("re-thumbnailing {remote_path}: opening the store: {e}"); + return; + } + }; + + // What the grid is drawing at, so the cell can be corrected on screen + // rather than only on disk. + let drawn = dr_thumbs::ThumbSize::for_cell(window.get_library_cell_size().max(1.0) as u32); + + for class in [dr_thumbs::ThumbSize::Grid, dr_thumbs::ThumbSize::Large] { + let (w, h, rgba) = match render(class.edge()) { + Ok(r) => r, + Err(e) => { + log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); + continue; + } + }; + + match dr_thumbs::codec::encode_rgba(w, h, &rgba) { + Ok(bytes) => { + let thumb = dr_thumbs::Thumbnail { + width: w, + height: h, + bytes, + }; + if let Err(e) = store.put(file_id, class, &thumb) { + log::warn!("re-thumbnailing {remote_path} at {class:?}: {e}"); + } + } + Err(e) => { + log::warn!("re-thumbnailing {remote_path} at {class:?}: encoding: {e}"); + continue; + } + } + + if class == drawn { + // Straight into the model, so the edit is on the cell the moment + // the grid comes back rather than after a scroll evicts and + // refetches it. + let model = window.get_library_cells(); + if let Some(mut cell) = model.row_data(row) { + cell.thumbnail = to_slint_image(w, h, &rgba); + cell.has_thumb = true; + cell.unavailable = false; + model.set_row_data(row, cell); + } + record_class(ctl, row, class); + } + } +} + +/// Note which size class a row's pixels came from. +/// +/// Silent about a row past the end: the model and this vector are rebuilt +/// together by [`load_window`], and the generation check above already refuses +/// anything addressed to a window that has since moved. +fn record_class(ctl: &Rc, row: usize, class: dr_thumbs::ThumbSize) { + if let Some(slot) = ctl.thumb_class.borrow_mut().get_mut(row) { + *slot = Some(class); + } +} + +/// Apply thumbnails to the model as they arrive. +fn drain_thumbnails( + weak: slint::Weak, + ctl: Rc, + rx: Receiver, + requested: usize, + class: dr_thumbs::ThumbSize, +) { + let timer = slint::Timer::default(); + let ctl_cb = ctl.clone(); + + // One row per batch, measured against the cells this window asked for. + // Starting a new batch does not extend the last one: a scroll abandons + // whatever the previous window wanted, and a denominator carried across + // both would describe neither. + let job = ctl + .activity + .begin(crate::activity::Kind::Thumbnails, "Loading thumbnails"); + job.total(requested); + // Which window this batch was requested for. Captured at spawn, compared on + // every tick. + let mine = ctl.generation.get(); + + timer.start( + slint::TimerMode::Repeated, + std::time::Duration::from_millis(100), + move || { + let Some(w) = weak.upgrade() else { return }; + + // A reload replaced the model under this worker. Two things must + // not happen now, and both did: + // + // - Applying a row. `t.row` indexes the window that asked for it, + // so after a reload it names a different photograph — thumbnails + // landed on unrelated cells, and `Unavailable` marked cells + // "no preview" for a fetch never attempted against them. + // - Calling `stop`. `thumb_timer` holds the *current* batch's timer + // by now, so a stale drain reaching `Disconnected` killed the + // live drain instead of itself. The new worker then fetched into + // a channel nobody read, and the grid stayed black until a scroll + // forced yet another load — which is the flicker being chased. + // + // Returning without stopping is deliberate: this timer is no longer + // reachable through the controller, so it is dropped with its + // receiver when the slot is overwritten, and the worker exits on + // its next failed send. + if ctl_cb.generation.get() != mine { + return; + } + + let model = w.get_library_cells(); + + loop { + let msg = match rx.try_recv() { + Ok(m) => m, + Err(std::sync::mpsc::TryRecvError::Empty) => return, + Err(std::sync::mpsc::TryRecvError::Disconnected) => { + // The worker finished or died. Either way nothing more + // is coming, so the bar must not sit part-filled + // forever. + // + // Quietly: a scroll starts one of these every second, + // and a history of them would bury anything worth + // reading. + job.finish_quietly(); + stop(&ctl_cb.thumb_timer); + return; + } + }; + + match msg { + // Bookkeeping, not an outcome — reports the split between + // store and network without advancing the bar. + ThumbnailMessage::Plan { + cached, + fetching, + dating, + } => { + // Date reads produce no cell, so they are counted into + // the bar's denominator or it finishes while work is + // still running. + job.add_total(dating); + + let mut parts = Vec::new(); + if cached > 0 { + parts.push(format!("{cached} cached")); + } + if fetching > 0 { + parts.push(format!("fetching {fetching}")); + } + if dating > 0 { + parts.push(format!("reading {dating} dates")); + } + if !parts.is_empty() { + let status = parts.join(" · "); + job.detail(status.clone()); + w.set_library_status(status.into()); + } + } + // A header-only date read. Advances the bar; draws nothing. + ThumbnailMessage::DateProgress => { + job.advance(); + } + // Dates landed, so the histogram can now be built. This is + // what makes the timeline appear on a library whose + // thumbnails were all cached. + ThumbnailMessage::DatesRecorded(n) => { + log::info!("timeline: {n} new dates"); + let borrow = ctl_cb.catalog.borrow(); + if let Some(catalog) = borrow.as_ref() { + refresh_timeline(&w, catalog, &ctl_cb); + } + } + // Every real outcome advances the bar. Counting only + // successes would stall it on a library where some files + // carry no embedded preview. + ThumbnailMessage::Ready(t) => { + job.advance(); + // Bytes arrived *from the server*, so it is reachable. + // This is what clears the banner when a connection + // returns while the user is simply scrolling, without + // waiting for a probe or a manual retry. + // + // Store hits are excluded deliberately: they are read + // from local disk and say nothing about the network. A + // window that is mostly cached delivers a run of them + // before the first request is even attempted, so + // counting them declared "back online" against a server + // that was plainly down. + if !t.from_cache + && ctl_cb + .reachability + .borrow_mut() + .mark_reachable(std::time::Instant::now()) + { + log::info!("back online"); + refresh_offline(&w, &ctl_cb); + // The sweep was refused while offline, so nothing + // else would ever restart it — the grid would stay + // partially dated until the next launch. + start_sweep(&w, &ctl_cb); + } + if let Some(mut row) = model.row_data(t.row) { + row.thumbnail = to_slint_image(t.width, t.height, &t.rgba); + row.has_thumb = true; + model.set_row_data(t.row, row); + // What this cell is now showing, so the next reload + // can carry it over and know not to ask again. + record_class(&ctl_cb, t.row, class); + } + } + ThumbnailMessage::Unavailable { row, reason } => { + job.advance(); + log::debug!("thumbnail {row}: {reason}"); + if let Some(mut r) = model.row_data(row) { + r.unavailable = true; + model.set_row_data(row, r); + // A verdict is worth carrying too: "no preview" is + // an answer about the file, and re-asking it on + // every scroll is a fetch that will fail again. + record_class(&ctl_cb, row, class); + } + } + // TRACES: FR-CAT-9 + ThumbnailMessage::Offline { reason } => { + log::info!("thumbnails stopped: {reason}"); + // The batch is over, so the bar must not be left + // showing a partial fetch that will never finish — it + // would sweep for ever. + job.fail(reason.clone()); + ctl_cb + .reachability + .borrow_mut() + .mark_unreachable(reason, std::time::Instant::now()); + refresh_offline(&w, &ctl_cb); + + // Cells left without pixels stay placeholders rather + // than being marked unavailable: the images are fine, + // and a reconnect should fill them in. Marking them + // would persist a verdict about the *file* from an + // event about the *connection*. + stop(&ctl_cb.thumb_timer); + return; + } + } + } + }, + ); + + *ctl.thumb_timer.borrow_mut() = Some(timer); +} + +/// Copy decoded RGBA into a Slint image. +/// +/// This is a CPU copy, which is acceptable here and not in the develop path: +/// a 256px thumbnail is 256 KB and happens once per image, where the canvas +/// would pay per frame (ARCH §6.1). +fn to_slint_image(width: u32, height: u32, rgba: &[u8]) -> slint::Image { + let mut buf = slint::SharedPixelBuffer::::new(width, height); + let expected = (width as usize) * (height as usize) * 4; + let src = &rgba[..expected.min(rgba.len())]; + buf.make_mut_bytes()[..src.len()].copy_from_slice(src); + slint::Image::from_rgba8(buf) +} + +/// TRACES: FR-UI-2 +/// A filename as a caption: without the part that says how it is stored. +/// +/// A grid cell is about four words wide, and `.CR2` spends one of them saying +/// something the photographer already knows — every frame in a RAW library +/// ends the same way, so the extension distinguishes nothing while taking +/// room from the part that does. The name is elided under pressure, and it is +/// the *end* that goes, so an extension can push the digits that identify a +/// frame off the visible part of its own label. +/// +/// Display only. `LibraryCell::name` keeps the true filename and +/// `remote_path` the full path, because both are used to find the file again +/// and a stem is not a filename. +/// +/// **The case this is wrong for**, worth knowing before it is reported: a +/// library holding `IMG_1234.CR2` beside `IMG_1234.JPG` shows two cells +/// captioned `IMG_1234`. They are still two rows with two thumbnails and two +/// entries in the info panel, and RAW+JPEG pairs are usually shot to be one +/// photograph anyway — but the caption alone no longer separates them. +fn without_extension(name: &str) -> &str { + match name.rsplit_once('.') { + // The guard is for a leading dot: `.hidden` splits to an empty stem, + // and a dotfile's name starts with that dot rather than ending with an + // extension. A *trailing* dot needs no guard — `odd.` splits to `odd`, + // which is the better caption anyway. + Some((stem, _)) if !stem.is_empty() => stem, + _ => name, + } +} + +#[cfg(test)] +mod display_name_tests { + use super::without_extension; + + #[test] + fn an_extension_is_dropped_and_nothing_else_is() { + assert_eq!(without_extension("IMG_1234.CR2"), "IMG_1234"); + assert_eq!(without_extension("IMG_1234.jpeg"), "IMG_1234"); + // Only the last one: a name can contain dots and they are part of it. + assert_eq!(without_extension("2026.08.23-a.dng"), "2026.08.23-a"); + // Nothing to drop. + assert_eq!(without_extension("IMG_1234"), "IMG_1234"); + // A dotfile is not an extensionless name with an extension. + assert_eq!(without_extension(".hidden"), ".hidden"); + // A trailing dot is an empty extension, and dropping it is right. + assert_eq!(without_extension("odd."), "odd"); + assert_eq!(without_extension(""), ""); + } +} + +#[cfg(test)] +mod tests { + use super::super::controller::SCREENFULS; + use super::*; + + #[test] + fn a_request_key_survives_the_window_moving() { + use dr_thumbs::ThumbSize; + use std::collections::HashSet; + + // Keyed on the photograph, not its position. The grid is a window over + // the catalog, so row 7 is a different image after every scroll — a + // set of row indices had to be cleared on each move, and every visible + // cell then looked unrequested and was re-issued. + let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new(); + + // A screenful at rows 0..3, holding images 100..103. + for id in 100..103 { + assert!(requested.insert((id, ThumbSize::Grid)), "first sight"); + } + + // Scrolled: the same photographs now occupy different rows. + for id in 100..103 { + assert!( + !requested.insert((id, ThumbSize::Grid)), + "image {id} must not be requested twice" + ); + } + + // A genuinely new photograph still is. + assert!(requested.insert((200, ThumbSize::Grid))); + } + + #[test] + fn the_two_size_classes_are_requested_independently() { + use dr_thumbs::ThumbSize; + use std::collections::HashSet; + + // Holding the 256px version says nothing about the large one, so + // zooming past the boundary must still ask. + let mut requested: HashSet<(i64, ThumbSize)> = HashSet::new(); + assert!(requested.insert((1, ThumbSize::Grid))); + assert!( + requested.insert((1, ThumbSize::Large)), + "the large class is a separate request" + ); + assert!(!requested.insert((1, ThumbSize::Grid))); + } + + #[test] + fn rgba_shorter_than_declared_does_not_panic() { + // A truncated decode must degrade to a partial image, not abort the + // grid. Decoders handle untrusted input (NFR-SEC-1). + let img = to_slint_image(4, 4, &[0u8; 8]); + assert_eq!(img.size().width, 4); + } + + #[test] + fn rgba_longer_than_declared_is_truncated() { + let img = to_slint_image(2, 2, &[255u8; 1024]); + assert_eq!(img.size().width, 2); + assert_eq!(img.size().height, 2); + } + + const ON_SCREEN: usize = 120; + + const W: usize = ON_SCREEN * SCREENFULS; + + const TOTAL: usize = 24_000; + + /// Where the window lands for a view at `first_visible`, as the scroll + /// handler would leave it. + fn settle(first_visible: usize) -> usize { + window_start(first_visible, W, TOTAL) + } + + #[test] + fn a_view_well_inside_the_loaded_window_does_not_move_it() { + // The whole point of loading screenfuls either side: scrolling within + // them must not touch the catalog. + let at = settle(5_000); + assert_eq!(window_move(5_000, at, W, ON_SCREEN, TOTAL), None); + assert_eq!(window_move(5_100, at, W, ON_SCREEN, TOTAL), None); + } + + #[test] + fn the_whole_view_stays_inside_the_loaded_window() { + // The bug this rule exists for: the test used to ask only where the + // *first* visible cell was, so the window sat still while the bottom + // of the view hung a quarter of a screenful past the last loaded cell + // — rows the model does not hold and the grid therefore draws blank. + let mut at = settle(0); + for first_visible in (0..TOTAL - ON_SCREEN).step_by(10) { + if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) { + at = moved; + } + assert!( + first_visible >= at && first_visible + ON_SCREEN <= at + W, + "view {first_visible}..{} is not covered by the window {at}..{}", + first_visible + ON_SCREEN, + at + W + ); + } + } + + #[test] + fn scrolling_back_a_row_does_not_reload() { + // The mirror fault, and the more expensive one: the window is placed a + // quarter of itself behind the view, and the old margin declared the + // view too close to the top at exactly that distance. So every single + // row scrolled upward re-read the catalog and rebuilt every cell. + let at = settle(5_200); + for row in 1..=4 { + let first_visible = 5_200 - row * 10; + assert_eq!( + window_move(first_visible, at, W, ON_SCREEN, TOTAL), + None, + "row {first_visible} reloaded the window it was already inside" + ); + } + } + + #[test] + fn a_view_reaching_the_edge_of_the_loaded_window_moves_it() { + // Far enough down that scrolling on would run into rows nobody has + // read. + let at = settle(5_000); + let far = at + W - ON_SCREEN; + let moved = window_move(far, at, W, ON_SCREEN, TOTAL).expect("the window follows the view"); + assert_eq!(moved, far - W / 4, "placed a quarter behind the view"); + } + + #[test] + fn the_window_moves_about_once_a_screenful() { + // Too eager is a stutter under the finger, so the rule is checked from + // both sides: the view must cross most of a screenful between reloads. + let mut at = settle(0); + let mut last = 0; + let mut gaps = Vec::new(); + for first_visible in (0..12_000).step_by(10) { + if let Some(moved) = window_move(first_visible, at, W, ON_SCREEN, TOTAL) { + at = moved; + gaps.push(first_visible - last); + last = first_visible; + } + } + assert!( + gaps.iter().skip(1).all(|g| *g >= ON_SCREEN), + "reloaded after less than a screenful of travel: {gaps:?}" + ); + } + + #[test] + fn the_top_of_the_library_is_not_reloaded_on_every_row() { + // `first_visible` cannot be placed further back than zero, so the + // margin test can never be satisfied here. Before this rule every one + // of these re-read the catalog to arrive at the offset it already had, + // which is the stutter at the top of every scope. + for first_visible in [0, 6, 30, 89] { + assert_eq!( + window_move(first_visible, 0, W, ON_SCREEN, TOTAL), + None, + "row {first_visible} asked for a move to offset 0, which is where it is" + ); + } + } + + #[test] + fn the_end_of_the_library_is_not_reloaded_on_every_row() { + // The mirror of the above, and the worse of the two: the window is + // clamped to `max_offset` while the view keeps travelling past it. + let pinned = TOTAL - W; + for first_visible in [TOTAL - W / 2, TOTAL - 30, TOTAL - 1] { + assert_eq!( + window_move(first_visible, pinned, W, ON_SCREEN, TOTAL), + None, + "row {first_visible} asked for a move to the offset it already had" + ); + } + } + + #[test] + fn the_window_never_starts_past_the_last_full_screenful() { + // Otherwise a scrub to the very end loads a handful of cells and the + // rest of the window addresses images that do not exist. + let moved = + window_move(TOTAL - 1, 0, W, ON_SCREEN, TOTAL).expect("a scrub to the end moves"); + assert_eq!(moved, TOTAL - W); + } + + #[test] + fn a_library_smaller_than_the_window_stays_at_the_beginning() { + // `max_offset` is zero, so there is one valid position and the view + // must never ask for another. + assert_eq!(window_move(0, 0, W, ON_SCREEN, 40), None); + assert_eq!(window_move(39, 0, W, ON_SCREEN, 40), None); + } + + #[test] + fn the_visible_cells_are_fetched_before_anything_else() { + // The window holds a quarter of itself above the view, and in model + // order those cells — which nobody is looking at — were fetched first, + // ahead of the whole screen. + let window: Vec = (0..W).collect(); + let first_on_screen = W / 4; + let mut order = window.clone(); + order.sort_by_key(|row| fetch_rank(*row, first_on_screen, ON_SCREEN)); + + assert_eq!( + &order[..ON_SCREEN], + &window[first_on_screen..first_on_screen + ON_SCREEN], + "the screen is not fetched first, in reading order" + ); + // The bottom row of the grid — the one reported missing — comes before + // every offscreen cell rather than after all of them. + let bottom = first_on_screen + ON_SCREEN - 1; + assert!( + order.iter().position(|r| *r == bottom).unwrap() < ON_SCREEN, + "the bottom row of the grid is still behind offscreen cells" + ); + } + + #[test] + fn offscreen_cells_are_fetched_nearest_first_below_before_above() { + let first_on_screen = W / 4; + let past = first_on_screen + ON_SCREEN; + // The row just below the view beats the row just above it, and both + // beat rows further out on their own side. + assert!( + fetch_rank(past, first_on_screen, ON_SCREEN) + < fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN) + ); + assert!( + fetch_rank(past, first_on_screen, ON_SCREEN) + < fetch_rank(past + 1, first_on_screen, ON_SCREEN) + ); + assert!( + fetch_rank(first_on_screen - 1, first_on_screen, ON_SCREEN) + < fetch_rank(0, first_on_screen, ON_SCREEN) + ); + } + + /// A model of cells, `has_thumb` set for the ids named. + fn model_of(ids: &[i64], with_pixels: &[i64]) -> slint::ModelRc { + let rows: Vec = ids + .iter() + .map(|id| LibraryCell { + has_thumb: with_pixels.contains(id), + thumbnail: if with_pixels.contains(id) { + to_slint_image(2, 2, &[255u8; 16]) + } else { + slint::Image::default() + }, + ..Default::default() + }) + .collect(); + slint::ModelRc::new(slint::VecModel::from(rows)) + } + + #[test] + fn a_reload_keeps_the_pixels_of_photographs_that_are_still_in_the_window() { + let ids = [10i64, 11, 12]; + let previous = model_of(&ids, &[10, 12]); + let classes = vec![Some(dr_thumbs::ThumbSize::Grid); 3]; + + let held = hold_thumbnails(&previous, &ids, &classes); + + assert!(held.contains_key(&10), "a drawn cell is carried"); + assert!(held.contains_key(&12)); + assert!( + !held.contains_key(&11), + "a cell still waiting has nothing to carry" + ); + } + + #[test] + fn a_no_preview_verdict_is_carried_too() { + // Otherwise every scroll re-asks a question the server has already + // answered, and the cell blinks from "no preview" back to "…". + let ids = [7i64]; + let rows = vec![LibraryCell { + unavailable: true, + ..Default::default() + }]; + let previous = slint::ModelRc::new(slint::VecModel::from(rows)); + + let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid)]); + assert!(held[&7].unavailable); + assert!(!held[&7].has_thumb); + } + + #[test] + fn a_carried_cell_is_not_fetched_again_but_a_newly_scrolled_in_one_is() { + // The scroll this describes: the window moved down by one image, so + // 11 and 12 are still on screen and 13 has just arrived. + let ids = [10i64, 11, 12]; + let previous = model_of(&ids, &[10, 11, 12]); + let held = hold_thumbnails(&previous, &ids, &[Some(dr_thumbs::ThumbSize::Grid); 3]); + + let served = already_served(&held, [11i64, 12, 13].into_iter()); + + assert!(served.contains(&(11, dr_thumbs::ThumbSize::Grid))); + assert!(served.contains(&(12, dr_thumbs::ThumbSize::Grid))); + assert!( + !served.contains(&(13, dr_thumbs::ThumbSize::Grid)), + "a photograph the window has just reached must still be fetched" + ); + } + + #[test] + fn a_photograph_scrolled_out_of_the_window_is_fetched_again_on_return() { + // The trap in keeping the set rather than rebuilding it: image 10 was + // served once, but the model that held its pixels is long gone, so the + // cell would sit blank for ever if it still counted as served. + let held = hold_thumbnails( + &model_of(&[10], &[10]), + &[10], + &[Some(dr_thumbs::ThumbSize::Grid)], + ); + + let served = already_served(&held, [40i64, 41].into_iter()); + assert!(served.is_empty(), "nothing in this window is already drawn"); + } + + #[test] + fn a_carried_thumbnail_does_not_satisfy_a_zoom_past_its_class() { + // Zooming past 256px reloads the window. The cell keeps showing the + // small thumbnail — no flash — but the large one must still be asked + // for, or the grid would stay soft until something else forced a fetch. + let held = hold_thumbnails( + &model_of(&[5], &[5]), + &[5], + &[Some(dr_thumbs::ThumbSize::Grid)], + ); + let mut served = already_served(&held, [5i64].into_iter()); + + assert!( + served.insert((5, dr_thumbs::ThumbSize::Large)), + "the large class is still unserved" + ); + assert!( + !served.insert((5, dr_thumbs::ThumbSize::Grid)), + "and the small one is not asked for twice" + ); + } + + #[test] + fn a_cell_whose_class_was_never_recorded_is_fetched_again() { + // Pixels with no class are pixels from before this bookkeeping existed + // — or from a row the drain never reached. Showing them is right; + // claiming they were served is not, because nothing knows at what size. + let held = hold_thumbnails(&model_of(&[5], &[5]), &[5], &[None]); + assert!(held.contains_key(&5), "still drawn"); + assert!(already_served(&held, [5i64].into_iter()).is_empty()); + } + + /// A thumbnail drain must be able to tell that the window it was started + /// for has been replaced. + /// + /// This is the whole of the black-grid fix, reduced to the comparison the + /// timer callback makes. `row` is an index into the window that requested + /// the fetch, so a drain that keeps writing after a reload paints + /// thumbnails onto unrelated photographs — and, worse, its `stop` lands on + /// the *current* batch's timer and leaves the new fetches undrained. + #[test] + fn a_reload_makes_an_in_flight_thumbnail_batch_stale() { + let ctl = LibraryController::new(crate::activity::ActivityLog::new()); + + // What `drain_thumbnails` captures when the batch is spawned. + let mine = ctl.generation.get(); + assert_eq!(ctl.generation.get(), mine, "its own batch is live"); + + // What `load_window` does just before swapping the model. + ctl.generation.set(ctl.generation.get().wrapping_add(1)); + + assert_ne!( + ctl.generation.get(), + mine, + "the batch must recognise itself as stale once the model is replaced" + ); + } + + /// Each load is distinct, so two reloads cannot alias back to a live batch. + #[test] + fn every_window_load_takes_a_fresh_generation() { + let ctl = LibraryController::new(crate::activity::ActivityLog::new()); + let seen: Vec = (0..4) + .map(|_| { + let g = ctl.generation.get(); + ctl.generation.set(g.wrapping_add(1)); + g + }) + .collect(); + + assert_eq!(seen, vec![0, 1, 2, 3]); + } +}